영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
67d2ddc59e
commit
8aa74c1a8d
+622
-66
@@ -1,15 +1,54 @@
|
||||
import { createResource, createSignal, For, Show, type Component } from "solid-js";
|
||||
// 상세 패널 (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 { refreshSidebar, selected, toast, view } from "../state/store";
|
||||
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`;
|
||||
@@ -18,20 +57,268 @@ function fmtSize(bytes: number): string {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function fmtDate(ms: number | null): string {
|
||||
/** ListView와 같은 형식 — YYYY-MM-DD HH:mm */
|
||||
function fmtDate(ms: number | null | undefined): string {
|
||||
if (!ms) return "—";
|
||||
return new Date(ms).toLocaleString("ko-KR");
|
||||
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())}`;
|
||||
}
|
||||
|
||||
const Row: Component<{ label: string; value: string }> = (props) => (
|
||||
<div class="px-3 py-1">
|
||||
<div class="text-[10px] text-[var(--text-muted)]">{props.label}</div>
|
||||
<div class="break-all text-xs text-[var(--text-primary)]" title={props.value}>
|
||||
{props.value}
|
||||
/** 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);
|
||||
@@ -59,6 +346,7 @@ const TagEditor: Component = () => {
|
||||
const r = await assignTags(ids, tagId);
|
||||
toast(ko.tags.assignTo(r.count, trimmed));
|
||||
setTagVersion((v) => v + 1);
|
||||
bumpTagVersion();
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
@@ -70,47 +358,268 @@ const TagEditor: Component = () => {
|
||||
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 py-1">
|
||||
<div class="text-[10px] text-[var(--text-muted)]">{ko.details.tags}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<Show when={singleId() != null}>
|
||||
<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="flex items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
|
||||
{t.name}
|
||||
<button
|
||||
class="hover:text-[var(--danger)]"
|
||||
onClick={() => void removeTag(t.id, t.name)}
|
||||
>
|
||||
×
|
||||
<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="mt-1.5 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-[11px] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
|
||||
placeholder={ko.tags.add}
|
||||
list="all-tags-datalist"
|
||||
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") {
|
||||
void addTag(e.currentTarget.value);
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
if (e.key !== "Enter") return;
|
||||
void addTag(e.currentTarget.value);
|
||||
setFilter("");
|
||||
}}
|
||||
/>
|
||||
<datalist id="all-tags-datalist">
|
||||
<For each={allTags()}>{(t) => <option value={t.name} />}</For>
|
||||
</datalist>
|
||||
<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();
|
||||
@@ -120,46 +629,93 @@ export const DetailsPanel: Component = () => {
|
||||
const list = await fileDetails([id]);
|
||||
return list[0] as FileDetails | undefined;
|
||||
});
|
||||
// 뷰 변경 시 details 리소스가 재평가되도록 view 추적
|
||||
void view;
|
||||
|
||||
// 썸네일이 없는 항목은 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="flex min-h-0 flex-col overflow-y-auto border-l border-[var(--border)] bg-[var(--bg-panel)]">
|
||||
<div class="px-3 pt-4 pb-1.5 text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
|
||||
{ko.details.title}
|
||||
</div>
|
||||
<Show
|
||||
when={details()}
|
||||
fallback={
|
||||
<div class="px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
|
||||
{selected().size > 1
|
||||
? ko.grid.selected(selected().size)
|
||||
: ko.details.noSelection}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(d) => (
|
||||
<>
|
||||
<Row label={ko.details.fileName} value={d().name} />
|
||||
<Row label={ko.details.filePath} value={d().dir} />
|
||||
<Row label={ko.details.fileSize} value={fmtSize(d().size)} />
|
||||
<Show when={d().width && d().height}>
|
||||
<Row label={ko.details.dimensions} value={`${d().width} × ${d().height}`} />
|
||||
</Show>
|
||||
<Show when={d().durationMs}>
|
||||
<Row
|
||||
label={ko.details.duration}
|
||||
value={`${Math.round((d().durationMs ?? 0) / 1000)}초`}
|
||||
<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>
|
||||
<Row label={ko.details.takenAt} value={fmtDate(d().takenAt)} />
|
||||
<Row label={ko.details.modifiedAt} value={fmtDate(d().mtimeMs)} />
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={selected().size > 0}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user