diff --git a/index.html b/index.html index ff93803..de771ca 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Tauri + React + Typescript + FileFlow diff --git a/src/App.css b/src/App.css index 83bf4f3..713359b 100644 --- a/src/App.css +++ b/src/App.css @@ -1,60 +1,6 @@ -:root { - color-scheme: light dark; - accent-color: #3b6fe0; - - --bg: #f4f5f7; - --surface: #ffffff; - --border: #e4e6ec; - --text: #1c1d22; - --muted: #696b76; - --accent: #3b6fe0; - --accent-2: #8b5cf6; - --ok: #1a8f3c; - --warn: #c2410c; - - --hover: rgba(20, 25, 50, 0.05); - --soft: rgba(59, 111, 224, 0.1); - --ring: rgba(59, 111, 224, 0.28); - --shadow: 0 10px 30px rgba(20, 25, 50, 0.08); - --shadow-sm: 0 1px 2px rgba(20, 25, 50, 0.06); - - --s1: 4px; - --s2: 8px; - --s3: 12px; - --s4: 16px; - --s5: 22px; - --s6: 32px; - - --r: 8px; - --r-lg: 14px; - --t: 150ms cubic-bezier(0.2, 0.6, 0.2, 1); - - --z-backdrop: 100; - --z-modal: 110; - - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-size: 13.5px; - color: var(--text); -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #15161a; - --surface: #212229; - --border: #34353f; - --text: #f1f2f6; - --muted: #9a9caa; - --accent: #5b8dff; - --accent-2: #a78bfa; - --ok: #30d158; - --warn: #ffb02e; - --hover: rgba(255, 255, 255, 0.06); - --soft: rgba(91, 141, 255, 0.16); - --ring: rgba(91, 141, 255, 0.36); - --shadow: 0 16px 40px rgba(0, 0, 0, 0.55); - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - } -} +/* Component styles. All color and structure tokens live in theme.css — nothing + in this file names a raw color, so every rule works in every theme. */ +@import "./theme.css"; * { box-sizing: border-box; @@ -87,6 +33,8 @@ body { height: 100vh; } +/* --- Top bar & progress ------------------------------------------------ */ + .topbar { display: flex; align-items: center; @@ -153,6 +101,8 @@ body { color: var(--accent); } +/* --- Layout & typography ------------------------------------------------ */ + main { flex: 1; overflow: auto; @@ -176,6 +126,8 @@ h3 { color: var(--muted); } +/* --- Buttons ------------------------------------------------------------ */ + button { font: inherit; font-weight: 500; @@ -207,13 +159,14 @@ button:disabled { button.primary, button.save:not(:disabled) { - background: linear-gradient(135deg, var(--accent), var(--accent-2)); - color: #fff; + background: var(--accent); + color: var(--on-accent); border-color: transparent; } button.primary:hover:not(:disabled), button.save:hover:not(:disabled) { + background: var(--accent); filter: brightness(1.07); } @@ -233,6 +186,8 @@ button.danger:hover:not(:disabled) { border-color: var(--warn); } +/* --- Rows & fields ------------------------------------------------------ */ + .row { display: flex; align-items: center; @@ -338,6 +293,8 @@ button.danger:hover:not(:disabled) { color: var(--muted); } +/* --- Lists, empty states, cards ----------------------------------------- */ + .empty { color: var(--muted); padding: var(--s6) 0; @@ -493,6 +450,8 @@ details.card-edit[open] > .card-head::before { border-radius: 6px; } +/* --- Badges -------------------------------------------------------------- */ + .badge { display: inline-block; font-size: 11px; @@ -514,17 +473,17 @@ details.card-edit[open] > .card-head::before { } .badge.drive { - background: linear-gradient(135deg, var(--accent), var(--accent-2)); + background: var(--flow-drive); color: #fff; } .badge.photos { - background: #5e5ce6; + background: var(--flow-photos); color: #fff; } .badge.folder { - background: #0d9488; + background: var(--flow-folder); color: #fff; } @@ -542,11 +501,13 @@ details.card-edit[open] > .card-head::before { font-variant-numeric: tabular-nums; } +/* --- Modal --------------------------------------------------------------- */ + .modal-backdrop { position: fixed; inset: 0; z-index: var(--z-backdrop); - background: rgba(10, 12, 24, 0.5); + background: var(--backdrop); backdrop-filter: blur(3px); display: flex; align-items: center; @@ -841,3 +802,73 @@ details.card-edit[open] > .card-head::before { .run-fail { color: var(--warn); } + +/* --- Theme picker (Settings → Appearance) ------------------------------ */ + +.themes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--s3); + margin-top: var(--s3); +} + +.theme-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: var(--s2); + text-align: left; +} + +.theme-card.on { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--ring); +} + +/* Mini preview: the theme's background holding a surface card with its accent + text. */ +.swatch { + display: flex; + align-items: flex-end; + height: 52px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.swatch-card { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + padding: 6px 7px; + border-radius: 5px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.swatch-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; +} + +.swatch-line { + height: 4px; + flex: 1; + border-radius: 2px; + opacity: 0.55; +} + +.theme-name { + font-weight: 600; + font-size: 12.5px; +} + +.theme-desc { + font-size: 11px; + line-height: 1.4; + color: var(--muted); + font-weight: 400; +} diff --git a/src/App.tsx b/src/App.tsx index b39f40c..ca207f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,20 @@ -import { useEffect, useState, type ReactNode } from "react"; +// The app shell: top bar with tabs and Save, the progress strip, live event +// subscriptions, and the shared naming modal. Each tab's content lives in views/. +import { useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; -import { open as openDialog } from "@tauri-apps/plugin-dialog"; -import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; import * as api from "./api"; -import type { - ActivityEntry, - AfterImport, - AlbumMode, - CardReady, - CardRule, - CleanupPolicy, - Config, - DateGroup, - EjectPolicy, - FolderKind, - FolderRule, - MountedCard, - NameMode, - PhotosReady, - Progress, - Route, -} from "./api"; +import type { ActivityEntry, CardReady, Config, PhotosReady, Progress } from "./api"; +import { NamingForm, type NamingReq } from "./components/NamingForm"; +import { ActivityView } from "./views/ActivityView"; +import { CardsView } from "./views/CardsView"; +import { FlowView } from "./views/FlowView"; +import { FoldersView } from "./views/FoldersView"; +import { SettingsView } from "./views/SettingsView"; +import { StatusView } from "./views/StatusView"; import "./App.css"; type Tab = "flow" | "status" | "cards" | "folders" | "activity" | "settings"; -// A request to name an import, from either flow — drives the shared naming modal. -type NamingReq = - | { kind: "card"; uuid: string; label: string; dates: DateGroup[] } - | { kind: "photos"; index: number; label: string; dates: DateGroup[] }; - const TABS: Tab[] = ["flow", "status", "cards", "folders", "activity", "settings"]; const TAB_LABELS: Record = { flow: "Flow", @@ -40,186 +25,6 @@ const TAB_LABELS: Record = { settings: "Settings", }; -const csvToList = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean); -const listToCsv = (l: string[]) => l.join(", "); - -async function pickFolder(): Promise { - try { - const res = await openDialog({ directory: true, multiple: false }); - return typeof res === "string" ? res : null; - } catch { - return null; - } -} - -/** A worked example of the folder a layout template produces. */ -function layoutExample(template: string): string { - const folder = template - .replace(/\{year\}/g, "2026") - .replace(/\{date\}/g, "2026-06-20") - .replace(/\{name\}/g, "Holiday") - .split("/") - .map((s) => s.trim()) - .filter(Boolean) - .join("/"); - return `${folder}/DSC0001.ARW`; -} - -/** A worked example of where a folder-move rule lands a file. */ -function folderExample(template: string): string { - const folder = template - .replace(/\{year\}/g, "2026") - .replace(/\{date\}/g, "2026-06-20") - .replace(/\{name\}/g, "") - .split("/") - .map((s) => s.trim()) - .filter(Boolean) - .join("/"); - return folder ? `${folder}/file.jpg` : "file.jpg"; -} - -/** A worked example of the filename a rename template produces (extension always kept). */ -function filenameExample(template: string): string { - if (!template.trim()) return "DSC0001.ARW (unchanged)"; - const stem = - template - .replace(/\{year\}/g, "2026") - .replace(/\{date\}/g, "2026-06-20") - .replace(/\{name\}/g, "Holiday") - .replace(/\{seq\}/g, "0001") - .replace(/\//g, "-") - .trim() || "0001"; - return `${stem}.ARW`; -} - -/** A worked example of the album name a date template produces. */ -function albumExample(template: string, name = ""): string { - const rendered = template - .replace(/\{year\}/g, "2026") - .replace(/\{date\}/g, "2026-06-20") - .replace(/\{name\}/g, name) - .split("/") - .map((s) => s.trim()) - .filter(Boolean) - .join("/"); - return rendered || "Imported"; -} - -/** Labelled field with an example placeholder and a one-line "how to fill it" hint. */ -function Field({ - label, - help, - badge, - children, -}: { - label: string; - help?: string; - badge?: ReactNode; - children: ReactNode; -}) { - return ( - - ); -} - -function Group({ title, children }: { title: string; children: ReactNode }) { - return ( -
-
{title}
- {children} -
- ); -} - -/** - * Comma-separated list editor. Keeps the raw typed text in local state so a trailing - * comma/space survives — normalizing on every keystroke would strip the separator and - * make it impossible to start a new entry. - */ -function CsvField({ - label, - help, - value, - onChange, - placeholder, -}: { - label: string; - help?: string; - value: string[]; - onChange: (v: string[]) => void; - placeholder?: string; -}) { - const [text, setText] = useState(() => listToCsv(value)); - // Re-seed when the external value diverges from what's typed — e.g. a list row is - // removed and React reuses this instance under an index key. Compared by content so an - // in-progress trailing comma/space (which parses to the same list) isn't clobbered. - const joined = value.join(","); - useEffect(() => { - if (csvToList(text).join(",") !== joined) setText(listToCsv(value)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [joined]); - return ( - - { - setText(e.target.value); - onChange(csvToList(e.target.value)); - }} - /> - - ); -} - -/** - * Single-pattern regex editor with an inline validity check. The authoritative - * validation is server-side (save_config uses Rust's regex engine, which differs - * slightly from JS); this just gives instant feedback on obvious typos. - */ -function RegexField({ - label, - help, - placeholder, - value, - onChange, -}: { - label: string; - help?: string; - placeholder?: string; - value: string; - onChange: (v: string) => void; -}) { - let err: string | null = null; - if (value) { - try { - new RegExp(value); - } catch (e) { - err = (e as Error).message; - } - } - // Patterns run in Rust's regex engine, which rejects lookarounds/backreferences — - // say so up front rather than letting a save fail mysteriously. - const dialect = "Rust regex syntax — lookarounds and backreferences aren't supported."; - return ( - invalid : undefined} - > - onChange(e.target.value)} /> - {err && {err}} - - ); -} - export default function App() { const [tab, setTab] = useState("flow"); const [config, setConfig] = useState(api.emptyConfig); @@ -328,1254 +133,3 @@ export default function App() { ); } - -// --- Flow map -------------------------------------------------------------- - -type LaneKind = "drive" | "photos" | "folder"; - -interface Lane { - key: string; // matches the Rust rule_key, joins a rule to its runs - kind: LaneKind; - label: string; - source: string; - filter: string; - action: string; - dest: string; - detail: string; // layout token, album name, or "Library" - revealPath: string; // a real path to open in Finder (empty = no Reveal) - run: api.RunRecord | null; // most-recent run - routed: number; // cumulative files routed - cardUuid?: string; - folderIndex?: number; - promptName: boolean; -} - -const fmtCount = (n: number) => n.toLocaleString(); - -/** "just now" / "5m ago" / "3h ago" / "2d ago" / a date. */ -function relTime(ts: string): string { - const t = new Date(ts).getTime(); - if (Number.isNaN(t)) return ""; - const s = Math.max(0, (Date.now() - t) / 1000); - if (s < 45) return "just now"; - if (s < 3600) return `${Math.round(s / 60)}m ago`; - if (s < 86400) return `${Math.round(s / 3600)}h ago`; - if (s < 604800) return `${Math.round(s / 86400)}d ago`; - return new Date(ts).toLocaleDateString(); -} - -const laneFilter = (exts: string[]) => (exts.length ? exts.join(", ") : "all types"); - -/** Project the config topology + run history into one lane per automation. */ -function buildLanes(config: Config, runs: api.RunRecord[]): Lane[] { - const byKey = new Map(); - for (const r of runs) { - const list = byKey.get(r.rule_key) ?? []; - list.push(r); // runs arrive most-recent-first - byKey.set(r.rule_key, list); - } - const attach = (key: string) => { - const list = byKey.get(key) ?? []; - return { run: list[0] ?? null, routed: list.reduce((n, r) => n + r.ok, 0) }; - }; - - const lanes: Lane[] = []; - - for (const c of config.card) { - const key = `card:${c.uuid}`; - const action = ["copy", "verify"]; - if (c.cleanup !== "never") action.push("wipe"); - lanes.push({ - key, - kind: "drive", - label: c.label || "Untitled drive", - source: c.sources.join(", ") || "drive", - filter: laneFilter(c.extensions), - action: action.join(" · "), - dest: c.dest || "…", - detail: c.layout, - revealPath: c.dest, - cardUuid: c.uuid, - promptName: c.prompt_name, - ...attach(key), - }); - } - - config.folder.forEach((f, i) => { - const key = `folder:${f.watch}`; - if (f.kind === "photos") { - const after = - f.after_import === "archive" ? " · archive" : f.after_import === "delete" ? " · delete" : ""; - lanes.push({ - key, - kind: "photos", - label: f.label || "Untitled import", - source: f.watch || "…", - filter: laneFilter(f.extensions), - action: `import${after}`, - dest: "Apple Photos", - detail: f.album_mode === "library" ? "Library" : f.photos_album, - revealPath: f.watch, - folderIndex: i, - promptName: f.prompt_name, - ...attach(key), - }); - } else { - lanes.push({ - key, - kind: "folder", - label: f.label || "Untitled folder", - source: f.watch || "…", - filter: laneFilter(f.extensions), - action: "move", - dest: f.dest || "…", - detail: f.layout || "flat", - revealPath: f.dest, - folderIndex: i, - promptName: f.prompt_name, - ...attach(key), - }); - } - }); - - return lanes; -} - -function FlowView({ - config, - onNeedNames, - onNavigate, -}: { - config: Config; - onNeedNames: (r: NamingReq) => void; - onNavigate: (t: Tab) => void; -}) { - const [runs, setRuns] = useState([]); - const [mode, setMode] = useState<"map" | "history">("map"); - - useEffect(() => { - const refresh = () => api.getRuns(500).then(setRuns); - refresh(); - const subs = [listen("run", refresh), listen("activity", refresh)]; - return () => subs.forEach((u) => u.then((f) => f())); - }, []); - - const lanes = buildLanes(config, runs); - const empty = config.card.length === 0 && config.folder.length === 0; - - async function runLane(l: Lane) { - try { - if (l.kind === "drive" && l.cardUuid) { - if (l.promptName) { - const dates = await api.prepareIngest(l.cardUuid); - onNeedNames({ kind: "card", uuid: l.cardUuid, label: l.label, dates }); - } else { - await api.runIngestNow(l.cardUuid, {}); - } - } else if (l.folderIndex != null) { - // A Photos rule that prompts for a name surfaces the form via the photos-ready event. - await api.runFolderNow(l.folderIndex); - } - } catch (e) { - alert(String(e)); - } - } - - return ( -
-
-
-

Flow

-

- Every automation, from where files come from to where they land. Health and counts come - from each rule's last run. -

-
-
- - -
-
- - {empty ? ( -
-

No automations yet.

-

Add a drive rule or a folder rule and it shows up here as a flow.

-
- - -
-
- ) : mode === "map" ? ( - - ) : ( - - )} -
- ); -} - -function FlowMap({ lanes, onRun }: { lanes: Lane[]; onRun: (l: Lane) => void }) { - const filesRouted = lanes.reduce((n, l) => n + l.routed, 0); - const attention = lanes.filter((l) => l.run?.status === "failed").length; - - return ( - <> -
-
- {lanes.length} - Automations -
-
- {fmtCount(filesRouted)} - Files routed -
-
- {attention} - Needs attention -
-
-
- {lanes.map((l) => ( - - ))} -
- - ); -} - -function LaneRow({ lane, onRun }: { lane: Lane; onRun: (l: Lane) => void }) { - const health = lane.run?.status ?? "idle"; - let healthText: string; - if (!lane.run) { - healthText = lane.kind === "drive" ? "Connect the drive to run" : "Watching · nothing routed yet"; - } else if (health === "failed") { - healthText = lane.run.detail || "Last run failed"; - } else { - const verb = lane.kind === "photos" ? "imported" : lane.kind === "drive" ? "synced" : "moved"; - const noun = lane.routed === 1 ? "file" : "files"; - const partial = health === "partial" ? " · last run had errors" : ""; - healthText = `${fmtCount(lane.routed)} ${noun} ${verb}${partial} · ${relTime(lane.run.ts)}`; - } - const kindLabel = lane.kind === "drive" ? "Drive" : lane.kind === "photos" ? "Photos" : "Folder"; - - return ( -
-
- {kindLabel} - {lane.label} - - {lane.source} · {lane.filter} - -
-
- {lane.action} -
-
- {lane.dest} - {lane.detail} - - - {healthText} - -
-
- - {lane.revealPath && ( - - )} -
-
- ); -} - -function RunHistory({ runs }: { runs: api.RunRecord[] }) { - if (runs.length === 0) { - return ( -
-

No runs recorded yet.

-

Every completed import or move is logged here, with counts and outcome.

-
- ); - } - return ( -
    - {runs.map((r, i) => { - const verb = r.flow === "photos" ? "imported" : r.flow === "drive" ? "copied" : "moved"; - return ( -
  • - {relTime(r.ts)} - {r.flow} - - {r.status === "failed" ? ( - {r.detail} - ) : ( - <> - {fmtCount(r.ok)} {verb} - {r.skipped > 0 && ` · ${fmtCount(r.skipped)} skipped`} - {r.failed > 0 && ` · ${fmtCount(r.failed)} failed`} - - )} - → {r.dest} - -
  • - ); - })} -
- ); -} - -function StatusView({ - config, - onNeedNames, - onImported, -}: { - config: Config; - onNeedNames: (r: NamingReq) => void; - onImported: () => void; -}) { - const [cards, setCards] = useState([]); - const [paused, setPaused] = useState(false); - - const refresh = () => { - api.listMountedCards().then(setCards); - api.getPaused().then(setPaused); - }; - useEffect(() => { - refresh(); - const u = listen("paused-changed", (e) => setPaused(e.payload)); - return () => { - u.then((f) => f()); - }; - }, []); - - async function importNow(uuid: string) { - const rule = config.card.find((c) => c.uuid.toLowerCase() === uuid.toLowerCase()); - try { - if (rule?.prompt_name) { - const dates = await api.prepareIngest(uuid); - onNeedNames({ kind: "card", uuid, label: rule.label, dates }); - } else { - await api.runIngestNow(uuid, {}); - onImported(); - } - } catch (e) { - alert(String(e)); - } - } - - async function ejectNow(path: string) { - try { - await api.ejectNow(path); - refresh(); - } catch (e) { - alert(String(e)); - } - } - - return ( -
-
-
-

Status

-

- Watchers run in the background. Connect a drive or drop a Lightroom export to start an import. -

-
-
- - -
-
- -

Watchers {paused ? "paused" : "active"}

- -

Mounted volumes

- {cards.length === 0 && ( -
-

No volumes detected.

-

Connect a drive, or add a rule under External Drive.

-
- )} -
    - {cards.map((c) => ( -
  • - - {c.label}{" "} - {c.matched ? ( - rule: {c.rule_label} - ) : ( - {c.uuid ?? "no uuid"} - )} - - - {c.matched && c.uuid && } - {c.ejectable && } - -
  • - ))} -
-
- ); -} - -function NamingForm({ - req, - mode, - onClose, -}: { - req: NamingReq; - mode: NameMode; - onClose: () => void; -}) { - const [single, setSingle] = useState(""); - const [perDate, setPerDate] = useState>({}); - - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - async function confirm() { - const names: Record = - mode === "single" - ? Object.fromEntries(req.dates.map((d) => [d.date, single])) - : perDate; - try { - if (req.kind === "card") await api.runIngestNow(req.uuid, names); - else await api.runPhotosImportNow(req.index, names); - onClose(); - } catch (e) { - alert(String(e)); - } - } - - const total = req.dates.reduce((n, d) => n + d.file_count, 0); - const target = req.kind === "card" ? "destination folders" : "Photos albums"; - - return ( -
-
e.stopPropagation()}> -

Name this import

-

- {req.label} · {total} files. These names become the {target}. -

- {mode === "single" ? ( - - setSingle(e.target.value)} - autoFocus - /> - - ) : ( - req.dates.map((d, i) => ( - - setPerDate((p) => ({ ...p, [d.date]: e.target.value }))} - /> - - )) - )} -
- - -
-
-
- ); -} - -function CardsView({ config, patch }: { config: Config; patch: (p: Partial) => void }) { - const updateCard = (i: number, p: Partial) => - patch({ card: config.card.map((r, j) => (j === i ? { ...r, ...p } : r)) }); - const removeCard = (i: number) => patch({ card: config.card.filter((_, j) => j !== i) }); - const addCard = () => patch({ card: [...config.card, api.newCard()] }); - - return ( -
-
-
-

External Drive

-

- Rules that run automatically when a recognised drive is connected. -

-
- -
- - {config.card.length === 0 && ( -
-

No drive rules yet.

-

Add a rule so a drive auto-imports the moment it's connected.

-
- )} - - {config.card.map((card, i) => ( -
- -
- {card.label || "Untitled drive"} - {card.dest &&
→ {card.dest}
} -
- -
- - - - updateCard(i, { label: e.target.value })} - /> - - -
- updateCard(i, { uuid: e.target.value })} - /> - -
-
-
- - - -