Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/__tests__/editorPages.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 4 additions & 1 deletion src/__tests__/exportAudio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });");
});

Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/projectFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
parseProjectFile,
projectFileName,
backgroundTravels,
projectPages,
type ProjectDocument,
} from "@/lib/projectFile";
import { templateBySlug } from "@/lib/templates";
Expand Down Expand Up @@ -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);
});
});
140 changes: 133 additions & 7 deletions src/app/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 });
Expand Down Expand Up @@ -195,6 +197,16 @@ function EditorInner() {
const [fps, setFps] = useState(parsedFrames ? parseInt(fpsParam || "24") : 24);
const [sections, setSections] = useState<Section[]>(() => templateSections ?? [defaultSection(0)]);
const [selectedSection, setSelectedSection] = useState<string>(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<PageList<EditorSection>>(() => [
{ slug: HOME_SLUG, title: "Home", sections: templateSections ?? [defaultSection(0)] },
]);
const [openPage, setOpenPage] = useState(0);
const [renamingPage, setRenamingPage] = useState<number | null>(null);
const allPages = (): PageList<EditorSection> => 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(
Expand Down Expand Up @@ -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<EditorSection>;
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);
Expand Down Expand Up @@ -653,6 +675,7 @@ function EditorInner() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pages: allPages(),
sections,
siteName,
siteDescription,
Expand All @@ -676,15 +699,20 @@ 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.
setExportStage("Building ZIP…");
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<HTMLInputElement>(null);

const handleOpenProject = async (file: File) => {
Expand All @@ -916,7 +992,10 @@ function EditorInner() {
return;
}
const doc = parsed.value.document;
const restored = doc.sections as Section[];
const restoredPages = projectPages(doc) as PageList<EditorSection>;
setPages(restoredPages);
setOpenPage(0);
const restored = restoredPages[0].sections;
if (restored.length) {
setSections(restored);
setSelectedSection(restored[0].id);
Expand Down Expand Up @@ -1228,8 +1307,55 @@ function EditorInner() {
)}

<main className="flex flex-col md:flex-row flex-1 overflow-y-auto md:overflow-x-auto md:overflow-y-hidden">
{/* Left panel: sections list */}
{/* Left panel: pages, then the open page's sections */}
<div className="w-full max-h-52 md:w-56 md:max-h-none border-b md:border-b-0 md:border-r border-white/5 flex flex-col bg-card/30 flex-shrink-0">
<nav aria-label="Pages" className="p-2 border-b border-white/5 flex flex-wrap items-center gap-1">
{pages.map((page, i) => (
<span key={page.slug} className="flex items-center">
{renamingPage === i ? (
<input
autoFocus
aria-label={`Name of page ${i + 1}`}
defaultValue={page.title}
onBlur={(e) => handleRenamePage(i, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenamePage(i, (e.target as HTMLInputElement).value);
if (e.key === "Escape") setRenamingPage(null);
}}
className="h-7 w-24 rounded-md border border-primary/50 bg-background px-2 text-xs"
/>
) : (
<button
onClick={() => (i === openPage ? setRenamingPage(i) : switchToPage(i))}
aria-current={i === openPage ? "page" : undefined}
title={i === openPage ? `Rename ${page.title}` : `Open ${page.title}`}
className={`h-7 rounded-md px-2 text-xs transition-colors ${i === openPage ? "bg-primary/20 text-primary-ink font-medium" : "text-muted-foreground hover:text-foreground hover:bg-white/5"}`}
>
{page.title}
{i === openPage && <Pencil className="ml-1 inline h-3 w-3 opacity-60" />}
</button>
)}
{i > 0 && (
<button
onClick={() => handleRemovePage(i)}
aria-label={`Delete the page ${page.title}`}
title={`Delete the page ${page.title}`}
className="ml-0.5 rounded p-1 text-muted-foreground hover:text-foreground hover:bg-white/5"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<button
onClick={handleAddPage}
aria-label="Add a page"
title="Add a page"
className="h-7 rounded-md px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-white/5"
>
<Plus className="h-3.5 w-3.5" />
</button>
</nav>
<div className="p-3 border-b border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Layers className="w-3.5 h-3.5 text-primary-ink" /> Sections
Expand Down
Loading
Loading