// 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) => ( ))}
{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.title}
: }
{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.title}

by {n.author}
{(n.cover || firstBlockImage(n)) ?
{n.title}
: }
))}
)}
); } 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

); } 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.title}
: }
{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 && ( <>
{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 (
{alt}
); } return (
{alt}
); } // 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 => ( ))}
); } Object.assign(window, { NewsIndex, Article, firstBlockImage });