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:
ypshimandClaude Haiku 5.5 committed 2026-10-22 09:44:49 +09:00
1 parent 5ce6d0e2f1
commit f5303e4c2b
3 files changed
+205

No files matched your search

+9
View File
@@ -19,11 +19,20 @@
</nav>
<div class="actions">
<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>
<input id="project-file" type="file" accept=".json" hidden />
<span id="save-status" class="muted">저장 전</span>
</div>
</header>
<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">
<aside class="panel piers" aria-labelledby="pier-title">
+143
View File
@@ -6,6 +6,8 @@ import { parseWorkbook, type SheetReport } from './core/parseWorkbook';
import { buildPier, type PierModel } from './core/geometry';
import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
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 { buildPierScene, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
@@ -53,8 +55,15 @@ const state = {
project: { name: '', bridge: '' },
filter: 'all' as 'all' | PierType,
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;
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}기`);
$('project-line').textContent = parts.join(' · ');
$('step1-status').textContent = pier?.model ? '생성됨' : '미생성';
renderSaveStatus();
$<HTMLButtonElement>('regen').disabled = !state.piers.some((p) => p.checked);
}
@@ -340,6 +350,102 @@ function renderAll(): void {
renderHeader();
renderPierList();
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 (시안 ④) ------------------------------------------------------------
@@ -480,6 +586,9 @@ function applyImport(): void {
state.active = state.piers[0]?.input.name ?? null;
state.filter = 'all';
state.pending = null;
state.source = { fileName: p.fileName, sheets: p.sheets.map((sheet) => sheet.name) };
state.history = [];
state.savedSnapshot = null;
$<HTMLDialogElement>('import-dialog').close();
setupScene();
renderAll();
@@ -571,12 +680,46 @@ $<HTMLButtonElement>('select-all').addEventListener('click', () => {
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 ------------------------------------------------------------------------------
buildPartToggles();
buildViewButtons();
renderDefaults();
renderDialog();
renderAll();
void checkBackup();
// Deep link for the bundled sample: ?sample=1 loads P1 directly and skips the dialog.
if (new URLSearchParams(window.location.search).get('sample') === '1') {
+53
View File
@@ -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));
}