diff --git a/src/App.tsx b/src/App.tsx index 02600a1..e6a6b4f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -210,6 +210,9 @@ import { // Seiteneffekt: registriert die zwei dockbaren Kern-Panels in der Registry. import { BUILTIN_PANEL_IDS } from "./panels/builtinPanels"; import { deriveDrawings } from "./DrawingTabs"; +import { SplashScreen } from "./ui/SplashScreen"; +import { StartScreen } from "./ui/StartScreen"; +import { emptyProject } from "./model/sampleProject"; /** * Offenes Kontextmenü — genau eine Quelle. `x/y` sind Viewport-Pixel (clientX/Y). @@ -266,6 +269,13 @@ export default function App() { // umgeschaltet wird (alle Kinder lesen denselben modulweiten Zustand über t). useT(); + // ── Boot-Sequenz: Splash → Startbildschirm (Neu/Öffnen/Zuletzt) → Editor ── + // Wie bei den meisten CAD-Programmen (s. ui/SplashScreen.tsx/StartScreen.tsx). + // Läuft NUR einmal pro Fensterstart; sobald "editor" erreicht ist, bleibt der + // volle Editor bestehen (auch bei späteren File>Neu/Öffnen-Aktionen, die + // weiterhin über das normale Menü laufen). + const [bootPhase, setBootPhase] = useState<"splash" | "start" | "editor">("splash"); + // ── Globaler Zustand aus dem Store (Slices: project/selection/view/layout) ─ // Pro useStore-Aufruf EIN Feld selektieren (Referenz-Gleichheit, sonst // Endlosschleife). Actions leben ebenfalls im Zustand und sind referenzstabil. @@ -2158,6 +2168,7 @@ export default function App() { onExportObj, onExportStl, onOpenProject, + openProjectAtPath, onProjectFileChosen, runImport, } = useProjectFileActions( @@ -4457,6 +4468,30 @@ export default function App() { if (f) openImportForFile(f); }; + if (bootPhase === "splash") { + return setBootPhase("start")} />; + } + if (bootPhase === "start") { + return ( + { + setProject(emptyProject); + setBootPhase("editor"); + }} + onOpenProject={() => { + onOpenProject(); + setBootPhase("editor"); + }} + onOpenRecent={(path) => { + void openProjectAtPath(path).then((found) => { + if (!found) notify(t("start.recentMissing"), "error"); + }); + setBootPhase("editor"); + }} + /> + ); + } + return (
= { "layouts.size.widthMm": "Width (mm)", "layouts.size.heightMm": "Height (mm)", "layouts.size.create": "Create", + + // Start screen (after the splash, before the editor) + "start.newProject": "New project", + "start.newProject.hint": "Create a blank project", + "start.openProject": "Open project …", + "start.openProject.hint": "Choose an existing .obp file", + "start.recent": "Recently opened", + "start.recent.remove": "Remove from list", + "start.recentMissing": "File not found — removed from the list.", }; diff --git a/src/io/projectFile.ts b/src/io/projectFile.ts index 1da7fcf..8830530 100644 --- a/src/io/projectFile.ts +++ b/src/io/projectFile.ts @@ -55,6 +55,26 @@ export async function openProjectFileNative(): Promise return { path: selected, content }; } +/** + * Öffnet eine Projektdatei über einen BEKANNTEN Pfad (kein Dialog) — für den + * Startbildschirm/„Zuletzt geöffnet" (state/recentProjects.ts): der Pfad kommt + * dort schon aus einem früheren Öffnen/Speichern. Nur unter Tauri sinnvoll. + * + * @returns `null` ohne Tauri oder wenn die Datei nicht mehr lesbar ist (z. B. + * gelöscht/verschoben) — der Aufrufer sollte den Eintrag dann aus der + * Zuletzt-geöffnet-Liste entfernen. + */ +export async function openProjectFileAtPath(path: string): Promise { + if (!isTauriRuntime()) return null; + try { + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + const content = await readTextFile(path); + return { path, content }; + } catch { + return null; + } +} + /** * Speichert das Projekt-JSON in eine `.obp`-Datei. Unter Tauri nativer * Speichern-Dialog mit Projekt-Filter, im Browser Blob-Download — beides via @@ -62,10 +82,16 @@ export async function openProjectFileNative(): Promise * * @param json Fertig serialisiertes Projekt (JSON.stringify des ProjectSlice). * @param projectName `project.name` für den Standard-Dateinamen (Fallback "projekt"). + * @param onSavedPath Optionaler Callback mit dem gewählten Pfad (Zuletzt- + * geöffnet-Liste, s. state/recentProjects.ts). */ -export async function saveProjectFile(json: string, projectName: string): Promise { +export async function saveProjectFile( + json: string, + projectName: string, + onSavedPath?: (path: string) => void, +): Promise { const filename = `${projectName || "projekt"}.${PROJECT_EXT}`; - return saveTextFile(json, filename, "application/json", PROJECT_DIALOG_FILTERS); + return saveTextFile(json, filename, "application/json", PROJECT_DIALOG_FILTERS, onSavedPath); } // ── Lock gegen Doppelöffnung (nur Tauri) ──────────────────────────────────── diff --git a/src/io/saveFile.ts b/src/io/saveFile.ts index 8d13636..b9a97f2 100644 --- a/src/io/saveFile.ts +++ b/src/io/saveFile.ts @@ -26,6 +26,9 @@ export interface FileDialogFilter { * @param filters Optionale Datei-Endungsfilter fuer den nativen Dialog (z. B. * Projekt-Endung). Additiv/optional, damit bestehende Aufrufer ohne * Filter unveraendert funktionieren (Default-Verhalten: irgendeine Endung). + * @param onSavedPath Optionaler Callback mit dem gewaehlten Pfad, NUR bei + * erfolgreichem Tauri-Speichern (z. B. fuer die Zuletzt-geoeffnet-Liste, + * s. state/recentProjects.ts) — additiv, aendert den Rueckgabewert nicht. * @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch (Dialog * ohne Pfad). Bei einem Tauri-Fehler wird auf den Browser-Fallback * zurueckgefallen. @@ -35,6 +38,7 @@ export async function saveTextFile( defaultName: string, mime = "application/octet-stream", filters?: FileDialogFilter[], + onSavedPath?: (path: string) => void, ): Promise { if (isTauri()) { try { @@ -43,6 +47,7 @@ export async function saveTextFile( if (!path) return false; // Nutzer hat abgebrochen const { writeTextFile } = await import("@tauri-apps/plugin-fs"); await writeTextFile(path, content); + onSavedPath?.(path); return true; } catch (err) { if (!warnedFallback) { diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 197b6f2..099d8a2 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -8,7 +8,7 @@ // laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert // RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu. -import { emptyProject } from "../model/sampleProject"; +import { sampleProject } from "../model/sampleProject"; import { flattenCategories, recomputeFloorElevations, @@ -363,10 +363,11 @@ export function createProjectSlice( return { ...history, - // TEMPORÄR für manuellen Test (Nutzer-Wunsch): leeres Projekt statt Demo- - // Haus. NICHT committen — vor dem nächsten echten Commit zurück auf - // `sampleProject` stellen. - project: emptyProject, + // Store-Default beim Modul-Laden — sichtbar nur ganz kurz HINTER dem + // Splash/Startbildschirm (App.tsx bootPhase: splash → start → editor), + // bevor eine der drei Optionen (Neu/Öffnen/Zuletzt) das echte Projekt + // setzt. Der konkrete Wert spielt daher praktisch keine Rolle mehr. + project: sampleProject, setProject, diff --git a/src/state/recentProjects.ts b/src/state/recentProjects.ts new file mode 100644 index 0000000..d95ac23 --- /dev/null +++ b/src/state/recentProjects.ts @@ -0,0 +1,51 @@ +// Liste zuletzt geöffneter/gespeicherter Projektdateien für den Startbildschirm +// (s. ui/StartScreen.tsx). Nur Pfade + Metadaten, lokal in localStorage — der +// Dateiinhalt selbst wird erst beim Anklicken über den echten Pfad geladen +// (io/projectFile.ts), damit die Liste nie mit dem Dateiinhalt auseinanderläuft. +// Nur unter Tauri sinnvoll (echte Dateipfade); im Browser bleibt sie leer. + +const STORAGE_KEY = "dossier.recentProjects"; +const MAX_ENTRIES = 8; + +export interface RecentProjectEntry { + path: string; + name: string; + /** ISO-Zeitstempel des letzten Öffnens/Speicherns. */ + openedAt: string; +} + +function readAll(): RecentProjectEntry[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as RecentProjectEntry[]) : []; + } catch { + return []; + } +} + +function writeAll(entries: RecentProjectEntry[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); +} + +/** Neueste zuerst. */ +export function getRecentProjects(): RecentProjectEntry[] { + return readAll(); +} + +/** Trägt einen Pfad als "gerade geöffnet/gespeichert" ein (an den Anfang, + * Duplikate entfernt, auf MAX_ENTRIES gekappt). */ +export function addRecentProject(path: string, name: string): void { + const rest = readAll().filter((e) => e.path !== path); + const next = [{ path, name, openedAt: new Date().toISOString() }, ...rest].slice( + 0, + MAX_ENTRIES, + ); + writeAll(next); +} + +/** Entfernt einen Eintrag (z. B. wenn die Datei beim Öffnen nicht mehr existiert). */ +export function removeRecentProject(path: string): void { + writeAll(readAll().filter((e) => e.path !== path)); +} diff --git a/src/state/useProjectFileActions.ts b/src/state/useProjectFileActions.ts index a6445c2..b931482 100644 --- a/src/state/useProjectFileActions.ts +++ b/src/state/useProjectFileActions.ts @@ -21,12 +21,14 @@ import { saveTextFile } from "../io/saveFile"; import { saveProjectFile, openProjectFileNative, + openProjectFileAtPath, acquireProjectLock, releaseProjectLock, registerLockAutoRelease, isTauriRuntime, } from "../io/projectFile"; import type { LockInfo } from "../io/projectFile"; +import { addRecentProject, removeRecentProject } from "./recentProjects"; import type { ContextObject, Drawing2D, Project, Vec2 } from "../model/types"; import type { PlanViewHandle } from "../plan/PlanView"; @@ -187,7 +189,9 @@ export function useProjectFileActions( // erlaubt beide Endungen). const onSaveProject = () => { const json = JSON.stringify(project, null, 2); - void saveProjectFile(json, project.name); + void saveProjectFile(json, project.name, (path) => + addRecentProject(path, project.name || "modell"), + ); }; // Gibt den Lock der zuletzt geöffneten Projektdatei frei (falls wir ihn // halten) und vergisst den Pfad — vor dem Wechsel auf ein anderes Projekt. @@ -212,6 +216,7 @@ export function useProjectFileActions( setProject(parsed as Project); openedProjectPathRef.current = path; projectLockOwnedRef.current = lockOwned; + addRecentProject(path, (parsed as Project).name || "modell"); } catch (err) { console.error("Projekt-Import fehlgeschlagen:", err); } @@ -238,6 +243,24 @@ export function useProjectFileActions( setLockConflict({ path: opened.path, content: opened.content, info: lock.info }); } }; + // Öffnen über einen BEKANNTEN Pfad (Startbildschirm/„Zuletzt geöffnet", + // s. ui/StartScreen.tsx) — kein Dialog, sonst identischer Lock-Fluss wie + // `openProjectViaNativeDialog`. Ist die Datei nicht mehr lesbar (gelöscht/ + // verschoben), wird der verwaiste Eintrag aus der Liste entfernt. + const openProjectAtPath = async (path: string): Promise => { + const opened = await openProjectFileAtPath(path); + if (!opened) { + removeRecentProject(path); + return false; + } + const lock = await acquireProjectLock(opened.path); + if (lock.ok) { + applyOpenedProjectFile(opened.path, opened.content, true); + } else { + setLockConflict({ path: opened.path, content: opened.content, info: lock.info }); + } + return true; + }; // Datei speichern: unter Tauri nativer „Speichern unter"-Dialog (Ordner + Name), // im Browser Blob-Download-Fallback — die gesamte Logik (inkl. WKWebView- // Härtung: octet-stream-MIME + verzögertes revoke) steckt in saveTextFile. @@ -351,6 +374,7 @@ export function useProjectFileActions( releaseCurrentProjectLock, applyOpenedProjectFile, openProjectViaNativeDialog, + openProjectAtPath, downloadTextFile, projectFileName, runExport, diff --git a/src/styles.css b/src/styles.css index f370816..a42f4d6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6880,3 +6880,136 @@ body { transform: translateY(0); } } + +/* ── Splash + Startbildschirm (App.tsx: bootPhase splash → start → editor) ── + Immer dunkel (wie der Über-Dialog), unabhängig vom Hell/Dunkel-Theme — + eigener Marken-Moment vor dem eigentlichen Editor. */ +.splash-screen, +.start-screen { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #16181c; + color: #e6e8ea; +} +.splash-brand, +.start-brand { + font-weight: 700; + font-size: 32px; + text-transform: lowercase; + letter-spacing: -0.01em; +} +.start-brand { + margin-bottom: 36px; +} +.start-actions { + display: flex; + gap: 14px; +} +.start-action { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 200px; + padding: 16px 18px; + border-radius: 10px; + border: 1px solid #3a3f47; + background: #23262b; + color: inherit; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} +.start-action:hover { + border-color: var(--accent, #0f766e); + background: #292d33; +} +.start-action-title { + font-size: 14px; + font-weight: 600; +} +.start-action-hint { + font-size: 11px; + color: #9aa0a6; +} +.start-recent { + margin-top: 36px; + width: 420px; + max-width: calc(100vw - 48px); +} +.start-recent-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9aa0a6; + margin-bottom: 8px; +} +.start-recent-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow-y: auto; +} +.start-recent-row { + display: flex; + align-items: center; + gap: 4px; +} +.start-recent-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; + min-width: 0; + padding: 7px 10px; + border-radius: 7px; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; +} +.start-recent-item:hover { + background: #23262b; +} +.start-recent-name { + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.start-recent-path { + font-size: 10.5px; + color: #767c83; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.start-recent-remove { + flex: 0 0 auto; + width: 22px; + height: 22px; + border-radius: 6px; + border: none; + background: transparent; + color: #767c83; + cursor: pointer; + font-size: 14px; + line-height: 1; +} +.start-recent-remove:hover { + background: #23262b; + color: #e6e8ea; +} diff --git a/src/ui/SplashScreen.tsx b/src/ui/SplashScreen.tsx new file mode 100644 index 0000000..9341b4d --- /dev/null +++ b/src/ui/SplashScreen.tsx @@ -0,0 +1,31 @@ +// Kurzer Marken-Splash beim Start, bevor der Startbildschirm (StartScreen) +// erscheint. BEWUSST keine künstliche Wartezeit/„Ladebalken"-Inszenierung: +// kommerzielle CAD-Programme brauchen beim Start oft mehrere Sekunden für +// Lizenzserver-Handshakes, riesige Bauteil-/Vorlagenbibliotheken und +// historisch gewachsene Plugin-Ketten — wir starten schnell, WEIL es nichts +// dergleichen gibt, nicht obwohl wir etwas Echtes überspringen. Die einzige +// Wartezeit hier ist eine kurze, feste Mindestdauer für die Markenwirkung +// (sonst nur ein Flackern bei einem sehr schnellen Rechner) — kein Fake-Fortschritt. +// +// Der stille Update-Check (state/checkForUpdate.ts) läuft bewusst NICHT hier, +// sondern unabhängig im Hintergrund weiter (App.tsx) — ein langsames/nicht +// erreichbares Netz soll den Start nie verzögern. +import { useEffect } from "react"; + +/** Mindest-Anzeigedauer (ms) — rein für die Markenwirkung, kein simulierter Ladevorgang. */ +const MIN_VISIBLE_MS = 500; + +export function SplashScreen({ onDone }: { onDone: () => void }) { + useEffect(() => { + const timer = setTimeout(onDone, MIN_VISIBLE_MS); + return () => clearTimeout(timer); + }, [onDone]); + + return ( +
+
+ dossier. +
+
+ ); +} diff --git a/src/ui/StartScreen.tsx b/src/ui/StartScreen.tsx new file mode 100644 index 0000000..4d2b150 --- /dev/null +++ b/src/ui/StartScreen.tsx @@ -0,0 +1,75 @@ +// Startbildschirm nach dem Splash (s. SplashScreen.tsx) — wie bei den meisten +// CAD-Programmen: „Neues Projekt" / „Projekt öffnen…" plus eine Liste zuletzt +// geöffneter/gespeicherter Projekte (state/recentProjects.ts, nur Tauri — +// im Browser gibt es keine echten Dateipfade zum Wiederöffnen). +import { useState } from "react"; +import { t } from "../i18n"; +import { isTauriRuntime } from "../io/projectFile"; +import { + getRecentProjects, + removeRecentProject, + type RecentProjectEntry, +} from "../state/recentProjects"; + +export function StartScreen({ + onNewProject, + onOpenProject, + onOpenRecent, +}: { + onNewProject: () => void; + onOpenProject: () => void; + onOpenRecent: (path: string) => void; +}) { + const [recent, setRecent] = useState(() => getRecentProjects()); + const showRecent = isTauriRuntime() && recent.length > 0; + + return ( +
+
+ dossier. +
+
+ + +
+ {showRecent && ( +
+
{t("start.recent")}
+
    + {recent.map((e) => ( +
  • + + +
  • + ))} +
+
+ )} +
+ ); +}