Add native empty project workflow
This commit is contained in:
+33
-2
@@ -47,6 +47,21 @@ const initial: State = {
|
||||
styleTraits: ["Clear, reflective sentences", "Concrete institutional examples", "Measured, invitational argument"], disallowedHabits: ["Overstated certainty", "Generic scene-setting"], finalized: false, updatedAt: "2026-07-16T17:42:00.000Z",
|
||||
};
|
||||
|
||||
function createEmptyProject(id = uid("project")): State {
|
||||
const createdAt = now();
|
||||
const title = "Untitled Project";
|
||||
const summary = `Created ${title}`;
|
||||
return {
|
||||
id, title, subtitle: "", phase: "ideation", privacy: "Local", cloudApproved: false, challenge: "Balanced", spokenReplies: false,
|
||||
turns: [], ideas: [], manuscript: "",
|
||||
generation: { id: "", state: "idle", text: "", prompt: "", relation: "synthesize" },
|
||||
events: [{ id: uid("event"), type: "PROJECT_CREATED", actor: "user", at: createdAt, summary, previousHash: null, hash: hash(`|PROJECT_CREATED|${summary}|${createdAt}`) }],
|
||||
checkpoints: [],
|
||||
provider: { kind: "ollama", name: "Ollama", endpoint: "http://127.0.0.1:11434", model: "llama3.2", mode: "local", connected: false },
|
||||
styleTraits: [], disallowedHabits: [], finalized: false, updatedAt: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeNative<T>(command: string, args: Record<string, unknown> = {}): Promise<T | null> { const host = window as unknown as { __TAURI_INTERNALS__?: { invoke: (name: string, payload: Record<string, unknown>) => Promise<T> } }; return host.__TAURI_INTERNALS__ ? host.__TAURI_INTERNALS__.invoke(command, args) : null; }
|
||||
function download(name: string, contents: string, type = "text/plain") { const href = URL.createObjectURL(new Blob([contents], { type })); const a = document.createElement("a"); a.href = href; a.download = name; a.click(); URL.revokeObjectURL(href); }
|
||||
function Mark({ children, tone = "neutral" }: { children: React.ReactNode; tone?: string }) { return <span className={`mark mark-${tone}`}>{children}</span>; }
|
||||
@@ -116,13 +131,29 @@ export default function Thinkloom() {
|
||||
const exportFile = async (format: "markdown" | "html" | "text" | "evidence") => { const native = await invokeNative<string>("export_project", { format, sanitized }); if (native) { setNotice(`Exported to ${native}`); return; } if (format === "markdown") download(`${project.title}.md`, project.manuscript, "text/markdown"); if (format === "text") download(`${project.title}.txt`, clean(project.manuscript)); if (format === "html") download(`${project.title}.html`, `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(project.title)}</title><style>body{max-width:720px;margin:64px auto;font:18px/1.7 Georgia,serif;padding:24px}</style></head><body>${toHtml(project.manuscript)}</body></html>`, "text/html"); if (format === "evidence") download(`${project.title}-creative-process.json`, JSON.stringify({ schemaVersion: "1.0", projectId: project.id, createdAt: now(), sanitized, provenanceChainHead: project.events.at(-1)?.hash, manuscriptHash: hash(project.manuscript), events: sanitized ? project.events.filter((event) => !event.provider) : project.events }, null, 2), "application/json"); mutate("EXPORT_CREATED", `Created ${format} export`, (current) => current); };
|
||||
const startVoice = () => { if (listening) { setListening(false); setNotice("Voice input stopped; no audio was retained."); return; } speechSynthesis?.cancel(); const host = window as unknown as { webkitSpeechRecognition?: new () => { lang: string; interimResults: boolean; start: () => void; onresult: (e: { results: ArrayLike<{ 0: { transcript: string } }> }) => void; onend: () => void; onerror: () => void } }; if (!host.webkitSpeechRecognition) { setNotice("Voice transcription is unavailable here. Typed input is ready."); return; } const recognition = new host.webkitSpeechRecognition(); recognition.lang = "en-US"; recognition.interimResults = true; recognition.onresult = (e) => setMessage(Array.from(e.results).map((result) => result[0].transcript).join("")); recognition.onend = () => { setListening(false); setNotice("Transcript ready to review. Audio was discarded."); }; recognition.onerror = () => { setListening(false); setNotice("Transcription failed. Audio was discarded; retry or type instead."); }; recognition.start(); setListening(true); setNotice("Listening… provisional transcript will appear below."); };
|
||||
const verifyChain = () => { const valid = project.events.every((event, index) => event.previousHash === (index ? project.events[index - 1].hash : null)); setNotice(valid ? `History verified: ${project.events.length} linked events.` : "History needs repair. Your writing remains safely autosaved."); };
|
||||
const createEmptyNativeProject = async () => {
|
||||
const hasWork = Boolean(project.turns.length || project.ideas.length || project.manuscript.trim() || project.checkpoints.length);
|
||||
if (hasWork && !window.confirm("Start a new empty project? Your current project will remain in its existing folder, but unsaved in-app changes should be saved first.")) return;
|
||||
try {
|
||||
const folder = await invokeNative<string>("choose_project_folder");
|
||||
if (!folder) { setNotice("New project cancelled. Your current project is unchanged."); return; }
|
||||
const blank = createEmptyProject();
|
||||
const result = await invokeNative<{ path: string; manifest: { id: string } }>("create_project", { path: folder, title: blank.title });
|
||||
if (!result) { setNotice("Creating project folders is available in the installed desktop app."); return; }
|
||||
const next = { ...blank, id: result.manifest.id };
|
||||
setProject(next); setProjectPath(result.path); setMessage(""); setShowRejected(false); setEditingIdea(null); setView("ideation");
|
||||
localStorage.setItem("thinkloom-project-v1", JSON.stringify(next));
|
||||
await invokeNative("persist_state", { appState: next });
|
||||
setNotice("New empty project created. Give it a title when you are ready.");
|
||||
} catch (error) { setNotice(`New project could not be created: ${String(error)}`); }
|
||||
};
|
||||
const createNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) { setNotice("Choose a folder when you are ready to create the desktop project."); return; } const result = await invokeNative<{ path: string }>("create_project", { path: folder, title: project.title }); if (!result) { setNotice("Project folders are available in the installed desktop app."); return; } setProjectPath(result.path); await invokeNative("persist_state", { appState: project }); setNotice("Self-contained project created and ready."); } catch (error) { setNotice(`Project could not be created: ${String(error)}`); } };
|
||||
const openNativeProject = async () => { try { const folder = await invokeNative<string>("choose_project_folder"); if (!folder) return; const result = await invokeNative<{ path: string }>("open_project", { path: folder }); if (!result) { setNotice("Opening project folders is available in the installed desktop app."); return; } setProjectPath(result.path); const restored = await invokeNative<State>("load_project_state"); if (restored) setProject(restored); setNotice("Project reopened and its history verified."); } catch (error) { setNotice(`Project could not be opened: ${String(error)}`); } };
|
||||
|
||||
if (!hydrated) return <main className="loading"><div className="brand-mark">T</div><p>Gathering your threads…</p></main>;
|
||||
return <main className="app-shell">
|
||||
<a className="skip-link" href="#workspace">Skip to workspace</a>
|
||||
<header className="project-bar"><button className="brand" onClick={() => navigate("project")} aria-label="Open project overview"><span className="brand-mark">T</span><span>Thinkloom</span></button><div className="project-identity"><strong>{project.title}</strong><span>{project.subtitle}</span></div><div className="project-status"><Mark tone={project.privacy === "Local" ? "good" : project.privacy === "Cloud" ? "cloud" : "warm"}><span className="status-dot" />{project.privacy}</Mark><button className="history-state" onClick={() => navigate("provenance")}>◇ History recorded</button><span className="save-state">Saved</span></div></header>
|
||||
<header className="project-bar"><button className="brand" onClick={() => navigate("project")} aria-label="Open project overview"><span className="brand-mark">T</span><span>Thinkloom</span></button><div className="project-identity"><strong>{project.title}</strong><span>{project.subtitle || "New project"}</span></div><button className="new-project-button" onClick={() => void createEmptyNativeProject()}>+ New project</button><div className="project-status"><Mark tone={project.privacy === "Local" ? "good" : project.privacy === "Cloud" ? "cloud" : "warm"}><span className="status-dot" />{project.privacy}</Mark><button className="history-state" onClick={() => navigate("provenance")}>◇ History recorded</button><span className="save-state">Saved</span></div></header>
|
||||
<nav className="main-nav" aria-label="Main navigation"><div className="phase-nav">{(["ideation", "drafting", "finalization"] as Phase[]).map((phase, index) => <button key={phase} className={view === phase ? "active" : ""} onClick={() => navigate(phase)}><span>{index + 1}</span>{phase[0].toUpperCase() + phase.slice(1)}</button>)}</div><div className="utility-nav">{(["project", "history", "provenance", "export", "settings"] as View[]).map((item) => <button key={item} className={view === item ? "active" : ""} onClick={() => navigate(item)}>{item[0].toUpperCase() + item.slice(1)}</button>)}</div></nav>
|
||||
<div className="notice" role="status" aria-live="polite"><span>✦</span>{notice}</div>
|
||||
<section id="workspace" className="workspace" tabIndex={-1}>
|
||||
@@ -146,7 +177,7 @@ export default function Thinkloom() {
|
||||
<aside className="release-panel panel"><span className="eyebrow">Release desk</span><h2>Prepare to publish</h2><div className="release-check"><span>✓</span><div><strong>History chain intact</strong><small>{project.events.length} events linked</small></div></div><div className="release-check"><span>✓</span><div><strong>Manuscript has a title</strong><small>{countWords(project.manuscript)} words ready</small></div></div><div className="release-check"><span>{project.checkpoints.length ? "✓" : "·"}</span><div><strong>Version saved</strong><small>{project.checkpoints.at(-1)?.name ?? "Save a version first"}</small></div></div>{project.generation.state === "staged" && <div className="generation-preview compact"><div className="preview-label"><span>Editorial preview</span><Mark tone="warm">Not applied</Mark></div><p>{project.generation.text}</p><div className="preview-actions"><button className="primary-button" onClick={() => acceptGeneration("replace")}>Apply to selection</button><button onClick={discardGeneration}>Discard</button></div></div>}<button className="secondary-button full" onClick={() => saveCheckpoint("Pre-release review")}>Save review version</button><button className="release-button" onClick={finalize}>Finalize release ↗</button><p className="fine-print">Finalizing creates a named, restorable release. You can continue writing afterward.</p></aside>
|
||||
</section>}
|
||||
|
||||
{view === "project" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">One publication, one place</span><h1>Project overview</h1><p>Everything needed to restore this publication travels with it.</p></div><div className="overview-grid"><article className="feature-card wide"><span className="card-index">01</span><h2>{project.title}</h2><input aria-label="Project title" value={project.title} onChange={(event) => setProject((current) => ({ ...current, title: event.target.value }))} /><textarea aria-label="Project description" value={project.subtitle} onChange={(event) => setProject((current) => ({ ...current, subtitle: event.target.value }))} /><div className="stat-row"><div><strong>{countWords(project.manuscript)}</strong><span>words</span></div><div><strong>{project.ideas.filter((idea) => idea.status === "accepted").length}</strong><span>accepted ideas</span></div><div><strong>{project.events.length}</strong><span>history events</span></div></div></article><article className="feature-card"><span className="card-index">02</span><h2>Project health</h2><p>Writing, ideas, and creative history are autosaved. Canonical files are refreshed by the desktop app.</p><div className="project-actions"><button className="primary-button" onClick={() => void createNativeProject()}>Create project folder</button><button className="secondary-button" onClick={() => void openNativeProject()}>Open existing</button><button className="secondary-button" onClick={verifyChain}>Verify history</button></div>{projectPath && <small className="project-path">{projectPath}</small>}</article><article className="feature-card"><span className="card-index">03</span><h2>Recovery</h2><p>Rotating snapshots keep the last seven valid project states outside the active folder.</p><button className="secondary-button" onClick={() => setNotice("Latest recovery point verified.")}>Check recovery point</button></article></div></section>}
|
||||
{view === "project" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">One publication, one place</span><h1>Project overview</h1><p>Everything needed to restore this publication travels with it.</p></div><div className="overview-grid"><article className="feature-card wide"><span className="card-index">01</span><h2>{project.title}</h2><input aria-label="Project title" placeholder="Name your project" value={project.title} onChange={(event) => setProject((current) => ({ ...current, title: event.target.value }))} /><textarea aria-label="Project description" placeholder="What are you exploring?" value={project.subtitle} onChange={(event) => setProject((current) => ({ ...current, subtitle: event.target.value }))} /><div className="stat-row"><div><strong>{countWords(project.manuscript)}</strong><span>words</span></div><div><strong>{project.ideas.filter((idea) => idea.status === "accepted").length}</strong><span>accepted ideas</span></div><div><strong>{project.events.length}</strong><span>history events</span></div></div></article><article className="feature-card"><span className="card-index">02</span><h2>Project health</h2><p>Writing, ideas, and creative history are autosaved. Canonical files are refreshed by the desktop app.</p><div className="project-actions"><button className="primary-button" onClick={() => void createEmptyNativeProject()}>New empty project</button><button className="secondary-button" onClick={() => void createNativeProject()}>Save current to folder</button><button className="secondary-button" onClick={() => void openNativeProject()}>Open existing</button><button className="secondary-button" onClick={verifyChain}>Verify history</button></div>{projectPath && <small className="project-path">{projectPath}</small>}</article><article className="feature-card"><span className="card-index">03</span><h2>Recovery</h2><p>Rotating snapshots keep the last seven valid project states outside the active folder.</p><button className="secondary-button" onClick={() => setNotice("Latest recovery point verified.")}>Check recovery point</button></article></div></section>}
|
||||
|
||||
{view === "history" && <section className="page-layout"><div className="page-heading"><span className="eyebrow">Restorable moments</span><h1>Version history</h1><p>Compare or return to meaningful stages without technical terminology.</p></div><div className="history-layout"><div className="version-list panel">{[...project.checkpoints].reverse().map((point, index) => <article className="version-row" key={point.id}><div className="version-symbol">{index === 0 ? "◆" : "◇"}</div><div><strong>{point.name}</strong><span>{fmt(point.at)} · {point.words} words</span></div><div><button onClick={() => setNotice(`Compared with “${point.name}”: ${countWords(project.manuscript) - point.words >= 0 ? "+" : ""}${countWords(project.manuscript) - point.words} words.`)}>Compare</button><button onClick={() => restoreCheckpoint(point)}>Restore</button></div></article>)}</div><aside className="panel history-aside"><span className="eyebrow">Current work</span><h2>{countWords(project.manuscript)} words</h2><p>{project.checkpoints.length ? `${countWords(project.manuscript) - project.checkpoints.at(-1)!.words >= 0 ? "+" : ""}${countWords(project.manuscript) - project.checkpoints.at(-1)!.words} words since ${project.checkpoints.at(-1)!.name}.` : "Save your first version when the draft reaches a meaningful moment."}</p><button className="primary-button full" onClick={() => saveCheckpoint()}>Save current version</button></aside></div></section>}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -31,6 +31,10 @@ test("implements the control and privacy contracts", async () => {
|
||||
assert.match(source, /GENERATION_PARTIALLY_ACCEPTED/);
|
||||
assert.match(source, /CLOUD_PROCESSING_APPROVED/);
|
||||
assert.match(source, /store_provider_secret/);
|
||||
assert.match(source, /New empty project/);
|
||||
assert.match(source, /function createEmptyProject/);
|
||||
assert.match(source, /turns: \[\], ideas: \[\], manuscript: ""/);
|
||||
assert.match(source, /New project cancelled\. Your current project is unchanged\./);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.match(css, /:focus-visible/);
|
||||
assert.match(css, /html,body,#root\{[^}]*height:100%[^}]*overflow:hidden/);
|
||||
|
||||
Reference in New Issue
Block a user