/* ============================================================
   Store — in-memory "database" + provider, persisted to localStorage.
   Simulates the API the real site would talk to.
   ============================================================ */
const { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback } = React;

const DB_KEY = "cr_site_db_v12";

/* ---- section business types (Database Design Document v16 §5) ----
   A section's TYPE drives which content fields are pre-filled and how a piece
   renders. The legacy per-content `kind` is kept as the storage key; `type`
   is the v16 business type chosen at section creation.
     MULTIMODAL      → kind 'blog'      — rich article (text + alternative media)
     VIDEO           → kind 'video'     — a video
     IMAGE_GALLERY   → kind 'image'     — one piece = a gallery of images
     DOCUMENT_LIBRARY→ kind 'document'  — one piece = several documents (PDF)
     AUDIO_LIBRARY   → kind 'sound'     — one piece = an album of sounds        */
const SECTION_TYPES = {
  multimodal: { id: "multimodal", kind: "blog",     icon: "edit",       label: "Multimodal blog",  labelFr: "Blog multimodal",     blurb: "Rich articles — text, human & AI voice, music, video, illustrations." },
  video:      { id: "video",      kind: "video",    icon: "play",       label: "Videos",           labelFr: "Vidéos",             blurb: "A collection of videos (YouTube, other link, or upload)." },
  image:      { id: "image",      kind: "image",    icon: "compass",    label: "Image gallery",    labelFr: "Galerie d'images",   blurb: "Each piece is a gallery of images — cover + arrows, or auto-switch." },
  document:   { id: "document",   kind: "document", icon: "doc",        label: "Document library", labelFr: "Bibliothèque de documents", blurb: "Each piece holds several documents, each read in a PDF popup." },
  audio:      { id: "audio",      kind: "sound",    icon: "headphones", label: "Audio library",    labelFr: "Bibliothèque audio", blurb: "Podcasts, music & voice — each piece is an album of sounds with a player." },
};
const SECTION_TYPE_ORDER = ["multimodal", "video", "image", "document", "audio"];
/* legacy kinds → v16 business type */
function sectionTypeOf(s) {
  const k = (s && s.kind) || "blog";
  if (k === "blog") return "multimodal";
  if (k === "video") return "video";
  if (k === "image") return "image";
  if (k === "document") return "document";
  return "audio"; // podcast | music | audio | sound
}
function sectionTypeMeta(s) { return SECTION_TYPES[sectionTypeOf(s)] || SECTION_TYPES.multimodal; }
const AUDIO_KINDS = ["podcast", "music", "audio", "sound"];
function isAudioContentKind(k) { return AUDIO_KINDS.includes(k); }

const LEGAL_SEED = {
  mentions: {
    title: "Legal notice",
    body: "Site publisher: Cannelle Richter.\nContact: hello@cannellerichter.fr\n\nHosting: provided by a third-party hosting service (details on request).\n\nThis personal website presents the work, projects and writing of Cannelle Richter. All content is published under her own responsibility.",
    fallback: "fallback",
    tr: { fr: { title: "Mentions légales", body: "Éditrice du site : Cannelle Richter.\nContact : hello@cannellerichter.fr\n\nHébergement : assuré par un prestataire tiers (détails sur demande).\n\nCe site personnel présente les travaux, projets et écrits de Cannelle Richter. Tout le contenu est publié sous sa propre responsabilité." } },
  },
  copyright: {
    title: "Copyright",
    body: "© Cannelle Richter. All original articles, videos, illustrations and code on this site are the property of their author unless stated otherwise.\n\nYou may quote short excerpts with a clear link back. For any other reuse, please get in touch first.",
    fallback: "fallback",
    tr: { fr: { title: "Droits d'auteur", body: "© Cannelle Richter. Tous les articles, vidéos, illustrations et codes originaux de ce site sont la propriété de leur autrice, sauf mention contraire.\n\nVous pouvez citer de courts extraits avec un lien clair vers la source. Pour tout autre réemploi, merci de me contacter au préalable." } },
  },
  cgu: {
    title: "Terms of use (CGU)",
    body: "By using cannellerichter.fr you agree to browse it in good faith.\n\n1. Content is provided for information and inspiration; it comes with no warranty.\n2. External links are shared in good faith — their content is not under our control.\n3. Forms (contact, feedback, CV request) collect only the data you choose to send, used solely to reply to you.\n\nThese terms may evolve; the current version always applies.",
    fallback: "fallback",
    tr: { fr: { title: "Conditions d'utilisation (CGU)", body: "En utilisant cannellerichter.fr, vous acceptez de le consulter de bonne foi.\n\n1. Le contenu est fourni à titre d'information et d'inspiration ; il est sans garantie.\n2. Les liens externes sont partagés de bonne foi — leur contenu échappe à notre contrôle.\n3. Les formulaires (contact, feedback, demande de CV) ne collectent que les données que vous choisissez d'envoyer, utilisées uniquement pour vous répondre.\n\nCes conditions peuvent évoluer ; la version en vigueur s'applique toujours." } },
  },
};

/* editable copy for the simple static form pages (Contact / Feedback).
   EN = base fields, FR = tr.fr, with the same fallback model as content. */
const PAGES_SEED = {
  contact: {
    kicker: "", title: "Contact me", subtitle: "A question, a project, a collaboration? Drop me a line.",
    nameFirst: "First name", nameLast: "Last name", email: "Email", message: "Message", btn: "Send",
    fallback: "fallback",
    tr: { fr: { title: "Me contacter", subtitle: "Une question, un projet, une collaboration ? Écrivez-moi.", nameFirst: "Prénom", nameLast: "Nom", email: "E-mail", message: "Message", btn: "Envoyer" } },
  },
  feedback: {
    kicker: "", title: "Give feedback", subtitle: "Tell me what works, what doesn't, what you'd love to see.",
    name: "Name", rating: "Rating", message: "Message", btn: "Send feedback",
    fallback: "fallback",
    tr: { fr: { title: "Donner un feedback", subtitle: "Dites-moi ce qui marche, ce qui cloche, ce que vous aimeriez voir.", name: "Nom", rating: "Note", message: "Message", btn: "Envoyer le feedback" } },
  },
};

/* ---- roles & permissions (mirrors the backend user_access_grants model) ----
   admin    — can do everything (content, settings, users, permissions)
   editor   — edit DRAFT content & untranslated languages; no settings / users / publish / delete
   reviewer — see private + draft content, comment & give feedback; cannot edit/publish/delete
   tester   — same rights as guest, but also previews PRE-RELEASED (in-review) public pages
   guest    — view the private content shared with them, give feedback                       */
const ROLES = {
  admin:    { id: "admin",    label: "Admin",    color: "#2f6bdb", blurb: "Full access — content, settings, users & permissions.",
    can: { edit: true,  settings: true,  users: true,  publish: true,  delete: true,  board: true,  feedback: false, review: false, prerelease: true } },
  editor:   { id: "editor",   label: "Editor",   color: "#1f9e72", blurb: "Edit drafts and untranslated content. No settings, users or publishing.",
    can: { edit: true,  settings: false, users: false, publish: false, delete: false, board: false, feedback: false, review: false, prerelease: true } },
  reviewer: { id: "reviewer", label: "Reviewer", color: "#7a4fe0", blurb: "See private & draft work, comment and validate pieces in review. Cannot publish.",
    can: { edit: false, settings: false, users: false, publish: false, delete: false, board: false, feedback: true,  review: true,  prerelease: true } },
  publisher:{ id: "publisher",label: "Publisher",color: "#c0398a", blurb: "Publish validated content and schedule a go-live date. Cannot publish private pages.",
    can: { edit: false, settings: false, users: false, publish: true,  delete: false, board: true,  feedback: true,  review: false, prerelease: true } },
  tester:   { id: "tester",   label: "Tester",   color: "#0e9aa7", blurb: "Preview published & pre-released (in-review) public pages, plus the private content shared with you. Leaves feedback.",
    can: { edit: false, settings: false, users: false, publish: false, delete: false, board: false, feedback: true,  review: false, prerelease: true } },
  guest:    { id: "guest",    label: "Guest",    color: "#e08a1e", blurb: "View the private content shared with you and leave feedback.",
    can: { edit: false, settings: false, users: false, publish: false, delete: false, board: false, feedback: true,  review: false, prerelease: false } },
};
function roleMeta(r) { return ROLES[r] || ROLES.guest; }
function roleCan(r, perm) { const m = ROLES[r]; return !!(m && m.can[perm]); }

/* ---- editorial workflow: a piece moves Draft → Review needed → Validated → Published ----
   editor    : draft ⇄ review        (can flag "ready", never validate or publish)
   reviewer  : review → validated OR review → draft (kick back) + leave feedback
   publisher : validated → published (schedule a date) OR validated → review (kick back)
   admin     : free movement across all four                                    */
