// 상세 패널 (300px) — 헤더(썸네일·이름·닫기) → 밑줄 탭(정보/태그) → 스크롤 본문. // 색은 예외 없이 var() 경유 — 다크·라이트 두 팔레트에서 동시에 성립해야 한다. // 헤더 썸네일 슬롯은 '사진 면'이 아니라 24px 크롬 슬롯이라 --bg-panel-raised를 쓴다 // (--grid-cell-bg는 테마 무관 고정 다크라 흰 카드 안에 검은 사각형이 박힌다). import { IconCalendar, IconClock, IconFile, IconFolder, IconImage, IconLayers, IconTag, IconX } from "../ui/icons"; import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type Component, } from "solid-js"; import type { JSX } from "solid-js"; import { ko } from "../i18n/ko"; import { applyTagSet, assignTags, createTag, createTagSet, deleteTagSet, fileDetails, fileTagsOf, listTags, listTagSets, setRating, thumbUrl, unassignTags, updateTagSet, type FileDetails, type Tag, type TagSet, } from "../ipc/commands"; import { StarRating } from "../ui/StarRating"; import { performMarkDelete, performTrash } from "../features/fileops/ContextMenu"; import { openMoveDialog } from "../features/fileops/MoveDialog"; import { bumpTagVersion, detailsTab, focusItemView, info, refreshSidebar, refreshSnapshot, selected, setDetailsOpen, setDetailsTab, thumbVersions, toast, } from "../state/store"; function fmtSize(bytes: number): string { if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`; if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`; if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)} KB`; return `${bytes} B`; } /** ListView와 같은 형식 — YYYY-MM-DD HH:mm */ function fmtDate(ms: number | null | undefined): string { if (!ms) return "—"; const d = new Date(ms); const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } /** 0:42 / 1:03:20 — 현행 "212초" 표기 폐기 */ function fmtDuration(ms: number | null | undefined): string { if (!ms) return ""; const s = Math.round(ms / 1000); const m = Math.floor(s / 60); const h = Math.floor(m / 60); return h > 0 ? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}` : `${m}:${String(s % 60).padStart(2, "0")}`; } /** 정보 타일 1칸 */ const Tile: Component<{ icon: JSX.Element; label: string; value: string; clamp?: boolean; title?: string; }> = (props) => (
{props.icon}
{props.label}
{props.value}
); /** 정보 타일 2분할 — 300px 폭에서 깨지지 않는 상한이 2칸이다 */ const SplitTile: Component<{ icon: JSX.Element; labelA: string; valueA: string; labelB: string; valueB: string; }> = (props) => (
{props.icon}
{props.labelA}
{props.valueA}
{props.labelB}
{props.valueB}
); /** 선택 없음 — 두 탭이 공유한다 */ const EmptyState: Component = () => (
{ko.details.noSelection}
); /** * 세트 만들기·고치기. 태그 편집기 위에 겹쳐 뜬다 — 모달 3종과 같은 언어(스크림 + 카드 + Esc). * * 단축키는 **눌러서 지정한다**. 문자열로 적게 하면 "Ctrl+1"과 "ctrl+1"과 "^1"이 섞이고, * 실제로 그 조합이 브라우저에서 어떤 key로 오는지 사용자가 알 수 없다. */ const TagSetEditor: Component<{ target: TagSet | "new"; allTags: Tag[]; onClose: () => void; onSaved: () => void; }> = (props) => { const existing = () => (props.target === "new" ? undefined : props.target); const [name, setName] = createSignal(existing()?.name ?? ""); const [hotkey, setHotkey] = createSignal(existing()?.hotkey ?? null); const [picked, setPicked] = createSignal>( new Set((existing()?.tags ?? []).map((t) => t.id)), ); const [capturing, setCapturing] = createSignal(false); const toggle = (id: number) => setPicked((p) => { const n = new Set(p); if (n.has(id)) n.delete(id); else n.add(id); return n; }); const save = async () => { const n = name().trim(); if (!n) return; try { const ex = existing(); if (ex) { await updateTagSet(ex.id, { name: n, hotkey: hotkey(), tagIds: [...picked()] }); } else { const id = await createTagSet(n, [...picked()]); if (hotkey()) await updateTagSet(id, { hotkey: hotkey() }); } props.onSaved(); } catch (e) { toast(String(e)); } }; const remove = async () => { const ex = existing(); if (!ex) return; try { await deleteTagSet(ex.id); props.onSaved(); } catch (e) { toast(String(e)); } }; // 키 캡처 — 수식키만 눌린 상태는 무시한다(Ctrl을 누르는 중에 확정되면 안 된다) const onCapture = (e: KeyboardEvent) => { if (!capturing()) return; e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { setCapturing(false); return; } if (["Control", "Shift", "Alt", "Meta"].includes(e.key)) return; setHotkey(`${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`); setCapturing(false); }; onMount(() => { window.addEventListener("keydown", onCapture, { capture: true }); onCleanup(() => window.removeEventListener("keydown", onCapture, { capture: true })); }); return ( ); }; /** * 별점 편집기 — 선택한 전부에 같은 값을 준다. 태그 편집기와 같은 자리(태그 탭 위)에 산다. * * 여러 항목의 값이 서로 다르면 채우지 않고 '여러 값'이라고 말한다. 아무 값이나 골라 * 채워 보이면 사용자는 그게 이미 매겨진 점수라고 읽고, 건드리지 않은 항목까지 덮어쓴다. */ const RatingEditor: Component = () => { const [bump, setBump] = createSignal(0); const ids = () => [...selected()]; const [details] = createResource( () => (ids().length > 0 ? `${ids().join(",")}:${bump()}` : undefined), async () => fileDetails(ids().slice(0, 500)), ); const values = () => (details() ?? []).map((d) => d.rating); const mixed = () => new Set(values()).size > 1; const value = () => (mixed() ? 0 : (values()[0] ?? 0)); const apply = async (next: number) => { const list = ids(); if (list.length === 0) return; try { const n = await setRating(list, next); toast(ko.rating.applied(n, next)); setBump((v) => v + 1); // 그리드 오버레이의 별은 스냅샷에서 온다 — 다시 읽어야 반영된다 await refreshSnapshot(); } catch (e) { toast(String(e)); } }; return (
void apply(n)} /> {ko.rating.mixed}
); }; /** 선택 항목 태그 편집기 (단일/다중 선택 공용) */ const TagEditor: Component = () => { const [tagVersion, setTagVersion] = createSignal(0); const singleId = () => { const s = selected(); return s.size === 1 ? [...s][0] : undefined; }; const [currentTags] = createResource( () => (singleId() != null ? `${singleId()}:${tagVersion()}` : undefined), async () => fileTagsOf(singleId()!), ); const [allTags] = createResource( () => tagVersion(), () => listTags(), ); const addTag = async (name: string) => { const trimmed = name.trim(); if (!trimmed) return; const ids = [...selected()]; if (ids.length === 0) return; try { const existing = (allTags() ?? []).find((t) => t.name === trimmed); const tagId = existing ? existing.id : await createTag(trimmed); const r = await assignTags(ids, tagId); toast(ko.tags.assignTo(r.count, trimmed)); setTagVersion((v) => v + 1); bumpTagVersion(); refreshSidebar(); } catch (e) { toast(String(e)); } }; const removeTag = async (tagId: number, name: string) => { const ids = [...selected()]; const r = await unassignTags(ids, tagId); toast(ko.tags.removedFrom(r.count, name)); setTagVersion((v) => v + 1); bumpTagVersion(); refreshSidebar(); }; // ── 붙이기: 검색이 아니라 클릭 ────────────────────────────── // 예전에는 입력창에 매번 이름을 쳐야 했다. 태그는 몇 개~수십 개고 그중 같은 것을 반복해서 // 붙이는 작업이라, 목록을 보여주고 누르게 하는 편이 훨씬 빠르다. // 입력창은 **새 태그를 만들 때와 목록이 길 때 좁히는 용도**로만 남긴다. const [filter, setFilter] = createSignal(""); const attachedIds = () => new Set((currentTags() ?? []).map((t) => t.id)); const pickable = createMemo(() => { const q = filter().trim().toLowerCase(); const all = allTags() ?? []; return q ? all.filter((t) => t.name.toLowerCase().includes(q)) : all; }); /** 입력한 이름이 기존에 없으면 '새로 만들기'를 내민다 */ const canCreate = () => { const q = filter().trim(); return q.length > 0 && !(allTags() ?? []).some((t) => t.name === q); }; const toggleTag = async (t: Tag) => { if (selected().size === 0) return; if (attachedIds().has(t.id)) await removeTag(t.id, t.name); else await addTag(t.name); }; // ── 세트 ──────────────────────────────────────────────────── const [sets, { refetch: refetchSets }] = createResource(() => tagVersion(), listTagSets); const [setEditor, setSetEditor] = createSignal(); const runSet = async (s: TagSet) => { const ids = [...selected()]; if (ids.length === 0 || s.tags.length === 0) return; try { const r = await applyTagSet(ids, s.id); toast(ko.tags.setApplied(s.name, r.count)); setTagVersion((v) => v + 1); bumpTagVersion(); refreshSidebar(); } catch (e) { toast(String(e)); } }; // 세트 단축키 — 선택이 있고 입력창에 포커스가 없을 때만 듣는다. // (입력 중에 '1'을 눌렀다고 세트가 적용되면 안 된다) onMount(() => { const onKey = (e: KeyboardEvent) => { if (e.altKey || e.metaKey) return; const el = document.activeElement; if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return; if (selected().size === 0) return; const combo = `${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`; const hit = (sets() ?? []).find((s) => s.hotkey && s.hotkey === combo); if (!hit) return; e.preventDefault(); e.stopPropagation(); void runSet(hit); }; window.addEventListener("keydown", onKey); onCleanup(() => window.removeEventListener("keydown", onKey)); }); return (
{/* 붙어 있는 태그 */}
0} fallback={{ko.tags.noneYet}} > {(t) => ( {t.name} )}
{/* 세트 — 한 번에 여러 개 */} 0}>
{(s) => ( )}
{/* 태그 고르기 — 전체 목록을 눌러서 붙인다 */} setFilter(e.currentTarget.value)} onKeyDown={(e) => { if (e.key !== "Enter") return; void addTag(e.currentTarget.value); setFilter(""); }} />
{(t) => ( )} {ko.tags.noMatch}
{(target) => ( setSetEditor(undefined)} onSaved={() => { setSetEditor(undefined); void refetchSets(); setTagVersion((v) => v + 1); }} /> )}
); }; /** 정보 탭 — 단일 선택 */ const InfoTiles: Component<{ d: FileDetails }> = (props) => ( <>
} label={ko.details.fileSize} value={fmtSize(props.d.size)} /> } > } labelA={ko.details.dimensions} valueA={`${props.d.width} × ${props.d.height}`} labelB={ko.details.fileSize} valueB={fmtSize(props.d.size)} /> } label={ko.details.duration} value={fmtDuration(props.d.durationMs)} /> } label={ko.details.modifiedAt} value={fmtDate(props.d.mtimeMs)} /> } > } labelA={ko.details.takenAt} valueA={fmtDate(props.d.takenAt)} labelB={ko.details.modifiedAt} valueB={fmtDate(props.d.mtimeMs)} /> } label={ko.details.filePath} value={props.d.dir} clamp title={props.d.dir} />
} label={ko.details.kind} value={props.d.kind === 1 ? ko.filter.video : ko.filter.image} /> } label={ko.details.fileName} value={props.d.name} clamp title={props.d.name} />
); /** 정보 탭 — 다중 선택. 용량 합계·종류별 집계는 만들지 않는다(file_details 500 상한) */ const BulkPanel: Component = () => (
{selected().size.toLocaleString("ko-KR")}
{ko.details.selectedUnit}
); export const DetailsPanel: Component = () => { const singleId = () => { const s = selected(); return s.size === 1 ? [...s][0] : undefined; }; const [details] = createResource(singleId, async (id) => { const list = await fileDetails([id]); return list[0] as FileDetails | undefined; }); // 썸네일이 없는 항목은 404가 난다 — 실패한 id만 기억해 두고 선택이 바뀌면 자동 해제된다. const [thumbErrId, setThumbErrId] = createSignal(); const thumbSrc = () => { const id = singleId(); const i = info(); if (id == null || !i || thumbErrId() === id) return undefined; return `${thumbUrl(i, id)}&v=${thumbVersions[id] ?? 0}`; }; const headTitle = () => details()?.name ?? (selected().size > 1 ? ko.grid.selected(selected().size) : ko.details.title); return ( ); };