Add Web Push, guided start screen, SVG icons, chunked uploads, rename

- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 21:03:26 +01:00
1 parent 013695722a
commit a0d0e707f2
48 files changed
+2724 -228

No files matched your search

+20 -19
View File
@@ -1,3 +1,4 @@
import { Icon } from "./Icon";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { del, get, post, put } from "../api";
import { useFetch } from "../hooks";
@@ -45,13 +46,13 @@ const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) {
if (e.is_dir) return "📁";
if (IMAGE.test(e.name)) return "🖼";
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
if (/\.(md|txt)$/i.test(e.name)) return "📝";
return "📄";
if (e.is_dir) return <Icon name="folder" className="ficon dir" />;
if (IMAGE.test(e.name)) return <Icon name="image" className="ficon" />;
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return <Icon name="music" className="ficon" />;
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return <Icon name="cube" className="ficon" />;
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return <Icon name="file-doc" className="ficon" />;
if (/\.(md|txt)$/i.test(e.name)) return <Icon name="file-text" className="ficon" />;
return <Icon name="file" className="ficon" />;
}
export function Files({ onMenu }: { onMenu: () => void }) {
@@ -243,13 +244,13 @@ export function Files({ onMenu }: { onMenu: () => void }) {
)}
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
+ 📄
<Icon name="plus" className="tiny" /> <Icon name="file" />
</button>
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
+ 📁
<Icon name="plus" className="tiny" /> <Icon name="folder" />
</button>
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
⤒
<Icon name="upload" />
</button>
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
@@ -277,7 +278,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
) : (
hits.map((h) => (
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
<span>{h.is_dir ? "📁" : "📄"}</span>
<Icon name={h.is_dir ? "folder" : "file"} className="ficon" />
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
</button>
))
@@ -286,7 +287,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<>
{listing?.parent && (
<button className="entry" onClick={() => load(listing.parent!)}>
<span>↰</span>
<Icon name="folder-up" className="ficon" />
<span className="name">..</span>
</button>
)}
@@ -298,11 +299,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
<span>{icon(e)}</span>
<span className="name">{e.name}</span>
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark}</span>}
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark === "•" ? <Icon name="dot" className="tiny" /> : mark}</span>}
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
</button>
<details className="entry-menu">
<summary aria-label={t("files.actions")}>⋯</summary>
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu">
<button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
@@ -333,7 +334,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
←
<Icon name="arrow-left" />
</button>
<b className="grow">{pane.title}</b>
</div>
@@ -349,11 +350,11 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
←
<Icon name="arrow-left" />
</button>
<b className="grow" title={f.path}>
{baseName(f.path)}
{dirty && <span className="dirty"> ●</span>}
{dirty && <span className="dirty"> <Icon name="dot" className="tiny" /></span>}
</b>
{(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}>
@@ -361,7 +362,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
)}
<a className="button small" href={rawUrl(f.path, true)}>
⤓
<Icon name="download" />
</a>
{!f.binary && (
<button className="small primary" disabled={!dirty} onClick={() => save()}>
@@ -400,7 +401,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
☰
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.files")}</h1>