refactor(B03): 파일 입력 화면 마무리 — 파일 놓기 칸 · 모달 · 확인창 · 고르는 카드 · 목록 줄 부품 (PLAN 553-1)

- 놓기 칸 = 파일 놓기 칸 부품(한 줄로 눕힘) · 임시 보관함 창 = 모달 + 고르는 카드 + 빈 상태 · 교체 확인 = 확인창
- 업로드 도중 window.confirm 둘 → 확인창 부품(글 그대로 · 단추 이름으로 선택 안내) · 예정노선 고름 줄 = 목록 줄
- 모달 CSS · 임시 목록 옛 CSS 걷음 · 가이드 촬영 선택자 · 지킴 시험

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:13:40 +09:00
co-authored by Claude Sonnet 5.5
parent dbff07a4f8
commit 6bb0a5312d
7 changed files with 100 additions and 289 deletions
+1 -1
View File
@@ -262,7 +262,7 @@
"callouts": [
{
"n": 1,
"sel": ".b03-file__temp-modal-actions"
"sel": ".b03-file__temp-modal .ui-modal__actions"
}
]
},
+9 -35
View File
@@ -6,6 +6,7 @@ import {
} from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createBanner } from "@ui/ui_template_banner";
import { createDrop } from "@ui/ui_template_drop";
import { createButton, createIconButton, createTag } from "@ui/ui_template_elements";
import { createPanelHead } from "@ui/ui_template_panel_head";
import { createProgressBar, type ProgressBarHandle } from "@ui/ui_template_progress";
@@ -84,19 +85,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const subtitle = createText(L("B03_File_Subtitle"), "muted", 14);
subtitle.classList.add("b03-file__subtitle");
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.multiple = true;
fileInput.accept = UPLOAD_ALLOWED_EXT.join(",");
fileInput.className = "b03-file__native-input";
const dropzone = document.createElement("div");
dropzone.className = "b03-file__dropzone";
dropzone.tabIndex = 0;
const dropzoneLabel = document.createElement("strong");
dropzoneLabel.textContent = L("B03_File_Select_Label");
// 설명은 좌측 안내 패널 몫이다(2026-09-03 사용자 지시) — 여기는 자리 이름만 남긴다.
dropzone.append(dropzoneLabel, fileInput);
const drop = createDrop({
accept: UPLOAD_ALLOWED_EXT.join(","),
multiple: true,
text: L("B03_File_Select_Label"),
onFiles: (files) => onFileSelected(files),
});
const dropzone = drop.root;
dropzone.classList.add("b03-file__dropzone");
const pageError = document.createElement("p");
pageError.className = "ui-text ui-text--error b03-file__error";
@@ -530,29 +527,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const { detectPausedUploads, pollAnalysis, relockWhileInitialPipelineRuns, startChunkedUpload } =
flow;
function onB03_File_Select_Change(): void {
onFileSelected(fileInput.files ? Array.from(fileInput.files) : []);
fileInput.value = "";
}
function onB03_File_Drop(event: DragEvent): void {
event.preventDefault();
dropzone.classList.remove("is-dragging");
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
}
fileInput.addEventListener("change", onB03_File_Select_Change);
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") fileInput.click();
});
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
dropzone.addEventListener("drop", onB03_File_Drop);
uploadButton = createButton({
label: L("B03_File_Upload_Button"),
variant: "filled",
+3 -75
View File
@@ -29,7 +29,6 @@
margin-top: 0; /* 단일 layout gap에 의해 제어되도록 설정 */
}
.b03-file__native-input,
.b03-file__slot-input {
display: none;
}
@@ -58,49 +57,13 @@
height: 100%;
}
/* 한 줄 바(2026-09-03) — 종전 160px 블록은 안내 문구를 담느라 컸다. 문구는 좌측
패널로 갔으므로 여기는 「끌어 놓거나 눌러서 고르는 자리」 표시만 한다. */
/* 한 줄 바(2026-09-03) — 파일 놓기 칸 부품을 [아이콘 · 글 · 고르기] 한 줄로 눕힌다. 종전 160px 블록은
안내 문구를 담느라 컸고 문구는 좌측 패널로 갔다. 칸을 꽉 채워 세 칸 높이를 맞춘다. */
.b03-file__dropzone {
padding: var(--spacing-12) var(--spacing-16);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: var(--spacing-8);
cursor: pointer;
text-align: center;
transition:
background var(--transition-base),
border-color var(--transition-base),
box-shadow var(--transition-base);
}
.b03-file__dropzone:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent);
}
.b03-file__dropzone:focus-visible,
.b03-file__dropzone.is-dragging {
outline: none;
background: var(--color-accent-soft);
border-color: var(--color-accent);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 15%, transparent);
}
.b03-file__dropzone strong {
color: var(--color-text);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-medium);
}
.b03-file__dropzone span {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
padding: var(--spacing-8) var(--spacing-12);
}
/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만 자리를 차지한다. */
@@ -122,41 +85,6 @@
display: flex;
}
/* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */
.b03-file__modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-scrim);
}
.b03-file__modal {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 420px;
padding: var(--spacing-24);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
.b03-file__modal p {
margin: 0;
color: var(--color-text-secondary);
white-space: pre-line;
}
.b03-file__modal-actions {
display: flex;
gap: var(--spacing-8);
justify-content: flex-end;
}
/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시).
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다.
+2 -66
View File
@@ -1,7 +1,6 @@
/* =============================================================================
* B03_FileInput_UI_Style_Temp.css
* 임시 보관함 불러오기 — 업로드 컨테이너 안 버튼 + 선택 모달
* 모달 껍데기(.b03-file__modal / -backdrop)는 기존 확인 모달 스타일을 그대로 쓴다.
* 임시 보관함 불러오기 — 업로드 컨테이너 안 버튼 + 선택 모달(모달 부품)
* ========================================================================== */
/* 버튼 하나만 담는 자리 — 테두리를 또 두르면 버튼 테두리와 이중이 된다
@@ -11,73 +10,10 @@
align-items: center;
}
.b03-file__temp-modal {
min-width: min(560px, 92vw);
max-height: 80vh;
}
/* 제목은 브라우저 기본 h3 서식이 남지 않게 템플릿 값으로 맞춘다. */
.b03-file__temp-modal h3 {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-body);
font-weight: var(--font-weight-bold);
color: var(--color-text);
}
/* 임시 보관함 목록 — 모달 · 카드(고르는 카드) 부품 · 목록 칸만 */
.b03-file__temp-list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
overflow-y: auto;
}
.b03-file__temp-empty {
padding: var(--spacing-16);
text-align: center;
font-family: var(--font-body);
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
/* 목록 항목은 <button>이라 글꼴을 물려받지 않는다 — 본문 글꼴을 직접 지정한다.
글자색도 본문색을 쓴다(--color-primary-text는 '주 액션 배경 위' 색이라 밝은 배경에서 안 보인다). */
.b03-file__temp-option {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-paper);
font-family: var(--font-body);
font-size: var(--text-caption);
color: var(--color-text-body);
text-align: left;
cursor: pointer;
}
.b03-file__temp-option strong {
font-size: var(--text-body-sm);
color: var(--color-text);
}
.b03-file__temp-option:hover {
border-color: var(--color-primary);
}
.b03-file__temp-option.is-selected {
border-color: var(--color-primary);
background: var(--color-accent-soft);
}
.b03-file__temp-option-files {
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.b03-file__temp-modal-actions {
display: flex;
gap: var(--spacing-8);
justify-content: flex-end;
}
+54 -68
View File
@@ -7,8 +7,11 @@
* [업로드] 버튼을 눌렀을 때 일어난다(2026-08-08 사용자 지시).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { createCard, createButton, setCardSelected, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { openModal as openDialog } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { fetchTempBatches, type TempBatchItem } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
import "./B03_FileInput_UI_Style_Temp.css";
@@ -71,76 +74,59 @@ export function createTempPicker(
return;
}
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div");
modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("h3");
title.textContent = L("B03_Temp_Modal_Title");
const list = document.createElement("div");
list.className = "b03-file__temp-list";
let pending: TempBatchItem | null = selectedBatch;
if (batches.length === 0) {
const empty = document.createElement("p");
empty.className = "b03-file__temp-empty";
empty.textContent = L("B03_Temp_Modal_Empty");
list.append(empty);
}
for (const batch of batches) {
const option = document.createElement("button");
option.type = "button";
option.className = "b03-file__temp-option";
option.classList.toggle("is-selected", pending?.batch_id === batch.batch_id);
const name = document.createElement("strong");
name.textContent = batch.name;
const meta = document.createElement("span");
meta.textContent = `${batch.files.length}${L("B03_Temp_FileCount")} · ${formatBytes(
batch.total_size_bytes,
)}`;
const files = document.createElement("span");
files.className = "b03-file__temp-option-files";
files.textContent = batch.files.map((file) => file.file_type.toUpperCase()).join(", ");
option.append(name, meta, files);
option.addEventListener("click", () => {
pending = batch;
list
.querySelectorAll(".b03-file__temp-option")
.forEach((element) => element.classList.remove("is-selected"));
option.classList.add("is-selected");
});
list.append(option);
}
const actions = document.createElement("div");
actions.className = "b03-file__temp-modal-actions";
const close = (): void => backdrop.remove();
actions.append(
createButton({ label: L("Common_Btn_Cancel"), variant: "ghost", onClick: close }),
createButton({
label: L("Common_Btn_Confirm"),
variant: "filled",
onClick: () => {
if (!pending) {
showToast(L("B03_Temp_Select_Required"), "warning");
return;
}
applySelection(pending);
close();
},
}),
);
modal.append(title, list, actions);
backdrop.append(modal);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) close();
openDialog({
title: L("B03_Temp_Modal_Title"),
closeLabel: L("Common_Btn_Cancel"),
width: "40rem",
dialogClass: "b03-file__temp-modal",
mount: (body, close, actions) => {
const list = document.createElement("div");
list.className = "b03-file__temp-list";
if (batches.length === 0) {
list.append(createEmpty({ text: L("B03_Temp_Modal_Empty"), small: true }));
}
for (const batch of batches) {
const files = batch.files.map((file) => file.file_type.toUpperCase()).join(", ");
const option = createCard({
title: batch.name,
pad: 12,
selected: pending?.batch_id === batch.batch_id,
body: [
createText(
`${batch.files.length}${L("B03_Temp_FileCount")} · ${formatBytes(
batch.total_size_bytes,
)}`,
),
createText(files),
],
onSelect: () => {
pending = batch;
list
.querySelectorAll<HTMLElement>(".ui-card--select")
.forEach((card) => setCardSelected(card, card === option));
},
});
list.append(option);
}
body.append(list);
actions.append(
createButton({
label: L("Common_Btn_Confirm"),
variant: "filled",
onClick: () => {
if (!pending) {
showToast(L("B03_Temp_Select_Required"), "warning");
return;
}
applySelection(pending);
close();
},
}),
);
},
});
document.body.append(backdrop);
}
return {
+17 -44
View File
@@ -8,7 +8,7 @@
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
import { createButton } from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
@@ -35,41 +35,12 @@ function L(key: keyof typeof ui_locales): string {
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div");
modal.className = "b03-file__modal";
modal.setAttribute("role", "alertdialog");
modal.setAttribute("aria-modal", "true");
const title = document.createElement("strong");
title.textContent = L("B03_File_Replace_Title");
const message = document.createElement("p");
message.textContent = `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`;
const actions = document.createElement("div");
actions.className = "b03-file__modal-actions";
const done = (accepted: boolean): void => {
backdrop.remove();
resolve(accepted);
};
const cancel = createButton({
label: L("B03_File_Replace_Cancel"),
variant: "ghost",
onClick: () => done(false),
});
const accept = createButton({
label: L("B03_File_Replace_Confirm"),
variant: "filled",
onClick: () => done(true),
});
actions.append(cancel, accept);
modal.append(title, message, actions);
backdrop.append(modal);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) done(false);
});
document.body.append(backdrop);
accept.focus();
return askConfirm({
title: L("B03_File_Replace_Title"),
message: `${slotLabel}: ${fileName}\n${L("B03_File_Replace_Message")}`,
okLabel: L("B03_File_Replace_Confirm"),
cancelLabel: L("B03_File_Replace_Cancel"),
danger: true,
});
}
@@ -95,21 +66,23 @@ async function askReplaceOutputs<T>(
지워지는 것: ${error.targets.join(" · ")}`
: "";
const agreed = window.confirm(
`${error.message}${detail}
const agreed = await askConfirm({
message: `${error.message}${detail}
되돌릴 수 없습니다. 계속할까요?`,
);
danger: true,
});
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
// 지형 파일이 그대로면 전처리 결과는 살린다 — 「처음부터 다시」는 여기서 따로 고른다.
const freshStart =
error.preprocessKept &&
window.confirm(
`같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다.
(await askConfirm({
message: `같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다.
전처리까지 처음부터 다시 할까요?
[확인] 처음부터 다시 · [취소] 전처리 결과 살림`,
);
전처리까지 처음부터 다시 할까요?`,
okLabel: "처음부터 다시",
cancelLabel: "전처리 결과 살림",
}));
return await run(true, freshStart);
}
}
+14
View File
@@ -38,8 +38,22 @@ def test_b03_uses_parts():
"createPanelHead",
"createIconButton",
"createText",
"createDrop",
"openModal",
"askConfirm",
"createCard",
"createListRow",
):
assert part in ts, f"B03 이 부품 {part} 를 안 씀"
assert "window.confirm" not in ts # 시스템 대화상자 없음 — 확인창 부품
css = "".join(_src("B03_FileInput", ".css").values())
for old in (
"b03-file__modal",
"b03-file__temp-option",
"b03-file__progress-bar",
"native-input",
):
assert old not in css and old not in ts, f"옛 변종 {old} 가 남음"
support = _src("B03_FileInput", ".ts")["B03_FileInput_UI_Support.ts"]
assert "<button" not in support # 날 단추 없음 — 단추는 부품이 만듦
assert 'class="ui-card b03-file__card' in support # 파일 카드 = 카드 부품