const WORKFLOW = {
  draft:     { id: "draft",     label: "Draft",         short: "Draft",     color: "#8a8478", ink: "#5f5b51", wash: "#f1efe9", note: "In progress — hidden from the site." },
  review:    { id: "review",    label: "Review needed", short: "In review", color: "#d97706", ink: "#b56b08", wash: "#fdf3e3", note: "Finished by the editor — waiting for a reviewer to validate." },
  validated: { id: "validated", label: "Validated",     short: "Validated", color: "#2f6bdb", ink: "#1f4fb0", wash: "#eaf0fc", note: "Approved by a reviewer — ready for a publisher to push live." },
  published: { id: "published", label: "Published",     short: "Published",  color: "#1f9e72", ink: "#137a57", wash: "#e3f5ee", note: "Live on the site." },
};
const WF_ORDER = ["draft", "review", "validated", "published"];
function wfMeta(s) { return WORKFLOW[s] || WORKFLOW.draft; }
/* status of any content/page object, derived from the legacy `published` flag when absent */
function statusOf(o) { return (o && o.status) || (o && o.published === false ? "draft" : "published"); }
/* ---- per-language editorial workflow ----
   The workflow is UNIQUE PER LANGUAGE: a piece can be Published in EN while still
   In review (or Draft) in FR. statusByLang holds {en, fr}; statusOf() stays the
   language-agnostic legacy view (used by the board & coarse counts). */
function statusOfLang(o, lang) {
  if (o && o.statusByLang && o.statusByLang[lang]) return o.statusByLang[lang];
  return statusOf(o);
}
function publishedInLang(o, lang) { return statusOfLang(o, lang) === "published"; }
/* who may SEE a given workflow status while browsing publicly (i.e. not inside a
   review/edit mode): everyone sees Published; testers & admins also see the
   PRE-RELEASED (in-review / validated) public pages; Draft is never public. */
function canSeeStatus(role, status) {
  if (status === "published") return true;
  if (status === "review" || status === "validated") return role === "tester" || role === "admin" || role === "publisher";
  return false;
}
/* which target statuses a role may move a piece TO, given its current status */
function allowedStatusTransitions(role, cur) {
  if (role === "admin") return WF_ORDER.filter(s => s !== cur);
  if (role === "editor") return cur === "draft" ? ["review"] : cur === "review" ? ["draft"] : [];
  if (role === "reviewer") return cur === "review" ? ["validated", "draft"] : cur === "validated" ? ["review"] : [];
  // a publisher never moves a piece on the workflow RAIL: validated→published happens
  // only through the explicit "Publish" button (which schedules a go-live), and a publisher
  // can never send a piece back to review. The rail is therefore indication-only for them.
  if (role === "publisher") return [];
  return [];
}

function seedUsers() {
  return [
    { id: "u_cannelle", name: "Cannelle Richter", first: "Cannelle", email: "hello@cannellerichter.fr", role: "admin",    color: "#2f6bdb", pass: "hello", grants: [], editGrants: [] },
    { id: "u_tom",      name: "Tom Beaumont",     first: "Tom",      email: "tom@studio.fr",          role: "editor",   color: "#1f9e72", pass: "tom",   grants: ["c2", "v4"], editGrants: ["rl1", "podcasts"] },
    { id: "u_lea",      name: "L\u00e9a Marchand", first: "L\u00e9a", email: "lea@revue.fr",            role: "reviewer", color: "#7a4fe0", pass: "lea",   grants: ["c1", "v1"], editGrants: ["rl1"] },
    { id: "u_nora",     name: "Nora Pelletier",   first: "Nora",     email: "nora@press.fr",           role: "publisher",color: "#c0398a", pass: "nora",  grants: ["c3"], editGrants: ["documents"] },
    { id: "u_sam",      name: "Sam Rivera",       first: "Sam",      email: "sam@beta.cc",             role: "tester",   color: "#0e9aa7", pass: "sam",   grants: ["c5", "game", "music"], editGrants: [] },
    { id: "u_max",      name: "Max Okafor",       first: "Max",      email: "max@friends.cc",         role: "guest",    color: "#e08a1e", pass: "max",   grants: ["cv", "secret", "documents"], editGrants: [] },
  ];
}

const ACCENTS = {
  datascientist: { accent: "#2f6bdb", ink: "#1f4fb0", wash: "#eaf0fc" },
  engineer:      { accent: "#e08a1e", ink: "#b56b08", wash: "#fcf1de" },
  diy:           { accent: "#1f9e72", ink: "#137a57", wash: "#e3f5ee" },
  alpinist:      { accent: "#e1543f", ink: "#b73b29", wash: "#fcebe7" },
  human:         { accent: "#7a4fe0", ink: "#5d34bd", wash: "#f0eafd" },
};

/* ---- a believable FAKE QR code (SVG data URL) for the site address ----
   Stored in R2/D1 (media + profile.qr) and shown when the name-brand flips. */
function seedQrDataUrl() {
  const N = 25, cell = 8, pad = 16, size = N * cell + pad * 2;
  let s = 1979339339 >>> 0; const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
  const finder = (x, y) => `<rect x="${x}" y="${y}" width="${cell * 7}" height="${cell * 7}" fill="#211e1a"/><rect x="${x + cell}" y="${y + cell}" width="${cell * 5}" height="${cell * 5}" fill="#fff"/><rect x="${x + cell * 2}" y="${y + cell * 2}" width="${cell * 3}" height="${cell * 3}" fill="#211e1a"/>`;
  let mods = "";
  for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
    const inFinder = (r < 8 && c < 8) || (r < 8 && c >= N - 8) || (r >= N - 8 && c < 8);
    if (inFinder) continue;
    if (rnd() > 0.52) mods += `<rect x="${pad + c * cell}" y="${pad + r * cell}" width="${cell}" height="${cell}" fill="#211e1a"/>`;
  }
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" rx="16" fill="#fff"/>${mods}${finder(pad, pad)}${finder(pad + (N - 7) * cell, pad)}${finder(pad, pad + (N - 7) * cell)}</svg>`;
  return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
}

/* ---- reusable media library (Assets ↔ R2). `folder` is the logical R2 prefix
   the media picker searches; `url` is the playable/viewable source (a data URL
   once a file is uploaded, or an external link). ---- */
function seedMedia() {
  return [
    // voice-overs live under the "readloud-blog" folder — reused by blog "My voice"
    { id: "md_voice_en", key: "contents/readloud-blog/gallery/toolkitblog_voice_version1_en.mp3", folder: "readloud-blog", type: "audio", locale: "en", label: "Formatting toolkit — my voice (EN)", url: "", mime: "audio/mpeg" },
    { id: "md_voice_fr", key: "contents/readloud-blog/gallery/toolkitblog_voice_version1_fr.mp3", folder: "readloud-blog", type: "audio", locale: "fr", label: "Formatting toolkit — ma voix (FR)", url: "", mime: "audio/mpeg" },
    // AI read-aloud renders, per language
    { id: "md_voiceai_en", key: "media/audio/voice_ai/en.mp3", folder: "voice_ai", type: "audio", locale: "en", label: "AI read-aloud (EN)", url: "", mime: "audio/mpeg" },
    { id: "md_voiceai_fr", key: "media/audio/voice_ai/fr.mp3", folder: "voice_ai", type: "audio", locale: "fr", label: "AI read-aloud (FR)", url: "", mime: "audio/mpeg" },
    // reading soundtrack
    { id: "md_music_focus", key: "media/audio/soundtrack/focus.mp3", folder: "soundtrack", type: "audio", locale: "", label: "Reading soundtrack — focus", url: "", mime: "audio/mpeg" },
    // field recordings (the Audio album)
    { id: "md_field_glacier", key: "media/audio/field/dawn-glacier.mp3", folder: "field", type: "audio", locale: "en", label: "Dawn on the glacier", url: "", mime: "audio/mpeg" },
    { id: "md_field_col", key: "media/audio/field/wind-col.mp3", folder: "field", type: "audio", locale: "en", label: "Wind on the col", url: "", mime: "audio/mpeg" },
    { id: "md_field_ice", key: "media/audio/field/crampons-ice.mp3", folder: "field", type: "audio", locale: "en", label: "Crampons & ice", url: "", mime: "audio/mpeg" },
    // gallery images
    { id: "md_img_ridge", key: "media/images/gallery_dawn/001.webp", folder: "gallery_dawn", type: "image", locale: "", label: "Dawn ridge — frame 1", url: "", mime: "image/webp" },
    // documents
    { id: "md_doc_cheatsheet", key: "media/docs/avalanche/cheatsheet.pdf", folder: "avalanche", type: "document", locale: "", label: "Avalanche cheat-sheet", url: "", mime: "application/pdf" },
    { id: "md_doc_tree", key: "media/docs/avalanche/decision-tree.pdf", folder: "avalanche", type: "document", locale: "", label: "Slope-angle decision tree", url: "", mime: "application/pdf" },
    { id: "md_doc_checklist", key: "media/docs/avalanche/checklist.pdf", folder: "avalanche", type: "document", locale: "", label: "Pre-tour checklist", url: "", mime: "application/pdf" },
    // the site QR code (fake) — reused when the name-brand flips
    { id: "md_qr", key: "media/image/site/qrcode.svg", folder: "site", type: "image", locale: "", label: "Site QR code", url: seedQrDataUrl(), mime: "image/svg+xml" },
  ];
}

