feat(view): project save/open, Ctrl+S, save status, IndexedDB auto-backup (plan B2–B5)
- 저장 (Ctrl+S) downloads <교량명>_교각설계.pzr.json; 프로젝트 열기 reads it back and regenerates every pier without the source workbook. - Save status in the header: 저장 전 / 변경 있음 · 미저장 / 저장됨. The snapshot excludes the history so a save is not counted as a change. - Auto-backup to IndexedDB 0.8 s after changes while unsaved; on start a banner offers recovery or discard. Storage failures are reported, not fatal. - beforeunload warns while there are unsaved changes. - B1 (default-vs-changed tracking) is not added yet: inputs are not editable until the 2단계 input cards exist. - Not verified in a browser (no debuggable Chrome in this session). Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ce6d0e2f1
commit
f5303e4c2b
3 files changed
+205
No files matched your search
@@ -19,11 +19,20 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="open-import" class="btn" type="button">엑셀 불러오기</button>
|
<button id="open-import" class="btn" type="button">엑셀 불러오기</button>
|
||||||
|
<button id="open-project" class="btn" type="button">프로젝트 열기</button>
|
||||||
|
<button id="save-project" class="btn" type="button" disabled>저장 (Ctrl+S)</button>
|
||||||
<button id="regen" class="btn primary" type="button" disabled>선택 교각 형상 생성</button>
|
<button id="regen" class="btn primary" type="button" disabled>선택 교각 형상 생성</button>
|
||||||
|
<input id="project-file" type="file" accept=".json" hidden />
|
||||||
|
<span id="save-status" class="muted">저장 전</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p id="status" class="status-line" role="status" aria-live="polite">엑셀을 불러오면 교각 형상을 생성합니다.</p>
|
<p id="status" class="status-line" role="status" aria-live="polite">엑셀을 불러오면 교각 형상을 생성합니다.</p>
|
||||||
|
<div id="backup-banner" class="status-line" hidden>
|
||||||
|
<span id="backup-text"></span>
|
||||||
|
<button id="restore-backup" class="btn small" type="button">복구</button>
|
||||||
|
<button id="discard-backup" class="btn small" type="button">버리기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="grid">
|
<main class="grid">
|
||||||
<aside class="panel piers" aria-labelledby="pier-title">
|
<aside class="panel piers" aria-labelledby="pier-title">
|
||||||
|
|||||||
+143
@@ -6,6 +6,8 @@ import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
|
|||||||
import { buildPier, type PierModel } from './core/geometry';
|
import { buildPier, type PierModel } from './core/geometry';
|
||||||
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
|
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
|
||||||
import { buildTemplateWorkbook } from './core/template';
|
import { buildTemplateWorkbook } from './core/template';
|
||||||
|
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile } from './core/project';
|
||||||
|
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
|
||||||
import type { Issue, PierInput, PierType } from './core/types';
|
import type { Issue, PierInput, PierType } from './core/types';
|
||||||
import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
import { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||||||
|
|
||||||
@@ -53,8 +55,15 @@ const state = {
|
|||||||
project: { name: '', bridge: '' },
|
project: { name: '', bridge: '' },
|
||||||
filter: 'all' as 'all' | PierType,
|
filter: 'all' as 'all' | PierType,
|
||||||
pending: null as Pending | null,
|
pending: null as Pending | null,
|
||||||
|
source: { fileName: '', sheets: [] as string[] },
|
||||||
|
history: [] as ProjectFile['history'],
|
||||||
|
/** Snapshot of the inputs at the last save or open. null until the project is first saved. */
|
||||||
|
savedSnapshot: null as string | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let pendingBackup: Backup | null = null;
|
||||||
|
let backupTimer: number | undefined;
|
||||||
|
|
||||||
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||||
|
|
||||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
|
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
|
||||||
@@ -240,6 +249,7 @@ function renderHeader(): void {
|
|||||||
parts.push(`교각 ${state.piers.length}기`);
|
parts.push(`교각 ${state.piers.length}기`);
|
||||||
$('project-line').textContent = parts.join(' · ');
|
$('project-line').textContent = parts.join(' · ');
|
||||||
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
|
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
|
||||||
|
renderSaveStatus();
|
||||||
$<HTMLButtonElement>('regen').disabled = !state.piers.some((p) => p.checked);
|
$<HTMLButtonElement>('regen').disabled = !state.piers.some((p) => p.checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +350,102 @@ function renderAll(): void {
|
|||||||
renderHeader();
|
renderHeader();
|
||||||
renderPierList();
|
renderPierList();
|
||||||
renderPanels();
|
renderPanels();
|
||||||
|
scheduleBackup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- project file (plan B3·B4·B5) --------------------------------------------------------
|
||||||
|
/** Everything that a save writes except the history, so that a save itself does not look like a change. */
|
||||||
|
function snapshot(): string {
|
||||||
|
return JSON.stringify({ project: state.project, source: state.source, piers: state.piers.map((p) => p.input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(): boolean {
|
||||||
|
return state.piers.length > 0 && snapshot() !== state.savedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentProjectText(): string {
|
||||||
|
return serializeProject({
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
project: { name: state.project.name, bridge: state.project.bridge, client: '', author: '', crs: '', memo: '' },
|
||||||
|
source: state.source,
|
||||||
|
piers: state.piers.map((p) => ({ id: p.input.name, input: p.input })),
|
||||||
|
history: state.history,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSaveStatus(): void {
|
||||||
|
const saveBtn = $<HTMLButtonElement>('save-project');
|
||||||
|
saveBtn.disabled = state.piers.length === 0;
|
||||||
|
const status = $('save-status');
|
||||||
|
if (state.piers.length === 0) {
|
||||||
|
status.textContent = '저장 전';
|
||||||
|
} else if (state.savedSnapshot === null || isDirty()) {
|
||||||
|
status.textContent = state.savedSnapshot === null ? '저장 전 · 변경 있음' : '변경 있음 · 미저장';
|
||||||
|
} else {
|
||||||
|
status.textContent = '저장됨';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProject(): void {
|
||||||
|
if (state.piers.length === 0) return;
|
||||||
|
state.history = [...state.history, { version: String(state.history.length + 1), note: '저장', at: new Date().toISOString() }];
|
||||||
|
const text = currentProjectText();
|
||||||
|
const name = `${state.project.bridge || state.project.name || '교각설계'}_교각설계.pzr.json`;
|
||||||
|
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = name;
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
state.savedSnapshot = snapshot();
|
||||||
|
clearBackup().catch(() => undefined);
|
||||||
|
renderHeader();
|
||||||
|
setStatus(`${name} 파일을 저장했습니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens a project from its text. A backup stays unsaved until the user saves it. */
|
||||||
|
function openProjectText(text: string, saved: boolean): void {
|
||||||
|
const parsed = parseProject(text);
|
||||||
|
state.project = { name: parsed.project.name, bridge: parsed.project.bridge };
|
||||||
|
state.source = parsed.source;
|
||||||
|
state.history = parsed.history;
|
||||||
|
state.piers = parsed.piers.map((p) => buildEntry(p.input, false));
|
||||||
|
state.active = state.piers[0]?.input.name ?? null;
|
||||||
|
state.filter = 'all';
|
||||||
|
state.pending = null;
|
||||||
|
state.savedSnapshot = saved ? snapshot() : null;
|
||||||
|
setupScene();
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectFile(file: File): Promise<void> {
|
||||||
|
try {
|
||||||
|
openProjectText(await file.text(), true);
|
||||||
|
clearBackup().catch(() => undefined);
|
||||||
|
setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleBackup(): void {
|
||||||
|
window.clearTimeout(backupTimer);
|
||||||
|
backupTimer = window.setTimeout(() => {
|
||||||
|
if (!isDirty()) return;
|
||||||
|
writeBackup(currentProjectText()).catch(() => setStatus('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkBackup(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const backup = await readBackup();
|
||||||
|
if (!backup) return;
|
||||||
|
pendingBackup = backup;
|
||||||
|
$('backup-text').textContent = `저장되지 않은 작업이 있습니다 (${new Date(backup.at).toLocaleString('ko-KR')}).`;
|
||||||
|
$<HTMLDivElement>('backup-banner').hidden = false;
|
||||||
|
} catch {
|
||||||
|
// Storage unavailable (private window, blocked): no recovery is offered, and the app works without it.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- import dialog (시안 ④) ------------------------------------------------------------
|
// --- import dialog (시안 ④) ------------------------------------------------------------
|
||||||
@@ -480,6 +586,9 @@ function applyImport(): void {
|
|||||||
state.active = state.piers[0]?.input.name ?? null;
|
state.active = state.piers[0]?.input.name ?? null;
|
||||||
state.filter = 'all';
|
state.filter = 'all';
|
||||||
state.pending = null;
|
state.pending = null;
|
||||||
|
state.source = { fileName: p.fileName, sheets: p.sheets.map((sheet) => sheet.name) };
|
||||||
|
state.history = [];
|
||||||
|
state.savedSnapshot = null;
|
||||||
$<HTMLDialogElement>('import-dialog').close();
|
$<HTMLDialogElement>('import-dialog').close();
|
||||||
setupScene();
|
setupScene();
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -571,12 +680,46 @@ $<HTMLButtonElement>('select-all').addEventListener('click', () => {
|
|||||||
renderAll();
|
renderAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$<HTMLButtonElement>('save-project').addEventListener('click', saveProject);
|
||||||
|
$<HTMLButtonElement>('open-project').addEventListener('click', () => $<HTMLInputElement>('project-file').click());
|
||||||
|
$<HTMLInputElement>('project-file').addEventListener('change', (ev) => {
|
||||||
|
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) void openProjectFile(file);
|
||||||
|
(ev.target as HTMLInputElement).value = '';
|
||||||
|
});
|
||||||
|
window.addEventListener('keydown', (ev) => {
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 's') {
|
||||||
|
ev.preventDefault();
|
||||||
|
saveProject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', (ev) => {
|
||||||
|
if (isDirty()) ev.preventDefault();
|
||||||
|
});
|
||||||
|
$<HTMLButtonElement>('restore-backup').addEventListener('click', () => {
|
||||||
|
if (!pendingBackup) return;
|
||||||
|
try {
|
||||||
|
openProjectText(pendingBackup.text, false);
|
||||||
|
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
pendingBackup = null;
|
||||||
|
$<HTMLDivElement>('backup-banner').hidden = true;
|
||||||
|
});
|
||||||
|
$<HTMLButtonElement>('discard-backup').addEventListener('click', () => {
|
||||||
|
pendingBackup = null;
|
||||||
|
$<HTMLDivElement>('backup-banner').hidden = true;
|
||||||
|
clearBackup().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
// --- start ------------------------------------------------------------------------------
|
// --- start ------------------------------------------------------------------------------
|
||||||
buildPartToggles();
|
buildPartToggles();
|
||||||
buildViewButtons();
|
buildViewButtons();
|
||||||
renderDefaults();
|
renderDefaults();
|
||||||
renderDialog();
|
renderDialog();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
void checkBackup();
|
||||||
|
|
||||||
// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog.
|
// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog.
|
||||||
if (new URLSearchParams(window.location.search).get('sample') === '1') {
|
if (new URLSearchParams(window.location.search).get('sample') === '1') {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Browser auto-backup in IndexedDB (spec §8, plan B4). Only the latest project text is kept.
|
||||||
|
* Every call can fail (private window, blocked storage); callers catch and show the failure.
|
||||||
|
*/
|
||||||
|
const DB_NAME = 'pierzainer';
|
||||||
|
const STORE = 'backup';
|
||||||
|
const KEY = 'current';
|
||||||
|
|
||||||
|
export interface Backup {
|
||||||
|
text: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDb(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run<T>(mode: IDBTransactionMode, action: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||||
|
return openDb().then(
|
||||||
|
(db) =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, mode);
|
||||||
|
const req = action(tx.objectStore(STORE));
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve(req.result);
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
reject(tx.error);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeBackup(text: string): Promise<unknown> {
|
||||||
|
const value: Backup = { text, at: new Date().toISOString() };
|
||||||
|
return run('readwrite', (store) => store.put(value, KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBackup(): Promise<Backup | null> {
|
||||||
|
const value = await run<Backup | undefined>('readonly', (store) => store.get(KEY));
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearBackup(): Promise<unknown> {
|
||||||
|
return run('readwrite', (store) => store.delete(KEY));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user