Files
Archive/src/shell/DetailsPanel.tsx
T
ncakanghanandClaude Opus 5 8aa74c1a8d
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s
영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지
- 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시)
- 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션)
- 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다.
  배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29.
- 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생
- 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영

간단 편집 (원본을 직접 고침, Ctrl+Z로 되돌림)
- 회전·반전: JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 — 화질 손실 0.
  방향값 전이표는 군의 성질(90도x4=제자리)로 검산하고, 실제 화소를 transpose=1과
  비교해 "시계 방향"이 정말 시계인지 확인한다.
- 자르기: 화면 좌표 기준(회전 메타가 붙어 있어도 어긋나지 않는다)
- 구간 자르기: 스트림 복사라 무손실. 필름스트립 타임라인 + 손잡이 끌기 + 구간 미리보기.
- 되돌릴 수 없는 편집(자르기·반전)은 원본을 .archive_trash/edits로 옮겨 보관
- 편집이 수정시각을 바꾸지 않는다 — 기본 정렬이 촬영일이라 항목이 튀면 다시 찾아야 한다

동시 재생
- 고른 영상을 최대 9개 격자에 놓고 함께 재생. 소리는 한 칸만.
- 실시간 변환이 필요한 코덱은 2개까지만 — 그 이상은 ffmpeg가 기계를 멈춘다

그 외
- 별점(스냅샷 flags 남는 비트에 얹어 항목당 바이트 증가 0)
- 태그: 검색 없이 클릭으로 붙이기, 세트+단축키, 이름 수정 시 붙은 항목에 전파
- 파일 로깅(tracing-appender) — 릴리스는 콘솔이 없어 stdout만으로는 원인을 못 찾았다
- ARM64 크로스 컴파일 스크립트(관리자 권한 없이 VS 카탈로그에서 조립)
- DB 마이그레이션 v7~v10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:18:24 +09:00