function seed() {
  return {
    profile: {
      first: "Cannelle",
      last: "Richter",
      tagline: "Trust me — I am",
      defaultRoute: "/datascientist", // home redirects here
    },
    domains: [
      {
        id: "datascientist", route: "/datascientist", label: "Data Scientist",
        title: "a Data Scientist", display: true, art: "network",
        presTitle: "the Data Scientist",
        quote: "All models are wrong, but some are useful — and a few are beautiful.",
        presBody: "I turn messy data into decisions. **Statistics**, machine learning and a stubborn love for the question behind the question. I build [pipelines](https://github.com/cannellerichter), dashboards and the occasional brain-shaped neural net.",
        presImage: null,
        puzzle: "gradient",
        sections: ["blog", "videos"],
        extraPages: ["cv"],
        socials: ["linkedin", "github", "youtube"],
        featured: { en: [], fr: [] },
      },
      {
        id: "engineer", route: "/engineer", label: "Engineer",
        title: "an Engineer", display: true, art: "blueprint",
        presTitle: "the Engineer",
        quote: "Everything should be made as simple as possible — but no simpler.",
        presBody: "From firmware to data platforms, I like systems that are robust, observable and a little bit elegant. If it has an API, I want to understand it; if it doesn't, I'll probably build one.",
        presImage: null,
        puzzle: null,
        sections: ["blog", "videos"],
        extraPages: [],
        socials: ["linkedin", "github"],
        featured: { en: [], fr: [] },
      },
      {
        id: "diy", route: "/diy", label: "DIY / Maker",
        title: "a Maker", display: true, art: "tools",
        presTitle: "the Maker",
        quote: "If you can't open it, you don't own it.",
        presBody: "Wood, electronics, 3D prints and a soldering iron that's always warm. I document my builds so other people can break them faster than I did.",
        presImage: null,
        puzzle: null,
        sections: ["blog", "videos"],
        extraPages: [],
        socials: ["instagram", "youtube"],
        featured: { en: [], fr: [] },
      },
      {
        id: "alpinist", route: "/alpinist", label: "Alpinist",
        title: "an Alpinist", display: true, art: "contour",
        presTitle: "the Alpinist",
        quote: "The mountain decides. We just get to choose how we ask.",
        presBody: "Long approaches, thin air and good coffee at altitude. Trail running, ski touring and the quiet math of risk on a ridge line.",
        presImage: null,
        puzzle: null,
        sections: ["blog", "videos"],
        extraPages: [],
        socials: ["instagram", "youtube"],
        featured: { en: [], fr: [] },
      },
    ],
    // content sections (CRUD-able). Fixed menu items handled separately.
    sections: [
      { id: "blog", route: "/blog", label: "Blog", kind: "blog", inMenu: true, logo: null, tr: { fr: { label: "Blog" } } },
      { id: "videos", route: "/videos", label: "Videos", kind: "video", inMenu: true, logo: null, tr: { fr: { label: "Vidéos" } } },
      { id: "photos", route: "/photos", label: "Photos", kind: "image", inMenu: true, logo: null, tr: { fr: { label: "Photos" } } },
      { id: "podcasts", route: "/podcasts", label: "Podcasts", kind: "podcast", inMenu: false, logo: null, tr: { fr: { label: "Podcasts" } } },
      { id: "music", route: "/music", label: "Music", kind: "music", inMenu: false, logo: null, tr: { fr: { label: "Musique" } } },
      { id: "audio", route: "/audio", label: "Audio", kind: "audio", inMenu: false, logo: null, tr: { fr: { label: "Audio" } } },
      { id: "documents", route: "/documents", label: "Documents", kind: "document", inMenu: false, logo: null, tr: { fr: { label: "Documents" } } },
    ],
    // "Other" pages — created pages (CV, survey, game, secret) not shown in the public Sections menu.
    otherPages: [
      { id: "cv",     label: "CV",                route: "/datascientist/cv", kind: "page",   desc: "On-request CV page" },
      { id: "survey", label: "Reader survey",     route: "/other/survey",      kind: "page",   desc: "Short feedback survey" },
      { id: "game",   label: "Mini-game",         route: "/other/game",        kind: "page",   desc: "A small interactive game" },
      { id: "secret", label: "Cannelle's secret", route: "/other/secret",      kind: "secret", desc: "Hidden — unlocked by solving an illustration puzzle" },
    ],
    secretUnlocked: false,
    socials: [
      { id: "linkedin",  name: "LinkedIn",  link: "https://linkedin.com/in/cannellerichter",  color: "#0a66c2", domains: ["datascientist","engineer"] },
      { id: "github",    name: "GitHub",    link: "https://github.com/cannellerichter",        color: "#211e1a", domains: ["datascientist","engineer","diy"] },
      { id: "youtube",   name: "YouTube",   link: "https://youtube.com/@cannellerichter",       color: "#e1543f", domains: ["datascientist","diy","alpinist"] },
      { id: "instagram", name: "Instagram", link: "https://instagram.com/cannellerichter",      color: "#d62976", domains: ["diy","alpinist"] },
      { id: "x",         name: "X",         link: "https://x.com/cannellerichter",              color: "#211e1a", domains: ["datascientist"] },
    ],
    /* ---- reusable media library (Cloudflare R2 mirror — Assets table) ----
       One physical file, reusable across contents / languages / domains without
       duplication (DDD v16 §13-15). `key` is the R2 object key; `folder` is its
       logical folder used by the media picker's search. */
    media: seedMedia(),
    apps: [
      { id: "a1", name: "Notion",  link: "https://notion.so",  color: "#211e1a", glyph: "N", desc: "Notes & roadmap" },
      { id: "a2", name: "GitHub",  link: "https://github.com", color: "#211e1a", glyph: "G", desc: "Code & open source" },
      { id: "a3", name: "Strava",  link: "https://strava.com", color: "#e1543f", glyph: "S", desc: "Trails & ski touring" },
      { id: "a4", name: "Figma",   link: "https://figma.com",  color: "#7a4fe0", glyph: "F", desc: "Design files" },
      { id: "a5", name: "Kaggle",  link: "https://kaggle.com", color: "#2f6bdb", glyph: "K", desc: "Datasets & notebooks" },
    ],
    content: [
      { id: "c1", ref: "ds-causal-2026", type: "blog", domain: "datascientist", title: "Causal inference without the hand-waving", description: "A practical walk-through of do-calculus on a real churn dataset — and where it quietly breaks.", tags: ["stats","causality","python"], source: "Original", rating: 5, createdAt: "2026-05-02", updatedAt: "2026-05-10" },
      { id: "c2", ref: "ds-embeddings", type: "blog", domain: "datascientist", title: "What embeddings actually remember", description: "Probing a small model to see which features survive compression. Spoiler: not the ones you'd hope.", tags: ["ml","nlp","experiments"], source: "Original", rating: 4, createdAt: "2026-04-18", updatedAt: "2026-04-18" },
      { id: "c3", ref: "eng-observability", type: "blog", domain: "engineer", title: "Observability for data pipelines that won't page you at 3am", description: "Metrics, traces and the three dashboards I actually look at.", tags: ["platform","ops","reliability"], source: "Original", rating: 5, createdAt: "2026-03-30", updatedAt: "2026-04-01" },
      { id: "c4", ref: "diy-soldering-rig", type: "blog", domain: "diy", title: "A fume extractor from a dead PC fan", description: "Forty minutes, two parts I already owned, and lungs that thank me.", tags: ["electronics","build","cheap"], source: "Original", rating: 4, createdAt: "2026-02-11", updatedAt: "2026-02-11" },
      { id: "c5", ref: "alp-risk-math", type: "blog", domain: "alpinist", title: "The quiet math of avalanche risk", description: "Bayesian thinking on a ridge line, and why gut feeling is a prior, not a verdict.", tags: ["risk","ski","decision"], source: "Original", rating: 5, createdAt: "2026-01-22", updatedAt: "2026-01-25" },
      { id: "c6", ref: "ds-dashboards", type: "blog", domain: "datascientist", title: "Dashboards nobody asked for (but everybody uses)", description: "Designing for the question, not the metric.", tags: ["viz","product"], source: "Original", rating: 3, createdAt: "2025-12-09", updatedAt: "2025-12-09" },

      { id: "v1", ref: "ds-vid-pca", type: "video", domain: "datascientist", title: "PCA explained with a coffee grinder", description: "Dimensionality reduction you can taste.", tags: ["stats","explainer"], source: "YouTube", rating: 5, youtube: "wTcMtvyXcSE", createdAt: "2026-05-20", updatedAt: "2026-05-20" },
      { id: "v2", ref: "diy-vid-cnc", type: "video", domain: "diy", title: "Building a desktop CNC under €300", description: "Full build log, mistakes included.", tags: ["build","cnc"], source: "YouTube", rating: 4, youtube: "aircAruvnKk", createdAt: "2026-04-02", updatedAt: "2026-04-02" },
      { id: "v3", ref: "alp-vid-touring", type: "video", domain: "alpinist", title: "Dawn patrol: ski touring the Aiguilles", description: "Headlamps, frozen fingers, perfect light.", tags: ["ski","film"], source: "YouTube", rating: 5, youtube: "ScMzIvxBSi4", createdAt: "2026-03-15", updatedAt: "2026-03-15" },
      { id: "v4", ref: "eng-vid-kafka", type: "video", domain: "engineer", title: "Kafka in 12 minutes, honestly", description: "No buzzwords, just topics, partitions and back-pressure.", tags: ["platform","streaming"], source: "YouTube", rating: 4, youtube: "B5j3uNBH8X4", createdAt: "2026-02-28", updatedAt: "2026-02-28" },

      // ---- podcasts (hidden section) ----
      { id: "p1", ref: "pod-signal-noise-1", type: "podcast", domain: "datascientist", title: "Signal & Noise — Ep.1: priors that pay off", description: "A conversation about Bayesian thinking in everyday data work, with too many coffee metaphors.", tags: ["bayes","conversation"], source: "Original", rating: 5, playlistKind: "spotify", playlist: "https://open.spotify.com/embed/episode/4rOoJ6Egrf8K2IrywzwOMk", subscribe: ["youtube","x"], createdAt: "2026-05-28", updatedAt: "2026-05-28" },
      { id: "p2", ref: "pod-trail-talk", type: "podcast", domain: "alpinist", title: "Trail Talk — decisions above 3000m", description: "Two alpinists unpack how they actually call it on a marginal day.", tags: ["risk","mountain"], source: "Original", rating: 4, playlistKind: "spotify", playlist: "https://open.spotify.com/embed/episode/5XzBjJ8Xy9b3a1V4yY9JpQ", subscribe: ["instagram","youtube"], createdAt: "2026-04-09", updatedAt: "2026-04-09" },

      // ---- music (hidden section) ----
      { id: "m1", ref: "mus-compile-focus", type: "music", domain: "engineer", title: "Focus set for long compiles", description: "A low-key instrumental playlist for deep work and slow builds.", tags: ["focus","instrumental"], source: "Original", rating: 5, playlistKind: "spotify", playlist: "https://open.spotify.com/embed/playlist/37i9dQZF1DWZeKCadgRdKQ", subscribe: ["youtube"], createdAt: "2026-03-21", updatedAt: "2026-03-21" },

      // ---- audio (hidden section) ----
      { id: "au1", ref: "aud-dawn-glacier", type: "audio", domain: "alpinist", title: "Field recording — dawn on the glacier", description: "Eight minutes of wind, crampons and a distant serac. Headphones recommended.", tags: ["field","ambient"], source: "Original", rating: 5, playlistKind: "url", playlist: "https://cdn.example.com/audio/dawn-glacier.mp3", subscribe: ["instagram"], createdAt: "2026-02-02", updatedAt: "2026-02-02" },

      // ---- documents (hidden section) ----
      { id: "d1", ref: "doc-avalanche-cheatsheet", type: "document", domain: "alpinist", title: "Avalanche risk — field references", description: "A small library of printable decision aids for marginal slopes.", tags: ["risk","reference"], source: "Original", rating: 5, pdf: "", subscribe: [], createdAt: "2026-01-12", updatedAt: "2026-01-12",
        docs: [
          { id: "dc1", label: "Field cheat-sheet (1 page)", assetId: "md_doc_cheatsheet", pdf: "" },
          { id: "dc2", label: "Slope-angle decision tree",  assetId: "md_doc_tree",       pdf: "" },
          { id: "dc3", label: "Pre-tour checklist",         assetId: "md_doc_checklist",  pdf: "" },
        ] },
      { id: "d2", ref: "doc-pipeline-runbook", type: "document", domain: "engineer", title: "Pipeline on-call runbook", description: "What to check, in what order, when a data pipeline pages you.", tags: ["ops","reference"], source: "Original", rating: 4, pdf: "", subscribe: [], createdAt: "2025-12-18", updatedAt: "2025-12-18" },

      // ---- IMAGE_GALLERY (photos section) — one piece = a gallery of images ----
      { id: "g1", ref: "img-dawn-ridge", type: "image", domain: "alpinist", title: "Dawn on the ridge — a photo essay", description: "Eight frames from a single morning above 3000m, in sequence.", tags: ["mountain","photo"], source: "Original", rating: 5, subscribe: ["instagram"],
        galleryMode: "auto", galleryInterval: 5,
        gallery: [
          { id: "gi1", src: null, caption: "04:50 — leaving the hut", cover: true },
          { id: "gi2", src: null, caption: "05:30 — first light on the face" },
          { id: "gi3", src: null, caption: "06:10 — the crux pitch" },
          { id: "gi4", src: null, caption: "07:00 — summit ridge" },
        ], createdAt: "2026-05-18", updatedAt: "2026-05-18" },
      { id: "g2", ref: "img-bench-builds", type: "image", domain: "diy", title: "Workbench builds, frame by frame", description: "A visual log of the shop bench coming together.", tags: ["build","photo"], source: "Original", rating: 4, subscribe: ["instagram","youtube"],
        galleryMode: "arrows", galleryInterval: 10,
        gallery: [
          { id: "gb1", src: null, caption: "Raw stock, cut to length", cover: true },
          { id: "gb2", src: null, caption: "Dry-fit of the leg frame" },
          { id: "gb3", src: null, caption: "Glue-up under clamps" },
        ], createdAt: "2026-04-26", updatedAt: "2026-04-26" },

      // ---- AUDIO_LIBRARY (album of sounds) — sample on the Audio section ----
      { id: "al1", ref: "aud-mountain-sessions", type: "audio", domain: "alpinist", title: "Mountain field sessions", description: "An album of short field recordings from a season in the Alps.", tags: ["field","ambient"], source: "Original", rating: 5, subscribe: ["instagram"], playlistKind: "album",
        tracks: [
          { id: "t_alp1", title: "Dawn on the glacier", assetId: "md_field_glacier", locale: "en" },
          { id: "t_alp2", title: "Wind on the col",     assetId: "md_field_col",     locale: "en" },
          { id: "t_alp3", title: "Crampons & ice",      assetId: "md_field_ice",     locale: "en" },
        ], createdAt: "2026-03-06", updatedAt: "2026-03-06" },

      // ---- PRIVATE content (DDD v16 §7) — never published publicly; an audio
      //      asset library reused by the blog's “My voice” alternative media. ----
      { id: "rl1", ref: "readloud-blog", type: "audio", domain: "datascientist", title: "Read-aloud — blog voice‑overs (PRIVATE)", description: "My recorded voice-overs for blog articles. Private — reused as “My voice” alternative media, never published on its own.", tags: ["voice","readloud"], source: "Original", rating: 0, subscribe: [], playlistKind: "album", private: true,
        tracks: [
          { id: "tk_en", title: "Formatting toolkit — EN", assetId: "md_voice_en", locale: "en" },
          { id: "tk_fr", title: "Formatting toolkit — FR", assetId: "md_voice_fr", locale: "fr" },
        ], createdAt: "2026-06-13", updatedAt: "2026-06-13" },
    ],
    // -------- admin / board --------
    personalBoard: [
      { id: "t1", title: "Job & Career", color: "#2f6bdb", links: [
        { title: "Notion roadmap", url: "https://notion.so/roadmap" },
        { title: "Check CV last update", url: "https://docs/cv" },
        { title: "Job board", url: "https://jobs" },
      ]},
      { id: "t2", title: "Perso / Location", color: "#1f9e72", links: [
        { title: "Furnished flat search", url: "https://flats" },
        { title: "Cloud Files", url: "https://drive" },
        { title: "GitHub", url: "https://github.com" },
        { title: "My map", url: "https://maps" },
      ]},
      { id: "t3", title: "Hype services", color: "#7a4fe0", links: [
        { title: "Site", url: "https://cannellerichter.fr" },
        { title: "Drive", url: "https://drive" },
        { title: "Mail", url: "https://mail" },
      ]},
      { id: "t4", title: "Daily", color: "#e08a1e", links: [
        { title: "Newsletter flat", url: "https://news" },
        { title: "App routine", url: "https://routine" },
        { title: "CGV update", url: "https://cgv" },
      ]},
      { id: "t5", title: "Tools", color: "#e1543f", links: [
        { title: "Figma", url: "https://figma.com" },
        { title: "Excalidraw", url: "https://excalidraw.com" },
      ]},
    ],
    notifications: [
      { id: "n1", tile: "t1", time: "2026-06-11 09:14", text: "New job alert matched 'Lead Data Scientist · Remote'." },
      { id: "n2", tile: "t3", time: "2026-06-11 08:02", text: "cannellerichter.fr — uptime 99.98% over last 30 days." },
      { id: "n3", tile: "t5", time: "2026-06-10 19:40", text: "Figma file 'Site v4' was edited by an agent." },
      { id: "n4", tile: "t2", time: "2026-06-10 12:11", text: "Drive backup completed (4.2 GB)." },
      { id: "n5", tile: "t1", time: "2026-06-09 17:55", text: "CV last updated 41 days ago — consider refreshing." },
      { id: "n6", tile: "t4", time: "2026-06-09 07:30", text: "Newsletter scheduled: 1,204 recipients." },
      { id: "n7", tile: "t5", time: "2026-06-08 22:03", text: "GitHub Action 'deploy-site' succeeded." },
    ],
    feedbacks: [
      { id: "f1", name: "Léa M.", date: "2026-06-10", rating: 5, text: "Love the multi-hat idea — found your avalanche article from the data page. Great cross-links!" },
      { id: "f2", name: "Anon", date: "2026-06-08", rating: 4, text: "The hero is gorgeous on mobile. Podcast section when?" },
      { id: "f3", name: "T. Okafor", date: "2026-06-05", rating: 5, text: "Used your observability post at work today. Thank you." },
      { id: "f4", name: "Anon", date: "2026-06-01", rating: 3, text: "Menu took me a sec to find on tablet." },
    ],
    analytics: {
      visitors30d: 4218, pageviews30d: 11940, avgMin: 2.7, bounce: 38,
      spark: [12,18,15,22,19,26,24,31,28,35,30,42],
      topPages: [
        { path: "/datascientist", views: 3120 },
        { path: "/blog", views: 2480 },
        { path: "/alpinist", views: 1610 },
        { path: "/diy", views: 1190 },
      ],
    },
    contactMessages: [],
    cvRequests: [],
    users: seedUsers(),
    contentFeedback: [
      { id: "cf1", target: "c3", targetLabel: "Observability for data pipelines", by: "L\u00e9a Marchand", role: "reviewer", date: "2026-06-12", text: "The three-dashboards framing is great. Could you add a note on alert fatigue before publishing?" },
      { id: "cf2", target: "game", targetLabel: "Mini-game", by: "Max Okafor", role: "guest", date: "2026-06-11", text: "Played it twice — loved it. The restart button is a little hard to find on mobile." },
    ],
    legal: JSON.parse(JSON.stringify(LEGAL_SEED)),
  };
}

