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>
|
||||
<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
@@ -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') {
|
||||
|
||||
@@ -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