auto: 2026-09-02 16:48 (EOMSANGDON-HOME)

This commit is contained in:
2026-09-02 16:48:22 +09:00
parent b4c7a7eedb
commit 397a135073
192 changed files with 6816 additions and 6014 deletions
+45 -67
View File
@@ -69,15 +69,12 @@ export async function uploadProjectFiles(
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/files`,
{
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
},
);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
});
return await readJsonOrThrow<FileUploadResponse>(response);
} finally {
window.clearTimeout(timeoutId);
@@ -92,22 +89,19 @@ export async function createUploadSession(
completeUpload = false,
lasFree = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
}),
},
);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
original_filename: file.name,
size_bytes: file.size,
chunk_size_bytes: chunkSizeBytes,
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
}),
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
}
@@ -138,21 +132,18 @@ export async function finalizeUploadSession(
fingerprint?: string | null,
lasFree = false,
): Promise<FileUploadResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/finalize`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
}),
},
);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
total_chunks: totalChunks,
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
}
@@ -160,13 +151,10 @@ export async function fetchUploadStatus(
projectId: string,
sessionId: string,
): Promise<UploadStatusResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
{
method: "GET",
credentials: "include",
},
);
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<UploadStatusResponse>(response);
}
@@ -200,16 +188,11 @@ export interface UploadOverviewResponse {
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
export async function fetchUploadOverview(
projectId: string,
): Promise<UploadOverviewResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
{
method: "GET",
credentials: "include",
},
);
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
@@ -223,15 +206,10 @@ export interface WF1AnalysisStatus {
error?: string;
}
export async function checkWF1AnalysisStatus(
projectId: string,
): Promise<WF1AnalysisStatus> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/surface/status`,
{
method: "GET",
credentials: "include",
},
);
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
method: "GET",
credentials: "include",
});
return await readJsonOrThrow<WF1AnalysisStatus>(response);
}
+42 -126
View File
@@ -9,14 +9,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import {
createStepBar,
WORKFLOW_STEP_ICONS,
} from "@ui/ui_template_workflow_layout";
import {
fetchUploadOverview,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
@@ -129,10 +123,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return Array.from(slots.values()).filter((state) => state.file);
}
function setCardState(
slot: FileSlot,
stateName: "empty" | "selected" | UploadStatus,
): void {
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
const card = cardMap.get(slot);
if (!card) return;
card.classList.remove(
@@ -146,9 +137,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const cssState = stateName === "failed" ? "error" : stateName;
card.classList.add(`b03-file__card--${cssState}`);
const badgeContainer = card.querySelector<HTMLDivElement>(
".b03-file__card-badge-container",
);
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
if (badgeContainer) {
badgeContainer.replaceChildren();
if (stateName === "empty") {
@@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!state || !card) return;
renderExtensionLabel(card, state);
const fileName = card.querySelector<HTMLSpanElement>(
".b03-file__file-name",
);
const fileSize = card.querySelector<HTMLSpanElement>(
".b03-file__file-size",
);
const progress = card.querySelector<HTMLDivElement>(
".b03-file__progress-bar",
);
const progressBytes = card.querySelector<HTMLSpanElement>(
".b03-file__progress-bytes",
);
const progressSpeed = card.querySelector<HTMLSpanElement>(
".b03-file__progress-speed",
);
const progressEta = card.querySelector<HTMLSpanElement>(
".b03-file__progress-eta",
);
const error = card.querySelector<HTMLDivElement>(
".b03-file__error-message",
);
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
);
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
const percent = state.file
? Math.min(100, (state.progressBytes / state.file.size) * 100)
: 0;
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
if (fileName)
fileName.textContent =
state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileSize) {
fileSize.textContent = state.file
? formatBytes(state.file.size)
@@ -271,13 +240,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (remove) remove.hidden = !state.file;
if (state.error) setCardState(slot, "failed");
else if (!state.file)
setCardState(slot, state.serverUploaded ? "completed" : "empty");
else
setCardState(
slot,
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
);
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
updateUploadButton();
}
@@ -289,23 +253,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderSlot(slot);
}
function validateFileForSlot(
file: File,
state: FileSlotState,
): string | null {
function validateFileForSlot(file: File, state: FileSlotState): string | null {
const extension = getExtension(file.name);
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
if (!state.extensions.includes(extension))
return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes)
return L("B03_File_Error_Size");
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
return null;
}
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
): Promise<void> {
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
const state = targetSlot ? slots.get(targetSlot) : undefined;
if (!state) {
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
@@ -317,19 +273,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return;
}
if (!targetSlot && state.file && state.file.name !== file.name) {
showErrorMessage(
state.slot,
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
);
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
return;
}
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
if (state.serverUploaded) {
const accepted = await confirmReplaceUpload(
L(state.labelKey),
state.serverUploaded.name,
);
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
if (!accepted) return;
}
@@ -350,18 +300,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
updateUploadButton();
}
function onFileSelected(
selection: readonly File[],
targetSlot?: FileSlot,
): void {
function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void {
if (selection.length === 0) return;
// LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) —
// 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다.
const pointCloudExtensions = slots.get("las_laz")?.extensions ?? [];
const files = lasFreeDesign
? selection.filter(
(file) => !pointCloudExtensions.includes(getExtension(file.name)),
)
? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name)))
: selection;
const blocked = files.length !== selection.length;
if (blocked && files.length === 0) {
@@ -433,8 +378,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
*/
function isSlotRequired(state: FileSlotState): boolean {
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
return routeIsShapefile();
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile();
if (state.slot === "las_laz") return !lasFreeDesign;
return state.isRequired;
}
@@ -447,14 +391,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
const missingRequired = Array.from(slots.values()).some(
(state) =>
isSlotRequired(state) && !state.file && !state.serverUploaded,
(state) => isSlotRequired(state) && !state.file && !state.serverUploaded,
);
if (missingRequired) return L("B03_File_Error_RequiredSlots");
if (!lasFreeDesign) {
const lasState = slots.get("las_laz");
if (!lasState?.file && !lasState?.serverUploaded)
return L("B03_File_Error_Las");
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
}
for (const state of selected) {
if (state.error) return state.error;
@@ -490,8 +432,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
: "prj"
: Array.from(slots.values()).find(
(candidate) =>
candidate.slot !== "route_prj" &&
candidate.extensions.includes(extension),
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
)?.slot;
const state = slot ? slots.get(slot) : undefined;
if (state) {
@@ -533,26 +474,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!card) throw new Error("file-card-template is invalid");
card.dataset.slotId = state.slot;
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
card.querySelector(".b03-file__card-label")!.textContent = L(
state.labelKey,
);
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
renderExtensionLabel(card, state);
const input = card.querySelector<HTMLInputElement>(
".b03-file__slot-input",
)!;
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(
".b03-file__card-select",
)!;
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
select.textContent = L("B03_File_Card_Select");
select.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
input.value = "";
});
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
)!;
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
remove.textContent = "×";
remove.title = L("B03_File_Card_Remove");
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
@@ -599,9 +532,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!activeProjectId) return;
for (const state of selectedStates()) {
const stored = localStorage.getItem(
makeSessionKey(activeProjectId, state.file!),
);
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
if (!stored) continue;
const session = JSON.parse(stored) as StoredUploadSession;
state.uploadSessionId = session.uploadSessionId;
@@ -671,8 +602,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const detail =
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
showToast(L("B03_Temp_Attach_Failed"), "error");
}
@@ -701,9 +631,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
async function startChunkedUpload(
targetStates = selectedStates(),
): Promise<void> {
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
if (isUploading) return;
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
if (tempPicker.selected()) {
@@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const failed = targetStates.find(
(state) => state.uploadStatus === "uploading",
);
const detail =
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
if (failed) showErrorMessage(failed.slot, detail);
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error");
@@ -789,9 +714,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
function onB03_File_Drop(event: DragEvent): void {
event.preventDefault();
dropzone.classList.remove("is-dragging");
onFileSelected(
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
);
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
}
fileInput.addEventListener("change", onB03_File_Select_Change);
@@ -803,9 +726,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
dropzone.addEventListener("dragleave", () =>
dropzone.classList.remove("is-dragging"),
);
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
dropzone.addEventListener("drop", onB03_File_Drop);
uploadButton = createButton({
@@ -872,10 +793,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
lasFreeCheck.addEventListener("change", () => {
lasFreeDesign = lasFreeCheck.checked;
if (activeProjectId) {
localStorage.setItem(
`b03_las_free_${activeProjectId}`,
lasFreeDesign ? "1" : "0",
);
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
}
applyLasFreeState();
pageError.textContent = "";
@@ -885,13 +803,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
terrainGroup.append(lasFreeRow);
const routePanel = document.createElement("div");
routePanel.className =
"b03-file__control-panel b03-file__cards-container-panel";
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
routePanel.append(routeGroup);
const terrainPanel = document.createElement("div");
terrainPanel.className =
"b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.append(terrainGroup);
const cardsContainer = document.createElement("div");
+2 -6
View File
@@ -295,9 +295,7 @@
color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
font-size: var(--text-body-sm, 14px);
margin-right: var(
--spacing-8
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
}
.b03-file__card-heading {
@@ -348,9 +346,7 @@
font-size: var(--text-body-sm, 14px);
line-height: 1;
padding: 0;
margin-left: var(
--spacing-8
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
transition: all var(--transition-base, 0.2s);
}
+5 -31
View File
@@ -6,40 +6,16 @@ import { ui_locales } from "@ui/ui_template_locale";
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
*/
export type FileSlot =
| "csv"
| "shx"
| "dbf"
| "cpg"
| "route_prj"
| "las_laz"
| "prj"
| "tfw"
| "tif"
| "dxf";
"csv" | "shx" | "dbf" | "cpg" | "route_prj" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
export const ROUTE_SLOTS: readonly FileSlot[] = [
"csv",
"shx",
"dbf",
"cpg",
"route_prj",
];
export const ROUTE_SLOTS: readonly FileSlot[] = ["csv", "shx", "dbf", "cpg", "route_prj"];
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
export const TERRAIN_SLOTS: readonly FileSlot[] = [
"las_laz",
"prj",
"tfw",
"tif",
];
export const TERRAIN_SLOTS: readonly FileSlot[] = ["las_laz", "prj", "tfw", "tif"];
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
"shx",
"dbf",
"route_prj",
];
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = ["shx", "dbf", "route_prj"];
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
export interface SlotConfig {
@@ -179,9 +155,7 @@ export function planSlotAssignments(
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
}
const config = slotConfigs.find(
(candidate) =>
candidate.slot !== "route_prj" &&
candidate.extensions.includes(extension),
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
);
return { file, slot: config?.slot };
});
+12 -46
View File
@@ -6,14 +6,8 @@
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
* ========================================================================== */
import {
PROGRESS_UPDATE_INTERVAL_MS,
UPLOAD_CHUNK_SIZE_MB,
} from "@config/config_frontend";
import {
fetchWorkflowState,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
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 { fileFingerprint } from "./B03_FileInput_Fingerprint";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -24,10 +18,7 @@ import {
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import {
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
import {
makeSessionKey,
type FileSlotState,
@@ -42,10 +33,7 @@ function L(key: keyof typeof ui_locales): string {
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
export function confirmReplaceUpload(
slotLabel: string,
fileName: string,
): Promise<boolean> {
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
@@ -120,9 +108,7 @@ export async function uploadOneFile(
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
const fingerprint = state.uploadSessionId
? null
: await fileFingerprint(file);
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await createUploadSession(
@@ -156,22 +142,11 @@ export async function uploadOneFile(
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(file.size, start + chunkSizeBytes);
const chunkStartedAt = performance.now();
await uploadFileChunk(
projectId,
session,
chunkIndex,
file.slice(start, end),
);
const elapsedSec = Math.max(
0.001,
(performance.now() - chunkStartedAt) / 1000,
);
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
state.progressBytes = end;
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
state.etaSeconds =
state.speedMbs > 0
? (file.size - end) / 1024 / 1024 / state.speedMbs
: null;
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
const stored: StoredUploadSession = {
key: storageKey,
@@ -188,10 +163,7 @@ export async function uploadOneFile(
localStorage.setItem(storageKey, JSON.stringify(stored));
const now = performance.now();
if (
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
chunkIndex === totalChunks - 1
) {
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
lastPaintAt = now;
onProgress();
}
@@ -213,10 +185,7 @@ export async function uploadOneFile(
});
state.progressBytes = file.size;
state.speedMbs =
file.size /
1024 /
1024 /
Math.max(0.001, (performance.now() - startedAt) / 1000);
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
state.etaSeconds = 0;
state.uploadStatus = "completed";
onProgress();
@@ -237,12 +206,9 @@ export async function uploadOneFile(
*
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
*/
export function isInitialPipelineRunning(
state: WorkflowState | undefined,
): boolean {
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
if (!state?.stages?.length) return false;
const stageAt = (stageNo: number) =>
state.stages.find((stage) => stage.stage_no === stageNo);
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
const fileInput = stageAt(0);
const preprocess = stageAt(1);
const section = stageAt(3);