diff --git a/src/App.css b/src/App.css index 75e21dd..0cf7a01 100644 --- a/src/App.css +++ b/src/App.css @@ -39,6 +39,153 @@ margin: 0; } +.topbar-project-name { + min-width: 0; + width: clamp(120px, 18vw, 240px); + padding: 4px 8px; + font: inherit; + font-size: 13px; + color: var(--text-h); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + text-overflow: ellipsis; +} + +.topbar-project-name:hover, +.topbar-project-name:focus { + border-color: var(--border); + background: var(--bg); + outline: none; +} + +.modal-projects { + width: min(560px, calc(100vw - 32px)); + max-height: min(680px, calc(100vh - 48px)); +} + +.project-library-body { + overflow-y: auto; +} + +.project-library-empty { + margin: 0; + font: 13px var(--mono); + color: var(--text); +} + +.project-library-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.project-library-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; +} + +.project-library-item.is-current { + border-color: var(--accent); +} + +.project-library-open { + display: flex; + flex: 1; + align-items: center; + gap: 12px; + min-width: 0; + padding: 0; + font: inherit; + color: inherit; + text-align: left; + background: none; + border: 0; + cursor: pointer; +} + +.project-library-thumb { + flex-shrink: 0; + width: 64px; + height: 48px; + overflow: hidden; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; +} + +.project-library-thumb img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.project-library-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.project-library-name { + font-size: 14px; + font-weight: 600; + line-height: 1.3; + color: var(--text-h); + overflow-wrap: anywhere; +} + +.project-library-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 11px; + font-weight: 500; + vertical-align: middle; + color: #fff; + background: var(--accent); + border-radius: 999px; +} + +.project-library-meta { + font-size: 12px; + color: var(--text); +} + +.project-library-rename { + flex: 1; + min-width: 0; +} + +.project-library-rename input { + width: 100%; + padding: 6px 8px; + font: inherit; + color: var(--text-h); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} + +.project-library-actions { + display: flex; + flex-shrink: 0; + gap: 4px; +} + +.button-danger { + color: #b91c1c; + border-color: rgba(225, 29, 72, 0.35); +} + .topbar-actions { display: flex; align-items: center; diff --git a/src/App.tsx b/src/App.tsx index 24e7ae4..f049891 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,17 +8,13 @@ import BottomToolBar from './components/BottomToolBar' import TouchHints from './components/TouchHints' import ToastBanner from './components/ToastBanner' import PwaUpdateBanner from './components/PwaUpdateBanner' -import ReplaceProjectModal from './components/ReplaceProjectModal' import { useLayoutMode } from './hooks/useLayoutMode' import { useEffect, useRef } from 'react' import { useAppStore } from './store' -import { currentProjectHasContent, runProjectOpen } from './utils/project' function App() { const layoutMode = useLayoutMode() const setActiveTool = useAppStore((s) => s.setActiveTool) - const pendingProjectOpen = useAppStore((s) => s.pendingProjectOpen) - const setPendingProjectOpen = useAppStore((s) => s.setPendingProjectOpen) const prevLayoutRef = useRef(layoutMode) useEffect(() => { @@ -49,21 +45,6 @@ function App() { {layoutMode === 'touch' && } - {pendingProjectOpen && ( - { - const request = pendingProjectOpen - setPendingProjectOpen(null) - void runProjectOpen(request) - }} - onClose={() => setPendingProjectOpen(null)} - /> - )} ) } diff --git a/src/components/CanvasArea.tsx b/src/components/CanvasArea.tsx index 56b112c..568766c 100644 --- a/src/components/CanvasArea.tsx +++ b/src/components/CanvasArea.tsx @@ -7,7 +7,7 @@ import { useAppStore } from '../store' import { stageRef } from '../stageRef' import { simplifyPath } from '../utils/geometry' import { importDroppedFiles, importImageFile } from '../utils/files' -import { requestOpenProject } from '../utils/project' +import { openSampleProject } from '../utils/project' import type { LayoutMode } from '../hooks/useLayoutMode' import { usePinchZoom } from '../hooks/usePinchZoom' import MapImageLayer from './canvas/MapImageLayer' @@ -517,7 +517,7 @@ export default function CanvasArea({ const handleLoadSample = useCallback(async () => { setLoadingSample(true) try { - await requestOpenProject({ kind: 'sample' }) + await openSampleProject() } finally { setLoadingSample(false) } diff --git a/src/components/ProjectLibraryModal.tsx b/src/components/ProjectLibraryModal.tsx new file mode 100644 index 0000000..e978f80 --- /dev/null +++ b/src/components/ProjectLibraryModal.tsx @@ -0,0 +1,296 @@ +import { useEffect, useState } from 'react' +import { FilePlus, Pencil, Trash2, X } from 'lucide-react' +import { useAppStore } from '../store' +import { useEscapeKey } from '../hooks/useEscapeKey' +import type { LayoutMode } from '../hooks/useLayoutMode' +import { + listProjectMetas, + readThumbnail, + type ProjectMeta, +} from '../persistence/library' +import { flushProjectSaves } from '../persistence/projectStorage' +import { + createProject, + deleteLibraryProject, + openLibraryProject, + renameLibraryProject, +} from '../projectLibrary' +import { useSaveState } from '../saveStatus' + +export default function ProjectLibraryModal({ + layoutMode, + onClose, +}: { + layoutMode: LayoutMode + onClose: () => void +}) { + const currentId = useAppStore((s) => s.projectId) + const saveState = useSaveState() + const [metas, setMetas] = useState(null) + const [thumbs, setThumbs] = useState>({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [editingId, setEditingId] = useState(null) + const [draftName, setDraftName] = useState('') + const [confirmDeleteId, setConfirmDeleteId] = useState(null) + + useEscapeKey(onClose) + + // Refresh after the current project's autosave lands, so names and times stay current + useEffect(() => { + let cancelled = false + void (async () => { + await flushProjectSaves() + const list = await listProjectMetas() + if (!cancelled) setMetas(list) + })() + return () => { + cancelled = true + } + }, [currentId, saveState.savedAt]) + + useEffect(() => { + if (!metas) return + let cancelled = false + const urls: string[] = [] + void (async () => { + const next: Record = {} + for (const meta of metas) { + if (cancelled) break + const blob = await readThumbnail(meta.id) + if (blob) { + const url = URL.createObjectURL(blob) + urls.push(url) + next[meta.id] = url + } + } + if (!cancelled) setThumbs(next) + })() + return () => { + cancelled = true + urls.forEach((url) => URL.revokeObjectURL(url)) + } + }, [metas]) + + const refresh = async () => setMetas(await listProjectMetas()) + + const run = async (action: () => Promise) => { + setBusy(true) + setError(null) + try { + await action() + } catch (err) { + setError(err instanceof Error ? err.message : 'Something went wrong') + } finally { + setBusy(false) + } + } + + const open = (meta: ProjectMeta) => + run(async () => { + await openLibraryProject(meta.id) + onClose() + }) + + const startNew = () => + run(async () => { + await createProject() + onClose() + }) + + const commitRename = (meta: ProjectMeta) => + run(async () => { + if (editingId !== meta.id) return + setEditingId(null) + if (draftName.trim() !== meta.name) { + await renameLibraryProject(meta.id, draftName) + } + await refresh() + }) + + const remove = (meta: ProjectMeta) => { + if (confirmDeleteId !== meta.id) { + setConfirmDeleteId(meta.id) + return + } + void run(async () => { + setConfirmDeleteId(null) + await deleteLibraryProject(meta.id) + if (meta.id === currentId) onClose() + else await refresh() + }) + } + + return ( +
+
e.stopPropagation()} + > +
+

