This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
+47
View File
@@ -0,0 +1,47 @@
/* B03 다중 파일 업로드 API 클라이언트 */
import { API_BASE_URL, API_TIMEOUT_MS, AUTH_TOKEN_KEY } from "@config/config_frontend";
export interface UploadedFileResult {
input_file_id: number;
original_filename: string;
file_type: string;
relative_path: string;
size_bytes: number;
metadata: Record<string, unknown>;
}
export interface FileUploadResponse {
status: string;
project_id: string;
files: UploadedFileResult[];
}
export async function uploadProjectFiles(
projectId: string,
files: readonly File[],
): Promise<FileUploadResponse> {
const formData = new FormData();
for (const file of files) formData.append("files", file, file.name);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const token = localStorage.getItem(AUTH_TOKEN_KEY);
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: formData,
signal: controller.signal,
});
const payload = (await response.json()) as Partial<FileUploadResponse> & {
message?: string;
};
if (!response.ok) {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload as FileUploadResponse;
} finally {
window.clearTimeout(timeoutId);
}
}
+60
View File
@@ -0,0 +1,60 @@
"""B03 파일 입력 저장 엔진."""
import os
import tempfile
from pathlib import Path
from fastapi import UploadFile
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
from common_util.common_util_storage import get_project_stage_path
from config.config_system import UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_MB
def resolve_upload_destination(
project_root: str | Path,
descriptor: FileUploadDescriptor,
) -> Path:
"""검증된 파일의 B03 입력 저장 경로를 생성해 반환한다."""
stage_root = Path(get_project_stage_path(str(project_root), "B03_FileInput")).resolve()
file_type = Path(descriptor.original_filename).suffix.lower().lstrip(".")
destination = (stage_root / "input" / file_type / descriptor.original_filename).resolve()
if os.path.commonpath((stage_root, destination)) != str(stage_root):
raise ValueError("업로드 저장 경로가 B03 단계 폴더를 벗어났습니다.")
destination.parent.mkdir(parents=True, exist_ok=True)
return destination
async def save_upload_stream(upload: UploadFile, destination: Path) -> int:
"""업로드 스트림을 크기 제한 내에서 임시 파일에 기록한 뒤 교체한다."""
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
written_bytes = 0
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=destination.parent,
prefix=f".{destination.name}.",
suffix=".upload",
delete=False,
) as temporary:
temporary_path = Path(temporary.name)
while chunk := await upload.read(UPLOAD_CHUNK_SIZE_BYTES):
written_bytes += len(chunk)
if written_bytes > maximum_bytes:
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
temporary.write(chunk)
temporary.flush()
os.fsync(temporary.fileno())
if written_bytes == 0:
raise ValueError("빈 파일은 업로드할 수 없습니다.")
os.replace(temporary_path, destination)
temporary_path = None
return written_bytes
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
@@ -0,0 +1,162 @@
"""B03 원본 입력 파일 메타데이터 분석."""
import math
from pathlib import Path
from typing import Any
import laspy
import numpy as np
import rasterio
from pyproj import CRS
def analyze_las_metadata(path: str | Path) -> dict[str, Any]:
"""LAS/LAZ 헤더와 분류 통계를 메모리에 전체 적재하지 않고 분석한다."""
source = Path(path)
with laspy.open(source) as las_file:
header = las_file.header
point_format = header.point_format
dimension_names = list(point_format.dimension_names)
point_count = int(header.point_count)
crs = header.parse_crs()
metadata: dict[str, Any] = {
"file": source.name,
"version": f"{header.version.major}.{header.version.minor}",
"point_format": {
"id": point_format.id,
"dimensions": dimension_names,
},
"point_count": point_count,
"bounds": {
"x": [float(header.mins[0]), float(header.maxs[0])],
"y": [float(header.mins[1]), float(header.maxs[1])],
"z": [float(header.mins[2]), float(header.maxs[2])],
},
"scale": [float(value) for value in header.scales],
"offset": [float(value) for value in header.offsets],
"has_crs": crs is not None,
"crs": crs.to_string() if crs else None,
"epsg": crs.to_epsg() if crs else None,
"has_classification": "classification" in dimension_names,
"has_rgb": all(name in dimension_names for name in ("red", "green", "blue")),
"has_intensity": "intensity" in dimension_names,
"has_return_number": "return_number" in dimension_names,
}
if metadata["has_classification"] and point_count > 0:
classification_counts: dict[int, int] = {}
for chunk in las_file.chunk_iterator(500_000):
values, counts = np.unique(
np.asarray(chunk.classification, dtype=np.uint8),
return_counts=True,
)
for value, count in zip(values.tolist(), counts.tolist(), strict=True):
classification_counts[value] = classification_counts.get(value, 0) + count
metadata["classification_summary"] = {
str(key): value for key, value in sorted(classification_counts.items())
}
return metadata
def analyze_prj_metadata(path: str | Path) -> dict[str, Any]:
"""PRJ WKT에서 좌표계 식별자와 명칭을 추출한다."""
source = Path(path)
text = source.read_text(encoding="utf-8", errors="replace").strip()
metadata: dict[str, Any] = {
"file": source.name,
"text_preview": text[:500],
"epsg": None,
"name": None,
"authority": None,
"is_valid": False,
}
if not text:
metadata["error"] = "PRJ 파일이 비어 있습니다."
return metadata
try:
crs = CRS.from_wkt(text)
except Exception as exc:
metadata["error"] = str(exc)
return metadata
metadata.update(
{
"epsg": crs.to_epsg(),
"name": crs.name,
"authority": crs.to_authority(),
"is_valid": True,
}
)
return metadata
def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]:
"""TFW의 affine 변환 계수와 유효성을 분석한다."""
source = Path(path)
values = [
float(line.strip())
for line in source.read_text(encoding="utf-8", errors="replace").splitlines()
if line.strip()
]
if any(not math.isfinite(value) for value in values):
raise ValueError("TFW 변환 계수는 유한한 숫자여야 합니다.")
return {
"file": source.name,
"values": values,
"pixel_size_x": values[0] if len(values) > 0 else None,
"rotation_y": values[1] if len(values) > 1 else None,
"rotation_x": values[2] if len(values) > 2 else None,
"pixel_size_y": values[3] if len(values) > 3 else None,
"origin_x": values[4] if len(values) > 4 else None,
"origin_y": values[5] if len(values) > 5 else None,
"is_valid": len(values) == 6,
}
def analyze_tif_metadata(path: str | Path) -> dict[str, Any]:
"""TIF/GeoTIFF 데이터셋의 공간 및 밴드 메타데이터를 분석한다."""
source = Path(path)
with rasterio.open(source) as dataset:
crs = dataset.crs
bounds = dataset.bounds
return {
"file": source.name,
"width": int(dataset.width),
"height": int(dataset.height),
"count": int(dataset.count),
"dtypes": list(dataset.dtypes),
"nodata": float(dataset.nodata) if dataset.nodata is not None else None,
"crs": crs.to_string() if crs else None,
"epsg": crs.to_epsg() if crs else None,
"bounds": {
"left": float(bounds.left),
"bottom": float(bounds.bottom),
"right": float(bounds.right),
"top": float(bounds.top),
},
"transform": [float(value) for value in list(dataset.transform)[:6]],
"resolution": [float(value) for value in dataset.res],
"likely_type": "dem" if dataset.count == 1 else "image",
}
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
"""입력 파일 확장자에 맞는 B03 메타데이터 분석 함수를 호출한다."""
source = Path(path)
extension = source.suffix.lower()
if extension in {".las", ".laz"}:
return analyze_las_metadata(source)
if extension == ".prj":
return analyze_prj_metadata(source)
if extension == ".tfw":
return analyze_tfw_metadata(source)
if extension in {".tif", ".tiff"}:
return analyze_tif_metadata(source)
return {
"file": source.name,
"extension": extension.lstrip("."),
"size_bytes": source.stat().st_size,
}
+86
View File
@@ -0,0 +1,86 @@
"""B03 input_files 테이블의 aiomysql Raw SQL 접근."""
import json
from pathlib import PurePosixPath
from typing import Any
from uuid import UUID
import aiomysql
async def create_input_file(
connection: aiomysql.Connection,
*,
project_id: UUID,
file_type: str,
original_filename: str,
relative_path: str,
file_size_bytes: int,
upload_by: int | None,
crs_epsg: int | None,
metadata: dict[str, Any],
) -> int:
"""업로드 원본 파일 메타데이터를 저장하고 생성된 ID를 반환한다."""
normalized_path = PurePosixPath(relative_path)
if normalized_path.is_absolute() or ".." in normalized_path.parts:
raise ValueError("DB에는 프로젝트 루트 기준 상대 경로만 저장할 수 있습니다.")
if normalized_path.parts[:2] != ("B03_FileInput", "input"):
raise ValueError("입력 파일 경로는 B03_FileInput/input 아래여야 합니다.")
async with connection.cursor() as cursor:
await cursor.execute(
"""
INSERT INTO input_files (
project_id,
file_type,
original_filename,
raw_file_path,
file_size_mb,
upload_by,
crs_epsg,
metadata,
status
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'UPLOADED')
""",
(
str(project_id),
file_type,
original_filename,
normalized_path.as_posix(),
file_size_bytes / (1024 * 1024),
upload_by,
crs_epsg,
json.dumps(metadata, ensure_ascii=False),
),
)
input_file_id = cursor.lastrowid
if not input_file_id:
raise RuntimeError("input_files 레코드 생성 결과에 ID가 없습니다.")
return int(input_file_id)
async def get_project_storage_relative_path(
connection: aiomysql.Connection,
project_id: UUID,
) -> str:
"""프로젝트의 검증된 저장소 상대 경로를 조회한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT storage_path
FROM projects
WHERE id = %s AND deleted_at IS NULL
""",
(str(project_id),),
)
row = await cursor.fetchone()
if not row or not row[0]:
raise LookupError("프로젝트 또는 프로젝트 저장 경로를 찾을 수 없습니다.")
normalized_path = PurePosixPath(str(row[0]).replace("\\", "/"))
if normalized_path.is_absolute() or ".." in normalized_path.parts:
raise ValueError("프로젝트 저장 경로는 안전한 상대 경로여야 합니다.")
return normalized_path.as_posix()
+144
View File
@@ -0,0 +1,144 @@
"""B03 파일 입력 FastAPI 라우터."""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Engine import (
resolve_upload_destination,
save_upload_stream,
)
from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
get_project_storage_relative_path,
)
from B03_FileInput.B03_FileInput_Schema import (
FileUploadDescriptor,
FileUploadResponse,
UploadedFileResult,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow import load_project_workflow
from config.config_db import get_db_pool
from config.config_system import UPLOAD_MAX_FILES
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B03 File Input"])
@router.post("/{project_id}/files", response_model=FileUploadResponse)
async def upload_project_files(
project_id: UUID,
files: list[UploadFile] = File(...),
) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
if not files or len(files) > UPLOAD_MAX_FILES:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": f"파일은 1~{UPLOAD_MAX_FILES}개까지 가능합니다.",
},
)
filenames = [upload.filename or "" for upload in files]
if len({filename.casefold() for filename in filenames}) != len(filenames):
return JSONResponse(
status_code=400,
content={"status": "error", "message": "동일한 파일명을 중복 업로드할 수 없습니다."},
)
las_count = sum(Path(filename).suffix.lower() in {".las", ".laz"} for filename in filenames)
if las_count != 1:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
},
)
pool = get_db_pool()
saved_paths: list[Path] = []
try:
results: list[UploadedFileResult] = []
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
await connection.begin()
try:
for upload in files:
preliminary = FileUploadDescriptor(
original_filename=upload.filename or "",
size_bytes=max(upload.size or 0, 1),
)
destination = resolve_upload_destination(project_root, preliminary)
written_bytes = await save_upload_stream(upload, destination)
saved_paths.append(destination)
descriptor = FileUploadDescriptor(
original_filename=preliminary.original_filename,
size_bytes=written_bytes,
)
metadata = await asyncio.to_thread(analyze_input_metadata, destination)
relative_path = destination.relative_to(project_root).as_posix()
file_type = destination.suffix.lower().lstrip(".")
crs_epsg = metadata.get("epsg")
input_file_id = await create_input_file(
connection,
project_id=project_id,
file_type=file_type,
original_filename=descriptor.original_filename,
relative_path=relative_path,
file_size_bytes=written_bytes,
upload_by=None,
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
metadata=metadata,
)
results.append(
UploadedFileResult(
input_file_id=input_file_id,
original_filename=descriptor.original_filename,
file_type=file_type,
relative_path=relative_path,
size_bytes=written_bytes,
metadata=metadata,
)
)
await connection.commit()
except Exception:
await connection.rollback()
raise
stage_root = project_root / "B03_FileInput"
atomic_write_json(
stage_root / "metadata.json",
{"project_id": str(project_id), "files": [result.model_dump() for result in results]},
)
workflow_path = project_root / "workflow.json"
if not workflow_path.exists():
atomic_write_json(workflow_path, load_project_workflow(project_root))
return FileUploadResponse(project_id=str(project_id), files=results)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc:
for saved_path in saved_paths:
saved_path.unlink(missing_ok=True)
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception:
for saved_path in saved_paths:
saved_path.unlink(missing_ok=True)
logger.exception("B03 파일 업로드 처리 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "파일 업로드 처리 중 오류가 발생했습니다."},
)
finally:
for upload in files:
await upload.close()
+52
View File
@@ -0,0 +1,52 @@
"""B03 파일 입력 요청·응답 검증 모델."""
from pathlib import PurePath
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
from config.config_system import UPLOAD_ALLOWED_EXT, UPLOAD_MAX_MB
class FileUploadDescriptor(BaseModel):
"""업로드 전에 검증할 단일 파일의 이름과 크기."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
original_filename: str = Field(min_length=1, max_length=255)
size_bytes: int = Field(gt=0)
@model_validator(mode="after")
def validate_file(self) -> "FileUploadDescriptor":
filename = self.original_filename
if PurePath(filename).name != filename or "/" in filename or "\\" in filename:
raise ValueError("파일명에는 경로가 포함될 수 없습니다.")
extension = PurePath(filename).suffix.lower()
if extension not in UPLOAD_ALLOWED_EXT:
allowed = ", ".join(UPLOAD_ALLOWED_EXT)
raise ValueError(f"허용되지 않은 파일 확장자입니다. 허용 형식: {allowed}")
maximum_bytes = UPLOAD_MAX_MB * 1024 * 1024
if self.size_bytes > maximum_bytes:
raise ValueError(f"파일 크기는 {UPLOAD_MAX_MB}MB를 초과할 수 없습니다.")
return self
class UploadedFileResult(BaseModel):
"""저장 완료된 단일 입력 파일 정보."""
input_file_id: int
original_filename: str
file_type: str
relative_path: str
size_bytes: int
metadata: dict[str, Any]
class FileUploadResponse(BaseModel):
"""B03 다중 파일 업로드 성공 응답."""
status: str = "success"
project_id: str
files: list[UploadedFileResult]
+178 -19
View File
@@ -1,29 +1,188 @@
/* =============================================================================
* B03_FileInput_UI_Page.ts
* 로그인 후 03: 파일 입력 (지형·포인트클라우드·도면 업로드)
*
* ⚠️ 본문(업로드 드롭존/파일 목록)은 준비 중 — 헤더/안내만 구현.
* 실제 업로드 UI는 0_old 참고하여 추후 구체화.
*
* 제약 준수 (frontend.md): 문구는 locale 참조(§3), 공통 컴포넌트 사용(§2).
* ========================================================================== */
/* B03 파일 입력 페이지 */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { renderPendingContent } from "../A00_Common/b_page_scaffold";
import {
CURRENT_PROJECT_ID_KEY,
UPLOAD_ALLOWED_EXT,
UPLOAD_MAX_FILES,
UPLOAD_MAX_MB,
} from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createCard,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { uploadProjectFiles, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
import "./B03_FileInput_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
function validateFiles(files: readonly File[]): string | null {
if (files.length === 0) return L("B03_File_Error_Required");
if (files.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count");
const maximumBytes = UPLOAD_MAX_MB * 1024 * 1024;
const normalizedNames = new Set<string>();
let lasCount = 0;
for (const file of files) {
const extensionIndex = file.name.lastIndexOf(".");
const extension = extensionIndex >= 0 ? file.name.slice(extensionIndex).toLowerCase() : "";
if (!UPLOAD_ALLOWED_EXT.includes(extension as (typeof UPLOAD_ALLOWED_EXT)[number])) {
return `${L("B03_File_Error_Extension")} ${file.name}`;
}
if (file.size === 0 || file.size > maximumBytes) {
return `${L("B03_File_Error_Size")} ${file.name}`;
}
const normalizedName = file.name.toLocaleLowerCase();
if (normalizedNames.has(normalizedName)) return `${L("B03_File_Error_Extension")} ${file.name}`;
normalizedNames.add(normalizedName);
if (extension === ".las" || extension === ".laz") lasCount += 1;
}
return lasCount === 1 ? null : L("B03_File_Error_Las");
}
export function renderB03FileInput(root: HTMLElement): void {
renderPendingContent(root, {
pageClass: "b03-file",
title: L("B03_File_Title"),
subtitle: L("B03_File_Subtitle"),
let selectedFiles: File[] = [];
const page = document.createElement("div");
page.className = "b03-file";
const header = document.createElement("div");
header.className = "b03-file__header";
const title = document.createElement("h1");
title.className = "b03-file__title";
title.textContent = L("B03_File_Title");
const subtitle = document.createElement("p");
subtitle.className = "b03-file__subtitle";
subtitle.textContent = L("B03_File_Subtitle");
header.append(title, 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");
const dropzoneHint = document.createElement("span");
dropzoneHint.textContent = L("B03_File_Select_Hint");
dropzone.append(dropzoneLabel, dropzoneHint, fileInput);
const errorSlot = document.createElement("p");
errorSlot.className = "b03-file__error";
errorSlot.setAttribute("role", "alert");
const selectedTitle = document.createElement("h2");
selectedTitle.className = "b03-file__section-title";
selectedTitle.textContent = L("B03_File_Selected_Title");
const selectedList = document.createElement("ul");
selectedList.className = "b03-file__list";
const resultList = document.createElement("ul");
resultList.className = "b03-file__results";
function renderSelectedFiles(): void {
selectedList.replaceChildren();
if (selectedFiles.length === 0) {
const empty = document.createElement("li");
empty.className = "b03-file__empty";
empty.textContent = L("B03_File_Selected_Empty");
selectedList.append(empty);
return;
}
for (const file of selectedFiles) {
const item = document.createElement("li");
const name = document.createElement("span");
name.textContent = file.name;
const size = document.createElement("span");
size.textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
item.append(name, size);
selectedList.append(item);
}
}
function setSelectedFiles(files: readonly File[]): void {
selectedFiles = Array.from(files);
errorSlot.textContent = validateFiles(selectedFiles) ?? "";
renderSelectedFiles();
}
function renderUploadResults(results: readonly UploadedFileResult[]): void {
resultList.replaceChildren();
for (const result of results) {
const item = document.createElement("li");
const filename = document.createElement("strong");
filename.textContent = result.original_filename;
const path = document.createElement("span");
path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`;
item.append(filename, path);
resultList.append(item);
}
}
function onB03_File_Select_Change(): void {
setSelectedFiles(fileInput.files ? Array.from(fileInput.files) : []);
}
function onB03_File_Drop(event: DragEvent): void {
event.preventDefault();
dropzone.classList.remove("is-dragging");
setSelectedFiles(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
}
async function onB03_File_Upload_Click(): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const validationError = validateFiles(selectedFiles);
if (!projectId) {
errorSlot.textContent = L("B03_File_Error_Project");
return;
}
if (validationError) {
errorSlot.textContent = validationError;
return;
}
errorSlot.textContent = "";
showLoadingOverlay();
try {
const response = await uploadProjectFiles(projectId, selectedFiles);
renderUploadResults(response.files);
showToast(L("B03_File_Upload_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
errorSlot.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
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);
const uploadButton = createButton({
label: L("B03_File_Upload_Button"),
variant: "filled",
onClick: () => void onB03_File_Upload_Click(),
});
const body = document.createElement("div");
body.className = "b03-file__body";
body.append(dropzone, errorSlot, selectedTitle, selectedList, uploadButton, resultList);
page.append(header, createCard({ body: [body], raised: true }));
root.replaceChildren(page);
renderSelectedFiles();
}
+117 -6
View File
@@ -1,7 +1,118 @@
/* =============================================================================
* B03_FileInput_UI_Style.css
* 파일 입력 페이지 전용 스타일 (theme.css 변수만 사용)
* ⚠️ 본문(업로드 드롭존) 준비 중 — 현재는 공통 스캐폴드 스타일에 위임.
* ========================================================================== */
.b03-file {
max-width: var(--page-max-width);
margin: 0 auto;
padding: var(--spacing-40) var(--spacing-24);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
/* 본문 구현 시 .b03-file 하위에 업로드 드롭존/파일 목록 스타일 추가 예정 */
.b03-file__header,
.b03-file__body {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.b03-file__title {
font-family: var(--font-display);
font-size: var(--text-heading);
line-height: 1;
color: var(--color-text);
}
.b03-file__subtitle,
.b03-file__dropzone span,
.b03-file__empty {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.b03-file__native-input {
display: none;
}
.b03-file__dropzone {
min-height: 180px;
padding: var(--spacing-24);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-8);
cursor: pointer;
text-align: center;
}
.b03-file__dropzone:focus-visible,
.b03-file__dropzone.is-dragging {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
background: var(--color-mist-violet);
}
.b03-file__dropzone strong,
.b03-file__section-title,
.b03-file__results strong {
color: var(--color-text);
font-weight: var(--font-weight-medium);
}
.b03-file__section-title {
font-size: var(--text-subheading);
}
.b03-file__error {
min-height: var(--spacing-16);
color: var(--color-error);
font-size: var(--text-body-sm);
}
.b03-file__list,
.b03-file__results {
margin: 0;
padding: 0;
list-style: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
overflow: hidden;
}
.b03-file__list li,
.b03-file__results li {
padding: var(--spacing-16);
display: flex;
justify-content: space-between;
gap: var(--spacing-16);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.b03-file__list li:nth-child(even),
.b03-file__results li:nth-child(even) {
background: var(--color-surface);
}
.b03-file__list li:last-child,
.b03-file__results li:last-child {
border-bottom: 0;
}
.b03-file__results:empty {
display: none;
}
.b03-file__results li {
flex-direction: column;
}
@media (max-width: 640px) {
.b03-file__list li {
flex-direction: column;
gap: var(--spacing-8);
}
}