// site-news.jsx — journal index (stream) + full article renderer.
function NewsIndex({ navigate }) {
const news = usePublishedNews();
const [cat, setCat] = React.useState("All");
const cats = ["All", ...Array.from(new Set(news.map((n) => n.category)))];
const shown = news.filter((n) => cat === "All" || n.category === cat);
const lead = shown[0];
const stream = shown.slice(1);
return (
News · Journal · Field Notes
The Journal
{cats.map((c) => (
setCat(c)}
style={cat === c ? { background: "var(--paper)", borderColor: "var(--paper)", color: "var(--ink)" } : { borderColor: "var(--line-d)", color: "var(--muted-d)" }}>{c}
))}
{shown.length === 0 ? (
No posts in this category yet.
) : (
<>
{/* lead */}
navigate("/news/" + lead.id)} style={{ cursor: "pointer", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "clamp(20px,3vw,48px)", alignItems: "center" }} className="news-lead">
{(lead.cover || firstBlockImage(lead))
?
:
}
{lead.category}
{fmtDate(lead.date)}
{lead.title}
{firstText(lead)}
by {lead.author}
Read
{/* stream */}
{stream.map((n) => (
navigate("/news/" + n.id)}
style={{ cursor: "pointer", display: "grid", gridTemplateColumns: "160px 1fr 220px", gap: 28, alignItems: "center", padding: "26px 0", borderTop: "1px solid var(--line)" }} className="news-row">
{fmtDate(n.date)}
{n.category}
{(n.cover || firstBlockImage(n))
?
:
}
))}
>
)}
);
}
function firstText(article) {
const b = (article.blocks || []).find((b) => b.type === "text");
if (!b) return "";
if (b.html) {
const tmp = document.createElement("div");
tmp.innerHTML = b.html;
return tmp.textContent || "";
}
return b.text || "";
}
// ── Full article ──────────────────────────────────────────────────────────────
function Article({ newsId, navigate }) {
const d = useSite();
const a = d.news.find((n) => n.id === newsId);
if (!a) {
return (
Post not found
navigate("/news")} style={{ marginTop: 16 }}>Back to the journal
);
}
const more = d.news.filter((n) => n.status === "published" && n.id !== newsId).slice(0, 3);
return (
navigate("/news")} style={{ color: "var(--muted-d)", cursor: "pointer" }}>
Journal
{a.category}
{fmtDate(a.date)}
{a.title}
By {a.author}
{a.cover
?
:
}
{(a.blocks || []).map((b) =>
)}
{(!a.blocks || a.blocks.length === 0) &&
This post has no content yet.
}
Keep reading
{more.map((n) => (
navigate("/news/" + n.id)}>
{(n.cover || firstBlockImage(n))
?
:
}
{n.category} {fmtDate(n.date)}
{n.title}
By {n.author}
))}
);
}
// ── Site carousel widget ─────────────────────────────────────────────────────
function SiteCarousel({ images, renderSlide, style={} }) {
const [cur, setCur] = React.useState(0);
const [phase, setPhase] = React.useState('idle'); // idle | setup | animating
const [slotsConf, setSlotsConf] = React.useState([]);
const [trackX, setTrackX] = React.useState(0);
const [targetX, setTargetX] = React.useState(0);
const pendingRef = React.useRef(null);
const touchX = React.useRef(null);
const total = images.length;
if (!total) return null;
const navigate = (dir) => {
if (phase !== 'idle') return;
const next = cur + (dir === 'next' ? 1 : -1);
if (next < 0 || next >= total) return;
pendingRef.current = next;
if (dir === 'next') {
// [cur, next] — start at 0, animate to -100
setSlotsConf([{ idx: cur }, { idx: next }]);
setTrackX(0);
setTargetX(-100);
} else {
// [prev, cur] — start at -100 (showing cur), animate to 0 (showing prev)
setSlotsConf([{ idx: next }, { idx: cur }]);
setTrackX(-100);
setTargetX(0);
}
setPhase('setup');
};
// After 'setup' renders the initial position (no transition), kick off animation
React.useEffect(() => {
if (phase === 'setup') {
requestAnimationFrame(() => requestAnimationFrame(() => setPhase('animating')));
}
}, [phase]);
const onTransitionEnd = () => {
if (phase !== 'animating') return;
setCur(pendingRef.current);
pendingRef.current = null;
setPhase('idle');
setSlotsConf([]);
};
const onTouchStart = (e) => { touchX.current = e.touches[0].clientX; };
const onTouchEnd = (e) => {
if (touchX.current === null) return;
const delta = e.changedTouches[0].clientX - touchX.current;
touchX.current = null;
if (Math.abs(delta) < 40) return;
navigate(delta < 0 ? 'next' : 'prev');
};
const displaySlots = phase === 'idle' ? [{ idx: cur }] : slotsConf;
const currentX = phase === 'animating' ? targetX : (phase === 'setup' ? trackX : 0);
const transition = phase === 'animating' ? 'transform 0.45s cubic-bezier(0.16,1,0.3,1)' : 'none';
const arrowStyle = (disabled) => ({
position:"absolute", top:"50%", transform:"translateY(-50%)", zIndex:2,
background: disabled ? "rgba(0,0,0,0.25)" : "var(--accent)", color:"#fff",
border:"none", borderRadius:0, width:32, height:32,
cursor: disabled ? "default" : "pointer",
display:"flex", alignItems:"center", justifyContent:"center",
transition:"background 0.15s",
});
return (
{displaySlots.map((slot, i) => (
{renderSlide(images[slot.idx], slot.idx)}
))}
{total > 1 && (
<>
navigate('prev')} disabled={cur===0}
onMouseEnter={e => { if(cur!==0){ e.currentTarget.style.background="#fff"; e.currentTarget.style.color="var(--ink)"; }}}
onMouseLeave={e => { e.currentTarget.style.background=cur===0?"rgba(0,0,0,0.25)":"var(--accent)"; e.currentTarget.style.color="#fff"; }}
style={{ ...arrowStyle(cur===0), left:6 }}>
navigate('next')} disabled={cur===total-1}
onMouseEnter={e => { if(cur!==total-1){ e.currentTarget.style.background="#fff"; e.currentTarget.style.color="var(--ink)"; }}}
onMouseLeave={e => { e.currentTarget.style.background=cur===total-1?"rgba(0,0,0,0.25)":"var(--accent)"; e.currentTarget.style.color="#fff"; }}
style={{ ...arrowStyle(cur===total-1), right:6 }}>
{Array.from({length:total},(_,i) => (
navigate(i > cur ? 'next' : 'prev')}
style={{ width:i===cur?16:5, height:5, borderRadius:0, background:i===cur?"var(--accent)":"rgba(255,255,255,.45)", cursor:"pointer", display:"block", transition:"all .2s" }} />
))}
>
)}
);
}
function SitePosImg({ src, posX=50, posY=50, zoom=100, alt="", aspect="16/9", style={}, forceAspect=false }) {
const [usedAspect, setUsedAspect] = React.useState(aspect);
const [naturalW, setNaturalW] = React.useState(null);
const scale = zoom / 100;
const { borderRadius, ...containerStyle } = style;
const onImgLoad = e => {
const {naturalWidth:w,naturalHeight:h}=e.target;
if(w&&h) { if(!forceAspect) setUsedAspect(`${w}/${h}`); setNaturalW(w); }
};
const maxW = naturalW ? `${naturalW}px` : "none";
if (scale <= 1 && !forceAspect) {
return (
);
}
return (
);
}
// Helper: get first real image URL from blocks
function firstBlockImage(article) {
for (const b of (article.blocks || [])) {
if (b.imageUrl) return b.imageUrl;
if (b.images && b.images[0] && b.images[0].url) return b.images[0].url;
}
return null;
}
function Block({ b }) {
// Rich text — HTML from the block editor, or legacy plain text
if (b.type === "text") {
if (b.html) return
;
return {b.text}
;
}
// Standalone heading block
if (b.type === "heading") {
const Tag = b.level || "h2";
return {b.text} ;
}
// Carousel + Text side by side
if (b.type === "carousel-text") {
const isLeft = b.imagePos !== "right";
const w = parseInt(b.imageWidth || "40", 10);
const cols = isLeft ? `${w}% 1fr` : `1fr ${w}%`;
const imgs = (b.images && b.images.length) ? b.images.filter(i => i.url) : [];
const imgEl = (
{imgs.length > 0
? openLightbox(imgs.map(im => ({ url:im.url, caption:im.caption })), i)} style={{ cursor:"zoom-in" }}>
}
/>
:
}
);
const txtEl =
;
return (
{isLeft ? <>{imgEl}{txtEl}> : <>{txtEl}{imgEl}>}
);
}
// Image + Text side by side
if (b.type === "image-text") {
const isLeft = b.imagePos !== "right";
const w = parseInt(b.imageWidth || "40", 10);
const cols = isLeft ? `${w}% 1fr` : `1fr ${w}%`;
const imgEl = (
{b.imageUrl
? openLightbox([{ url: b.imageUrl, caption: b.caption }], 0)} style={{ cursor:"zoom-in" }}>
:
}
{b.caption && {b.caption} }
);
const txtEl = (
);
return (
{isLeft ? <>{imgEl}{txtEl}> : <>{txtEl}{imgEl}>}
);
}
// Section divider
if (b.type === "divider") return ;
// Pull quote
if (b.type === "quote") return (
{b.text}
{b.cite && — {b.cite}
}
);
// Image (full / wide / inset / float left / float right)
if (b.type === "image") {
const isFloat = b.layout === "left" || b.layout === "right";
const ratio = b.layout === "wide" ? "21/9" : "16/9";
const cls = ["a-figure", b.layout, isFloat ? "float-img" : ""].filter(Boolean).join(" ");
return (
{b.imageUrl
? openLightbox([{ url: b.imageUrl, caption: b.caption }], 0)} style={{ cursor:"zoom-in" }}>
:
}
{b.caption && {b.caption} }
);
}
// Gallery
if (b.type === "gallery") {
const imgs = (b.images && b.images.length) ? b.images : Array.from({length:b.count||0},(_,i)=>({id:i,url:"",posX:50,posY:50,zoom:100}));
if (b.layout === "carousel") {
const cw = (b.carouselWidth || "100") + "%";
const validImgs = imgs.filter(i => i.url);
return (
{validImgs.length > 0
?
openLightbox(validImgs.map(im => ({ url: im.url, caption: im.caption })), i)} style={{ cursor:"zoom-in" }}>
}
/>
:
}
{b.caption && {b.caption} }
);
}
const cols = b.layout === "grid" ? 2 : b.layout === "row" ? Math.min(imgs.length||1, 4) : 1;
const ar = b.layout === "stack" ? "16/9" : "4/3";
return (
{imgs.map((img, i) => img.url
?
openLightbox(imgs.filter(im=>im.url).map(im=>({ url:im.url, caption:im.caption })), imgs.filter(im=>im.url).findIndex(im=>im===img))} style={{ cursor:"zoom-in" }}>
:
)}
{b.caption && {b.caption} }
);
}
return null;
}
// ── Article footer — category + social share ──────────────────────────────────
function ArticleFooter({ article }) {
const [copied, setCopied] = React.useState(false);
const pageUrl = () => {
if (location.protocol === "file:") return "https://ljmumashortfilmfestival.org/og.php?id=" + encodeURIComponent(article.id);
const base = location.href.split("#")[0].replace(/\/[^/]*$/, "/");
return base + "og.php?id=" + encodeURIComponent(article.id);
};
const share = (platform) => {
const url = encodeURIComponent(pageUrl());
const txt = encodeURIComponent(article.title + " — LJMU MA Short Film Festival");
const targets = {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
linkedin: `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${txt}`,
bluesky: `https://bsky.app/intent/compose?text=${txt}%20${url}`,
threads: `https://www.threads.net/intent/post?text=${txt}%20${url}`,
};
window.open(targets[platform], "_blank", "width=620,height=480,noopener");
};
const copyLink = async () => {
const url = pageUrl();
try {
await navigator.clipboard.writeText(url);
setCopied(true); setTimeout(() => setCopied(false), 2000);
} catch {
try {
const el = document.createElement("textarea");
el.value = url; el.style.cssText = "position:fixed;opacity:0;top:0;left:0";
document.body.appendChild(el); el.select();
document.execCommand("copy"); document.body.removeChild(el);
setCopied(true); setTimeout(() => setCopied(false), 2000);
} catch {}
}
};
const PLATFORMS = [
{ id: "facebook", label: "Facebook", icon: "M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z" },
{ id: "linkedin", label: "LinkedIn", icon: "M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2zM4 6a2 2 0 100-4 2 2 0 000 4z" },
{ id: "bluesky", label: "Bluesky", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V9h2v7zm4 0h-2V9h2v7z" },
{ id: "threads", label: "Threads", icon: "M12 2a10 10 0 100 20A10 10 0 0012 2zm0 4c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" },
];
return (
{/* Category */}
Category:
{article.category}
{/* Share bar */}
Share
{PLATFORMS.map(p => (
share(p.id)} title={`Share on ${p.label}`}>
{p.label}
))}
{copied ? "Copied!" : "Copy link"}
);
}
Object.assign(window, { NewsIndex, Article, firstBlockImage });