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:
1 parent
e999b11a21
commit
e8541b29cb
3 files changed
+58
-20
No files matched your search
+8
-5
@@ -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
@@ -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}`));
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user