// 상세 패널 (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 (
e.stopPropagation()}>
{existing() ? ko.tags.editSet : ko.tags.newSet}
{ko.tags.setName}
setName(e.currentTarget.value)}
/>
{ko.tags.setHotkey}
{ko.tags.hotkeyNote}
{ko.tags.setMembers(picked().size)}
{(t) => (
)}
);
};
/**
* 별점 편집기 — 선택한 전부에 같은 값을 준다. 태그 편집기와 같은 자리(태그 탭 위)에 산다.
*
* 여러 항목의 값이 서로 다르면 채우지 않고 '여러 값'이라고 말한다. 아무 값이나 골라
* 채워 보이면 사용자는 그게 이미 매겨진 점수라고 읽고, 건드리지 않은 항목까지 덮어쓴다.
*/
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 (
{ko.rating.label}
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 (
{/* 붙어 있는 태그 */}
{ko.details.tags}
0}
fallback={{ko.tags.noneYet}}
>
{(t) => (
{t.name}
)}
{/* 세트 — 한 번에 여러 개 */}
0}>
{ko.tags.sets}
{(s) => (
)}
{/* 태그 고르기 — 전체 목록을 눌러서 붙인다 */}
{ko.tags.pick}
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) => (
<>
{ko.details.sectionMedia}
} 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} />
{ko.details.sectionBasic}
}
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 (
);
};