function seedOtherPages() {
  return [
    { id: "cv",     label: "CV",                route: "/datascientist/cv", kind: "page",   desc: "On-request CV page" },
    { id: "survey", label: "Reader survey",     route: "/other/survey",      kind: "page",   desc: "Short feedback survey" },
    { id: "game",   label: "Mini-game",         route: "/other/game",        kind: "page",   desc: "A small interactive game" },
    { id: "secret", label: "Cannelle's secret", route: "/other/secret",      kind: "secret", desc: "Hidden — unlocked by solving an illustration puzzle" },
  ];
}

/* hidden reference article that demonstrates every editor feature.
   Kept unpublished — visible only in edit mode. */
function TEMPLATE_ARTICLE() {
  return {
    id: "tpl_toolkit", ref: "template-toolkit", type: "blog", domain: "datascientist",
    domains: ["datascientist", "engineer"],
    title: "Formatting toolkit — every block, one page",
    description: "A hidden, unpublished reference article that shows every text and block feature available in the editor.",
    tags: ["template", "reference"], source: "Original", rating: 0, votes: 0, views: 0,
    ai: null, cover: null, alt: [], sources: [{ label: "Editor documentation — internal", url: "#" }],
    published: false, publishedAt: "", createdAt: "2026-06-13", updatedAt: "2026-06-13",
    tr: {},
    body: [
      { id: "t1", type: "h", level: 1, text: "Everything the editor can do" },
      { id: "t2", type: "p", text: "This paragraph mixes **bold**, __underline__ and an [external link](https://example.com). Internal links work too — jump to [the Blog](#/blog).\nThis sentence sits on a new line thanks to a single line break." },
      { id: "t3", type: "p", text: "Hover a defined term like [[do-calculus|A set of rules for reasoning about cause and effect from a causal graph.]] to read its definition in a bubble. You can reference [[PCA|Principal Component Analysis — compressing correlated features into fewer axes.]] the same way." },
      { id: "t4", type: "h", level: 2, text: "Lists" },
      { id: "t5", type: "list", text: "- A bullet written with a dash\n* Or written with a star\n- Each line becomes its own point" },
      { id: "t6", type: "callout", tone: "tip", text: "**Callout box.** Use it to highlight a tip, a warning or an important aside. Inline **bold** and __underline__ work inside it too." },
      { id: "t7", type: "quote", text: "All models are wrong, but some are useful — and a few are beautiful.", author: "paraphrasing George Box" },
      { id: "t8", type: "h", level: 2, text: "Tables" },
      { id: "t9", type: "table", header: true, rows: [["Method","Strength","Cost"],["Linear model","Interpretable","Low"],["Gradient boosting","Accurate","Medium"],["Neural net","Flexible","High"]] },
      { id: "t10", type: "img", src: null, caption: "a figure / diagram placeholder" },
      { id: "t11", type: "code", code: "import numpy as np\nX = np.array([[1,2],[3,4]])\nprint(X.mean(axis=0))" },
      { id: "t12", type: "hr" },
      { id: "t13", type: "p", text: "That's the full toolkit. Duplicate this article as a starting point, or keep it unpublished as a living reference." },
    ],
  };
}