Projects

+ +
+ +
+ {error && ( +

+ {error} +

+ )} + + {metas === null &&

Loading…

} + + {metas?.length === 0 && ( +

+ No saved projects yet. Import a map to start one. +

+ )} + + {metas && metas.length > 0 && ( +
    + {metas.map((meta) => { + const isCurrent = meta.id === currentId + const isEditing = editingId === meta.id + const isConfirming = confirmDeleteId === meta.id + return ( +
  • + {isEditing ? ( +
    { + e.preventDefault() + void commitRename(meta) + }} + > + setDraftName(e.target.value)} + onBlur={() => void commitRename(meta)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation() + setEditingId(null) + } + }} + /> +
    + ) : ( + + )} + +
    + {isConfirming ? ( + <> + + + + ) : ( + <> + + + + )} +
    +
  • + ) + })} +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/components/ReplaceProjectModal.tsx b/src/components/ReplaceProjectModal.tsx deleted file mode 100644 index 65b6c77..0000000 --- a/src/components/ReplaceProjectModal.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { Save, X } from 'lucide-react' -import { saveProjectFile, PROJECT_FILE_EXTENSION } from '../utils/project' -import type { LayoutMode } from '../hooks/useLayoutMode' -import Tooltip from './Tooltip' -import { useEscapeKey } from '../hooks/useEscapeKey' - -/** Confirms before the current project is replaced by a new or opened one. */ -export default function ReplaceProjectModal({ - layoutMode, - title, - description, - confirmLabel, - canSave, - onConfirm, - onClose, - onExport, -}: { - layoutMode: LayoutMode - title: string - description: string - confirmLabel: string - canSave: boolean - onConfirm: () => void - onClose: () => void - /** Omit to hide the Export shortcut */ - onExport?: () => void -}) { - const isTouch = layoutMode === 'touch' - - useEscapeKey(onClose) - - const confirm = () => { - onConfirm() - onClose() - } - - const saveButton = ( - - ) - - const exportButton = onExport && ( - - ) - - return ( -
-
e.stopPropagation()} - > -
-

{title}

- -
- -

{description}

- -
-
- {isTouch ? ( - <> - {saveButton} - {exportButton} - - ) : ( - <> - - {saveButton} - - {exportButton && ( - - {exportButton} - - )} - - )} -
-
- - -
-
-
-
- ) -} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 10c47a8..13d5d36 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -6,6 +6,7 @@ import { FilePlus, FolderOpen, Image, + Library, Redo2, RefreshCw, Route, @@ -15,13 +16,14 @@ import { } from 'lucide-react' import { useAppStore } from '../store' import { importGpxFile, importImageFile } from '../utils/files' -import { requestOpenProject, saveProjectFile, PROJECT_FILE_EXTENSION } from '../utils/project' +import { openProjectFile, saveProjectFile, PROJECT_FILE_EXTENSION } from '../utils/project' +import { createProject, renameCurrentProject } from '../projectLibrary' +import ProjectLibraryModal from './ProjectLibraryModal' import { usePwaInstall } from '../hooks/usePwaInstall' import { usePwaUpdate } from '../hooks/usePwaUpdate' import type { LayoutMode } from '../hooks/useLayoutMode' import EditGpxModal from './EditGpxModal' import ExportModal from './ExportModal' -import ReplaceProjectModal from './ReplaceProjectModal' import Tooltip from './Tooltip' import TopBarMenu from './TopBarMenu' import { buildOverflowActions } from './overflowActions' @@ -43,14 +45,15 @@ export default function TopBar({ const canRedo = useAppStore((s) => s.future.length > 0) const undo = useAppStore((s) => s.undo) const redo = useAppStore((s) => s.redo) - const newProject = useAppStore((s) => s.newProject) + const projectName = useAppStore((s) => s.projectName) + const setProjectName = useAppStore((s) => s.setProjectName) const imageInputRef = useRef(null) const gpxInputRef = useRef(null) const projectInputRef = useRef(null) const [gpxModalOpen, setGpxModalOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false) - const [newProjectModalOpen, setNewProjectModalOpen] = useState(false) + const [libraryOpen, setLibraryOpen] = useState(false) const { canInstall, install } = usePwaInstall() const { updateAvailable, applyUpdate } = usePwaUpdate() @@ -58,13 +61,7 @@ export default function TopBar({ const isCompact = layoutMode === 'compact' const showOverflow = isTouch || isCompact - const handleNewProject = () => { - if (!canSave) { - newProject() - return - } - setNewProjectModalOpen(true) - } + const handleNewProject = () => void createProject() const overflowActions = buildOverflowActions({ mapImage: mapImage !== null, @@ -76,6 +73,7 @@ export default function TopBar({ onImportGpx: () => gpxInputRef.current?.click(), onEditGpx: () => setGpxModalOpen(true), onNew: handleNewProject, + onProjects: () => setLibraryOpen(true), onOpen: () => projectInputRef.current?.click(), onSave: saveProjectFile, onExport: () => setExportModalOpen(true), @@ -112,6 +110,18 @@ export default function TopBar({ height={20} /> {!isTouch &&

O-Analysis

} + {!isTouch && ( + setProjectName(e.target.value)} + onBlur={(e) => renameCurrentProject(e.target.value)} + /> + )} {importError && !isTouch && errorBanner} @@ -146,7 +156,7 @@ export default function TopBar({ hidden onChange={(e) => { const file = e.target.files?.[0] - if (file) void requestOpenProject({ kind: 'file', file, source: 'file_picker' }) + if (file) void openProjectFile(file, 'file_picker') e.target.value = '' }} /> @@ -223,6 +233,16 @@ export default function TopBar({ New + + +