Commit multilingual editorial frontend work
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function stripHtml(html) {
|
||||
return html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function excerpt(html, length = 220) {
|
||||
const plain = stripHtml(html);
|
||||
return plain.length <= length ? plain : `${plain.slice(0, length).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function LoadingBlock() {
|
||||
return (
|
||||
<section className="business-live__loading panel">
|
||||
<div className="capsule__kicker">
|
||||
<span>Business desk</span>
|
||||
<span>Fetching live file</span>
|
||||
</div>
|
||||
<h2>Loading the April business issue...</h2>
|
||||
<p>The public business desk is pulling its copy from the live PHP archive.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBlock({ message }) {
|
||||
return (
|
||||
<section className="business-live__loading panel">
|
||||
<div className="capsule__kicker">
|
||||
<span>Business desk</span>
|
||||
<span>Archive offline</span>
|
||||
</div>
|
||||
<h2>The business issue could not be loaded.</h2>
|
||||
<p>{message}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BusinessIssue() {
|
||||
const [state, setState] = useState({
|
||||
status: "loading",
|
||||
section: null,
|
||||
error: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const response = await fetch("/api/sections.php?lang=en&slug=business", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error("The CMS endpoint did not respond cleanly.");
|
||||
const json = await response.json();
|
||||
const section = Array.isArray(json) ? json[0] : json;
|
||||
if (!section) throw new Error("The business desk payload was empty.");
|
||||
setState({ status: "ready", section, error: "" });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({
|
||||
status: "error",
|
||||
section: null,
|
||||
error: error instanceof Error ? error.message : "Unknown loading error.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (state.status === "loading") return <LoadingBlock />;
|
||||
if (state.status === "error") return <ErrorBlock message={state.error} />;
|
||||
|
||||
const { section } = state;
|
||||
const articles = Array.isArray(section.articles) ? section.articles : [];
|
||||
const feature =
|
||||
articles.find((article) => article.title.startsWith("April Feature")) ?? articles[0] ?? null;
|
||||
const memo =
|
||||
articles.find((article) => article.title.startsWith("Companion Memo")) ?? articles[1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="business-live">
|
||||
<section className="container business-live__hero">
|
||||
<div className="business-live__copy">
|
||||
<span className="eyebrow">{section.headline}</span>
|
||||
<h1>{section.name}</h1>
|
||||
<div
|
||||
className="business-live__lede"
|
||||
dangerouslySetInnerHTML={{ __html: section.homepage_html }}
|
||||
/>
|
||||
{feature && (
|
||||
<div className="business-live__signals">
|
||||
<article className="panel business-live__signal">
|
||||
<span>Lead file</span>
|
||||
<strong>{feature.title}</strong>
|
||||
<p>{excerpt(feature.body, 210)}</p>
|
||||
</article>
|
||||
{memo && (
|
||||
<article className="panel business-live__signal">
|
||||
<span>Companion memo</span>
|
||||
<strong>{memo.title}</strong>
|
||||
<p>{excerpt(memo.body, 170)}</p>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="panel business-live__cover">
|
||||
{section.image_url && (
|
||||
<img
|
||||
src={section.image_url}
|
||||
alt={section.photo_description || section.name}
|
||||
loading="eager"
|
||||
/>
|
||||
)}
|
||||
<div className="business-live__cover-copy">
|
||||
<div className="capsule__kicker">
|
||||
<span>April issue</span>
|
||||
<span>AI x labour x pataphysics</span>
|
||||
</div>
|
||||
<h2>The science of imaginary co-workers.</h2>
|
||||
<p>{excerpt(section.page_html, 220)}</p>
|
||||
{section.photo_credit && (
|
||||
<p className="business-live__credit">Image credit: {section.photo_credit}</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="container business-live__columns">
|
||||
<article className="panel business-live__feature">
|
||||
<div className="capsule__kicker">
|
||||
<span>Feature essay</span>
|
||||
<span>Agents / labour / Queneau / Ionesco / Prévert</span>
|
||||
</div>
|
||||
{feature ? (
|
||||
<div
|
||||
className="business-live__rich"
|
||||
dangerouslySetInnerHTML={{ __html: feature.body }}
|
||||
/>
|
||||
) : (
|
||||
<p>No feature article is available yet.</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<aside className="business-live__side">
|
||||
{memo && (
|
||||
<article className="panel business-live__card">
|
||||
<div className="capsule__kicker">
|
||||
<span>Companion memo</span>
|
||||
<span>Three tests</span>
|
||||
</div>
|
||||
<div
|
||||
className="business-live__rich business-live__rich--compact"
|
||||
dangerouslySetInnerHTML={{ __html: memo.body }}
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<article className="panel business-live__card">
|
||||
<div className="capsule__kicker">
|
||||
<span>Live from the CMS</span>
|
||||
<span>{articles.length} article{articles.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<ul className="business-live__article-list">
|
||||
{articles.map((article) => (
|
||||
<li key={article.id ?? article.title}>
|
||||
<strong>{article.title}</strong>
|
||||
<p>{excerpt(article.body, 140)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="panel business-live__card">
|
||||
<div className="capsule__kicker">
|
||||
<span>Editorial note</span>
|
||||
<span>Fake quotes, real sources</span>
|
||||
</div>
|
||||
<p>
|
||||
The invented quotations are marked as invented. The adoption, labour, and pataphysical
|
||||
framing are anchored to cited OECD, WEF, and Collège sources.
|
||||
</p>
|
||||
{feature?.published_at && (
|
||||
<p className="business-live__meta">Feature published: {feature.published_at}</p>
|
||||
)}
|
||||
{memo?.published_at && (
|
||||
<p className="business-live__meta">Memo published: {memo.published_at}</p>
|
||||
)}
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
import LocaleCopy from "./LocaleCopy.astro";
|
||||
import { cookieBannerCopy } from "../data/locales";
|
||||
---
|
||||
|
||||
<aside class="cookie-banner" data-cookie-banner hidden>
|
||||
<div class="cookie-banner__panel">
|
||||
<div class="cookie-banner__copy">
|
||||
<p class="cookie-banner__eyebrow">
|
||||
<LocaleCopy copy={cookieBannerCopy.eyebrow} />
|
||||
</p>
|
||||
<h2>
|
||||
<LocaleCopy copy={cookieBannerCopy.title} />
|
||||
</h2>
|
||||
<p class="cookie-banner__body">
|
||||
<LocaleCopy copy={cookieBannerCopy.body} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="cookie-banner__actions">
|
||||
<button type="button" class="button button--dark button--small" data-cookie-accept="all">
|
||||
<LocaleCopy copy={cookieBannerCopy.acceptAll} />
|
||||
</button>
|
||||
<button type="button" class="button button--soft button--small" data-cookie-accept="essential">
|
||||
<LocaleCopy copy={cookieBannerCopy.essentialOnly} />
|
||||
</button>
|
||||
<button type="button" class="button button--ghost button--small" data-cookie-customize>
|
||||
<LocaleCopy copy={cookieBannerCopy.customize} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cookie-banner__details" data-cookie-details hidden>
|
||||
<label class="cookie-toggle">
|
||||
<input type="checkbox" checked disabled />
|
||||
<span>
|
||||
<strong><LocaleCopy copy={cookieBannerCopy.categories.essential} /></strong>
|
||||
<em>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Always on",
|
||||
fr: "Toujours actif",
|
||||
nb: "Alltid pa",
|
||||
}}
|
||||
/>
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="cookie-toggle">
|
||||
<input type="checkbox" data-cookie-option="analytics" />
|
||||
<span>
|
||||
<strong><LocaleCopy copy={cookieBannerCopy.categories.analytics} /></strong>
|
||||
<em>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Off by default",
|
||||
fr: "Desactive par defaut",
|
||||
nb: "Av som standard",
|
||||
}}
|
||||
/>
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="cookie-toggle">
|
||||
<input type="checkbox" data-cookie-option="embeds" />
|
||||
<span>
|
||||
<strong><LocaleCopy copy={cookieBannerCopy.categories.embeds} /></strong>
|
||||
<em>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Off by default",
|
||||
fr: "Desactive par defaut",
|
||||
nb: "Av som standard",
|
||||
}}
|
||||
/>
|
||||
</em>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="button" class="button button--dark button--small" data-cookie-save>
|
||||
<LocaleCopy copy={cookieBannerCopy.save} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const COOKIE_NAME = "dg_cookie_preferences";
|
||||
const banner = document.querySelector("[data-cookie-banner]");
|
||||
if (!banner) return;
|
||||
|
||||
const details = banner.querySelector("[data-cookie-details]");
|
||||
const analytics = banner.querySelector('[data-cookie-option="analytics"]');
|
||||
const embeds = banner.querySelector('[data-cookie-option="embeds"]');
|
||||
|
||||
const readCookie = (name) => {
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const writeCookie = (value) => {
|
||||
document.cookie = `${COOKIE_NAME}=${encodeURIComponent(value)}; path=/; max-age=${180 * 24 * 60 * 60}; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const applyPreference = (value) => {
|
||||
const pref = value || "essential";
|
||||
const analyticsOn = pref === "all" || pref.includes("analytics");
|
||||
const embedsOn = pref === "all" || pref.includes("embeds");
|
||||
document.documentElement.setAttribute("data-cookie-analytics", analyticsOn ? "true" : "false");
|
||||
document.documentElement.setAttribute("data-cookie-embeds", embedsOn ? "true" : "false");
|
||||
if (analytics) analytics.checked = analyticsOn;
|
||||
if (embeds) embeds.checked = embedsOn;
|
||||
};
|
||||
|
||||
const closeBanner = () => {
|
||||
banner.hidden = true;
|
||||
};
|
||||
|
||||
const savePreference = (value) => {
|
||||
writeCookie(value);
|
||||
applyPreference(value);
|
||||
closeBanner();
|
||||
window.dispatchEvent(new CustomEvent("dg:cookie-consent", { detail: { preference: value } }));
|
||||
};
|
||||
|
||||
const existing = readCookie(COOKIE_NAME);
|
||||
if (existing) {
|
||||
applyPreference(existing);
|
||||
closeBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
banner.hidden = false;
|
||||
applyPreference("essential");
|
||||
|
||||
banner.querySelector('[data-cookie-accept="all"]')?.addEventListener("click", () => {
|
||||
savePreference("all");
|
||||
});
|
||||
|
||||
banner.querySelector('[data-cookie-accept="essential"]')?.addEventListener("click", () => {
|
||||
savePreference("essential");
|
||||
});
|
||||
|
||||
banner.querySelector("[data-cookie-customize]")?.addEventListener("click", () => {
|
||||
if (!details) return;
|
||||
details.hidden = !details.hidden;
|
||||
});
|
||||
|
||||
banner.querySelector("[data-cookie-save]")?.addEventListener("click", () => {
|
||||
const parts = ["essential"];
|
||||
if (analytics?.checked) parts.push("analytics");
|
||||
if (embeds?.checked) parts.push("embeds");
|
||||
savePreference(parts.join(","));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const filterPhoto = (photo, filter) => {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "named") return photo.namedFile;
|
||||
return photo.tags?.includes(filter);
|
||||
};
|
||||
|
||||
const variantForIndex = (index) => {
|
||||
const variants = ["tall", "wide", "square", "tall", "square", "wide"];
|
||||
return variants[index % variants.length];
|
||||
};
|
||||
|
||||
export default function FamilyAtlas({ photos = [], featured = [], filters = [] }) {
|
||||
const [activeFilter, setActiveFilter] = useState("all");
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
const visiblePhotos = photos.filter((photo) => filterPhoto(photo, activeFilter));
|
||||
const currentPhoto = lightboxOpen ? visiblePhotos[lightboxIndex] : featured[0] ?? visiblePhotos[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightboxOpen) return undefined;
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setLightboxOpen(false);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
setLightboxIndex((index) => (index + 1) % visiblePhotos.length);
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
setLightboxIndex((index) => (index - 1 + visiblePhotos.length) % visiblePhotos.length);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [lightboxOpen, visiblePhotos.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setLightboxIndex(0);
|
||||
}, [activeFilter]);
|
||||
|
||||
const openLightbox = (index) => {
|
||||
setLightboxIndex(index);
|
||||
setLightboxOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="family-atlas" id="atlas">
|
||||
<div className="family-atlas__toolbar">
|
||||
<div className="family-atlas__topline">
|
||||
<span>Memory atlas / image-led archive test</span>
|
||||
<span>{visiblePhotos.length} visible frames</span>
|
||||
</div>
|
||||
|
||||
<div className="family-atlas__filters" role="tablist" aria-label="Photo filters">
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeFilter === filter.key}
|
||||
className={activeFilter === filter.key ? "is-active" : ""}
|
||||
onClick={() => setActiveFilter(filter.key)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="family-atlas__layout">
|
||||
<aside className="family-atlas__inspector">
|
||||
{currentPhoto && (
|
||||
<>
|
||||
<div className="family-atlas__inspector-image">
|
||||
<img src={currentPhoto.src} alt={currentPhoto.title} loading="lazy" />
|
||||
</div>
|
||||
<div className="family-atlas__inspector-copy">
|
||||
<p className="family-atlas__inspector-kicker">Current frame</p>
|
||||
<h3>{currentPhoto.title}</h3>
|
||||
<p>{currentPhoto.description}</p>
|
||||
<div className="family-atlas__tags">
|
||||
{(currentPhoto.tags ?? []).slice(0, 4).map((tag) => (
|
||||
<span key={tag}>{tag.replace("-", " ")}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="family-atlas__featured">
|
||||
<p className="family-atlas__inspector-kicker">Named reel</p>
|
||||
<div className="family-atlas__featured-grid">
|
||||
{featured.slice(0, 4).map((photo) => (
|
||||
<button
|
||||
key={photo.filename}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextIndex = visiblePhotos.findIndex((item) => item.filename === photo.filename);
|
||||
if (nextIndex >= 0) {
|
||||
openLightbox(nextIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={photo.src} alt={photo.title} loading="lazy" />
|
||||
<span>{photo.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="family-atlas__masonry">
|
||||
{visiblePhotos.map((photo, index) => (
|
||||
<button
|
||||
key={photo.filename}
|
||||
type="button"
|
||||
className={`family-atlas__tile family-atlas__tile--${variantForIndex(index)}`}
|
||||
onClick={() => openLightbox(index)}
|
||||
>
|
||||
<img src={photo.src} alt={photo.title} loading="lazy" />
|
||||
<span className="family-atlas__tile-meta">
|
||||
<strong>{photo.title}</strong>
|
||||
<small>{photo.namedFile ? "named frame" : "archive frame"}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxOpen && currentPhoto && (
|
||||
<div
|
||||
className="family-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${currentPhoto.title} lightbox`}
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
>
|
||||
<div className="family-lightbox__sheet" onClick={(event) => event.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="family-lightbox__close"
|
||||
onClick={() => setLightboxOpen(false)}
|
||||
aria-label="Close lightbox"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<div className="family-lightbox__frame">
|
||||
<button
|
||||
type="button"
|
||||
className="family-lightbox__nav family-lightbox__nav--prev"
|
||||
onClick={() => setLightboxIndex((index) => (index - 1 + visiblePhotos.length) % visiblePhotos.length)}
|
||||
aria-label="Previous image"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<img src={currentPhoto.src} alt={currentPhoto.title} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="family-lightbox__nav family-lightbox__nav--next"
|
||||
onClick={() => setLightboxIndex((index) => (index + 1) % visiblePhotos.length)}
|
||||
aria-label="Next image"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="family-lightbox__caption">
|
||||
<p className="family-atlas__inspector-kicker">
|
||||
Frame {lightboxIndex + 1} / {visiblePhotos.length}
|
||||
</p>
|
||||
<h3>{currentPhoto.title}</h3>
|
||||
<p>{currentPhoto.description}</p>
|
||||
<div className="family-atlas__tags">
|
||||
{(currentPhoto.tags ?? []).map((tag) => (
|
||||
<span key={tag}>{tag.replace("-", " ")}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import type { LocaleCode } from "../data/locales";
|
||||
|
||||
interface Props {
|
||||
copy: Record<LocaleCode, string>;
|
||||
className?: string;
|
||||
tag?: string;
|
||||
html?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
copy,
|
||||
className = "",
|
||||
tag = "span",
|
||||
html = false,
|
||||
} = Astro.props;
|
||||
|
||||
const Tag = tag;
|
||||
const order: LocaleCode[] = ["en", "fr", "nb"];
|
||||
---
|
||||
|
||||
<Tag class={`locale-copy ${className}`.trim()}>
|
||||
{order.map((lang) => (
|
||||
<span
|
||||
class="locale-copy__text"
|
||||
data-locale-option={lang}
|
||||
set:html={html ? copy[lang] : undefined}
|
||||
>
|
||||
{html ? undefined : copy[lang]}
|
||||
</span>
|
||||
))}
|
||||
</Tag>
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
import LocaleCopy from "./LocaleCopy.astro";
|
||||
import { localeMeta } from "../data/locales";
|
||||
---
|
||||
|
||||
<div class="locale-switcher" data-locale-switcher>
|
||||
<div class="locale-switcher__buttons" role="group" aria-label="Language switcher">
|
||||
<button type="button" class="locale-switcher__button" data-lang-button="en">
|
||||
{localeMeta.en.switcher}
|
||||
</button>
|
||||
<button type="button" class="locale-switcher__button" data-lang-button="fr">
|
||||
{localeMeta.fr.switcher}
|
||||
</button>
|
||||
<button type="button" class="locale-switcher__button" data-lang-button="nb">
|
||||
{localeMeta.nb.switcher}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LocaleCopy
|
||||
className="locale-switcher__note"
|
||||
copy={{
|
||||
en: localeMeta.en.note,
|
||||
fr: localeMeta.fr.note,
|
||||
nb: localeMeta.nb.note,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const COOKIE_NAME = "dg_ui_lang";
|
||||
const valid = ["en", "fr", "nb"];
|
||||
|
||||
const readCookie = (name) => {
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const writeCookie = (name, value, days) => {
|
||||
const maxAge = days * 24 * 60 * 60;
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const applyLang = (lang) => {
|
||||
const next = valid.includes(lang) ? lang : "en";
|
||||
document.documentElement.setAttribute("data-ui-lang", next);
|
||||
document.documentElement.lang = next;
|
||||
document.querySelectorAll("[data-lang-button]").forEach((button) => {
|
||||
button.classList.toggle("is-active", button.getAttribute("data-lang-button") === next);
|
||||
button.setAttribute(
|
||||
"aria-pressed",
|
||||
button.getAttribute("data-lang-button") === next ? "true" : "false",
|
||||
);
|
||||
});
|
||||
writeCookie(COOKIE_NAME, next, 180);
|
||||
window.dispatchEvent(new CustomEvent("dg:lang-change", { detail: { lang: next } }));
|
||||
};
|
||||
|
||||
const initial = readCookie(COOKIE_NAME) || document.documentElement.getAttribute("data-ui-lang") || "en";
|
||||
applyLang(initial);
|
||||
|
||||
document.querySelectorAll("[data-lang-button]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const lang = button.getAttribute("data-lang-button");
|
||||
if (lang) applyLang(lang);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -11,6 +11,80 @@ interface Props {
|
||||
}
|
||||
|
||||
const { slug, label, title, summary, tone, href = "#" } = Astro.props;
|
||||
|
||||
const sectionArt: Record<
|
||||
string,
|
||||
{ src: string; alt: string; eyebrow: string; caption: string }
|
||||
> = {
|
||||
business: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Grand-Place,_Brussels_%2839528722772%29.jpg",
|
||||
alt: "Grand-Place in Brussels.",
|
||||
eyebrow: "Ledger",
|
||||
caption: "Continental deals and operator weather",
|
||||
},
|
||||
education: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Jagiellonian_University_Collegium_Novum%2C_1882_designed_by_Feliks_Ksi%C4%99%C5%BCarski%2C_24_Go%C5%82%C4%99bia_street%2C_Old_Town%2C_Krak%C3%B3w%2C_Poland_%284%29.jpg",
|
||||
alt: "Collegium Novum at Jagiellonian University in Krakow.",
|
||||
eyebrow: "Dossier",
|
||||
caption: "Krakow, memory, and the institutional sublime",
|
||||
},
|
||||
family: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Kongsberg_IMG_0357.JPG",
|
||||
alt: "Riverfront in Kongsberg, Norway.",
|
||||
eyebrow: "Archive",
|
||||
caption: "Private weather by the Norwegian river",
|
||||
},
|
||||
"fun-postings": {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Grand-Place,_Brussels_%2839528722772%29.jpg",
|
||||
alt: "Grand-Place in Brussels at dusk.",
|
||||
eyebrow: "Poster wall",
|
||||
caption: "Flyers, evenings, and elegant side quests",
|
||||
},
|
||||
writing: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Escp-Paris.jpg",
|
||||
alt: "ESCP building in Paris.",
|
||||
eyebrow: "Notebook",
|
||||
caption: "Paris pages with brass in the margins",
|
||||
},
|
||||
"jazz-music": {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Level_42_Kongsberg_Jazzfestival_2017_%28214257%29.jpg",
|
||||
alt: "Performance at Kongsberg Jazzfestival.",
|
||||
eyebrow: "Low light",
|
||||
caption: "Kongsberg nights and disciplined swing",
|
||||
},
|
||||
languages: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Grand-Place,_Brussels_%2839528722772%29.jpg",
|
||||
alt: "Historic buildings in Brussels.",
|
||||
eyebrow: "Register shift",
|
||||
caption: "French, English, Norwegian, and trouble",
|
||||
},
|
||||
"ai-lab": {
|
||||
src: "/images/ai-lab/corpus-grid.svg",
|
||||
alt: "Illustrated knowledge corpus grid.",
|
||||
eyebrow: "Machine room",
|
||||
caption: "Private memory with cited answers",
|
||||
},
|
||||
norway: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Kongsberg_IMG_0357.JPG",
|
||||
alt: "Waterfront in Kongsberg, Norway.",
|
||||
eyebrow: "Field report",
|
||||
caption: "Silver city, civic weather, due process",
|
||||
},
|
||||
projects: {
|
||||
src: "/images/ai-lab/api-flow.svg",
|
||||
alt: "Diagram of a connected API workflow.",
|
||||
eyebrow: "Bench test",
|
||||
caption: "Products, repairs, and live deployments",
|
||||
},
|
||||
cv: {
|
||||
src: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Villanova_University_A_panoramic_shot.jpg",
|
||||
alt: "Panoramic view of Villanova University.",
|
||||
eyebrow: "Record",
|
||||
caption: "A timeline written across cities",
|
||||
},
|
||||
};
|
||||
|
||||
const art = sectionArt[slug];
|
||||
---
|
||||
|
||||
<article class="section-card">
|
||||
@@ -21,6 +95,15 @@ const { slug, label, title, summary, tone, href = "#" } = Astro.props;
|
||||
</div>
|
||||
<span class="section-card__tone">{tone}</span>
|
||||
</div>
|
||||
{art && (
|
||||
<figure class="section-card__art">
|
||||
<img src={art.src} alt={art.alt} loading="lazy" />
|
||||
<figcaption>
|
||||
<span>{art.eyebrow}</span>
|
||||
<strong>{art.caption}</strong>
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
<h3>{title}</h3>
|
||||
<p>{summary}</p>
|
||||
<a href={href} class="section-card__link">Open section</a>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function stripHtml(html) {
|
||||
return html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function excerpt(html, length = 220) {
|
||||
const plain = stripHtml(html);
|
||||
if (plain.length <= length) return plain;
|
||||
return `${plain.slice(0, length).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function LoadingBlock() {
|
||||
return (
|
||||
<section className="writing-live__loading panel">
|
||||
<div className="capsule__kicker">
|
||||
<span>Writing desk</span>
|
||||
<span>Fetching live file</span>
|
||||
</div>
|
||||
<h2>Loading the Boris Vian issue...</h2>
|
||||
<p>The public writing desk is pulling its copy from the live PHP archive.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBlock({ message }) {
|
||||
return (
|
||||
<section className="writing-live__loading panel">
|
||||
<div className="capsule__kicker">
|
||||
<span>Writing desk</span>
|
||||
<span>Archive offline</span>
|
||||
</div>
|
||||
<h2>The writing issue could not be loaded.</h2>
|
||||
<p>{message}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WritingIssue() {
|
||||
const [state, setState] = useState({
|
||||
status: "loading",
|
||||
section: null,
|
||||
feature: null,
|
||||
reading: null,
|
||||
error: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [sectionRes, featureRes, readingRes] = await Promise.all([
|
||||
fetch("/api/sections.php?lang=en&slug=writing", { signal: controller.signal }),
|
||||
fetch("/api/pages.php?lang=en&slug=boris-vian-april-2026", { signal: controller.signal }),
|
||||
fetch("/api/pages.php?lang=en&slug=boris-vian-reading-route", { signal: controller.signal }),
|
||||
]);
|
||||
|
||||
if (!sectionRes.ok || !featureRes.ok || !readingRes.ok) {
|
||||
throw new Error("The CMS endpoints did not respond cleanly.");
|
||||
}
|
||||
|
||||
const sectionJson = await sectionRes.json();
|
||||
const featureJson = await featureRes.json();
|
||||
const readingJson = await readingRes.json();
|
||||
const section = Array.isArray(sectionJson) ? sectionJson[0] : sectionJson;
|
||||
|
||||
if (!section || !featureJson || !readingJson) {
|
||||
throw new Error("The writing issue payload was incomplete.");
|
||||
}
|
||||
|
||||
setState({
|
||||
status: "ready",
|
||||
section,
|
||||
feature: featureJson,
|
||||
reading: readingJson,
|
||||
error: "",
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({
|
||||
status: "error",
|
||||
section: null,
|
||||
feature: null,
|
||||
reading: null,
|
||||
error: error instanceof Error ? error.message : "Unknown loading error.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (state.status === "loading") return <LoadingBlock />;
|
||||
if (state.status === "error") return <ErrorBlock message={state.error} />;
|
||||
|
||||
const { section, feature, reading } = state;
|
||||
const leadArticles = Array.isArray(section.articles) ? section.articles : [];
|
||||
const featureArticle =
|
||||
leadArticles.find((article) => article.title === feature.title) ?? leadArticles[0] ?? null;
|
||||
const readingArticle =
|
||||
leadArticles.find((article) => article.title === reading.title) ?? leadArticles[1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="writing-live">
|
||||
<section className="container writing-live__hero">
|
||||
<div className="writing-live__copy">
|
||||
<span className="eyebrow">{section.headline}</span>
|
||||
<h1>{section.name}</h1>
|
||||
<div
|
||||
className="writing-live__lede"
|
||||
dangerouslySetInnerHTML={{ __html: section.homepage_html }}
|
||||
/>
|
||||
|
||||
<div className="writing-live__notes">
|
||||
<article className="panel writing-live__signal">
|
||||
<span>Lead feature</span>
|
||||
<strong>{feature.title}</strong>
|
||||
<p>{excerpt(feature.content, 210)}</p>
|
||||
</article>
|
||||
|
||||
<article className="panel writing-live__signal">
|
||||
<span>Companion route</span>
|
||||
<strong>{reading.title}</strong>
|
||||
<p>{excerpt(reading.content, 180)}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="panel writing-live__cover">
|
||||
{section.image_url && (
|
||||
<img src={section.image_url} alt={section.photo_description || section.name} loading="eager" />
|
||||
)}
|
||||
<div className="writing-live__cover-copy">
|
||||
<div className="capsule__kicker">
|
||||
<span>April file</span>
|
||||
<span>{feature.category_name || "Writing desk"}</span>
|
||||
</div>
|
||||
<h2>{feature.title}</h2>
|
||||
<p>{excerpt(feature.content, 240)}</p>
|
||||
{section.photo_credit && (
|
||||
<p className="writing-live__credit">Image credit: {section.photo_credit}</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="container writing-live__columns">
|
||||
<article className="panel writing-live__body">
|
||||
<div className="capsule__kicker">
|
||||
<span>Feature essay</span>
|
||||
<span>Boris / Vernon / jazz / Paris</span>
|
||||
</div>
|
||||
<div
|
||||
className="writing-live__rich"
|
||||
dangerouslySetInnerHTML={{ __html: feature.content }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<aside className="writing-live__side">
|
||||
<article className="panel writing-live__card">
|
||||
<div className="capsule__kicker">
|
||||
<span>Reading route</span>
|
||||
<span>Companion piece</span>
|
||||
</div>
|
||||
<div
|
||||
className="writing-live__rich writing-live__rich--compact"
|
||||
dangerouslySetInnerHTML={{ __html: reading.content }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article className="panel writing-live__card">
|
||||
<div className="capsule__kicker">
|
||||
<span>What is live from the CMS</span>
|
||||
<span>{leadArticles.length} article{leadArticles.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<ul className="writing-live__article-list">
|
||||
{leadArticles.map((article) => (
|
||||
<li key={article.id ?? article.title}>
|
||||
<strong>{article.title}</strong>
|
||||
<p>{excerpt(article.body, 150)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="panel writing-live__card">
|
||||
<div className="capsule__kicker">
|
||||
<span>Editorial note</span>
|
||||
<span>Source-backed</span>
|
||||
</div>
|
||||
<p>
|
||||
This desk is now reading from the live PHP and SQL archive. Update the section intro,
|
||||
the Boris feature, the reading route, or the image credits in the CMS, and this page
|
||||
will follow.
|
||||
</p>
|
||||
{featureArticle?.published_at && (
|
||||
<p className="writing-live__meta">Feature published: {featureArticle.published_at}</p>
|
||||
)}
|
||||
{readingArticle?.published_at && (
|
||||
<p className="writing-live__meta">Reading route published: {readingArticle.published_at}</p>
|
||||
)}
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const familyLabFeature = {
|
||||
eyebrow: "Family laboratory / private test issue",
|
||||
title: "A pataphysical family atlas built from one unruly folder and a boy called Dave Jr.",
|
||||
lede:
|
||||
"Not a neutral gallery. Not a cold database. A bright proof-sheet salon for father-and-son evidence, Villanova weather, sleep-heavy domestic grace, and the gentle bureaucratic miracle of naming things before they vanish.",
|
||||
note:
|
||||
"This first pass keeps the captions honest, lets the images breathe, and treats the archive as material for a future family desk rather than a pile of uploads.",
|
||||
};
|
||||
|
||||
export const familyLabStats = [
|
||||
{
|
||||
value: "94",
|
||||
label: "unique frames",
|
||||
note: "The duplicates are folded away so the room reads like an edit, not an export.",
|
||||
},
|
||||
{
|
||||
value: "Dave Jr",
|
||||
label: "central orbit",
|
||||
note: "Your son is the emotional anchor, which is why the named files lead the composition.",
|
||||
},
|
||||
{
|
||||
value: "Moustache era",
|
||||
label: "authenticated",
|
||||
note: "The father-and-son images stay explicit so the archive starts with recognisable truth.",
|
||||
},
|
||||
];
|
||||
|
||||
export const familyLabFeaturedFilenames = [
|
||||
"0meanddave1.jpg",
|
||||
"meAndDave.jpg",
|
||||
"jr_nova.jpg",
|
||||
"villanova1.jpg",
|
||||
"withGrandma.jpg",
|
||||
"fireman.jpg",
|
||||
];
|
||||
|
||||
export const familyLabNotes = [
|
||||
{
|
||||
title: "Poster logic, not gallery logic",
|
||||
body: "The first screen behaves like a cover: one big emotional claim, then a controlled spill of proof.",
|
||||
},
|
||||
{
|
||||
title: "Named frames first",
|
||||
body: "Files with human names carry the warmth, while the Facebook hashes recede into the archive layer.",
|
||||
},
|
||||
{
|
||||
title: "Private by temperament",
|
||||
body: "The design can mature into a family-only desk, but this test page already gives the photographs dignity.",
|
||||
},
|
||||
];
|
||||
|
||||
export const familyLabFilters = [
|
||||
{ key: "all", label: "All frames" },
|
||||
{ key: "named", label: "Named files" },
|
||||
{ key: "dave-jr", label: "Dave Jr" },
|
||||
{ key: "dave", label: "Father and son" },
|
||||
{ key: "villanova", label: "Villanova weather" },
|
||||
{ key: "family", label: "Family orbit" },
|
||||
];
|
||||
@@ -0,0 +1,336 @@
|
||||
export type LocaleCode = "en" | "fr" | "nb";
|
||||
|
||||
export const localeOrder: LocaleCode[] = ["en", "fr", "nb"];
|
||||
|
||||
export const localeMeta: Record<LocaleCode, { label: string; switcher: string; note: string }> = {
|
||||
en: {
|
||||
label: "English",
|
||||
switcher: "EN edition",
|
||||
note: "The interface changes language first. Long-form features stay in their original edition until translated.",
|
||||
},
|
||||
fr: {
|
||||
label: "Francais",
|
||||
switcher: "Cahier FR",
|
||||
note: "L'interface change de langue d'abord. Les longs articles restent dans leur edition d'origine pour l'instant.",
|
||||
},
|
||||
nb: {
|
||||
label: "Norsk bokmal",
|
||||
switcher: "NB utgave",
|
||||
note: "Grensesnittet skifter sprak forst. Langartiklene blir staende i originalutgaven til de er oversatt.",
|
||||
},
|
||||
};
|
||||
|
||||
export const localizedSections: Record<
|
||||
string,
|
||||
Record<LocaleCode, { title: string; tone: string }>
|
||||
> = {
|
||||
business: {
|
||||
en: { title: "Business", tone: "Sharp, practical, anti-buzzword." },
|
||||
fr: { title: "Affaires", tone: "Net, pratique, allergique au jargon." },
|
||||
nb: { title: "Naeringsliv", tone: "Skarpt, praktisk og anti-buzzword." },
|
||||
},
|
||||
education: {
|
||||
en: { title: "Education", tone: "Curious, rigorous, lightly mischievous." },
|
||||
fr: { title: "Etudes", tone: "Curieux, rigoureux, legerement malicieux." },
|
||||
nb: { title: "Utdanning", tone: "Nysgjerrig, grundig og litt rampete." },
|
||||
},
|
||||
family: {
|
||||
en: { title: "Family", tone: "Private-minded, generous, alive." },
|
||||
fr: { title: "Famille", tone: "Prive, genereux, bien vivant." },
|
||||
nb: { title: "Familie", tone: "Privat, varm og levende." },
|
||||
},
|
||||
"fun-postings": {
|
||||
en: { title: "Fun Postings", tone: "Playful, deadpan, collectible." },
|
||||
fr: { title: "Annonces Delicieuses", tone: "Ludique, pince-sans-rire, a collectionner." },
|
||||
nb: { title: "Leken Oppslagstavle", tone: "Leken, torrr og samleverdig." },
|
||||
},
|
||||
writing: {
|
||||
en: { title: "Writing", tone: "Literary, international, smoky." },
|
||||
fr: { title: "Ecriture", tone: "Litteraire, international, fumeux dans le bon sens." },
|
||||
nb: { title: "Skriving", tone: "Litteraer, internasjonal og litt roykfylt." },
|
||||
},
|
||||
"jazz-music": {
|
||||
en: { title: "Jazz and Music", tone: "Velvet, brassy, precise." },
|
||||
fr: { title: "Jazz et Musique", tone: "Velours, cuivre, precision." },
|
||||
nb: { title: "Jazz og Musikk", tone: "Fløyel, messing og presisjon." },
|
||||
},
|
||||
languages: {
|
||||
en: { title: "Languages", tone: "Polyglot, sly, welcoming." },
|
||||
fr: { title: "Langues", tone: "Polyglotte, malin, accueillant." },
|
||||
nb: { title: "Sprak", tone: "Polyglott, lur og inkluderende." },
|
||||
},
|
||||
"ai-lab": {
|
||||
en: { title: "AI Lab", tone: "Forward-looking, grounded, open source friendly." },
|
||||
fr: { title: "Laboratoire IA", tone: "Tourne vers l'avenir, solide, ami de l'open source." },
|
||||
nb: { title: "AI-lab", tone: "Fremtidsrettet, jordnaer og vennlig mot apen kildekode." },
|
||||
},
|
||||
norway: {
|
||||
en: { title: "Norway", tone: "Observant, civic, place-aware." },
|
||||
fr: { title: "Norvege", tone: "Observateur, civique, attentif au lieu." },
|
||||
nb: { title: "Norge", tone: "Observant, samfunnsbevisst og stedsnart." },
|
||||
},
|
||||
projects: {
|
||||
en: { title: "Projects", tone: "Builder energy, clean receipts." },
|
||||
fr: { title: "Projets", tone: "Energie d'atelier, comptes propres." },
|
||||
nb: { title: "Prosjekter", tone: "Byggerenergi og ryddige spor." },
|
||||
},
|
||||
cv: {
|
||||
en: { title: "CV", tone: "Professional, legible, confident." },
|
||||
fr: { title: "CV", tone: "Professionnel, lisible, assure." },
|
||||
nb: { title: "CV", tone: "Profesjonell, lesbar og trygg." },
|
||||
},
|
||||
"family-lab": {
|
||||
en: { title: "Family Lab", tone: "Private archive, atlas, memory room." },
|
||||
fr: { title: "Laboratoire Familial", tone: "Archive privee, atlas, chambre de memoire." },
|
||||
nb: { title: "Familielab", tone: "Privat arkiv, atlas og minnerom." },
|
||||
},
|
||||
};
|
||||
|
||||
type ChromeContext = {
|
||||
activeSlug: string;
|
||||
issueDate: Record<LocaleCode, string>;
|
||||
articleKey?: "norway" | "jazz" | "projects" | null;
|
||||
};
|
||||
|
||||
const articleLabels: Record<string, Record<LocaleCode, { label: string; note: string }>> = {
|
||||
norway: {
|
||||
en: { label: "Article / Norway desk", note: "Field report / family life / fathers / immigrants" },
|
||||
fr: { label: "Article / Cahier Norvege", note: "Reportage / famille / peres / immigration" },
|
||||
nb: { label: "Artikkel / Norge-desk", note: "Feltrapport / familieliv / fedre / innvandring" },
|
||||
},
|
||||
jazz: {
|
||||
en: { label: "Article / Jazz and Music", note: "Field report / Kongsberg x Paris" },
|
||||
fr: { label: "Article / Jazz et Musique", note: "Reportage / Kongsberg x Paris" },
|
||||
nb: { label: "Artikkel / Jazz og Musikk", note: "Feltrapport / Kongsberg x Paris" },
|
||||
},
|
||||
projects: {
|
||||
en: { label: "Article / Projects desk", note: "Field report / music trivia / Blue Note Rhino / April build" },
|
||||
fr: { label: "Article / Cahier Projets", note: "Reportage / quiz musical / Blue Note Rhino / chantier d'avril" },
|
||||
nb: { label: "Artikkel / Prosjekt-desk", note: "Feltrapport / musikktrivia / Blue Note Rhino / aprilbygg" },
|
||||
},
|
||||
};
|
||||
|
||||
export function getChromeCopy({ activeSlug, issueDate, articleKey }: ChromeContext) {
|
||||
const currentSection = localizedSections[activeSlug];
|
||||
const article = articleKey ? articleLabels[articleKey] : null;
|
||||
const sectionOrder = {
|
||||
business: "01",
|
||||
education: "02",
|
||||
family: "03",
|
||||
"fun-postings": "04",
|
||||
writing: "05",
|
||||
"jazz-music": "06",
|
||||
languages: "07",
|
||||
"ai-lab": "08",
|
||||
norway: "09",
|
||||
projects: "10",
|
||||
cv: "11",
|
||||
"family-lab": "12",
|
||||
} as const;
|
||||
const sectionNumber = sectionOrder[activeSlug as keyof typeof sectionOrder];
|
||||
|
||||
return {
|
||||
ribbonIssue: {
|
||||
en: `Founding issue / ${issueDate.en}`,
|
||||
fr: `Numero fondateur / ${issueDate.fr}`,
|
||||
nb: `Grunnutgave / ${issueDate.nb}`,
|
||||
},
|
||||
ribbonNote: currentSection
|
||||
? {
|
||||
en: currentSection.en.tone,
|
||||
fr: currentSection.fr.tone,
|
||||
nb: currentSection.nb.tone,
|
||||
}
|
||||
: article
|
||||
? {
|
||||
en: article.en.note,
|
||||
fr: article.fr.note,
|
||||
nb: article.nb.note,
|
||||
}
|
||||
: {
|
||||
en: "Ringwood / Villanova / Brussels / Paris / Krakow / Oslo / Kongsberg",
|
||||
fr: "Ringwood / Villanova / Bruxelles / Paris / Cracovie / Oslo / Kongsberg",
|
||||
nb: "Ringwood / Villanova / Brussel / Paris / Krakow / Oslo / Kongsberg",
|
||||
},
|
||||
issueLabel: currentSection
|
||||
? {
|
||||
en: `Section ${sectionNumber} / ${currentSection.en.title}`,
|
||||
fr: `Section ${sectionNumber} / ${currentSection.fr.title}`,
|
||||
nb: `Seksjon ${sectionNumber} / ${currentSection.nb.title}`,
|
||||
}
|
||||
: article
|
||||
? {
|
||||
en: article.en.label,
|
||||
fr: article.fr.label,
|
||||
nb: article.nb.label,
|
||||
}
|
||||
: {
|
||||
en: "Founding issue / Personal edition",
|
||||
fr: "Numero fondateur / Edition personnelle",
|
||||
nb: "Grunnutgave / Personlig utgave",
|
||||
},
|
||||
mastheadLine: {
|
||||
en: "Jazz desk / machine room / family archive / pataphysical bulletin",
|
||||
fr: "Cahier jazz / salle des machines / archive familiale / bulletin pataphysique",
|
||||
nb: "Jazzdesk / maskinrom / familiearkiv / pataphysisk bulletin",
|
||||
},
|
||||
domainPrompt: {
|
||||
en: "Private AI. Jazz rooms. Civic weather. Pataphysical field notes.",
|
||||
fr: "IA privee. Salles de jazz. Meteo civique. Notes de terrain pataphysiques.",
|
||||
nb: "Privat AI. Jazzrom. Samfunnsvaer. Pataphysiske feltnotater.",
|
||||
},
|
||||
domainLede: {
|
||||
en: "A bright retro paper for machine rooms, multilingual weather, Norway, family rights, live culture, and the deliberate misuse of the impossible.",
|
||||
fr: "Un journal retro-lumineux pour les salles des machines, le temps multilingue, la Norvege, les droits familiaux, la culture vivante et l'usage delibere de l'impossible.",
|
||||
nb: "Et lyst retroblad for maskinrom, flerspraklig vaer, Norge, familierett, levende kultur og bevisst misbruk av det umulige.",
|
||||
},
|
||||
languageNote: {
|
||||
en: localeMeta.en.note,
|
||||
fr: localeMeta.fr.note,
|
||||
nb: localeMeta.nb.note,
|
||||
},
|
||||
footerHeadline: {
|
||||
en: "Jazz desk, machine room, civic archive, and practical impossibility.",
|
||||
fr: "Cahier jazz, salle des machines, archive civique et impossibilite pratique.",
|
||||
nb: "Jazzdesk, maskinrom, samfunnsarkiv og praktisk umulighet.",
|
||||
},
|
||||
footerBody: {
|
||||
en: "Built in Astro with React islands, backed by PHP and SQL, and edited like a proper newspaper rather than a consultant PDF with lipstick.",
|
||||
fr: "Construit avec Astro et des ilots React, soutenu par PHP et SQL, puis edite comme un vrai journal plutot qu'un PDF de conseil maquille.",
|
||||
nb: "Bygget i Astro med React-oyer, drevet av PHP og SQL, og redigert som en ordentlig avis i stedet for en konsulent-PDF med leppestift.",
|
||||
},
|
||||
footerNoteLeft: {
|
||||
en: "Astro + React islands + PHP + SQL",
|
||||
fr: "Astro + ilots React + PHP + SQL",
|
||||
nb: "Astro + React-oyer + PHP + SQL",
|
||||
},
|
||||
footerNoteRight: {
|
||||
en: "Blue Note Logic / Gilligan TECH / Kongsberg / multilingual edition",
|
||||
fr: "Blue Note Logic / Gilligan TECH / Kongsberg / edition multilingue",
|
||||
nb: "Blue Note Logic / Gilligan TECH / Kongsberg / flerspraklig utgave",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const footerCallouts = {
|
||||
blueNoteLogic: {
|
||||
href: "https://bluenotelogic.com/",
|
||||
label: {
|
||||
en: "Blue Note Logic",
|
||||
fr: "Blue Note Logic",
|
||||
nb: "Blue Note Logic",
|
||||
},
|
||||
title: {
|
||||
en: "Private AI and document intelligence with a real memory.",
|
||||
fr: "IA privee et intelligence documentaire avec une vraie memoire.",
|
||||
nb: "Privat AI og dokumentintelligens med ekte hukommelse.",
|
||||
},
|
||||
body: {
|
||||
en: "Blue Note Logic frames AI as owned infrastructure: strategy, deployment, document intelligence, and source-aware systems that keep working knowledge inside the firm.",
|
||||
fr: "Blue Note Logic traite l'IA comme une infrastructure possedee : strategie, deploiement, intelligence documentaire et systemes cites a la source qui gardent la connaissance dans l'entreprise.",
|
||||
nb: "Blue Note Logic behandler AI som eid infrastruktur: strategi, utrulling, dokumentintelligens og kildebevisste systemer som holder kunnskapen i virksomheten.",
|
||||
},
|
||||
},
|
||||
gilliganTech: {
|
||||
href: "https://gilligan.tech/",
|
||||
label: {
|
||||
en: "Gilligan TECH",
|
||||
fr: "Gilligan TECH",
|
||||
nb: "Gilligan TECH",
|
||||
},
|
||||
title: {
|
||||
en: "Kongsberg-side systems engineering for Nordic SMBs.",
|
||||
fr: "Ingenierie de systemes depuis Kongsberg pour les PME nordiques.",
|
||||
nb: "Systemingeniorarbeid fra Kongsberg for nordiske SMB-er.",
|
||||
},
|
||||
body: {
|
||||
en: "Gilligan Tech is the Norwegian operating arm: AI audits, system builds, fractional CTO work, and sovereign European delivery for companies that want results before theatre.",
|
||||
fr: "Gilligan Tech est le bras norvegien d'operation : audits IA, constructions de systemes, travail de CTO fractionnaire et livraison souveraine europeenne pour les entreprises qui veulent des resultats avant le theatre.",
|
||||
nb: "Gilligan Tech er den norske operasjonsarmen: AI-audits, systembygging, fractional CTO-arbeid og suveren europeisk levering for selskaper som vil ha resultater foran teater.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const policyLinkCopy = {
|
||||
privacy: {
|
||||
href: "/privacy",
|
||||
title: {
|
||||
en: "Privacy policy",
|
||||
fr: "Politique de confidentialite",
|
||||
nb: "Personvern",
|
||||
},
|
||||
body: {
|
||||
en: "How the site handles accounts, family access, forms, and first-party data.",
|
||||
fr: "Comment le site gere les comptes, l'acces familial, les formulaires et les donnees de premiere main.",
|
||||
nb: "Hvordan nettstedet handterer kontoer, familieadgang, skjemaer og forstepartsdata.",
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
href: "/cookies",
|
||||
title: {
|
||||
en: "Cookie policy",
|
||||
fr: "Politique de cookies",
|
||||
nb: "Cookiepolicy",
|
||||
},
|
||||
body: {
|
||||
en: "Essential-first consent, optional analytics later, and external embeds only after permission.",
|
||||
fr: "Consentement essential d'abord, analyses optionnelles ensuite, et contenus externes seulement apres permission.",
|
||||
nb: "Forst essensielle cookies, valgfri analyse senere, og eksterne innbygginger bare etter samtykke.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const cookieBannerCopy = {
|
||||
eyebrow: {
|
||||
en: "Privacy desk / local build",
|
||||
fr: "Cahier vie privee / build local",
|
||||
nb: "Personverndesk / lokal bygg",
|
||||
},
|
||||
title: {
|
||||
en: "This paper keeps optional tracking turned off until you say yes.",
|
||||
fr: "Ce journal laisse le pistage optionnel eteint tant que vous ne dites pas oui.",
|
||||
nb: "Denne avisen holder valgfri sporing av til du sier ja.",
|
||||
},
|
||||
body: {
|
||||
en: "Essential cookies keep sign-in, family access, and language choice working. Analytics and future third-party embeds stay off by default.",
|
||||
fr: "Les cookies essentiels maintiennent la connexion, l'acces familial et le choix de langue. Les analyses et les futurs contenus tiers restent desactives par defaut.",
|
||||
nb: "Nodvendige cookies holder innlogging, familieadgang og sprakvalg i gang. Analyse og fremtidige tredjepartsinnbygginger er av som standard.",
|
||||
},
|
||||
acceptAll: {
|
||||
en: "Accept all",
|
||||
fr: "Tout accepter",
|
||||
nb: "Godta alt",
|
||||
},
|
||||
essentialOnly: {
|
||||
en: "Essential only",
|
||||
fr: "Essentiels seulement",
|
||||
nb: "Bare nodvendige",
|
||||
},
|
||||
customize: {
|
||||
en: "Customize",
|
||||
fr: "Personnaliser",
|
||||
nb: "Tilpass",
|
||||
},
|
||||
save: {
|
||||
en: "Save choices",
|
||||
fr: "Enregistrer les choix",
|
||||
nb: "Lagre valgene",
|
||||
},
|
||||
categories: {
|
||||
essential: {
|
||||
en: "Essential site operations",
|
||||
fr: "Fonctions essentielles du site",
|
||||
nb: "Essensiell drift",
|
||||
},
|
||||
analytics: {
|
||||
en: "Anonymous analytics later",
|
||||
fr: "Analyses anonymes plus tard",
|
||||
nb: "Anonym analyse senere",
|
||||
},
|
||||
embeds: {
|
||||
en: "External media embeds",
|
||||
fr: "Integrations medias externes",
|
||||
nb: "Eksterne medieinnbygginger",
|
||||
},
|
||||
},
|
||||
};
|
||||
+51
-8
@@ -27,6 +27,10 @@ export type VentureSignal = {
|
||||
strap: string;
|
||||
summary: string;
|
||||
href: string;
|
||||
imageSrc: string;
|
||||
imageAlt: string;
|
||||
imageNote: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export type CollageImage = {
|
||||
@@ -54,9 +58,10 @@ export const routeStops: RouteStop[] = [
|
||||
{ place: "Columbia, South Carolina", note: "business school orbit" },
|
||||
{ place: "Paris, France", note: "international MBA" },
|
||||
{ place: "Washington, DC", note: "capital interval" },
|
||||
{ place: "New York", note: "city tempo" },
|
||||
{ place: "Manhattan, New York", note: "city tempo" },
|
||||
{ place: "Hamilton, Bermuda", note: "Atlantic detour" },
|
||||
{ place: "the Midwest", note: "American middle distance" },
|
||||
{ place: "Washington, DC", note: "federal coda" },
|
||||
{ place: "Brooklyn, New York", note: "borough voltage" },
|
||||
{ place: "Krakow, Poland", note: "EU studies" },
|
||||
{ place: "Oslo, Norway", note: "Nordic transition" },
|
||||
{ place: "Kongsberg, Norway", note: "current desk" },
|
||||
@@ -113,11 +118,27 @@ export const ventureDesk: Venture[] = [
|
||||
|
||||
export const ventureSignals: VentureSignal[] = [
|
||||
{
|
||||
name: "Trivia & Tunes",
|
||||
strap: "live-hosted games and music-led connection",
|
||||
name: "Blue Note Logic",
|
||||
strap: "private AI, document intelligence, and source-cited memory",
|
||||
summary:
|
||||
"A cultural desk for knowledge, playlists, rooms full of people, and the social engineering of a good night.",
|
||||
href: "https://triviaandtunes.com/",
|
||||
"The machine room behind the paper: owned infrastructure, private corpora, multilingual controls, and AI that keeps its receipts.",
|
||||
href: "https://ai.bluenotelogic.com/",
|
||||
imageSrc: "/images/ai-lab/hero-lab.svg",
|
||||
imageAlt: "Illustrated AI lab diagram for Blue Note Logic.",
|
||||
imageNote: "Private corpus / cited answers / EU hosting",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "Trivia & Tunes",
|
||||
strap: "live-hosted games, music rounds, and true AI in the loop",
|
||||
summary:
|
||||
"A culture product with venue instincts: playlists, game craft, AI grading, and voice features edging toward the microphone.",
|
||||
href: "https://triviaandtunes.no/",
|
||||
imageSrc:
|
||||
"https://commons.wikimedia.org/wiki/Special:Redirect/file/Level_42_Kongsberg_Jazzfestival_2017_%28214257%29.jpg",
|
||||
imageAlt: "Crowd-facing stage image from Kongsberg Jazzfestival.",
|
||||
imageNote: "Live rooms / music energy / quiz-night voltage",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "Do Better Norge",
|
||||
@@ -125,6 +146,22 @@ export const ventureSignals: VentureSignal[] = [
|
||||
summary:
|
||||
"A civic and advocacy desk grounded in primary sources, practical guidance, and a refusal to treat children as procedural debris.",
|
||||
href: "https://dobetternorge.no/",
|
||||
imageSrc: "https://commons.wikimedia.org/wiki/Special:Redirect/file/Kongsberg_IMG_0357.JPG",
|
||||
imageAlt: "Riverfront view in Kongsberg, Norway.",
|
||||
imageNote: "Norway desk / family life / civic weather",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "School dossier",
|
||||
strap: "Brussels, Krakow, Paris, Villanova, and the long route north",
|
||||
summary:
|
||||
"The education issue turns institutions into chapters, cities into footnotes, and degrees into a proper migration story.",
|
||||
href: "/education",
|
||||
imageSrc:
|
||||
"https://commons.wikimedia.org/wiki/Special:Redirect/file/Grand-Place,_Brussels_%2839528722772%29.jpg",
|
||||
imageAlt: "Grand-Place in Brussels.",
|
||||
imageNote: "Brussels / multilingual weather / first European chapter",
|
||||
external: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -218,7 +255,7 @@ export const aiLabSources: SourceCredit[] = [
|
||||
},
|
||||
{
|
||||
label: "Trivia & Tunes",
|
||||
url: "https://triviaandtunes.com/",
|
||||
url: "https://triviaandtunes.no/",
|
||||
note:
|
||||
"Used for the live-hosted trivia and music angle on the homepage culture desk.",
|
||||
},
|
||||
@@ -226,6 +263,12 @@ export const aiLabSources: SourceCredit[] = [
|
||||
label: "Do Better Norge",
|
||||
url: "https://dobetternorge.no/",
|
||||
note:
|
||||
"Used for the advocacy and children’s-rights positioning on the homepage civic desk.",
|
||||
"Used for the advocacy and children's-rights positioning on the homepage civic desk.",
|
||||
},
|
||||
{
|
||||
label: "Wikimedia Commons",
|
||||
url: "https://commons.wikimedia.org/",
|
||||
note:
|
||||
"Used for the public-domain and openly licensed city, campus, and festival images woven through the homepage and section cards.",
|
||||
},
|
||||
];
|
||||
|
||||
+30
-26
@@ -21,12 +21,12 @@ export type SchoolDossier = {
|
||||
};
|
||||
|
||||
export const hero = {
|
||||
kicker: "Blue Note Logic presents",
|
||||
title: "Dave Gilligan, a literary jazz magazine disguised as a personal site.",
|
||||
kicker: "Pataphysical bulletin / Kongsberg edition",
|
||||
title: "A high-tech newsmagazine disguised as one man's improbable paper trail.",
|
||||
lede:
|
||||
"Writing, technology, music, languages, family history, consulting, and a little applied pataphysics, arranged as a bright editorial salon rather than a brochure.",
|
||||
"Private AI, jazz basements, multilingual weather, family rights, and systems work from Ringwood to Kongsberg, edited with equal parts brass, evidence, and deliberate mischief.",
|
||||
sublede:
|
||||
"Built for essays, dossiers, dispatches, experiments, job postings, and AI-assisted editions that can move between English, Norwegian, and French without losing their mood.",
|
||||
"Inside this issue: Blue Note Logic in the machine room, Gilligan Tech in the field, Do Better Norge in the civic file, Trivia & Tunes in the live-wire culture pages, and school dossiers written like contraband literature.",
|
||||
};
|
||||
|
||||
export const launchSections: LaunchSection[] = [
|
||||
@@ -34,7 +34,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "business",
|
||||
label: "01",
|
||||
title: "Business",
|
||||
summary: "Consulting notes, operator essays, client stories, and strategy with sleeves rolled up.",
|
||||
summary: "Consulting notes, AI architecture, and operator essays for people who prefer working systems to rented theater.",
|
||||
tone: "Sharp, practical, anti-buzzword.",
|
||||
strap: "Operator studies for adults who are tired of consultant vapor.",
|
||||
coverline: "The anti-buzzword ledger.",
|
||||
@@ -56,7 +56,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "family",
|
||||
label: "03",
|
||||
title: "Family",
|
||||
summary: "A warmer archive for memory, milestones, and the people who keep the music human.",
|
||||
summary: "A warmer archive for memory, milestones, and the private weather that keeps the machinery worth running.",
|
||||
tone: "Private-minded, generous, alive.",
|
||||
strap: "The soft archive, still edited like it matters.",
|
||||
coverline: "Domestic front pages.",
|
||||
@@ -67,7 +67,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "fun-postings",
|
||||
label: "04",
|
||||
title: "Fun Postings",
|
||||
summary: "Odd notices, cultural flyers, side projects, and delightfully unnecessary announcements.",
|
||||
summary: "Odd notices, cultural flyers, side projects, and the sort of elegant nonsense that deserves proper typesetting.",
|
||||
tone: "Playful, deadpan, collectible.",
|
||||
strap: "The classified page gets strange and starts wearing cologne.",
|
||||
coverline: "Useful nonsense, neatly set.",
|
||||
@@ -78,18 +78,22 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "writing",
|
||||
label: "05",
|
||||
title: "Writing",
|
||||
summary: "Features, columns, notebooks, and dispatches for readers who like style with backbone.",
|
||||
summary: "This month's writing desk runs on Boris Vian: novels with trapdoors, Vernon Sullivan weather, Saint-Germain smoke, and bibliography arranged like a contraband route.",
|
||||
tone: "Literary, international, smoky.",
|
||||
strap: "Columns with brass in the lungs and data in the pockets.",
|
||||
coverline: "Smoke, syntax, and reportage.",
|
||||
motif: "Essays, dispatches, notebook pages.",
|
||||
samples: ["On systems and sorrow", "Nordic field notes", "Paris after the spreadsheet"],
|
||||
strap: "A low-lit file on Boris Vian, jazz syntax, and the exact science of glorious exception.",
|
||||
coverline: "Boris Vian in the side door.",
|
||||
motif: "Novels, jazz, pataphysics, counterfeit signatures, and Paris after midnight.",
|
||||
samples: [
|
||||
"The engineer of exceptions",
|
||||
"Five doors into Boris Vian",
|
||||
"Why Saint-Germain still leaks into the prose",
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "jazz-music",
|
||||
label: "06",
|
||||
title: "Jazz and Music",
|
||||
summary: "Listening notes, deep cuts, rhythm studies, and the low-lit logic of serious groove.",
|
||||
summary: "Listening notes, Kongsberg nights, Caveau memories, and the low-lit logic of serious groove.",
|
||||
tone: "Velvet, brassy, precise.",
|
||||
strap: "For records, rooms, and players who understand that rhythm is governance.",
|
||||
coverline: "Blue notes and side doors.",
|
||||
@@ -100,7 +104,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "languages",
|
||||
label: "07",
|
||||
title: "Languages",
|
||||
summary: "Translation, vocabulary, cross-border humor, and the pleasures of switching registers.",
|
||||
summary: "English, French, and Norwegian switching places without losing the joke, the seduction, or the filing detail.",
|
||||
tone: "Polyglot, sly, welcoming.",
|
||||
strap: "A section for mistranslation, seduction, and grammatical diplomacy.",
|
||||
coverline: "The multilingual cabinet.",
|
||||
@@ -111,7 +115,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "ai-lab",
|
||||
label: "08",
|
||||
title: "AI Lab",
|
||||
summary: "Built-in tools, experiments, prompt systems, and practical machine intelligence with taste.",
|
||||
summary: "Private corpora, cited answers, multilingual agents, and practical machine intelligence with actual memory.",
|
||||
tone: "Forward-looking, grounded, open source friendly.",
|
||||
strap: "Machine intelligence without the conference lanyard.",
|
||||
coverline: "The atelier for useful futures.",
|
||||
@@ -122,7 +126,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "norway",
|
||||
label: "09",
|
||||
title: "Norway",
|
||||
summary: "Kongsberg dispatches, civic notes, local texture, and Scandinavian reality at street level.",
|
||||
summary: "Kongsberg dispatches, civic reporting, immigrant-family realities, and Norwegian life observed without brochure language.",
|
||||
tone: "Observant, civic, place-aware.",
|
||||
strap: "A local paper for one town and several realities.",
|
||||
coverline: "Kongsberg, correctly observed.",
|
||||
@@ -133,7 +137,7 @@ export const launchSections: LaunchSection[] = [
|
||||
slug: "projects",
|
||||
label: "10",
|
||||
title: "Projects",
|
||||
summary: "Things launched, repaired, modernized, or imagined into being across code, content, and data.",
|
||||
summary: "Things launched, repaired, modernized, or made slightly dangerous across code, content, venues, and data.",
|
||||
tone: "Builder energy, clean receipts.",
|
||||
strap: "The workshop floor, but art directed.",
|
||||
coverline: "Built, fixed, and shipped.",
|
||||
@@ -207,21 +211,21 @@ export const schoolDossiers: SchoolDossier[] = [
|
||||
];
|
||||
|
||||
export const fieldNotes = [
|
||||
"AI-backed edition controls for English, Norwegian, and French.",
|
||||
"A magazine structure ready for essays, archives, member access, and dossiers.",
|
||||
"A future-facing front end sitting on top of PHP, SQL, and role-based permissions.",
|
||||
"Blue Note Logic keeps the machine room full of private AI, cited answers, and document intelligence that behaves like evidence instead of theater.",
|
||||
"Trivia & Tunes is now a living product story: venue-grade quiz nights, real AI in the game loop, and voice layers warming up for live testing.",
|
||||
"Do Better Norge keeps the civic file open on family life, immigrant fathers, due process, and the legal weather in contemporary Norway.",
|
||||
];
|
||||
|
||||
export const editorialPromises = [
|
||||
"Keep the light background and the page breathable.",
|
||||
"Make AI visible as a craft tool, not a gimmick.",
|
||||
"Treat the CV, the essays, and the family archive with equal design seriousness.",
|
||||
"Keep the paper light, breathable, and a little dangerous around the edges.",
|
||||
"Make AI visible as a craft tool, a newsroom instrument, and never a plastic gimmick.",
|
||||
"Treat the CV, the jazz notebook, the civic archive, and the family pages with equal design seriousness.",
|
||||
];
|
||||
|
||||
export const coverLines = [
|
||||
"A literary technology salon with jazz smoke in the margins.",
|
||||
"Five school dossiers, each signed with a clearly counterfeit blessing.",
|
||||
"AI in the machinery, not sprayed on top like fresh cologne.",
|
||||
"A pataphysical field paper with jazz smoke in the margins and SQL under the floorboards.",
|
||||
"Five school dossiers, each signed with a clearly counterfeit blessing and a straight face.",
|
||||
"AI in the machinery, not sprayed on top like fresh conference cologne.",
|
||||
];
|
||||
|
||||
export function getSectionHref(section: LaunchSection) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import CookieBanner from "../components/CookieBanner.astro";
|
||||
import LocaleCopy from "../components/LocaleCopy.astro";
|
||||
import LocaleSwitcher from "../components/LocaleSwitcher.astro";
|
||||
import SectionMark from "../components/SectionMark.astro";
|
||||
import { getSectionHref, launchSections } from "../data/site";
|
||||
import { footerCallouts, getChromeCopy, localizedSections, policyLinkCopy } from "../data/locales";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
@@ -15,47 +19,42 @@ const {
|
||||
lang = "en",
|
||||
} = Astro.props;
|
||||
|
||||
const issueDate = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date());
|
||||
const now = new Date();
|
||||
const issueDate = {
|
||||
en: new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(now),
|
||||
fr: new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(now),
|
||||
nb: new Intl.DateTimeFormat("nb-NO", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(now),
|
||||
};
|
||||
|
||||
const pathname = Astro.url.pathname.replace(/\/+$/, "") || "/";
|
||||
const activeSlug = pathname === "/" ? "home" : pathname.split("/").filter(Boolean)[0];
|
||||
const primarySlugs = ["business", "education", "writing", "jazz-music", "ai-lab", "norway"];
|
||||
const primaryNav = launchSections.filter((section) => primarySlugs.includes(section.slug));
|
||||
const footerNav = launchSections.filter((section) => !primarySlugs.includes(section.slug));
|
||||
const currentSection = launchSections.find((section) => section.slug === activeSlug);
|
||||
const articleMeta = pathname.startsWith("/articles/norway")
|
||||
? {
|
||||
label: "Article / Norway desk",
|
||||
note: "Field report / family life / fathers / immigrants",
|
||||
}
|
||||
const articleKey = pathname.startsWith("/articles/norway")
|
||||
? "norway"
|
||||
: pathname.startsWith("/articles/kongsberg-jazz-2026")
|
||||
? {
|
||||
label: "Article / Jazz and Music",
|
||||
note: "Field report / Kongsberg x Paris",
|
||||
}
|
||||
? "jazz"
|
||||
: pathname.startsWith("/articles/trivia-and-tunes-april-2026")
|
||||
? {
|
||||
label: "Article / Projects desk",
|
||||
note: "Field report / music trivia / Blue Note Rhino / April build",
|
||||
}
|
||||
: null;
|
||||
const issueLabel = currentSection
|
||||
? `Section ${currentSection.label} / ${currentSection.title}`
|
||||
: articleMeta
|
||||
? articleMeta.label
|
||||
: "Founding issue / Personal edition";
|
||||
const ribbonNote = currentSection?.tone
|
||||
?? (articleMeta
|
||||
? articleMeta.note
|
||||
: "Ringwood / Villanova / Brussels / Paris / Krakow / Oslo / Kongsberg");
|
||||
? "projects"
|
||||
: null;
|
||||
const chromeCopy = getChromeCopy({ activeSlug, issueDate, articleKey });
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={lang}>
|
||||
<html lang={lang} data-ui-lang="en" data-cookie-analytics="false" data-cookie-embeds="false">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -74,28 +73,31 @@ const ribbonNote = currentSection?.tone
|
||||
<body>
|
||||
<header class="site-ribbon">
|
||||
<div class="container site-ribbon__row">
|
||||
<span>Founding issue / {issueDate}</span>
|
||||
<span>{ribbonNote}</span>
|
||||
<LocaleCopy copy={chromeCopy.ribbonIssue} />
|
||||
<LocaleCopy copy={chromeCopy.ribbonNote} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="site-masthead">
|
||||
<div class="container masthead">
|
||||
<div class="masthead__row">
|
||||
<span>{issueLabel}</span>
|
||||
<span>Jazz desk / machine room / family archive / pataphysical bulletin</span>
|
||||
<LocaleCopy copy={chromeCopy.issueLabel} />
|
||||
<LocaleCopy copy={chromeCopy.mastheadLine} />
|
||||
</div>
|
||||
|
||||
<a href="/" class="masthead__brand masthead__brand--site">
|
||||
<p class="masthead__domain">davegilligan.com</p>
|
||||
<strong>Dave Gilligan</strong>
|
||||
<p class="masthead__prompt">Hybrid IT. Private AI. Jazz rooms. Literary weather.</p>
|
||||
<p class="masthead__prompt">
|
||||
<LocaleCopy copy={chromeCopy.domainPrompt} />
|
||||
</p>
|
||||
<span>
|
||||
A personal site edited like a bright retro paper: systems, music, languages, Norway,
|
||||
civic weather, and useful mischief under one masthead.
|
||||
<LocaleCopy copy={chromeCopy.domainLede} />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<LocaleSwitcher />
|
||||
|
||||
<nav class="site-nav" aria-label="Primary sections">
|
||||
{primaryNav.map((section) => (
|
||||
<a
|
||||
@@ -103,7 +105,15 @@ const ribbonNote = currentSection?.tone
|
||||
class={`site-nav__link ${activeSlug === section.slug ? "site-nav__link--active" : ""}`}
|
||||
>
|
||||
<SectionMark slug={section.slug} className="section-mark--nav" />
|
||||
<span>{section.title}</span>
|
||||
<span>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: localizedSections[section.slug]?.en.title ?? section.title,
|
||||
fr: localizedSections[section.slug]?.fr.title ?? section.title,
|
||||
nb: localizedSections[section.slug]?.nb.title ?? section.title,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
@@ -116,27 +126,63 @@ const ribbonNote = currentSection?.tone
|
||||
<div class="container site-footer__top">
|
||||
<div class="site-footer__brand">
|
||||
<p>davegilligan.com</p>
|
||||
<strong>Jazz desk, machine room, and civic archive.</strong>
|
||||
<span>
|
||||
Built in Astro with React islands, backed by PHP and SQL, and art-directed like a
|
||||
magazine instead of a brochure.
|
||||
</span>
|
||||
<strong><LocaleCopy copy={chromeCopy.footerHeadline} /></strong>
|
||||
<span><LocaleCopy copy={chromeCopy.footerBody} /></span>
|
||||
</div>
|
||||
|
||||
<div class="footer-links">
|
||||
{footerNav.map((section) => (
|
||||
<a href={getSectionHref(section)}>
|
||||
<SectionMark slug={section.slug} className="section-mark--footer" />
|
||||
<span>{section.title}</span>
|
||||
<span>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: localizedSections[section.slug]?.en.title ?? section.title,
|
||||
fr: localizedSections[section.slug]?.fr.title ?? section.title,
|
||||
nb: localizedSections[section.slug]?.nb.title ?? section.title,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container site-footer__callouts">
|
||||
<a class="footer-callout" href={footerCallouts.blueNoteLogic.href} target="_blank" rel="noreferrer">
|
||||
<p class="footer-callout__label">
|
||||
<LocaleCopy copy={footerCallouts.blueNoteLogic.label} />
|
||||
</p>
|
||||
<strong><LocaleCopy copy={footerCallouts.blueNoteLogic.title} /></strong>
|
||||
<span><LocaleCopy copy={footerCallouts.blueNoteLogic.body} /></span>
|
||||
</a>
|
||||
|
||||
<a class="footer-callout" href={footerCallouts.gilliganTech.href} target="_blank" rel="noreferrer">
|
||||
<p class="footer-callout__label">
|
||||
<LocaleCopy copy={footerCallouts.gilliganTech.label} />
|
||||
</p>
|
||||
<strong><LocaleCopy copy={footerCallouts.gilliganTech.title} /></strong>
|
||||
<span><LocaleCopy copy={footerCallouts.gilliganTech.body} /></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="container site-footer__policies">
|
||||
<a class="policy-link" href={policyLinkCopy.privacy.href}>
|
||||
<strong><LocaleCopy copy={policyLinkCopy.privacy.title} /></strong>
|
||||
<span><LocaleCopy copy={policyLinkCopy.privacy.body} /></span>
|
||||
</a>
|
||||
<a class="policy-link" href={policyLinkCopy.cookies.href}>
|
||||
<strong><LocaleCopy copy={policyLinkCopy.cookies.title} /></strong>
|
||||
<span><LocaleCopy copy={policyLinkCopy.cookies.body} /></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="container footer-note">
|
||||
<span>Astro + React islands + PHP + SQL</span>
|
||||
<span>Blue Note Logic / Gilligan TECH / Kongsberg / multilingual edition</span>
|
||||
<span><LocaleCopy copy={chromeCopy.footerNoteLeft} /></span>
|
||||
<span><LocaleCopy copy={chromeCopy.footerNoteRight} /></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<CookieBanner />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-127
@@ -1,136 +1,13 @@
|
||||
---
|
||||
import { launchSections } from "../data/site";
|
||||
import { aiLabSources, routeStops, ventureDesk, ventureSignals } from "../data/profile";
|
||||
import BusinessIssue from "../components/BusinessIssue.jsx";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
|
||||
const business = launchSections.find((section) => section.slug === "business");
|
||||
const [gilliganTech, blueNoteLogic] = ventureDesk;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Business Desk | Dave Gilligan"
|
||||
description="A source-backed business desk for Gilligan TECH ENK, Blue Note Logic Inc, and the surrounding portfolio of cultural and civic projects."
|
||||
description="A live business desk fed from the CMS: agentic AI, labour, Queneau, Ionesco, and the anti-buzzword management of imaginary solutions."
|
||||
>
|
||||
<main class="issue-page">
|
||||
<section class="container issue-hero">
|
||||
<div class="issue-hero__copy">
|
||||
<span class="eyebrow">Section {business?.label} / business desk</span>
|
||||
<h1>The work ledger, but dressed like a front page.</h1>
|
||||
<p class="issue-hero__lede">
|
||||
Consulting, product work, AI infrastructure, live cultural formats, and advocacy all
|
||||
belong here because the operating style is the same: build useful things, keep the
|
||||
language clean, and make sure the system still stands up once the meeting ends.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="panel issue-hero__note">
|
||||
<div class="capsule__kicker">
|
||||
<span>Editorial posture</span>
|
||||
<span>{business?.tone}</span>
|
||||
</div>
|
||||
<h2>{business?.coverline}</h2>
|
||||
<p>{business?.strap}</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="container route-marquee">
|
||||
{routeStops.slice(0, 8).map((stop) => (
|
||||
<span>
|
||||
<strong>{stop.place}</strong>
|
||||
<em>{stop.note}</em>
|
||||
</span>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="container venture-grid">
|
||||
{[gilliganTech, blueNoteLogic].map((venture) => (
|
||||
<article class="panel venture-card">
|
||||
<div class="venture-card__meta">
|
||||
<span>{venture.years}</span>
|
||||
<span>{venture.role}</span>
|
||||
<span>{venture.location}</span>
|
||||
</div>
|
||||
<p class="venture-card__label">{venture.label}</p>
|
||||
<h2>{venture.name}</h2>
|
||||
<p class="venture-card__summary">{venture.summary}</p>
|
||||
<p class="venture-card__detail">{venture.detail}</p>
|
||||
|
||||
<ul class="venture-card__list">
|
||||
{venture.highlights.map((item) => (
|
||||
<li>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p class="venture-card__source">
|
||||
Source:
|
||||
<a href={venture.source.url} target="_blank" rel="noreferrer">{venture.source.label}</a>
|
||||
</p>
|
||||
<p class="venture-card__source-note">{venture.source.note}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="container signal-strip">
|
||||
{ventureSignals.map((signal) => (
|
||||
<a class="signal-strip__card" href={signal.href} target="_blank" rel="noreferrer">
|
||||
<span>{signal.strap}</span>
|
||||
<strong>{signal.name}</strong>
|
||||
<p>{signal.summary}</p>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="container issue-columns">
|
||||
<article class="panel issue-columns__main">
|
||||
<div class="capsule__kicker">
|
||||
<span>Working thesis</span>
|
||||
<span>Hybrid desk</span>
|
||||
</div>
|
||||
<p>
|
||||
Gilligan Tech is the local operating desk: close to clients, close to constraints, and
|
||||
comfortable with the practical mess of real organizations. Blue Note Logic is the wider
|
||||
lab: document intelligence, private corpora, AI services, and the harder technical
|
||||
infrastructure needed to own outcomes instead of renting them.
|
||||
</p>
|
||||
<p>
|
||||
Around that core, Trivia & Tunes proves the live-hosted entertainment and room-energy
|
||||
side of the profile, while Do Better Norge carries the children's-rights and civic
|
||||
seriousness that keeps the whole publication from turning into mere aesthetics.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="panel issue-columns__side">
|
||||
<div class="capsule__kicker">
|
||||
<span>Next issue</span>
|
||||
<span>AI Lab</span>
|
||||
</div>
|
||||
<h2>Read the machine room.</h2>
|
||||
<p>
|
||||
The AI Lab issue turns the Blue Note Logic and Gilligan Tech material into a dedicated
|
||||
product and infrastructure story, with credited sources and a more technical editorial
|
||||
voice.
|
||||
</p>
|
||||
<a class="button button--dark" href="/ai-lab">Open AI Lab</a>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="container source-block">
|
||||
<div class="section-header">
|
||||
<div class="section-header__title">Source Notes</div>
|
||||
<div class="section-header__meta">
|
||||
Business copy on this page is paraphrased from official venture sites and labeled so the
|
||||
editorial voice stays distinct from the source material.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-list">
|
||||
{aiLabSources.slice(0, 2).map((source) => (
|
||||
<article>
|
||||
<a href={source.url} target="_blank" rel="noreferrer">{source.label}</a>
|
||||
<p>{source.note}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<main class="business-page">
|
||||
<BusinessIssue client:load />
|
||||
</main>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
import LocaleCopy from "../components/LocaleCopy.astro";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Cookie Desk | Dave Gilligan"
|
||||
description="Draft cookie notice for the localhost multilingual build, covering essential cookies, optional analytics, and future embedded media consent."
|
||||
>
|
||||
<main class="policy-page">
|
||||
<section class="policy-hero container">
|
||||
<p class="eyebrow eyebrow--cover">
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Cookie desk / consent before ornament",
|
||||
fr: "Cahier cookies / consentement avant l'ornement",
|
||||
nb: "Cookiedesk / samtykke for pynt",
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<h1>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Essential first. Optional later.",
|
||||
fr: "Essentiel d'abord. Optionnel ensuite.",
|
||||
nb: "Nodvendig forst. Valgfritt senere.",
|
||||
}}
|
||||
/>
|
||||
</h1>
|
||||
<p class="policy-hero__lede">
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "The local build now uses a consent pattern meant for a proper EU launch: essential cookies can run immediately, while analytics and third-party embeds stay off until a visitor chooses otherwise.",
|
||||
fr: "La version locale utilise desormais un schema de consentement pense pour un vrai lancement europeen : les cookies essentiels peuvent fonctionner immediatement, tandis que les analyses et les integrations tierces restent desactivees tant que le visiteur ne choisit pas autre chose.",
|
||||
nb: "Den lokale byggen bruker na et samtykkemonster laget for en ordentlig EU-lansering: essensielle cookies kan kjore med en gang, mens analyse og tredjepartsinnbygginger forblir av til en besokende velger noe annet.",
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="container policy-grid">
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "What runs without asking", fr: "Ce qui fonctionne sans demander", nb: "Hva som kan kjore uten a sporre" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "Only the minimum needed for the site to function: sign-in state, role-protected family access, and remembered interface language. These are first-party functions tied to operating the service.",
|
||||
fr: "Seulement le minimum necessaire au fonctionnement du site : etat de connexion, acces familial protege par role et langue d'interface memorisee. Ce sont des fonctions de premiere main liees au service lui-meme.",
|
||||
nb: "Bare minimumet som trengs for at nettstedet skal fungere: innloggingsstatus, rollebeskyttet familieadgang og husket grensesnittsprak. Dette er forstepartsfunksjoner knyttet til selve tjenesten.",
|
||||
}} /></p>
|
||||
</article>
|
||||
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "What stays off by default", fr: "Ce qui reste desactive par defaut", nb: "Hva som forblir avslatt som standard" }} /></h2>
|
||||
<ul>
|
||||
<li><LocaleCopy copy={{
|
||||
en: "Anonymous analytics, until the visitor actively accepts them.",
|
||||
fr: "Les analyses anonymes, jusqu'a acceptation active du visiteur.",
|
||||
nb: "Anonym analyse, til besokende aktivt godtar det.",
|
||||
}} /></li>
|
||||
<li><LocaleCopy copy={{
|
||||
en: "Third-party media embeds such as external players, maps, or social widgets.",
|
||||
fr: "Les integrations medias tierces comme les lecteurs externes, cartes ou widgets sociaux.",
|
||||
nb: "Tredjeparts medieinnbygginger som eksterne spillere, kart eller sosiale widgets.",
|
||||
}} /></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "Current cookie model", fr: "Modele actuel de cookies", nb: "Dagens cookiemodell" }} /></h2>
|
||||
<ul>
|
||||
<li><strong>`dg_ui_lang`</strong>: <LocaleCopy copy={{
|
||||
en: "stores the preferred interface language for up to 180 days.",
|
||||
fr: "memorise la langue d'interface preferee pendant 180 jours maximum.",
|
||||
nb: "lagrer foretrukket grensesnittsprak i opptil 180 dager.",
|
||||
}} /></li>
|
||||
<li><strong>`dg_cookie_preferences`</strong>: <LocaleCopy copy={{
|
||||
en: "stores the visitor's consent choices for up to 180 days.",
|
||||
fr: "memorise les choix de consentement du visiteur pendant 180 jours maximum.",
|
||||
nb: "lagrer besokendes samtykkevalg i opptil 180 dager.",
|
||||
}} /></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "How the controls should behave", fr: "Comment les controles doivent se comporter", nb: "Hvordan kontrollene skal oppfore seg" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "Refusing optional cookies should be as easy as accepting them. Optional tools should remain dormant until consent exists. The site should keep a readable record of what each category is for before anything decorative or analytical is switched on.",
|
||||
fr: "Refuser les cookies optionnels doit etre aussi simple que les accepter. Les outils optionnels doivent rester dormants tant que le consentement n'existe pas. Le site doit garder une description lisible de l'utilite de chaque categorie avant d'activer quoi que ce soit de decoratif ou analytique.",
|
||||
nb: "Det skal vaere like enkelt a avvise valgfrie cookies som a godta dem. Valgfrie verktoy skal forbli sovende til samtykke finnes. Nettstedet bor ha en lesbar forklaring pa hva hver kategori er til for for noe dekorativt eller analytisk slas pa.",
|
||||
}} /></p>
|
||||
</article>
|
||||
|
||||
<article class="policy-block policy-block--full">
|
||||
<h2><LocaleCopy copy={{ en: "Before public launch", fr: "Avant lancement public", nb: "For offentlig lansering" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "This page is still a draft. Before deployment we should review every script, font, embed, analytics tool, and cookie lifetime against the final production stack, then update the policy and banner copy to match the exact live behavior.",
|
||||
fr: "Cette page reste un brouillon. Avant de deployer, il faudra verifier chaque script, police, integration, outil d'analyse et duree de vie des cookies par rapport a la pile de production finale, puis mettre a jour la politique et la banniere pour correspondre au comportement reel.",
|
||||
nb: "Denne siden er fortsatt et utkast. For utrulling bor vi ga gjennom hvert script, hver font, hver innbygging, hvert analyseverktoy og hver cookielevetid mot den endelige produksjonsstakken, og deretter oppdatere policyen og bannerteksten slik at de matcher den faktiske adferden.",
|
||||
}} /></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import FamilyAtlas from "../components/FamilyAtlas.jsx";
|
||||
import {
|
||||
familyLabFeature,
|
||||
familyLabFeaturedFilenames,
|
||||
familyLabFilters,
|
||||
familyLabNotes,
|
||||
familyLabStats,
|
||||
} from "../data/family-lab";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
|
||||
const manifestPath = path.join(process.cwd(), "public", "images", "family-lab", "facebook-060426", "manifest.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
|
||||
const photos = manifest.map((photo) => ({
|
||||
filename: photo.filename,
|
||||
src: photo.src,
|
||||
title: photo.title,
|
||||
description: photo.description,
|
||||
tags: photo.tags ?? [],
|
||||
namedFile: Boolean(photo.named_file),
|
||||
}));
|
||||
|
||||
const photosByFilename = new Map(photos.map((photo) => [photo.filename, photo]));
|
||||
const featuredPhotos = familyLabFeaturedFilenames
|
||||
.map((filename) => photosByFilename.get(filename))
|
||||
.filter(Boolean);
|
||||
const namedPhotos = photos.filter((photo) => photo.namedFile).slice(0, 12);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Family Laboratory | Dave Gilligan"
|
||||
description="A bright pataphysical family-atlas test page built from the Facebook archive, with a custom editorial collage and a full lightbox for the cleaned photo set."
|
||||
>
|
||||
<main class="family-lab-page">
|
||||
<section class="container family-lab-hero">
|
||||
<div class="family-lab-hero__copy">
|
||||
<span class="eyebrow">{familyLabFeature.eyebrow}</span>
|
||||
<h1>{familyLabFeature.title}</h1>
|
||||
<p class="family-lab-hero__lede">{familyLabFeature.lede}</p>
|
||||
|
||||
<div class="family-lab-hero__actions">
|
||||
<a class="button button--dark" href="#atlas">Open the atlas</a>
|
||||
<a class="button button--soft" href="#named-reel">See named frames</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="family-lab-hero__montage" aria-label="Featured family collage">
|
||||
{featuredPhotos.slice(0, 4).map((photo, index) => (
|
||||
<figure class={`family-lab-hero__card family-lab-hero__card--${index + 1}`}>
|
||||
<img src={photo.src} alt={photo.title} loading="eager" />
|
||||
<figcaption>
|
||||
<span>{photo.title}</span>
|
||||
<small>{photo.description}</small>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="container family-lab-manifesto">
|
||||
<article class="panel family-lab-manifesto__note">
|
||||
<div class="capsule__kicker">
|
||||
<span>Visual thesis</span>
|
||||
<span>Light paper / domestic cinema / archival swagger</span>
|
||||
</div>
|
||||
<p>{familyLabFeature.note}</p>
|
||||
</article>
|
||||
|
||||
<div class="family-lab-stats">
|
||||
{familyLabStats.map((item) => (
|
||||
<article>
|
||||
<strong>{item.value}</strong>
|
||||
<span>{item.label}</span>
|
||||
<p>{item.note}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container family-lab-notes">
|
||||
{familyLabNotes.map((note) => (
|
||||
<article class="family-lab-notes__item">
|
||||
<h2>{note.title}</h2>
|
||||
<p>{note.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="container family-lab-strip" id="named-reel">
|
||||
<div class="section-header section-header--tight">
|
||||
<div class="section-header__title">Named Frames</div>
|
||||
<div class="section-header__meta">
|
||||
The filenames with actual human memory in them get the first clean spread.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="family-lab-strip__rail">
|
||||
{namedPhotos.map((photo) => (
|
||||
<figure class="family-lab-strip__frame">
|
||||
<img src={photo.src} alt={photo.title} loading="lazy" />
|
||||
<figcaption>
|
||||
<strong>{photo.title}</strong>
|
||||
<span>{photo.description}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container family-lab-atlas-shell">
|
||||
<div class="section-header">
|
||||
<div class="section-header__title">Archive Atlas</div>
|
||||
<div class="section-header__meta">
|
||||
Browse the cleaned test corpus, switch filters, and open any frame into a full-screen reading room.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FamilyAtlas client:load photos={photos} featured={featuredPhotos} filters={familyLabFilters} />
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
+27
-11
@@ -11,6 +11,7 @@ import {
|
||||
} from "../data/profile";
|
||||
import {
|
||||
editorialPromises,
|
||||
fieldNotes,
|
||||
getSectionHref,
|
||||
hero,
|
||||
launchSections,
|
||||
@@ -41,8 +42,8 @@ const projects = launchSections.find((section) => section.slug === "projects");
|
||||
<p class="cover-hero__sublede">{hero.sublede}</p>
|
||||
|
||||
<div class="cover-hero__actions">
|
||||
<a class="button button--dark" href="#venture-desk">Open the cover story</a>
|
||||
<a class="button button--soft" href="/ai-lab">Read the AI Lab issue</a>
|
||||
<a class="button button--dark" href="#venture-desk">Enter the front page</a>
|
||||
<a class="button button--soft" href="/ai-lab">Read the machine-room issue</a>
|
||||
</div>
|
||||
|
||||
<div class="cover-hero__billboard">
|
||||
@@ -85,12 +86,19 @@ const projects = launchSections.find((section) => section.slug === "projects");
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class="container frontline">
|
||||
{fieldNotes.map((note) => (
|
||||
<span>{note}</span>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section id="venture-desk" class="container venture-desk">
|
||||
<div class="section-header section-header--tight">
|
||||
<div class="section-header__title">Four Desks, One Signature</div>
|
||||
<div class="section-header__meta">
|
||||
The new front page is organized around the recurring energies of the work: consultancy,
|
||||
AI infrastructure, live cultural formats, and children's-rights advocacy.
|
||||
This front page is not a brochure. It is the running file on the real occupations:
|
||||
private AI, field consulting, jazz-adjacent live products, advocacy, and the long
|
||||
European afterglow of the schools.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,10 +129,17 @@ const projects = launchSections.find((section) => section.slug === "projects");
|
||||
|
||||
<div class="signal-deck">
|
||||
{ventureSignals.map((signal) => (
|
||||
<a class="signal-deck__card" href={signal.href} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
class="signal-deck__card"
|
||||
href={signal.href}
|
||||
target={signal.external ? "_blank" : undefined}
|
||||
rel={signal.external ? "noreferrer" : undefined}
|
||||
>
|
||||
<img src={signal.imageSrc} alt={signal.imageAlt} loading="lazy" />
|
||||
<span>{signal.strap}</span>
|
||||
<strong>{signal.name}</strong>
|
||||
<p>{signal.summary}</p>
|
||||
<small>{signal.imageNote}</small>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -139,13 +154,13 @@ const projects = launchSections.find((section) => section.slug === "projects");
|
||||
<p class="ai-observatory__eyebrow">Special report / useful futures</p>
|
||||
<h2>Private AI, cited answers, and machine intelligence with a memory.</h2>
|
||||
<p>
|
||||
The AI Lab issue is the technical heart of the site: private corpora, document
|
||||
intelligence, multilingual edition controls, and deployment paths that stay close to the
|
||||
evidence instead of drifting into vendor theatre.
|
||||
The AI Lab issue is where the machinery stops posing and starts working: private corpora,
|
||||
document intelligence, multilingual controls, and deployment paths that stay close to the
|
||||
evidence instead of wandering off into vendor theatre.
|
||||
</p>
|
||||
<div class="ai-observatory__actions">
|
||||
<a class="button button--dark" href="/ai-lab">Enter the AI Lab</a>
|
||||
<a class="button button--soft" href="/business">See the business desk</a>
|
||||
<a class="button button--soft" href="/projects">See the April builds</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -174,8 +189,9 @@ const projects = launchSections.find((section) => section.slug === "projects");
|
||||
<div class="section-header section-header--tight">
|
||||
<div class="section-header__title">Elsewhere In The Paper</div>
|
||||
<div class="section-header__meta">
|
||||
These sections stay distinct in tone, but the site now treats them like desks inside one
|
||||
publication rather than equally weighted navigation blocks.
|
||||
Every desk has its own rhythm, but the page keeps them under one masthead: less menu,
|
||||
more newsroom, with clippings, notebooks, civic files, and machine-room traces in plain
|
||||
sight.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
import LocaleCopy from "../components/LocaleCopy.astro";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Privacy Desk | Dave Gilligan"
|
||||
description="Draft privacy notice for the local multilingual edition, covering accounts, family access, contact handling, and editorial infrastructure."
|
||||
>
|
||||
<main class="policy-page">
|
||||
<section class="policy-hero container">
|
||||
<p class="eyebrow eyebrow--cover">
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Privacy desk / draft for local review",
|
||||
fr: "Cahier confidentialite / brouillon pour revue locale",
|
||||
nb: "Personverndesk / utkast for lokal gjennomgang",
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<h1>
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "Privacy first, theatre second.",
|
||||
fr: "La vie privee d'abord, le theatre ensuite.",
|
||||
nb: "Personvern forst, teater etterpa.",
|
||||
}}
|
||||
/>
|
||||
</h1>
|
||||
<p class="policy-hero__lede">
|
||||
<LocaleCopy
|
||||
copy={{
|
||||
en: "This is the working privacy notice for the localhost multilingual build. It explains how the site should handle accounts, private family areas, contact flows, and first-party operational data before we ship the public version.",
|
||||
fr: "Voici la notice de confidentialite de travail pour la version multilingue sur localhost. Elle explique comment le site doit gerer les comptes, les espaces familiaux prives, les formulaires de contact et les donnees operationnelles de premiere main avant la mise en ligne publique.",
|
||||
nb: "Dette er arbeidsutgaven av personvernerklaeringen for den flerspraklige localhost-byggen. Den forklarer hvordan nettstedet skal handtere kontoer, private familieomrader, kontaktskjemaer og operasjonelle forstepartsdata for offentlig lansering.",
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="container policy-grid">
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "Who is in the room", fr: "Qui est dans la piece", nb: "Hvem som er i rommet" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "davegilligan.com operates as an editorial and professional site linked to Blue Note Logic and Gilligan TECH. Blue Note Logic covers private AI, document intelligence, and knowledge systems. Gilligan TECH covers Norwegian systems work, architecture, and delivery.",
|
||||
fr: "davegilligan.com fonctionne comme site editorial et professionnel lie a Blue Note Logic et Gilligan TECH. Blue Note Logic couvre l'IA privee, l'intelligence documentaire et les systemes de connaissance. Gilligan TECH couvre le travail de systemes en Norvege, l'architecture et la livraison.",
|
||||
nb: "davegilligan.com fungerer som et redaksjonelt og profesjonelt nettsted koblet til Blue Note Logic og Gilligan TECH. Blue Note Logic dekker privat AI, dokumentintelligens og kunnskapssystemer. Gilligan TECH dekker norsk systemarbeid, arkitektur og leveranse.",
|
||||
}} /></p>
|
||||
</article>
|
||||
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "What data this site may process", fr: "Quelles donnees le site peut traiter", nb: "Hvilke data nettstedet kan behandle" }} /></h2>
|
||||
<ul>
|
||||
<li><LocaleCopy copy={{
|
||||
en: "Account information for approved users, including role-based access for admin and family areas.",
|
||||
fr: "Informations de compte pour les utilisateurs approuves, avec acces fonde sur les roles pour l'administration et la famille.",
|
||||
nb: "Kontoinformasjon for godkjente brukere, med rollebasert tilgang til admin- og familieomrader.",
|
||||
}} /></li>
|
||||
<li><LocaleCopy copy={{
|
||||
en: "Messages sent through contact forms, email links, or private family workflows.",
|
||||
fr: "Messages envoyes via les formulaires de contact, les liens email ou les flux familiaux prives.",
|
||||
nb: "Meldinger sendt via kontaktskjema, e-postlenker eller private familieprosesser.",
|
||||
}} /></li>
|
||||
<li><LocaleCopy copy={{
|
||||
en: "Technical data needed for security, sign-in, and audit trails in the PHP and SQL backend.",
|
||||
fr: "Donnees techniques necessaires a la securite, a la connexion et aux traces d'audit dans le backend PHP et SQL.",
|
||||
nb: "Tekniske data som trengs for sikkerhet, innlogging og sporbarhet i PHP- og SQL-bakenden.",
|
||||
}} /></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "Why the site processes data", fr: "Pourquoi le site traite des donnees", nb: "Hvorfor nettstedet behandler data" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "The working purposes are straightforward: to publish the site, respond to legitimate enquiries, protect the admin and family sections, and keep the system secure and auditable.",
|
||||
fr: "Les finalites de travail sont simples : publier le site, repondre aux demandes legitimes, proteger l'administration et la section famille, et garder le systeme sur et auditable.",
|
||||
nb: "Arbeidsformalene er enkle: publisere nettstedet, svare pa legitime henvendelser, beskytte admin- og familieseksjonene og holde systemet sikkert og sporbar.",
|
||||
}} /></p>
|
||||
</article>
|
||||
|
||||
<article class="policy-block">
|
||||
<h2><LocaleCopy copy={{ en: "Family and private access", fr: "Acces famille et prive", nb: "Familie- og privattilgang" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "Family areas should be available only to approved users with the family role. Private areas should stay limited to admin-level users. That distinction is now part of the backend role model and should remain visible in the privacy logic as the frontend grows.",
|
||||
fr: "Les zones famille doivent etre reservees aux utilisateurs approuves ayant le role famille. Les zones privees doivent rester limitees aux utilisateurs de niveau administrateur. Cette distinction fait maintenant partie du modele de roles du backend et doit rester visible dans la logique de confidentialite pendant la croissance du frontend.",
|
||||
nb: "Familieomrader skal bare vaere tilgjengelige for godkjente brukere med familierollen. Private omrader skal forbli begrenset til administratorniva. Dette skillet er na en del av rollemodellen i bakenden og bor forbli synlig i personvernlogikken etter hvert som frontenden vokser.",
|
||||
}} /></p>
|
||||
</article>
|
||||
|
||||
<article class="policy-block policy-block--full">
|
||||
<h2><LocaleCopy copy={{ en: "Rights and review", fr: "Droits et relecture", nb: "Rettigheter og gjennomgang" }} /></h2>
|
||||
<p><LocaleCopy copy={{
|
||||
en: "Before public deployment, this draft should be tightened into a formal privacy notice with contact details, retention periods, lawful bases, and a final list of processors. The intended rights model includes access, correction, deletion, restriction, objection, and portability where applicable.",
|
||||
fr: "Avant tout deploiement public, ce brouillon doit devenir une notice formelle avec coordonnees, durees de conservation, bases legales et liste finale des sous-traitants. Le modele de droits vise comprend l'acces, la rectification, l'effacement, la limitation, l'opposition et la portabilite lorsque cela s'applique.",
|
||||
nb: "For offentlig lansering bor dette utkastet skjerpes til en formell personvernerklaering med kontaktopplysninger, lagringstider, behandlingsgrunnlag og en endelig liste over databehandlere. Den tiltenkte rettighetsmodellen omfatter innsyn, retting, sletting, begrensning, protest og dataportabilitet der det gjelder.",
|
||||
}} /></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import WritingIssue from "../components/WritingIssue.jsx";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Writing Desk | Dave Gilligan"
|
||||
description="A live writing desk fed from the PHP archive: Boris Vian, Vernon Sullivan weather, jazz syntax, and pataphysical essays."
|
||||
>
|
||||
<main class="writing-page">
|
||||
<WritingIssue client:load />
|
||||
</main>
|
||||
</BaseLayout>
|
||||
+1596
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user