/* default reading body (block model) generated from a description */
function bodyFromDesc(c) {
  return [
    { id: "b1", type: "p", text: `This piece is part of my ${c.domain} work. ${c.description}` },
    { id: "b2", type: "h", text: "How it actually went" },
    { id: "b3", type: "p", text: "Below I walk through the approach, the dead-ends, and what I'd do differently next time. Where there's **code**, it's linked from the sources at the bottom." },
    { id: "b4", type: "img", src: null, caption: "inline figure / diagram" },
    { id: "b5", type: "p", text: "The full toolkit shows up in the tags. If something here is useful to you, that's the whole point." },
  ];
}
function wordsOf(body) {
  return (body || []).reduce((n, b) => n + ((b.text || b.code || "").trim().split(/\s+/).filter(Boolean).length), 0);
}
function readTime(body) { return Math.max(1, Math.round(wordsOf(body) / 200)); }

function normalize(db) {
  db.myRatings = db.myRatings || {};
  if (db.secretUnlocked == null) db.secretUnlocked = false;
  // multi-user accounts + role-scoped feedback (injected for older saved DBs)
  if (!db.users || !db.users.length) db.users = seedUsers();
  if (!db.contentFeedback) db.contentFeedback = [];
  // tagline migration: drop a trailing " a" (the domain title already starts with a/an)
  if (db.profile && /\s+a$/i.test(db.profile.tagline || "")) db.profile.tagline = db.profile.tagline.replace(/\s+a$/i, "");
  // About page: portrait image OR embedded app
  if (db.about) { if (db.about.portrait === undefined) db.about.portrait = null; if (db.about.embed === undefined) db.about.embed = null; if (db.about.tr === undefined) db.about.tr = {}; if (db.about.fallback === undefined) db.about.fallback = "fallback"; }
  if (!db.legal) db.legal = JSON.parse(JSON.stringify(LEGAL_SEED));
  if (!db.media || !db.media.length) db.media = seedMedia();
  // the site QR code is auto-generated by the front end — no global setting stored
  if (db.profile) delete db.profile.qr;
  // legal pages are bilingual: base = EN, tr.fr override, with a fallback mode
  Object.keys(LEGAL_SEED).forEach(k => {
    db.legal[k] = db.legal[k] || JSON.parse(JSON.stringify(LEGAL_SEED[k]));
    if (db.legal[k].tr === undefined) db.legal[k].tr = {};
    if (db.legal[k].fallback === undefined) db.legal[k].fallback = "fallback";
  });
  // editable copy for static form pages (contact / feedback)
  if (!db.pages) db.pages = JSON.parse(JSON.stringify(PAGES_SEED));
  Object.keys(PAGES_SEED).forEach(k => {
    db.pages[k] = { ...JSON.parse(JSON.stringify(PAGES_SEED[k])), ...(db.pages[k] || {}) };
    if (db.pages[k].tr === undefined) db.pages[k].tr = JSON.parse(JSON.stringify(PAGES_SEED[k].tr));
    if (db.pages[k].fallback === undefined) db.pages[k].fallback = "fallback";
  });
  if (!db.otherPages) db.otherPages = JSON.parse(JSON.stringify(seedOtherPages()));
  (db.otherPages || []).forEach(p => {
    if (p.html === undefined) p.html = "";
    if (p.published === undefined) p.published = true;
    if (p.status === undefined) p.status = p.published === false ? "draft" : "published";
    p.published = p.status === "published";
    if (p.statusByLang === undefined) p.statusByLang = { en: pageHasLang(p, "en") ? p.status : "draft", fr: pageHasLang(p, "fr") ? p.status : "draft" };  // per-language workflow (missing language = draft)
    if (p.editedBy === undefined) p.editedBy = "";
    if (p.reviewedBy === undefined) p.reviewedBy = "";
    if (p.tr === undefined) p.tr = {};          // { fr: { label, desc, html } }
    if (p.fallback === undefined) p.fallback = "en";  // 'en' = EN-by-default | 'strict' = published only if defined
  });
  (db.sections || []).forEach(s => { if (s.tr === undefined) s.tr = {}; if (s.logo === undefined) s.logo = null; if (s.type === undefined) s.type = sectionTypeOf(s); });   // { fr: { label } }
  (db.domains || []).forEach(d => {
    if (d.presImage === undefined) d.presImage = null;
    if (d.extraPages === undefined) d.extraPages = [];
    if (d.puzzle === undefined) d.puzzle = d.id === "datascientist" ? "gradient" : null;
    if (d.puzzle === "median") d.puzzle = "gradient";
    if (d.appEmbed === undefined) d.appEmbed = null;
    if (d.tr === undefined) d.tr = {};   // { fr: { title, presTitle, quote, presBody } }
    // the Human domain gets its own dedicated artwork (migrate from the default)
    if (d.id === "human" && (!d.art || d.art === "network")) d.art = "human";
    // Top picks are PER-LANGUAGE: { en:[ids], fr:[ids] }. Migrate old flat arrays
    // (they were chosen in EN) into the EN slot only, so they stop bleeding into FR.
    if (Array.isArray(d.featured)) d.featured = { en: d.featured.slice(), fr: [] };
    else if (!d.featured || typeof d.featured !== "object") d.featured = { en: [], fr: [] };
    else { if (!Array.isArray(d.featured.en)) d.featured.en = []; if (!Array.isArray(d.featured.fr)) d.featured.fr = []; }
  });
  (db.apps || []).forEach(a => { if (a.desc === undefined) a.desc = ""; if (a.published === undefined) a.published = true; });
  // inject the hidden formatting-template article once
  if (db.content && !db.content.find(c => c.ref === "template-toolkit")) db.content.unshift(TEMPLATE_ARTICLE());
  (db.content || []).forEach((c, i) => {
    // stale test data: a translation note ("Translated from French") is NOT a publication
    // source — translation belongs to the AI-transparency field. Never surface it as a
    // source chip on cards or in the sources list.
    if (typeof c.source === "string" && /translated from/i.test(c.source)) c.source = "Original";
    if (Array.isArray(c.sources)) c.sources = c.sources.filter((s) => !(s && /translated from/i.test(s.label || "")));
    if (c.views == null) c.views = 240 + ((i * 1337 + (c.ref ? c.ref.length : 0) * 97) % 4600);
    if (c.votes == null) c.votes = 6 + ((i * 7) % 42);
    if (c.published === undefined) c.published = true;
    // editorial workflow status — derive from the legacy `published` flag, then keep them in sync
    if (c.status === undefined) c.status = c.published === false ? "draft" : "published";
    c.published = c.status === "published";
    if (c.statusByLang === undefined) c.statusByLang = { en: langExists(c, "en") ? c.status : "draft", fr: langExists(c, "fr") ? c.status : "draft" };   // per-language workflow (missing language = draft, never inherits)
    if (c.editedBy === undefined) c.editedBy = "";       // hidden metadata — who last edited
    if (c.reviewedBy === undefined) c.reviewedBy = "";   // hidden metadata — who approved
    if (!c.publishedAt) c.publishedAt = c.createdAt;
    if (!c.body) c.body = bodyFromDesc(c);
    if (!c.domains) c.domains = c.domain ? [c.domain] : [];           // multi-domain association
    if (c.domain == null && c.domains.length) c.domain = c.domains[0];
    if (!c.tr) c.tr = {};                                              // { fr: { title, description, body } }
    if (c.ai === undefined) c.ai = null;                 // null | 'generated' | 'augmented' | 'translated'
    if (c.cover === undefined) c.cover = null;           // optional cover image (data URL)
    if (!c.alt) c.alt = [];                               // linked alternative media
    if (!c.related) c.related = [];                       // manually-chosen related items (ordered ids)
    if (c.subscribe === undefined) c.subscribe = [];      // social ids offered as "subscribe / follow"
    if (c.playlist === undefined) c.playlist = "";        // podcast / music / audio embed URL
    if (c.playlistKind === undefined) c.playlistKind = "spotify";
    if (c.pdf === undefined) c.pdf = "";                  // document section — uploaded PDF (data URL)
    if (c.private === undefined) c.private = false;       // PRIVATE content (DDD v16 §7) — never published publicly
    if (c.gallery === undefined) c.gallery = [];          // IMAGE_GALLERY — ordered images
    if (c.galleryMode === undefined) c.galleryMode = "arrows";   // 'arrows' | 'auto'
    if (c.galleryInterval === undefined) c.galleryInterval = 5;  // seconds between auto-switch (5 | 10)
    if (c.docs === undefined) c.docs = [];                // DOCUMENT_LIBRARY — several documents
    if (c.tracks === undefined) c.tracks = [];            // AUDIO_LIBRARY — album of sounds
    if (c.fallback === undefined) c.fallback = "en";      // 'en' = EN-by-default | 'strict' = published only if defined
    if (!c.sources) c.sources = (c.source && c.source !== "Original")
      ? [{ label: c.source, url: "#" }]
      : [{ label: "Original publication — cannellerichter.fr", url: "#" }];
  });
  // demo: link the causal-inference article to a video + audio alternatives
  const causal = (db.content || []).find(c => c.ref === "ds-causal-2026");  if (causal && (!causal.alt || causal.alt.length === 0)) {
    causal.alt = [
      { kind: "video", ref: "ds-vid-pca", label: "Watch" },
      { kind: "voice", label: "Listen (my voice)" },
      { kind: "aivoice", label: "AI read-aloud" },
      { kind: "music", label: "Reading soundtrack" },
    ];
    causal.ai = "augmented";
  }
  const pca = (db.content || []).find(c => c.ref === "ds-vid-pca");
  if (pca && (!pca.alt || pca.alt.length === 0)) {
    pca.alt = [{ kind: "read", ref: "ds-causal-2026", label: "Read instead" }];
  }
  // demo: a few Blog & Video pieces offer subscribe / follow networks
  [["ds-causal-2026", ["linkedin", "github", "x"]], ["eng-observability", ["linkedin", "github"]], ["ds-vid-pca", ["youtube", "x"]], ["eng-vid-kafka", ["youtube", "linkedin"]]].forEach(([ref, subs]) => {
    const c = (db.content || []).find(x => x.ref === ref); if (c && (!c.subscribe || !c.subscribe.length)) c.subscribe = subs;
  });
  // demo: the hidden formatting-toolkit article reuses the PRIVATE readloud-blog
  // voice-overs as its "My voice" alternative media (R2 reuse, no duplication).
  const tpl = (db.content || []).find(c => c.ref === "template-toolkit");
  if (tpl && (!tpl.alt || tpl.alt.length === 0)) {
    tpl.alt = [
      { kind: "voice", label: "Listen (my voice)", assetId: "md_voice_en" },
      { kind: "aivoice", label: "AI read-aloud" },
      { kind: "music", label: "Reading soundtrack", assetId: "md_music_focus" },
    ];
  }
  // demo workflow — seed a few pieces mid-pipeline so Edit & Review modes have something to show
  if (!db._wfDemo) {
    const setS = (ref, s, edited, reviewed) => { const c = (db.content || []).find(x => x.ref === ref); if (c) { c.status = s; c.published = s === "published"; c.statusByLang = { en: langExists(c, "en") ? s : "draft", fr: langExists(c, "fr") ? s : "draft" }; if (edited) c.editedBy = edited; if (reviewed) c.reviewedBy = reviewed; } };
    setS("ds-embeddings", "review", "Tom Beaumont");
    setS("eng-observability", "published", "Tom Beaumont", "L\u00e9a Marchand");
    setS("ds-dashboards", "review", "Tom Beaumont");
    setS("diy-soldering-rig", "draft", "Tom Beaumont");
    setS("diy-vid-cnc", "validated", "Tom Beaumont", "L\u00e9a Marchand");
    setS("alp-risk-math", "published", "Tom Beaumont", "L\u00e9a Marchand");
    // new hidden sections — a couple mid-pipeline so Edit/Review/Publish modes have something to show
    setS("pod-trail-talk", "review", "Tom Beaumont");
    setS("mus-compile-focus", "draft", "Tom Beaumont");
    setS("doc-pipeline-runbook", "validated", "Tom Beaumont", "L\u00e9a Marchand");
    // ds-causal-2026 is published in EN but never translated to FR — normalize already
    // seeds { en: published, fr: draft } (a missing language stays Draft, never inherits).
    const gm = (db.otherPages || []).find(p => p.id === "game"); if (gm) { gm.status = "review"; gm.published = false; gm.statusByLang = { en: "review", fr: pageHasLang(gm, "fr") ? "review" : "draft" }; gm.editedBy = "Tom Beaumont"; }
    const sv = (db.otherPages || []).find(p => p.id === "survey"); if (sv) { sv.status = "draft"; sv.published = false; sv.statusByLang = { en: "draft", fr: "draft" }; sv.editedBy = "Tom Beaumont"; }
    db._wfDemo = true;
  }
  return db;
}