722 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 상세 패널 (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) => (
<div class="info-tile">
<span class="info-tile__icon">{props.icon}</span>
<div class="info-tile__body">
<div class="info-tile__label">{props.label}</div>
<div
class="info-tile__value"
classList={{ "info-tile__value--clamp": props.clamp }}
title={props.title}
>
{props.value}
</div>
</div>
</div>
);
/** 정보 타일 2분할 — 300px 폭에서 깨지지 않는 상한이 2칸이다 */
const SplitTile: Component<{
icon: JSX.Element;
labelA: string;
valueA: string;
labelB: string;
valueB: string;
}> = (props) => (
<div class="info-tile info-tile--split">
<span class="info-tile__icon">{props.icon}</span>
<div class="info-tile__body">
<div class="min-w-0">
<div class="info-tile__label">{props.labelA}</div>
<div class="info-tile__value">{props.valueA}</div>
</div>
<div class="min-w-0">
<div class="info-tile__label">{props.labelB}</div>
<div class="info-tile__value">{props.valueB}</div>
</div>
</div>
</div>
);
/** 선택 없음 — 두 탭이 공유한다 */
const EmptyState: Component = () => (
<div class="flex h-[180px] flex-col items-center justify-center gap-2 px-6 text-center">
<IconImage size={32} class="text-[var(--text-muted)] opacity-50" />
<span class="text-[11px] leading-4 text-[var(--text-muted)]">{ko.details.noSelection}</span>
</div>
);
/**
* 세트 만들기·고치기. 태그 편집기 위에 겹쳐 뜬다 — 모달 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<string | null>(existing()?.hotkey ?? null);
const [picked, setPicked] = createSignal<Set<number>>(
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 (
<div class="modal-scrim" onClick={props.onClose}>
<div class="modal-card w-[420px]" onClick={(e) => e.stopPropagation()}>
<div class="modal-head">
<span class="modal-head__title">
{existing() ? ko.tags.editSet : ko.tags.newSet}
</span>
<button class="icon-btn ml-auto" onClick={props.onClose} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
<div class="modal-body">
<div class="section-label">{ko.tags.setName}</div>
<input
class="field field--block mt-1.5"
value={name()}
placeholder={ko.tags.setNamePlaceholder}
onInput={(e) => setName(e.currentTarget.value)}
/>
<div class="section-label mt-3">{ko.tags.setHotkey}</div>
<div class="mt-1.5 flex items-center gap-2">
<button
class="toolbar-btn"
classList={{ "is-on": capturing() }}
onClick={() => setCapturing(true)}
>
{capturing() ? ko.tags.pressKey : (hotkey() ?? ko.tags.noHotkey)}
</button>
<Show when={hotkey()}>
<button class="link-btn" onClick={() => setHotkey(null)}>
{ko.tags.clearHotkey}
</button>
</Show>
</div>
<div class="mt-1 text-[10px] leading-4 text-[var(--text-muted)]">
{ko.tags.hotkeyNote}
</div>
<div class="section-label mt-3">{ko.tags.setMembers(picked().size)}</div>
<div class="tag-pick mt-1.5">
<For each={props.allTags}>
{(t) => (
<button
class="chip chip--button"
classList={{ "is-on": picked().has(t.id) }}
onClick={() => toggle(t.id)}
>
{t.name}
</button>
)}
</For>
</div>
</div>
<div class="modal-bar">
<Show when={existing()}>
<button class="toolbar-btn toolbar-btn--danger-fill" onClick={() => void remove()}>
{ko.common.delete}
</button>
</Show>
<button
class="toolbar-btn toolbar-btn--primary ml-auto"
disabled={!name().trim()}
onClick={() => void save()}
>
{ko.common.save}
</button>
</div>
</div>
</div>
);
};
/**
* 별점 편집기 — 선택한 전부에 같은 값을 준다. 태그 편집기와 같은 자리(태그 탭 위)에 산다.
*
* 여러 항목의 값이 서로 다르면 채우지 않고 '여러 값'이라고 말한다. 아무 값이나 골라
* 채워 보이면 사용자는 그게 이미 매겨진 점수라고 읽고, 건드리지 않은 항목까지 덮어쓴다.
*/
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 (
<div class="flex items-center gap-2 px-3 pt-2.5">
<span class="section-label !mb-0">{ko.rating.label}</span>
<StarRating value={value()} mixed={mixed()} onChange={(n) => void apply(n)} />
<Show when={mixed()}>
<span class="text-[10px] text-[var(--text-muted)]">{ko.rating.mixed}</span>
</Show>
</div>
);
};
/** 선택 항목 태그 편집기 (단일/다중 선택 공용) */
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<TagSet | "new" | undefined>();
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 (
<div class="px-3 pt-2.5 pb-3">
{/* 붙어 있는 태그 */}
<div class="section-label">{ko.details.tags}</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<Show
when={(currentTags()?.length ?? 0) > 0}
fallback={<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noneYet}</span>}
>
<For each={currentTags()}>
{(t) => (
<span class="chip chip--accent">
<span>{t.name}</span>
<button class="chip__x" onClick={() => void removeTag(t.id, t.name)}>
<IconX size={11} />
</button>
</span>
)}
</For>
</Show>
</div>
{/* 세트 — 한 번에 여러 개 */}
<Show when={(sets()?.length ?? 0) > 0}>
<div class="section-label mt-3">{ko.tags.sets}</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<For each={sets()}>
{(s) => (
<button
class="chip chip--button"
disabled={selected().size === 0}
title={ko.tags.setHint(s.tags.map((t) => t.name).join(", "))}
onClick={() => void runSet(s)}
onContextMenu={(e) => {
e.preventDefault();
setSetEditor(s);
}}
>
{s.name}
<Show when={s.hotkey}>
<kbd class="chip__kbd">{s.hotkey}</kbd>
</Show>
</button>
)}
</For>
</div>
</Show>
{/* 태그 고르기 — 전체 목록을 눌러서 붙인다 */}
<div class="section-label mt-3 flex items-center justify-between">
<span>{ko.tags.pick}</span>
<button class="link-btn" onClick={() => setSetEditor("new")}>
{ko.tags.newSet}
</button>
</div>
<input
class="field field--block mt-1.5"
placeholder={ko.tags.filterOrCreate}
value={filter()}
onInput={(e) => setFilter(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
void addTag(e.currentTarget.value);
setFilter("");
}}
/>
<div class="tag-pick mt-1.5">
<Show when={canCreate()}>
<button class="chip chip--button chip--new" onClick={() => void addTag(filter())}>
+ {filter().trim()}
</button>
</Show>
<For each={pickable()}>
{(t) => (
<button
class="chip chip--button"
classList={{ "is-on": attachedIds().has(t.id) }}
disabled={selected().size === 0}
onClick={() => void toggleTag(t)}
title={ko.tags.countHint(t.fileCount)}
>
{t.name}
</button>
)}
</For>
<Show when={(pickable().length ?? 0) === 0 && !canCreate()}>
<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noMatch}</span>
</Show>
</div>
<Show when={setEditor()}>
{(target) => (
<TagSetEditor
target={target()}
allTags={allTags() ?? []}
onClose={() => setSetEditor(undefined)}
onSaved={() => {
setSetEditor(undefined);
void refetchSets();
setTagVersion((v) => v + 1);
}}
/>
)}
</Show>
</div>
);
};
/** 정보 탭 — 단일 선택 */
const InfoTiles: Component<{ d: FileDetails }> = (props) => (
<>
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionMedia}</div>
<div class="grid gap-1.5 px-3">
<Show
when={props.d.width && props.d.height}
fallback={
<Tile icon={<IconImage size={14} />} label={ko.details.fileSize} value={fmtSize(props.d.size)} />
}
>
<SplitTile
icon={<IconImage size={14} />}
labelA={ko.details.dimensions}
valueA={`${props.d.width} × ${props.d.height}`}
labelB={ko.details.fileSize}
valueB={fmtSize(props.d.size)}
/>
</Show>
<Show when={props.d.durationMs}>
<Tile icon={<IconClock size={14} />} label={ko.details.duration} value={fmtDuration(props.d.durationMs)} />
</Show>
<Show
when={props.d.takenAt}
fallback={
<Tile icon={<IconCalendar size={14} />} label={ko.details.modifiedAt} value={fmtDate(props.d.mtimeMs)} />
}
>
<SplitTile
icon={<IconCalendar size={14} />}
labelA={ko.details.takenAt}
valueA={fmtDate(props.d.takenAt)}
labelB={ko.details.modifiedAt}
valueB={fmtDate(props.d.mtimeMs)}
/>
</Show>
<Tile icon={<IconFolder size={14} />} label={ko.details.filePath} value={props.d.dir} clamp title={props.d.dir} />
</div>
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionBasic}</div>
<div class="grid gap-1.5 px-3 pb-3">
<Tile
icon={<IconTag size={14} />}
label={ko.details.kind}
value={props.d.kind === 1 ? ko.filter.video : ko.filter.image}
/>
<Tile
icon={<IconFile size={14} />}
label={ko.details.fileName}
value={props.d.name}
clamp
title={props.d.name}
/>
</div>
</>
);
/** 정보 탭 — 다중 선택. 용량 합계·종류별 집계는 만들지 않는다(file_details 500 상한) */
const BulkPanel: Component = () => (
<div class="grid gap-1.5 px-3 py-3">
<div class="info-tile">
<span class="info-tile__icon"><IconLayers size={14} /></span>
<div class="info-tile__body">
<div class="info-tile__value info-tile__value--big tabular-nums">
{selected().size.toLocaleString("ko-KR")}
</div>
<div class="info-tile__label">{ko.details.selectedUnit}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button class="toolbar-btn" tabindex={-1} onClick={() => setDetailsTab("tags")}>
{ko.details.bulkTag}
</button>
<button class="toolbar-btn" tabindex={-1} onClick={openMoveDialog}>
{ko.ops.moveTo}
</button>
<button class="toolbar-btn" tabindex={-1} onClick={() => void performMarkDelete()}>
{ko.trash.mark}
</button>
<button
class="toolbar-btn toolbar-btn--danger"
tabindex={-1}
onClick={() => void performTrash()}
>
{ko.ops.trash}
</button>
</div>
</div>
);
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<number | undefined>();
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 (
<aside class="surface-card surface-card--fill">
{/* 헤더 h40 */}
<div class="flex h-10 flex-none items-center gap-2 border-b border-[var(--border)] px-3">
<div class="h-6 w-6 flex-none overflow-hidden rounded-[var(--r-sm)] bg-[var(--bg-panel-raised)]">
<Show when={thumbSrc()}>
{(src) => (
<img
src={src()}
class="h-full w-full object-cover"
decoding="async"
draggable={false}
onError={() => setThumbErrId(singleId())}
/>
)}
</Show>
</div>
<span
class="min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--text-primary)]"
title={headTitle()}
>
{headTitle()}
</span>
<button
class="icon-btn"
tabindex={-1}
title={ko.details.close}
onClick={() => {
setDetailsOpen(false);
focusItemView();
}}
>
<IconX size={13} />
</button>
</div>
<div class="tabs">
<button
classList={{ "is-on": detailsTab() === "info" }}
tabindex={-1}
onClick={() => setDetailsTab("info")}
>
{ko.details.tabInfo}
</button>
<button
classList={{ "is-on": detailsTab() === "tags" }}
tabindex={-1}
onClick={() => setDetailsTab("tags")}
>
{ko.details.tabTags}
</button>
</div>
{/* min-h-0을 빠뜨리면 body{overflow:hidden} 때문에 스크롤바 없이 조용히 잘린다 */}
<div class="min-h-0 flex-1 overflow-y-auto">
<Show
when={detailsTab() === "info"}
fallback={
<Show when={selected().size > 0} fallback={<EmptyState />}>
{/* 별점이 태그 위에 온다 — 분류 작업에서 먼저 손이 가는 쪽이고,
한 줄이라 태그 목록을 아래로 많이 밀지 않는다 */}
<RatingEditor />
<TagEditor />
</Show>
}
>
<Show when={selected().size > 0} fallback={<EmptyState />}>
<Show when={singleId() != null} fallback={<BulkPanel />}>
<Show when={details()}>{(d) => <InfoTiles d={d()} />}</Show>
</Show>
</Show>
</Show>
</div>
</aside>
);
};