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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p id="status" class="status-line" role="status" aria-live="polite">엑셀을 불러오면 교각 형상을 생성합니다.</p>
|
<div class="status-row">
|
||||||
<div id="backup-banner" class="status-line" hidden>
|
<p id="status" class="status-line"></p>
|
||||||
<span id="backup-text"></span>
|
<div id="backup-banner" class="backup-inline" hidden>
|
||||||
<button id="restore-backup" class="btn small" type="button">복구</button>
|
<span id="backup-text"></span>
|
||||||
<button id="discard-backup" class="btn small" type="button">버리기</button>
|
<button id="restore-backup" class="btn small" type="button">복구</button>
|
||||||
|
<button id="discard-backup" class="btn small" type="button">버리기</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="toast-host" class="toast-host" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
<main class="grid" id="view-shape">
|
<main class="grid" id="view-shape">
|
||||||
<aside class="panel piers" aria-labelledby="pier-title">
|
<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;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Results and short notices: a toast that goes away after 4 s. */
|
||||||
function setStatus(msg: string): void {
|
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 -------------------------------------------------------------
|
// --- 3D renderer -------------------------------------------------------------
|
||||||
@@ -421,6 +443,7 @@ function renderDefaults(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAll(): void {
|
function renderAll(): void {
|
||||||
|
$('status').textContent = state.piers.length ? '' : '엑셀을 불러오면 교각 형상을 생성합니다.';
|
||||||
renderHeader();
|
renderHeader();
|
||||||
renderPierList();
|
renderPierList();
|
||||||
renderPanels();
|
renderPanels();
|
||||||
@@ -554,7 +577,7 @@ function editField(name: string, key: EditableKey, raw: string): void {
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
setStatus(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
|
setError(`${fieldLabel(key)}은(는) 0보다 큰 숫자여야 합니다. 값을 되돌렸습니다.`);
|
||||||
renderAll();
|
renderAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -692,7 +715,7 @@ function renderShapeOptions(member: MemberId): void {
|
|||||||
try {
|
try {
|
||||||
choose(member, gi, option.label);
|
choose(member, gi, option.label);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus((err as Error).message);
|
setError((err as Error).message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.appendChild(chip);
|
row.appendChild(chip);
|
||||||
@@ -1119,7 +1142,7 @@ function setMemberFck(member: FckMember, raw: string): void {
|
|||||||
} else {
|
} else {
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
setStatus('부재별 fck는 0보다 큰 숫자여야 합니다.');
|
setError('부재별 fck는 0보다 큰 숫자여야 합니다.');
|
||||||
renderPlan();
|
renderPlan();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1159,7 +1182,7 @@ function renderMemberFck(box: HTMLDivElement, member: FckMember): void {
|
|||||||
function setWaste(raw: string): void {
|
function setWaste(raw: string): void {
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isFinite(value) || value < 0) {
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
setStatus('재료표 할증은 0 이상의 숫자여야 합니다.');
|
setError('재료표 할증은 0 이상의 숫자여야 합니다.');
|
||||||
renderMaterial();
|
renderMaterial();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1171,7 +1194,7 @@ function setWaste(raw: string): void {
|
|||||||
function setBend(key: keyof BendFactors, raw: string): void {
|
function setBend(key: keyof BendFactors, raw: string): void {
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
setStatus('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.');
|
setError('굽힘 내면반경 배수는 0보다 큰 숫자여야 합니다.');
|
||||||
renderMaterial();
|
renderMaterial();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1207,7 @@ function setMaterial(key: 'fck' | GradeKey, raw: string): void {
|
|||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value);
|
const valid = key === 'fck' ? Number.isFinite(value) && value > 0 : (GRADE_CHOICES as readonly number[]).includes(value);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setStatus(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.');
|
setError(key === 'fck' ? '공통 설정 fck는 0보다 큰 숫자여야 합니다.' : '철근 강도는 SD300 · SD400 · SD500 중 하나여야 합니다.');
|
||||||
renderMaterial();
|
renderMaterial();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1322,7 +1345,7 @@ function confirmSaveDialog(): void {
|
|||||||
const name = $<HTMLInputElement>('sv-name').value.trim();
|
const name = $<HTMLInputElement>('sv-name').value.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
$<HTMLInputElement>('sv-name').focus();
|
$<HTMLInputElement>('sv-name').focus();
|
||||||
setStatus('프로젝트명을 입력하세요.');
|
setError('프로젝트명을 입력하세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.project = {
|
state.project = {
|
||||||
@@ -1382,7 +1405,7 @@ async function openProjectFile(file: File): Promise<void> {
|
|||||||
clearBackup().catch(() => undefined);
|
clearBackup().catch(() => undefined);
|
||||||
setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`);
|
setStatus(`${file.name}을 열었습니다. ${state.piers.length}기를 원본 엑셀 없이 다시 생성했습니다.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
|
setError(`프로젝트 파일을 열지 못했습니다: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1390,7 +1413,7 @@ function scheduleBackup(): void {
|
|||||||
window.clearTimeout(backupTimer);
|
window.clearTimeout(backupTimer);
|
||||||
backupTimer = window.setTimeout(() => {
|
backupTimer = window.setTimeout(() => {
|
||||||
if (!isDirty()) return;
|
if (!isDirty()) return;
|
||||||
writeBackup(currentProjectText()).catch(() => setStatus('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
|
writeBackup(currentProjectText()).catch(() => setError('자동 백업을 저장하지 못했습니다. 브라우저 저장소 설정을 확인하세요.'));
|
||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1636,7 +1659,7 @@ $<HTMLInputElement>('file').addEventListener('change', async (ev) => {
|
|||||||
try {
|
try {
|
||||||
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
|
setError(`엑셀을 읽지 못했습니다: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/** The project's standing input and names (RAG/운곡교P1입력.xlsx → 프로젝트 운곡교P1, 교량 운곡교). */
|
/** 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)');
|
loadBytes(await fetchSample(), '샘플 P1 (운곡교P1입력.xlsx)');
|
||||||
fillSampleNames();
|
fillSampleNames();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
|
setError(`샘플을 불러오지 못했습니다: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1672,7 +1695,7 @@ drop.addEventListener('drop', async (ev) => {
|
|||||||
try {
|
try {
|
||||||
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
loadBytes(new Uint8Array(await file.arrayBuffer()), file.name);
|
||||||
} catch (err) {
|
} 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);
|
openProjectText(pendingBackup.text, false);
|
||||||
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
|
setStatus('자동 백업을 복구했습니다. 저장하지 않은 변경이 있습니다.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
|
setError(`백업을 복구하지 못했습니다: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
pendingBackup = null;
|
pendingBackup = null;
|
||||||
$<HTMLDivElement>('backup-banner').hidden = true;
|
$<HTMLDivElement>('backup-banner').hidden = true;
|
||||||
@@ -1736,5 +1759,5 @@ if (new URLSearchParams(window.location.search).get('sample') === '1') {
|
|||||||
fillSampleNames();
|
fillSampleNames();
|
||||||
applyImport();
|
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; }
|
#view-quantity .piers, #quantity-export, #quantity-print { display: none !important; }
|
||||||
.quantity .rebar-table { font-size: 10px; }
|
.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