From 2feb7e646c1364730b8f34d71ad3be301116c6be Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Sat, 3 Oct 2026 15:08:02 +0530 Subject: [PATCH 1/2] Let the editor build a site of several pages --- src/__tests__/editorPages.test.ts | 80 +++++++++++++++++ src/__tests__/projectFile.test.ts | 38 ++++++++ src/app/editor/page.tsx | 140 ++++++++++++++++++++++++++++-- src/lib/editorPages.ts | 56 ++++++++++++ src/lib/frameStorage.ts | 3 + src/lib/projectFile.ts | 15 +++- 6 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/editorPages.test.ts create mode 100644 src/lib/editorPages.ts diff --git a/src/__tests__/editorPages.test.ts b/src/__tests__/editorPages.test.ts new file mode 100644 index 0000000..7c2019c --- /dev/null +++ b/src/__tests__/editorPages.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { addPage, removePage, renamePage, uniquePageSlug, canAddPage, withLiveSections } from "@/lib/editorPages"; +import { HOME_SLUG, MAX_PAGES, pagesSchema } from "@/lib/sitePages"; +import type { Section } from "@/lib/siteSchema"; + +const section = (heading: string): Section => ({ id: `s-${heading}`, heading, scrollHeight: 1000 }); +const home = { slug: HOME_SLUG, title: "Home", sections: [section("Welcome")] }; + +describe("adding pages", () => { + it("names a new page's address after its title", () => { + const pages = addPage([home], "About us", [section("About")]); + expect(pages[1]).toMatchObject({ slug: "about-us", title: "About us" }); + }); + + it("will not let two pages take one address", () => { + const pages = addPage(addPage([home], "About", [section("A")]), "About", [section("B")]); + expect(pages.map((p) => p.slug)).toEqual([HOME_SLUG, "about", "about-2"]); + }); + + it("steps around an address the export already uses", () => { + expect(uniquePageSlug([home], "404")).not.toBe("404"); + expect(uniquePageSlug([home], "Frames")).not.toBe("frames"); + }); + + it("stops at a sane number of pages rather than growing forever", () => { + let pages = [home]; + for (let i = 0; i < MAX_PAGES + 5; i++) pages = addPage(pages, `Page ${i}`, [section(`${i}`)]); + expect(pages).toHaveLength(MAX_PAGES); + expect(canAddPage(pages)).toBe(false); + }); + + it("produces a list the exporter will accept", () => { + const pages = addPage(addPage([home], "Our services", [section("S")]), "Contact", [section("C")]); + expect(pagesSchema.safeParse(pages).success).toBe(true); + }); +}); + +describe("renaming pages", () => { + it("moves the address with the title", () => { + const pages = renamePage(addPage([home], "About", [section("A")]), 1, "Our story"); + expect(pages[1]).toMatchObject({ title: "Our story", slug: "our-story" }); + }); + + it("keeps the home page at index.html whatever it is called", () => { + // Every host looks for index.html, so the home page's address is not the owner's to change. + const pages = renamePage([home], 0, "Start here"); + expect(pages[0]).toMatchObject({ title: "Start here", slug: HOME_SLUG }); + }); + + it("ignores an empty name rather than leaving a nameless tab", () => { + const pages = renamePage([home], 0, " "); + expect(pages[0].title).toBe("Home"); + }); + + it("does not collide with a sibling when renamed onto its name", () => { + const pages = addPage(addPage([home], "About", [section("A")]), "Contact", [section("C")]); + expect(renamePage(pages, 2, "About")[2].slug).toBe("about-2"); + }); +}); + +describe("removing pages", () => { + it("removes the page asked for", () => { + const pages = addPage(addPage([home], "About", [section("A")]), "Contact", [section("C")]); + expect(removePage(pages, 1).map((p) => p.title)).toEqual(["Home", "Contact"]); + }); + + it("refuses to remove the home page, so there is always an index.html", () => { + const pages = addPage([home], "About", [section("A")]); + expect(removePage(pages, 0)).toEqual(pages); + }); +}); + +describe("the page being edited", () => { + it("writes what is on screen back into the open page, and leaves the others alone", () => { + const pages = addPage([home], "About", [section("Old about")]); + const edited = withLiveSections(pages, 1, [section("New about")]); + expect(edited[1].sections[0].heading).toBe("New about"); + expect(edited[0].sections[0].heading).toBe("Welcome"); + }); +}); diff --git a/src/__tests__/projectFile.test.ts b/src/__tests__/projectFile.test.ts index 0ffba43..a6c4a48 100644 --- a/src/__tests__/projectFile.test.ts +++ b/src/__tests__/projectFile.test.ts @@ -7,6 +7,7 @@ import { parseProjectFile, projectFileName, backgroundTravels, + projectPages, type ProjectDocument, } from "@/lib/projectFile"; import { templateBySlug } from "@/lib/templates"; @@ -140,3 +141,40 @@ describe("it says what will not travel", () => { expect(backgroundTravels({ ...doc, styleJson: null })).toBe(false); }); }); + +describe("a file carries every page of a site", () => { + const twoPages = { + ...doc, + pages: [ + { slug: "index", title: "Home", sections: kept.sections }, + { slug: "contact", title: "Contact", sections: kept.sections.slice(0, 3) }, + ], + }; + + it("round trips the pages, not only the open one", () => { + const parsed = parseProjectFile(buildProjectFile(twoPages)); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(projectPages(parsed.value.document).map((p) => p.slug)).toEqual(["index", "contact"]); + expect(projectPages(parsed.value.document)[1].sections).toHaveLength(3); + }); + + it("reads a file written before pages existed as a single home page", () => { + const v1 = JSON.parse(buildProjectFile(doc)); + v1.version = 1; + delete v1.document.pages; + const parsed = parseProjectFile(JSON.stringify(v1)); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + const pages = projectPages(parsed.value.document); + expect(pages).toHaveLength(1); + expect(pages[0]).toMatchObject({ slug: "index", title: "Home" }); + expect(pages[0].sections).toHaveLength(kept.sections.length); + }); + + it("refuses a page list the exporter would reject", () => { + const bad = JSON.parse(buildProjectFile(twoPages)); + bad.document.pages[1].slug = "404"; + expect(parseProjectFile(JSON.stringify(bad)).ok).toBe(false); + }); +}); diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 0b75b44..0276c2a 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -13,7 +13,7 @@ import { ArrowLeft, Download, Plus, Trash2, Eye, EyeOff, ChevronUp, ChevronDown, Layers, Loader2, AlignLeft, AlignCenter, AlignRight, Monitor, Tablet, Smartphone, Music, Volume2, VolumeX, Save, - Undo2, Redo2, Copy, Sparkles, FileDown, FileUp + Undo2, Redo2, Copy, Sparkles, FileDown, FileUp, X, Pencil } from "lucide-react"; import { useScrollAudio } from "@/lib/useScrollAudio"; import { AssistantBar, useAssistantAvailable } from "@/components/AssistantBar"; @@ -27,8 +27,10 @@ import { faviconSvg, notFoundHtml, exportReadme, renderSocialCard, renderTouchIc import { generate2DFrames } from "@/lib/generate2DFrames"; import { AUTOSAVE_DEBOUNCE_MS, saveStatusLabel, type SaveState } from "@/lib/saveStatus"; import { SIGNUP_PROVIDER_NAMES, signupForm, signupStatus } from "@/lib/signupForm"; -import { buildProjectFile, parseProjectFile, projectFileName, backgroundTravels, type ProjectDocument } from "@/lib/projectFile"; +import { buildProjectFile, parseProjectFile, projectFileName, backgroundTravels, projectPages, type ProjectDocument } from "@/lib/projectFile"; import { countEditorOpened, countFirstEdit, countExportStarted, countExportFinished } from "@/lib/funnel"; +import { HOME_SLUG } from "@/lib/sitePages"; +import { addPage, removePage, renamePage, canAddPage, withLiveSections, type PageList } from "@/lib/editorPages"; const ScrollEngine = dynamic(() => import("@/components/ScrollEngine"), { ssr: false }); const ScrollSection = dynamic(() => import("@/components/ScrollSection"), { ssr: false }); @@ -195,6 +197,16 @@ function EditorInner() { const [fps, setFps] = useState(parsedFrames ? parseInt(fpsParam || "24") : 24); const [sections, setSections] = useState(() => templateSections ?? [defaultSection(0)]); const [selectedSection, setSelectedSection] = useState(sections[0].id); + /** + * The pages of this site. `sections` is whichever page is open, so every other page's + * sections live here and the open one is written back whenever it is needed whole. + */ + const [pages, setPages] = useState>(() => [ + { slug: HOME_SLUG, title: "Home", sections: templateSections ?? [defaultSection(0)] }, + ]); + const [openPage, setOpenPage] = useState(0); + const [renamingPage, setRenamingPage] = useState(null); + const allPages = (): PageList => withLiveSections(pages, openPage, sections); // Named after the preset or upload it came from, so a generated site does not // arrive here as an anonymous "My ScrollCraft Site". const [siteName, setSiteName] = useState( @@ -384,10 +396,20 @@ function EditorInner() { return; } - if (Array.isArray(doc.sections) && doc.sections.length) { + if (Array.isArray(doc.pages) && doc.pages.length) { + const restoredPages = doc.pages as PageList; + setPages(restoredPages); + setOpenPage(0); + const first = restoredPages[0].sections; + if (first.length) { + setSections(first); + setSelectedSection(first[0].id); + } + } else if (Array.isArray(doc.sections) && doc.sections.length) { const restored = doc.sections as Section[]; setSections(restored); setSelectedSection(restored[0].id); + setPages([{ slug: HOME_SLUG, title: "Home", sections: restored as EditorSection[] }]); } if (doc.name) setSiteName(doc.name); if (doc.description) setSiteDescription(doc.description); @@ -653,6 +675,7 @@ function EditorInner() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + pages: allPages(), sections, siteName, siteDescription, @@ -676,7 +699,7 @@ function EditorInner() { const msg = await res.json().then((d) => d?.error).catch(() => null); throw new Error(msg || "Export failed. Please try again."); } - const { html, audioExt } = await res.json(); + const { html, pages: exportedPages, audioExt } = await res.json(); // Build ZIP entirely in the browser — no round-trip for large frame data. Load // JSZip on demand so it stays out of the editor's first-load bundle. @@ -684,7 +707,12 @@ function EditorInner() { toast.info("Building ZIP…"); const { default: JSZip } = await import("jszip"); const zip = new JSZip(); - zip.file("index.html", html); + // One file per page, named by the server. An older response carries only `html`. + if (Array.isArray(exportedPages) && exportedPages.length) { + for (const page of exportedPages as { path: string; html: string }[]) zip.file(page.path, page.html); + } else { + zip.file("index.html", html); + } // Everything below turns the ZIP from "an index.html" into something a // non-technical owner can actually put online: an icon, a social card, a 404, @@ -832,6 +860,7 @@ function EditorInner() { name: siteName, description: siteDescription || undefined, sections, + pages: allPages(), themeJson: siteTheme ? JSON.stringify(siteTheme) : null, styleJson: styleSpec ? JSON.stringify(styleSpec) : null, customHead, @@ -884,6 +913,7 @@ function EditorInner() { name: siteName, description: siteDescription || undefined, sections, + pages: allPages(), themeJson: siteTheme ? JSON.stringify(siteTheme) : null, styleJson: styleSpec ? JSON.stringify(styleSpec) : null, customHead, @@ -907,6 +937,52 @@ function EditorInner() { } }; + const switchToPage = (index: number) => { + if (index === openPage || !pages[index]) return; + const synced = allPages(); + setPages(synced); + const next = synced[index].sections.length ? synced[index].sections : [defaultSection(0)]; + setSections(next); + setSelectedSection(next[0].id); + setOpenPage(index); + setRenamingPage(null); + }; + + const handleAddPage = () => { + const synced = allPages(); + if (!canAddPage(synced)) { + toast.warning(`A site can have ${synced.length} pages at most.`); + return; + } + const fresh = [defaultSection(0)]; + const next = addPage(synced, `Page ${synced.length + 1}`, fresh); + setPages(next); + setSections(fresh); + setSelectedSection(fresh[0].id); + setOpenPage(next.length - 1); + setRenamingPage(next.length - 1); + setDirty(true); + }; + + const handleRenamePage = (index: number, title: string) => { + setPages(renamePage(allPages(), index, title)); + setRenamingPage(null); + setDirty(true); + }; + + const handleRemovePage = (index: number) => { + const synced = allPages(); + if (!window.confirm(`Delete the page "${synced[index].title}" and everything on it?`)) return; + const next = removePage(synced, index); + if (next === synced) return; + const nextOpen = openPage >= next.length ? next.length - 1 : openPage > index ? openPage - 1 : openPage; + setPages(next); + setSections(next[nextOpen].sections); + setSelectedSection(next[nextOpen].sections[0]?.id ?? ""); + setOpenPage(nextOpen); + setDirty(true); + }; + const openProjectInputRef = useRef(null); const handleOpenProject = async (file: File) => { @@ -916,7 +992,10 @@ function EditorInner() { return; } const doc = parsed.value.document; - const restored = doc.sections as Section[]; + const restoredPages = projectPages(doc) as PageList; + setPages(restoredPages); + setOpenPage(0); + const restored = restoredPages[0].sections; if (restored.length) { setSections(restored); setSelectedSection(restored[0].id); @@ -1228,8 +1307,55 @@ function EditorInner() { )}
- {/* Left panel: sections list */} + {/* Left panel: pages, then the open page's sections */}
+
Sections diff --git a/src/lib/editorPages.ts b/src/lib/editorPages.ts new file mode 100644 index 0000000..6f359c8 --- /dev/null +++ b/src/lib/editorPages.ts @@ -0,0 +1,56 @@ +import { HOME_SLUG, MAX_PAGES, RESERVED_SLUGS, pageSlugFromTitle } from "@/lib/sitePages"; + +/** + * The page list the editor edits. + * + * Pure functions, because the editor already holds more state than any one person can + * hold in their head, and because what matters here is the rules: a home page that + * cannot be removed or renamed out of existence, and addresses that stay unique. + * + * Generic over the section type: the editor holds a stricter shape than the exporter, and + * neither should have to cast to use these. + */ +export type PageList = { slug: string; title: string; sections: S[] }[]; + +export function uniquePageSlug(pages: PageList, wanted: string, ignoreIndex = -1): string { + const taken = new Set(pages.filter((_, i) => i !== ignoreIndex).map((p) => p.slug)); + const base = pageSlugFromTitle(wanted); + if (!taken.has(base) && !RESERVED_SLUGS.includes(base as (typeof RESERVED_SLUGS)[number])) return base; + for (let n = 2; n < 100; n++) { + const candidate = `${base}-${n}`; + if (!taken.has(candidate)) return candidate; + } + return `${base}-${Date.now()}`; +} + +export function canAddPage(pages: PageList): boolean { + return pages.length < MAX_PAGES; +} + +export function addPage(pages: PageList, title: string, sections: S[]): PageList { + if (!canAddPage(pages)) return pages; + const clean = title.trim() || `Page ${pages.length + 1}`; + return [...pages, { slug: uniquePageSlug(pages, clean), title: clean.slice(0, 60), sections }]; +} + +export function renamePage(pages: PageList, index: number, title: string): PageList { + const clean = title.trim().slice(0, 60); + if (!clean || !pages[index]) return pages; + return pages.map((page, i) => { + if (i !== index) return page; + // The home page keeps index.html whatever it is called, because every host looks for it. + const slug = page.slug === HOME_SLUG ? HOME_SLUG : uniquePageSlug(pages, clean, index); + return { ...page, title: clean, slug }; + }); +} + +export function removePage(pages: PageList, index: number): PageList { + // Index 0 is the home page: without it there is no index.html to open. + if (index <= 0 || index >= pages.length || pages.length === 1) return pages; + return pages.filter((_, i) => i !== index); +} + +/** The page list with the sections currently on screen written back into the open page. */ +export function withLiveSections(pages: PageList, openIndex: number, sections: S[]): PageList { + return pages.map((page, i) => (i === openIndex ? { ...page, sections } : page)); +} diff --git a/src/lib/frameStorage.ts b/src/lib/frameStorage.ts index 841c02a..e0a3045 100644 --- a/src/lib/frameStorage.ts +++ b/src/lib/frameStorage.ts @@ -94,7 +94,10 @@ export async function deleteFrames(key: string): Promise { export interface StoredDocument { name: string; description?: string; + /** The open page's sections, kept for documents saved before a site could have pages. */ sections: unknown[]; + /** Every page of the site, home first. Absent on a document saved before pages existed. */ + pages?: { slug: string; title: string; sections: unknown[] }[]; themeJson?: string | null; styleJson?: string | null; customHead?: string; diff --git a/src/lib/projectFile.ts b/src/lib/projectFile.ts index 11bd36e..88a1779 100644 --- a/src/lib/projectFile.ts +++ b/src/lib/projectFile.ts @@ -1,8 +1,9 @@ import { string, number, boolean, object, literal, type infer as zInfer } from "zod"; import { sectionsSchema, type Section } from "@/lib/siteSchema"; +import { pagesSchema, asPages, type SitePage } from "@/lib/sitePages"; export const PROJECT_FORMAT = "scrollcraft.project"; -export const PROJECT_VERSION = 1; +export const PROJECT_VERSION = 2; /** Room for a long site with custom CSS, and far short of what a stray file could cost. */ const MAX_FILE_BYTES = 5_000_000; @@ -11,6 +12,8 @@ const documentSchema = object({ name: string().max(200), description: string().max(1000).optional(), sections: sectionsSchema, + /** Absent in a version 1 file, which held one page under `sections`. */ + pages: pagesSchema.optional(), themeJson: string().max(20_000).nullable().optional(), styleJson: string().max(5_000).nullable().optional(), customHead: string().max(20_000).optional(), @@ -26,7 +29,15 @@ const projectFileSchema = object({ document: documentSchema, }).strip(); -export type ProjectDocument = Omit, "sections"> & { sections: Section[] }; +export type ProjectDocument = Omit, "sections" | "pages"> & { + sections: Section[]; + pages?: SitePage[]; +}; + +/** Every page in a file, whichever version wrote it. */ +export function projectPages(doc: ProjectDocument): SitePage[] { + return asPages({ pages: doc.pages, sections: doc.sections }); +} export type ProjectFile = { format: string; version: number; exportedAt?: string; document: ProjectDocument }; export type ProjectParse = { ok: true; value: ProjectFile } | { ok: false; error: string }; From 1e37a97f98532c776b1dc3dc64c16749e774c34e Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Sat, 3 Oct 2026 15:11:29 +0530 Subject: [PATCH 2/2] Keep the audio test on the response shape, not its exact line --- src/__tests__/exportAudio.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/__tests__/exportAudio.test.ts b/src/__tests__/exportAudio.test.ts index 0810cad..4c950ed 100644 --- a/src/__tests__/exportAudio.test.ts +++ b/src/__tests__/exportAudio.test.ts @@ -22,7 +22,10 @@ describe("the ZIP and the page agree on the audio filename", () => { expect(ROUTE).toContain("const audioExt = AUDIO_EXT[baseMime] ?? \"mp3\";"); expect(ROUTE).toContain("new Audio('audio/track.${audioExt}')"); expect(ROUTE).toContain("audioExt,"); - expect(EDITOR).toContain("const { html, audioExt } = await res.json();"); + // The shape of the response grew a `pages` field; what matters is that the client + // still takes the extension from the server rather than guessing a second time. + expect(EDITOR).toMatch(/const \{[^}]*\baudioExt\b[^}]*\} = await res\.json\(\);/); + expect(EDITOR).not.toMatch(/audioExt\s*=\s*["'`]/); expect(EDITOR).toContain("zip.file(`audio/track.${audioExt}`, audioBase64, { base64: true });"); });