function load() {
  try {
    const raw = localStorage.getItem(DB_KEY);
    if (raw) return normalize(JSON.parse(raw));
  } catch (e) {}
  return normalize(seed());
}

const StoreCtx = createContext(null);

function StoreProvider({ children }) {
  const [db, setDb] = useState(load);
  useEffect(() => {
    try { localStorage.setItem(DB_KEY, JSON.stringify(db)); } catch (e) {}
  }, [db]);

  const update = useCallback((fn) => {
    setDb((prev) => {
      const next = JSON.parse(JSON.stringify(prev));
      fn(next);
      return next;
    });
  }, []);

  const reset = useCallback(() => setDb(normalize(seed())), []);

  const api = useMemo(() => ({ db, update, reset, setDb }), [db, update, reset]);
  return <StoreCtx.Provider value={api}>{children}</StoreCtx.Provider>;
}

function useStore() { return useContext(StoreCtx); }

/* navigation context (provided by App) */
const NavCtx = createContext({ route: { name: "home" }, go: () => {}, auth: false, setAuth: () => {} });
function useNav() { return useContext(NavCtx); }

// helpers
function accentFor(id) { return ACCENTS[id] || ACCENTS.datascientist; }
/* socials shown for a domain = the ones picked (and ordered) in that domain's own settings.
   Domain settings are the single source of truth — the social definition no longer carries a domain list. */
