feat(view): one top row for the status and backup, transient results as toasts

The status line and the backup banner share one row (the banner appears only when a backup exists). Results and notices
show as toasts that close after 4 s; problems stay until closed. The status line keeps only the empty-state hint.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 10:51:12 +09:00
1 parent e999b11a21
commit e8541b29cb
3 files changed
+58 -20

No files matched your search

+8 -5
View File
@@ -29,12 +29,15 @@
</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 class="status-row">
<p id="status" class="status-line"></p>
<div id="backup-banner" class="backup-inline" 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>
</div>
<div id="toast-host" class="toast-host" role="status" aria-live="polite"></div>
<main class="grid" id="view-shape">
<aside class="panel piers" aria-labelledby="pier-title">
+38 -15
View File
@@ -117,8 +117,30 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, cls?:
return node;
}
/** Results and short notices: a toast that goes away after 4 s. */
function setStatus(msg: string): void {
$('status').textContent = msg;
showToast(msg, false);
}
/** Problems: a toast that stays until the user closes it. */
function setError(msg: string): void {
showToast(msg, true);
}
function showToast(msg: string, sticky: boolean): void {
const host = $<HTMLDivElement>('toast-host');
const t = el('div', undefined, sticky ? 'toast error' : 'toast');
t.appendChild(el('span', msg));
if (sticky) {
const close = el('button', '닫기', 'btn small');
close.type = 'button';
close.addEventListener('click', () => t.remove());
t.appendChild(close);
} else {
window.setTimeout(() => t.remove(), 4000);
}
host.appendChild(t);
while (host.childElementCount > 3) host.firstElementChild?.remove();
}
// --- 3D renderer -------------------------------------------------------------
@@ -421,6 +443,7 @@ function renderDefaults(): void {
}
function renderAll(): void {
$('status').textContent = state.piers.length ? '' : '엑셀을 불러오면 교각 형상을 생성합니다.';
renderHeader();
renderPierList();
renderPanels();
@@ -554,7 +577,7 @@ function editField(name: string, key: EditableKey, raw: string): void {
if (!p) return;
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
setStatus(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
setError(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
renderAll();
return;
}
@@ -692,7 +715,7 @@ function renderShapeOptions(member: MemberId): void {
try {
choose(member, gi, option.label);
} catch (err) {
setStatus((err as Error).message);
setError((err as Error).message);
}
});
row.appendChild(chip);
@@ -1119,7 +1142,7 @@ function setMemberFck(member: FckMember, raw: string): void {
} else {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
setStatus('부재별 fck는 0보다 큰 숫자여야 합니다.');
setError('부재별 fck는 0보다 큰 숫자여야 합니다.');
renderPlan();
return;
}
@@ -1159,7 +1182,7 @@ function renderMemberFck(box: HTMLDivElement, member: FckMember): void {
function setWaste(raw: string): void {
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
setStatus('재료표 할증은 0 이상의 숫자여야 합니다.');
setError('재료표 할증은 0 이상의 숫자여야 합니다.');
renderMaterial();
return;
}
@@ -1171,7 +1194,7 @@ function setWaste(raw: string): void {
function setBend(key: keyof BendFactors, raw: string): void {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) {
setStatus('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.');
setError('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.');
renderMaterial();
return;
}
@@ -1184,7 +1207,7 @@ function setMaterial(key: 'fck' | GradeKey, raw: string): void {
const value = Number(raw);
const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value);
if (!valid) {
setStatus(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.');
setError(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.');
renderMaterial();
return;
}
@@ -1322,7 +1345,7 @@ function confirmSaveDialog(): void {
const name = $<HTMLInputElement>('sv-name').value.trim();
if (!name) {
$<HTMLInputElement>('sv-name').focus();
setStatus('프로젝트명을 입력하세요.');
setError('프로젝트명을 입력하세요.');
return;
}
state.project = {
@@ -1382,7 +1405,7 @@ async function openProjectFile(file: File): Promise<void> {
clearBackup().catch(() => undefined);
setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`);
} catch (err) {
setStatus(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
setError(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
}
}
@@ -1390,7 +1413,7 @@ function scheduleBackup(): void {
window.clearTimeout(backupTimer);
backupTimer = window.setTimeout(() => {
if (!isDirty()) return;
writeBackup(currentProjectText()).catch(() => setStatus('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
writeBackup(currentProjectText()).catch(() => setError('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
}, 800);
}
@@ -1636,7 +1659,7 @@ $<HTMLInputElement>('file').addEventListener('change', async (ev) => {
try {
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
} catch (err) {
setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
}
});
/** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */
@@ -1654,7 +1677,7 @@ $<HTMLButtonElement>('pick-sample').addEventListener('click', async () => {
loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)');
fillSampleNames();
} catch (err) {
setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
setError(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
}
});
@@ -1672,7 +1695,7 @@ drop.addEventListener('drop', async (ev) => {
try {
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
} catch (err) {
setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
}
});
@@ -1707,7 +1730,7 @@ $<HTMLButtonElement>('restore-backup').addEventListener('click', () => {
openProjectText(pendingBackup.text, false);
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
} catch (err) {
setStatus(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
setError(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
}
pendingBackup = null;
$<HTMLDivElement>('backup-banner').hidden = true;
@@ -1736,5 +1759,5 @@ if (new URLSearchParams(window.location.search).get('sample') === '1') {
fillSampleNames();
applyImport();
})
.catch((err: Error) => setStatus(`샘플을 불러오지 못했습니다: ${err.message}`));
.catch((err: Error) => setError(`샘플을 불러오지 못했습니다: ${err.message}`));
}
+12
View File
@@ -722,3 +722,15 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
#view-quantity .piers, #quantity-export, #quantity-print { display: none !important; }
.quantity .rebar-table { font-size: 10px; }
}
/* 상단: 상태 줄과 백업 배너를 한 줄에 (승인 2026-10-23) */
.status-row { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 12px; padding: 4px 16px; }
.status-row .status-line { padding: 0; margin: 0; }
.backup-inline { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 13px; color: var(--muted); }
.backup-inline .btn.small { min-height: 44px; }
/* 알림 토스트: 일반 메시지는 4초 뒤 사라지고, 오류는 닫을 때까지 남음 */
.toast-host { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 50; max-width: min(360px, calc(100vw - 32px)); }
.toast { display: flex; align-items: center; gap: 10px; background: #1b2733; color: #ffffff; padding: 10px 14px; border-radius: 6px; font-size: 13px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); }
.toast.error { background: #8a1c1c; }
.toast .btn { min-height: 44px; }