function domainSocials(db, domainId) {
  const d = (db.domains || []).find(x => x.id === domainId);
  const ids = (d && d.socials) || [];
  return ids.map(id => (db.socials || []).find(s => s.id === id)).filter(Boolean);
}
function uid(p) { return (p || "id") + "_" + Math.random().toString(36).slice(2, 8); }

/* ---- media library helpers (Assets ↔ R2) ---- */
function assetById(db, id) { return (db.media || []).find(m => m.id === id) || null; }
function mediaFolders(db, type) {
  const set = [];
  (db.media || []).forEach(m => { if ((!type || m.type === type) && m.folder && !set.includes(m.folder)) set.push(m.folder); });
  return set;
}
function mediaInFolder(db, { type, folder, locale, q } = {}) {
  const needle = (q || "").trim().toLowerCase();
  return (db.media || []).filter(m =>
    (!type || m.type === type) &&
    (!folder || folder === "all" || m.folder === folder) &&
    (!locale || locale === "all" || !m.locale || m.locale === locale) &&
    (!needle || (m.label + " " + m.key + " " + (m.folder || "")).toLowerCase().includes(needle))
  );
}
/* a playable/viewable URL for an asset: its uploaded data URL, else its external url */
function assetSrc(m) { return m ? (m.url || "") : ""; }

/* ---- access grants: resolve a granted id (content / other-page / section) to an openable target ---- */
function resolveGrant(db, gid) {
  const c = (db.content || []).find(x => x.id === gid);
  if (c) {
    const sec = (db.sections || []).find(s => s.kind === c.type);
    // a grant is content-level (not per language): show the WHOLE original-language
    // unit (EN preferred, falling back entirely to FR) — title & blurb from one language.
    const w = wholeLang(c, "en");
    const label = w.title || c.ref;
    const desc = w.description || "";
    return { id: gid, label, route: `/${c.type === "video" ? "videos" : "blog"}/${c.ref}`, kind: c.type === "video" ? "video" : "article", privateItem: !!c.private || c.published === false, desc, logo: (sec && sec.logo) || null, cover: c.cover || null, statusByLang: c.statusByLang || null, sectionId: sec ? sec.id : null, sectionLabel: sec ? (secLabel(sec, "en") || sec.label) : (c.type === "video" ? "Videos" : "Blog"), sectionKind: c.type };
  }
  const p = (db.otherPages || []).find(x => x.id === gid);
  if (p) { const w = pageWholeLang(p, "en"); return { id: gid, label: w.label || p.id, route: p.route, kind: p.kind === "secret" ? "secret" : "page", privateItem: p.published === false, desc: w.desc || "", logo: null, cover: null, statusByLang: p.statusByLang || null, sectionId: "other", sectionLabel: "Other", sectionKind: p.kind }; }
  const s = (db.sections || []).find(x => x.id === gid);
  if (s) return { id: gid, label: secLabel(s, "en") || s.label, route: s.route, kind: "section", privateItem: false, desc: "", logo: s.logo || null, cover: null, statusByLang: null, sectionId: gid, sectionLabel: secLabel(s, "en") || s.label, sectionKind: s.kind };
  return null;
}
function userGrants(db, user) { return ((user && user.grants) || []).map(g => resolveGrant(db, g)).filter(Boolean); }
/* grants ranked by popularity (views) — used by the "Shared with you" shortcut (top 3 most-viewed) */
function grantViews(db, gid) { const c = (db.content || []).find(x => x.id === gid); return c ? (c.views || 0) : 0; }
function userGrantsRanked(db, user) {
  return userGrants(db, user).map(g => ({ ...g, views: grantViews(db, g.id) })).sort((a, b) => b.views - a.views);
}

/* ---- multi-domain helpers ---- */
function domainsOf(c) { return (c && c.domains && c.domains.length) ? c.domains : (c && c.domain ? [c.domain] : []); }
function primaryDomain(c) { return (c && c.domains && c.domains[0]) || (c && c.domain) || "datascientist"; }

/* ---- bilingual helpers — symmetric: EN = base fields, FR = c.tr.fr.
   Either language may be missing; the reader falls back to whichever exists. ---- */
function _fieldFilled(v) { return Array.isArray(v) ? v.length > 0 : (v != null && String(v).trim() !== ""); }
/* raw fields for ONE language, no fallback (used by editors) */
function langFieldsRaw(c, lang) {
  if (lang === "fr") {
    const t = (c.tr && c.tr.fr) || {};
    return { title: t.title || "", description: t.description || "", body: (t.body && t.body.length) ? t.body : [] };
  }
  return { title: c.title || "", description: c.description || "", body: (c.body && c.body.length) ? c.body : [] };
}
/* does a given language have authored content? (symmetric — EN is no longer assumed) */
function langExists(c, lang) {
  const f = langFieldsRaw(c, lang);
  return _fieldFilled(f.title) || _fieldFilled(f.body);
}
function hasLang(c, lang) { return langExists(c, lang); }
/* reader fields: requested language, falling back per-field to the other language */
function langFields(c, lang) {
  const want = langFieldsRaw(c, lang);
  const other = langFieldsRaw(c, lang === "fr" ? "en" : "fr");
  const pick = (k) => _fieldFilled(want[k]) ? want[k] : other[k];
  return { title: pick("title"), description: pick("description"), body: pick("body") };
}
/* ---- WHOLE-LANGUAGE display fields (no per-field mixing) ----
   A content version in a language is a UNIT: a thumbnail/cover shows one language's
   title + description + body together — never EN title with an empty/FR description.
   If the requested language has nothing authored, the WHOLE unit falls back to the
   other language. Use this everywhere a piece is *displayed* (cards, hero, grants). */
function wholeLang(c, dl) {
  const a = langFieldsRaw(c, dl);
  const ol = dl === "fr" ? "en" : "fr";
  const b = langFieldsRaw(c, ol);
  // anchor on the TITLE — a thumbnail shows the language that actually has a title,
  // wholesale. A half-authored language (e.g. only a body, or only a description)
  // never “wins” the display; we fall back entirely to the language that has a title.
  if (_fieldFilled(a.title)) return { ...a, lang: dl };
  if (_fieldFilled(b.title)) return { ...b, lang: ol };
  if (_fieldFilled(a.description) || (a.body && a.body.length)) return { ...a, lang: dl };
  return { ...b, lang: ol };
}
/* same idea for "Other" pages */
function pageWholeLang(p, dl) {
  const a = pageLangFieldsRaw(p, dl);
  const ol = dl === "fr" ? "en" : "fr";
  const b = pageLangFieldsRaw(p, ol);
  if (_fieldFilled(a.label)) return { ...a, lang: dl };
  if (_fieldFilled(b.label)) return { ...b, lang: ol };
  if (_fieldFilled(a.desc) || _fieldFilled(a.html)) return { ...a, lang: dl };
  return { ...b, lang: ol };
}
/* per-language status summary for a content/page — e.g. [{lang:'en',exists:false,status:'draft'},{lang:'fr',exists:true,status:'validated'}].
   Used by the access selector to print "EN — · FR validated" next to each item. */
function langStatusSummary(o) {
  return ["en", "fr"].map((lg) => ({ lang: lg, exists: itemExists(o, lg), status: statusOfLang(o, lg) }));
}
/* should a piece appear at all in the given language?
   strict hides it when the language is missing; otherwise it falls back to the other language. */
function isVisibleInLang(c, lang) {
  if (langExists(c, lang)) return true;
  if ((c && c.fallback) === "strict") return false;
  return langExists(c, lang === "fr" ? "en" : "fr");   // fall back to whatever exists
}
/* ---- PUBLIC reading visibility (role-INDEPENDENT) ----
   The normal reading site is identical for everyone (signed-out or signed-in) — it
   shows only PUBLISHED content. Pre-released (review/validated) pieces live in Tester
   mode and the collaborator modes, never in passive reading. A piece appears in `lang`
   iff that language is published, or (non-strict) the other language is published and
   is shown as a fallback. */
function readableInLang(c, lang) {
  if (langExists(c, lang) && publishedInLang(c, lang)) return true;
  if (c && c.fallback === "strict") return false;
  const o = lang === "fr" ? "en" : "fr";
  return langExists(c, o) && publishedInLang(c, o);
}
/* which language a public reader actually sees for `lang`: the requested one when it's
   published, else the published fallback language. */
function publicDisplayLang(c, lang) {
  if (langExists(c, lang) && publishedInLang(c, lang)) return lang;
  const o = lang === "fr" ? "en" : "fr";
  if ((!c || c.fallback !== "strict") && langExists(c, o) && publishedInLang(c, o)) return o;
  return lang;
}
/* ---- per-language "ready" gate ----
   A language is READY (may be flagged for review / validated / published) only when its
   title + description + the section's MAIN content are all filled. Title & description are
   per-language; the main content is per-type (body is per-language; video link / album /
   gallery / documents are shared across languages). */
function _bodyFilled(body) { return (body || []).some(b => _fieldFilled(b.text) || _fieldFilled(b.code) || (b.type === "img" && b.src) || b.type === "table"); }
function mainContentFilled(c, lang) {
  const k = (c && c.type) || "blog";
  if (k === "blog") { const f = langFieldsRaw(c, lang); return _bodyFilled(f.body); }
  if (k === "video") return _fieldFilled(c.youtube) || _fieldFilled(c.videoUrl) || !!c.videoFile;
  if (k === "image") return Array.isArray(c.gallery) && c.gallery.length > 0;
  if (k === "document") return (Array.isArray(c.docs) && c.docs.length > 0) || _fieldFilled(c.pdf);
  // audio kinds (podcast / music / audio / sound)
  if ((Array.isArray(c.tracks) && c.tracks.some(t => t.assetId || _fieldFilled(t.title)))) return true;
  return _fieldFilled(c.playlist) || !!c.playlistFile;
}
function langReady(c, lang) {
  const f = langFieldsRaw(c, lang);
  return _fieldFilled(f.title) && _fieldFilled(f.description) && mainContentFilled(c, lang);
}
/* missing fields, for an inline checklist */
function langMissingFields(c, lang) {
  const f = langFieldsRaw(c, lang); const out = [];
  if (!_fieldFilled(f.title)) out.push("title");
  if (!_fieldFilled(f.description)) out.push("description");
  if (!mainContentFilled(c, lang)) out.push((c && c.type) === "video" ? "video link" : (c && c.type) === "image" ? "images" : (c && c.type) === "document" ? "documents" : ((c && c.type) && c.type !== "blog") ? "audio source" : "body");
  return out;
}
/* ---- unified readiness / existence across content items AND other-pages ----
   content items carry a `type`; other-pages don't. A piece displays as a real
   language version (badge shows its status) only when that language is READY;
   otherwise it reads as "—" (missing). */
function itemReady(o, lang) { return o && o.type ? langReady(o, lang) : pageReady(o, lang); }
function itemExists(o, lang) { return o && o.type ? langExists(o, lang) : pageHasLang(o, lang); }
/* is a single language actionable in a given workflow status (draft counts untranslated
   languages too; every other status needs the language to actually exist). status may be
   a single id or an array (e.g. tester previews review+validated). */
function itemLangActionable(o, lg, status) {
  const st = statusOfLang(o, lg);
  if (Array.isArray(status)) return status.includes(st) && itemExists(o, lg);
  if (status === "draft") return st === "draft";
  return st === status && itemExists(o, lg);
}
/* does a piece belong in a collaborator's queue for `status`? Scoped to the caller's
   EDITORIAL access (Permissions_v2): public sections are reachable by default; hidden
   sections, PRIVATE content and "Other" pages need an explicit editorial grant. */
function sectionOfContent(db, c) { return (db.sections || []).find(s => s.kind === (c && c.type)) || null; }
function hasEditorialAccess(db, o, session, role) {
  if (role === "admin") return true;
  const eg = (session && session.editGrants) || [];
  const off = (session && session.editGrantsOff) || [];
  if (o && o.type) { // content
    const sec = sectionOfContent(db, o);
    const secId = sec && sec.id;
    const grantedItem = eg.includes(o.id);
    const grantedSec = !!secId && eg.includes(secId);
    if (o.private) { if (role === "publisher" || role === "tester") return false; return grantedItem || grantedSec; }
    if (sec && !sec.inMenu) return grantedItem || grantedSec;          // hidden section → grant required
    if (secId && off.includes(secId)) return grantedItem;             // public section explicitly removed
    if (off.includes(o.id)) return false;
    return true;                                                       // public section → default access
  }
  return eg.includes(o.id);                                           // "Other" page → explicit grant required
}
/* SHARED (view-only) access — the normal-mode “Shared content” menu */
function hasSharedAccess(db, o, session) {
  const g = (session && session.grants) || [];
  if (o && o.type) { const sec = sectionOfContent(db, o); return g.includes(o.id) || (!!sec && g.includes(sec.id)); }
  return g.includes(o.id);
}
/* can this role act on this SECTION at all in a collab mode? (mode dashboard + restricted menu) */
function sectionEditorialAccess(db, sec, session, role) {
  if (role === "admin") return true;
  if (!sec) return false;
  const eg = (session && session.editGrants) || [];
  const off = (session && session.editGrantsOff) || [];
  if (sec.inMenu) return !off.includes(sec.id);                       // public, unless explicitly removed
  if (eg.includes(sec.id)) return true;                               // hidden — whole-section grant
  return (db.content || []).some(c => c.type === sec.kind && eg.includes(c.id)); // … or a granted item inside
}
function itemActionable(db, o, status, session, role, adminEdit) {
  if (adminEdit) return true;
  if (!hasEditorialAccess(db, o, session, role)) return false;
  return ["en", "fr"].some(lg => itemLangActionable(o, lg, status));
}

/* other-page "ready" gate — title + description + content (html) all filled */
function pageReady(p, lang) {
  const f = pageLangFieldsRaw(p, lang);
  if (p && p.kind === "secret") return _fieldFilled(f.label);
  return _fieldFilled(f.label) && _fieldFilled(f.desc) && _fieldFilled(f.html);
}
/* translated section label: base = EN label, s.tr.fr.label override */
function secLabel(s, lang) {
  if (lang === "fr" && s && s.tr && s.tr.fr && s.tr.fr.label && s.tr.fr.label.trim()) return s.tr.fr.label;
  return s ? s.label : "";
}
/* other-page bilingual fields (symmetric, with per-field fallback) */
function pageLangFieldsRaw(p, lang) {
  if (lang === "fr") { const t = (p.tr && p.tr.fr) || {}; return { label: t.label || "", desc: t.desc || "", html: t.html || "" }; }
  return { label: p.label || "", desc: p.desc || "", html: p.html || "" };
}
function pageLangFields(p, lang) {
  const want = pageLangFieldsRaw(p, lang);
  const other = pageLangFieldsRaw(p, lang === "fr" ? "en" : "fr");
  const pick = (k) => _fieldFilled(want[k]) ? want[k] : other[k];
  return { label: pick("label"), desc: pick("desc"), html: pick("html") };
}
function pageHasLang(p, lang) {
  const f = pageLangFieldsRaw(p, lang);
  return _fieldFilled(f.label) || _fieldFilled(f.html);
}
function pageVisibleInLang(p, lang) {
  if (pageHasLang(p, lang)) return true;
  if ((p && p.fallback) === "strict") return false;
  return pageHasLang(p, lang === "fr" ? "en" : "fr");
}
/* domain bilingual: base = EN, d.tr.fr override */
function domLangFields(d, lang) {
  const base = { title: d.title, presTitle: d.presTitle, quote: d.quote, presBody: d.presBody };
  if (lang === "fr" && d.tr && d.tr.fr) {
    const t = d.tr.fr;
    return {
      title: t.title || d.title, presTitle: t.presTitle || d.presTitle,
      quote: t.quote || d.quote, presBody: t.presBody || d.presBody,
    };
  }
  return base;
}

/* generic bilingual object: base = EN fields, FR = obj.tr.fr override.
   Used by legal pages and the static form pages (contact / feedback / about). */
function objLangRaw(obj, lang, keys) {
  const src = lang === "fr" ? ((obj && obj.tr && obj.tr.fr) || {}) : (obj || {});
  const out = {};
  keys.forEach(k => { out[k] = src[k] != null ? src[k] : ""; });
  return out;
}
function objLangFields(obj, lang, keys) {
  const want = objLangRaw(obj, lang, keys);
  const other = objLangRaw(obj, lang === "fr" ? "en" : "fr", keys);
  const out = {};
  keys.forEach(k => { out[k] = _fieldFilled(want[k]) ? want[k] : other[k]; });
  return out;
}
function objHasLang(obj, lang, keys) {
  const f = objLangRaw(obj, lang, keys);
  return keys.some(k => _fieldFilled(f[k]));
}
/* set a bilingual field on an object: EN writes base, FR writes tr.fr */
function setObjLang(obj, lang, key, value) {
  if (lang === "fr") { obj.tr = obj.tr || {}; obj.tr.fr = obj.tr.fr || {}; obj.tr.fr[key] = value; }
  else obj[key] = value;
}

function fmtDate(s) {
  try { return new Date(s).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); }
  catch (e) { return s; }
}

Object.assign(window, { StoreProvider, useStore, NavCtx, useNav, accentFor, ACCENTS, ROLES, roleMeta, roleCan, WORKFLOW, WF_ORDER, wfMeta, statusOf, statusOfLang, publishedInLang, canSeeStatus, allowedStatusTransitions, langReady, mainContentFilled, langMissingFields, pageReady, itemReady, itemExists, itemLangActionable, itemActionable, hasEditorialAccess, hasSharedAccess, sectionEditorialAccess, sectionOfContent, readableInLang, publicDisplayLang, seedUsers, userGrants, userGrantsRanked, grantViews, resolveGrant, uid, fmtDate, seedDB: seed, LEGAL_SEED, PAGES_SEED, bodyFromDesc, readTime, wordsOf, domainsOf, primaryDomain, langFields, langFieldsRaw, wholeLang, pageWholeLang, langStatusSummary, langExists, hasLang, isVisibleInLang, secLabel, pageLangFields, pageLangFieldsRaw, pageHasLang, pageVisibleInLang, domLangFields, domainSocials, objLangRaw, objLangFields, objHasLang, setObjLang, SECTION_TYPES, SECTION_TYPE_ORDER, sectionTypeOf, sectionTypeMeta, isAudioContentKind, AUDIO_KINDS, assetById, mediaFolders, mediaInFolder, assetSrc, seedMedia, seedQrDataUrl });
