중복 탐지 - 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시) - 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(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>
571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
|
|
import { IconImage, IconTrash } from "../../ui/icons";
|
|
import {
|
|
createEffect,
|
|
createMemo,
|
|
createSignal,
|
|
For,
|
|
on,
|
|
onCleanup,
|
|
onMount,
|
|
Show,
|
|
type Component,
|
|
} from "solid-js";
|
|
import { createStore, produce, reconcile } from "solid-js/store";
|
|
import { computeLayout, type LayoutResult } from "./layout";
|
|
import {
|
|
clearSelection,
|
|
gridRowHeight,
|
|
info,
|
|
markedIds,
|
|
openLightbox,
|
|
registerItemViewFocus,
|
|
selected,
|
|
selectSingle,
|
|
setSelectionFromIds,
|
|
showInfo,
|
|
toggleSelect,
|
|
selectRange,
|
|
snapshot,
|
|
tagVersion,
|
|
thumbVersions,
|
|
trashTag,
|
|
zoomGrid,
|
|
} from "../../state/store";
|
|
import {
|
|
fileDetails,
|
|
fileTagsBulk,
|
|
setThumbPriority,
|
|
thumbUrl,
|
|
type Snapshot,
|
|
} from "../../ipc/commands";
|
|
import { ko } from "../../i18n/ko";
|
|
import { openContextMenu } from "../fileops/ContextMenu";
|
|
import { handleItemKeys } from "../browse/itemKeys";
|
|
|
|
const GAP = 8;
|
|
/** 스크롤러 안쪽 여백. 레이아웃 폭·마퀴 좌표가 이 한 상수를 공유한다 —
|
|
흩어져 있으면 여백을 바꾼 순간 드래그 사각형이 커서에서 어긋난다. */
|
|
const PAD = 8;
|
|
const OVERSCAN_PX = 800;
|
|
/** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */
|
|
const TAG_MIN_CELL_W = 120;
|
|
|
|
function fmtSize(bytes: number): string {
|
|
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`;
|
|
}
|
|
|
|
/** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */
|
|
function findRow(rowTops: Float32Array, y: number): number {
|
|
let lo = 0;
|
|
let hi = rowTops.length - 2; // 마지막 요소는 전체 높이
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1;
|
|
if (rowTops[mid] <= y) lo = mid;
|
|
else hi = mid - 1;
|
|
}
|
|
return lo;
|
|
}
|
|
|
|
export const JustifiedGrid: Component = () => {
|
|
let containerRef: HTMLDivElement | undefined;
|
|
const [layout, setLayout] = createSignal<LayoutResult | undefined>();
|
|
const [scrollTop, setScrollTop] = createSignal(0);
|
|
const [viewportH, setViewportH] = createSignal(600);
|
|
// 마퀴(드래그 사각형) 선택 — 콘텐츠 좌표계 [x1,y1,x2,y2]
|
|
const [marquee, setMarquee] = createSignal<[number, number, number, number] | null>(null);
|
|
|
|
// 레이아웃 재계산 — 컨테이너 폭을 DOM에서 직접 읽고 메인 스레드에서 동기 계산.
|
|
const recompute = () => {
|
|
const snap = snapshot();
|
|
if (!snap || !containerRef) {
|
|
setLayout(undefined);
|
|
return;
|
|
}
|
|
const w = containerRef.clientWidth - PAD * 2; // 패딩 보정
|
|
if (w <= 0) return;
|
|
const ratios = snap.aspectRatios();
|
|
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
|
|
};
|
|
|
|
// 데이터(스냅샷) 변경 시 즉시 재계산.
|
|
createEffect(on(snapshot, recompute));
|
|
|
|
onMount(() => {
|
|
const el = containerRef!;
|
|
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
|
|
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
|
|
let lastW = -1;
|
|
let lastRh = -1;
|
|
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
|
|
// (이벤트/이펙트 누락 환경에 견고하도록 폴링을 단일 진실원으로 사용)
|
|
const tick = () => {
|
|
const w = el.clientWidth;
|
|
const rh = gridRowHeight();
|
|
setViewportH(el.clientHeight);
|
|
if (Math.abs(w - lastW) > 0.5 || rh !== lastRh) {
|
|
lastW = w;
|
|
lastRh = rh;
|
|
recompute();
|
|
}
|
|
};
|
|
tick();
|
|
[50, 200, 500].forEach((ms) => setTimeout(tick, ms));
|
|
const ro = new ResizeObserver(tick);
|
|
ro.observe(el);
|
|
const onWinResize = () => requestAnimationFrame(tick);
|
|
window.addEventListener("resize", onWinResize);
|
|
const poll = setInterval(tick, 150);
|
|
onCleanup(() => {
|
|
ro.disconnect();
|
|
window.removeEventListener("resize", onWinResize);
|
|
clearInterval(poll);
|
|
});
|
|
});
|
|
|
|
// Ctrl+휠 → 그리드 줌 (썸네일 크기). 즉시 재계산.
|
|
const onWheel = (e: WheelEvent) => {
|
|
if (!e.ctrlKey && !e.metaKey) return;
|
|
e.preventDefault();
|
|
zoomGrid(e.deltaY < 0 ? 1.12 : 1 / 1.12);
|
|
recompute();
|
|
};
|
|
|
|
// rAF 스로틀 스크롤
|
|
let rafPending = false;
|
|
const onScroll = () => {
|
|
if (rafPending) return;
|
|
rafPending = true;
|
|
requestAnimationFrame(() => {
|
|
rafPending = false;
|
|
if (containerRef) setScrollTop(containerRef.scrollTop);
|
|
});
|
|
};
|
|
|
|
/** 가시 항목 인덱스 범위 */
|
|
const visibleRange = createMemo<[number, number]>(() => {
|
|
const l = layout();
|
|
const snap = snapshot();
|
|
if (!l || !snap || l.rowFirst.length === 0) return [0, -1];
|
|
const top = Math.max(0, scrollTop() - OVERSCAN_PX);
|
|
const bottom = scrollTop() + viewportH() + OVERSCAN_PX;
|
|
const firstRow = findRow(l.rowTops, top);
|
|
let lastRow = firstRow;
|
|
while (lastRow + 1 < l.rowFirst.length && l.rowTops[lastRow + 1] < bottom) lastRow++;
|
|
const first = l.rowFirst[firstRow];
|
|
const last =
|
|
lastRow + 1 < l.rowFirst.length ? l.rowFirst[lastRow + 1] - 1 : snap.count - 1;
|
|
return [first, last];
|
|
});
|
|
|
|
// 뷰포트 항목 중 썸네일 없는 것 우선 생성 요청 (디바운스 200ms)
|
|
let prioTimer: ReturnType<typeof setTimeout> | undefined;
|
|
createEffect(() => {
|
|
const [first, last] = visibleRange();
|
|
const snap = snapshot();
|
|
if (!snap || last < first) return;
|
|
clearTimeout(prioTimer);
|
|
prioTimer = setTimeout(() => {
|
|
const ids: number[] = [];
|
|
for (let i = first; i <= last && ids.length < 512; i++) {
|
|
if (!snap.hasThumb(i)) ids.push(snap.id(i));
|
|
}
|
|
if (ids.length > 0) void setThumbPriority(ids);
|
|
}, 200);
|
|
});
|
|
onCleanup(() => clearTimeout(prioTimer));
|
|
|
|
// 썸네일 위 태그 라벨 — 가시 범위만 배치 조회. 태그가 바뀌면(tagVersion) 캐시를 버린다.
|
|
const [tagCache, setTagCache] = createStore<Record<number, string[]>>({});
|
|
let tagTimer: ReturnType<typeof setTimeout> | undefined;
|
|
createEffect(
|
|
on(tagVersion, () => setTagCache(reconcile({})), { defer: true }),
|
|
);
|
|
createEffect(() => {
|
|
const [first, last] = visibleRange();
|
|
const snap = snapshot();
|
|
tagVersion(); // 태그 변경 후에도 다시 읽도록 구독
|
|
if (!snap || last < first) return;
|
|
clearTimeout(tagTimer);
|
|
tagTimer = setTimeout(() => {
|
|
const need: number[] = [];
|
|
for (let i = first; i <= last && need.length < 512; i++) {
|
|
const id = snap.id(i);
|
|
if (tagCache[id] === undefined) need.push(id);
|
|
}
|
|
if (need.length === 0) return;
|
|
void fileTagsBulk(need)
|
|
.then((links) =>
|
|
setTagCache(
|
|
produce((s) => {
|
|
// 조회한 id는 태그가 없어도 빈 배열로 채워 재조회를 막는다
|
|
for (const id of need) s[id] = [];
|
|
for (const l of links) {
|
|
if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당
|
|
(s[l.fileId] ??= []).push(l.name);
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
.catch(() => {});
|
|
}, 150);
|
|
});
|
|
onCleanup(() => clearTimeout(tagTimer));
|
|
|
|
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
|
|
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
|
|
let detTimer: ReturnType<typeof setTimeout> | undefined;
|
|
createEffect(() => {
|
|
if (!showInfo()) return;
|
|
const [first, last] = visibleRange();
|
|
const snap = snapshot();
|
|
if (!snap || last < first) return;
|
|
clearTimeout(detTimer);
|
|
detTimer = setTimeout(() => {
|
|
const need: number[] = [];
|
|
for (let i = first; i <= last && need.length < 512; i++) {
|
|
const id = snap.id(i);
|
|
if (detailsCache[id] === undefined) need.push(id);
|
|
}
|
|
if (need.length === 0) return;
|
|
void fileDetails(need)
|
|
.then((ds) =>
|
|
setDetailsCache(
|
|
produce((s) => {
|
|
for (const d of ds) s[d.id] = { name: d.name, size: d.size };
|
|
}),
|
|
),
|
|
)
|
|
.catch(() => {});
|
|
}, 150);
|
|
});
|
|
onCleanup(() => clearTimeout(detTimer));
|
|
|
|
const indices = createMemo(() => {
|
|
const [first, last] = visibleRange();
|
|
const out: number[] = [];
|
|
for (let i = first; i <= last; i++) out.push(i);
|
|
return out;
|
|
});
|
|
|
|
const onCellClick = (e: MouseEvent, snap: Snapshot, index: number) => {
|
|
const id = snap.id(index);
|
|
if (e.shiftKey) selectRange(snap, index);
|
|
else if (e.ctrlKey || e.metaKey) toggleSelect(id, index);
|
|
else selectSingle(id, index);
|
|
};
|
|
|
|
/** 위/아래 화살표: 레이아웃 기하 기반 — 인접 행에서 x중심이 가장 가까운 셀 */
|
|
const verticalNeighbor = (from: number, dir: -1 | 1): number | undefined => {
|
|
const l = layout();
|
|
const snap = snapshot();
|
|
if (!l || !snap) return undefined;
|
|
const cx = l.boxes[from * 4] + l.boxes[from * 4 + 2] / 2;
|
|
const cy = l.boxes[from * 4 + 1];
|
|
let best: number | undefined;
|
|
let bestDist = Infinity;
|
|
// 최대 3행 거리까지 탐색 (마지막 행 채움 편차 대응)
|
|
const step = dir === 1 ? 1 : -1;
|
|
for (let i = from + step; i >= 0 && i < snap.count; i += step) {
|
|
const y = l.boxes[i * 4 + 1];
|
|
if (dir === 1 ? y <= cy : y >= cy) continue; // 같은 행 스킵
|
|
const nx = l.boxes[i * 4] + l.boxes[i * 4 + 2] / 2;
|
|
const d = Math.abs(nx - cx);
|
|
if (d < bestDist) {
|
|
bestDist = d;
|
|
best = i;
|
|
} else if (best !== undefined && Math.abs(y - l.boxes[best * 4 + 1]) > 1) {
|
|
break; // 다음 행으로 넘어가면 종료
|
|
}
|
|
}
|
|
return best;
|
|
};
|
|
|
|
/** 선택된 셀을 화면 안으로 (키보드 이동용) */
|
|
const revealCell = (index: number) => {
|
|
const l = layout();
|
|
const el = containerRef;
|
|
if (!l || !el) return;
|
|
const top = l.boxes[index * 4 + 1];
|
|
const h = l.boxes[index * 4 + 3];
|
|
if (top < el.scrollTop) el.scrollTop = top;
|
|
else if (top + h > el.scrollTop + el.clientHeight)
|
|
el.scrollTop = top + h - el.clientHeight;
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const snap = snapshot();
|
|
if (!snap) return;
|
|
handleItemKeys(e, snap, {
|
|
neighbor: (from, key) => {
|
|
if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined;
|
|
if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined;
|
|
return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1);
|
|
},
|
|
onMove: revealCell,
|
|
});
|
|
};
|
|
|
|
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
|
|
e.preventDefault();
|
|
const id = snap.id(index);
|
|
if (!selected().has(id)) selectSingle(id, index);
|
|
void fileDetails([id]).then((d) => {
|
|
openContextMenu({ x: e.clientX, y: e.clientY, fileId: id, fileName: d[0]?.name });
|
|
});
|
|
};
|
|
|
|
const onCellDragStart = (e: DragEvent, snap: Snapshot, index: number) => {
|
|
const id = snap.id(index);
|
|
if (!selected().has(id)) selectSingle(id, index);
|
|
e.dataTransfer?.setData("application/x-archive-move", "1");
|
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
|
};
|
|
|
|
// ── 마퀴(드래그 사각형) 선택 ──
|
|
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(PAD) (+ y는 scrollTop)
|
|
const toContent = (clientX: number, clientY: number): [number, number] => {
|
|
const rect = containerRef!.getBoundingClientRect();
|
|
return [
|
|
clientX - rect.left - PAD,
|
|
clientY - rect.top - PAD + containerRef!.scrollTop,
|
|
];
|
|
};
|
|
|
|
const onBgMouseDown = (e: MouseEvent) => {
|
|
// 배경(빈 공간) 좌클릭에서만 시작 — 셀 클릭은 제외
|
|
if (e.button !== 0 || !(e.target as HTMLElement).dataset.gridbg) return;
|
|
const [sx, sy] = toContent(e.clientX, e.clientY);
|
|
setMarquee([sx, sy, sx, sy]);
|
|
if (!e.ctrlKey && !e.metaKey) clearSelection();
|
|
|
|
const onMove = (me: MouseEvent) => {
|
|
const [cx, cy] = toContent(me.clientX, me.clientY);
|
|
const m = marquee();
|
|
if (m) setMarquee([m[0], m[1], cx, cy]);
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
commitMarquee();
|
|
setMarquee(null);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
const commitMarquee = () => {
|
|
const m = marquee();
|
|
const l = layout();
|
|
const snap = snapshot();
|
|
if (!m || !l || !snap) return;
|
|
const [x1, y1, x2, y2] = [Math.min(m[0], m[2]), Math.min(m[1], m[3]), Math.max(m[0], m[2]), Math.max(m[1], m[3])];
|
|
// 너무 작은 드래그는 무시(클릭으로 간주)
|
|
if (x2 - x1 < 4 && y2 - y1 < 4) return;
|
|
const ids: number[] = [];
|
|
for (let i = 0; i < snap.count; i++) {
|
|
const bx = l.boxes[i * 4];
|
|
const by = l.boxes[i * 4 + 1];
|
|
const bw = l.boxes[i * 4 + 2];
|
|
const bh = l.boxes[i * 4 + 3];
|
|
// AABB 교차
|
|
if (bx < x2 && bx + bw > x1 && by < y2 && by + bh > y1) ids.push(snap.id(i));
|
|
}
|
|
setSelectionFromIds(ids);
|
|
};
|
|
|
|
const marqueeRect = createMemo(() => {
|
|
const m = marquee();
|
|
if (!m) return null;
|
|
return {
|
|
left: Math.min(m[0], m[2]),
|
|
top: Math.min(m[1], m[3]),
|
|
width: Math.abs(m[2] - m[0]),
|
|
height: Math.abs(m[3] - m[1]),
|
|
};
|
|
});
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
onScroll={onScroll}
|
|
onWheel={onWheel}
|
|
onKeyDown={onKeyDown}
|
|
onMouseDown={onBgMouseDown}
|
|
tabindex={0}
|
|
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
|
|
style={{ padding: `${PAD}px` }}
|
|
>
|
|
<Show
|
|
when={snapshot() && snapshot()!.count > 0}
|
|
fallback={
|
|
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
|
|
{ko.grid.empty}
|
|
</div>
|
|
}
|
|
>
|
|
<div
|
|
class="relative"
|
|
data-gridbg="1"
|
|
style={{ height: `${layout()?.totalHeight ?? 0}px` }}
|
|
>
|
|
<Show when={marqueeRect()}>
|
|
{(r) => (
|
|
<div
|
|
class="marquee pointer-events-none absolute z-10"
|
|
style={{
|
|
left: `${r().left}px`,
|
|
top: `${r().top}px`,
|
|
width: `${r().width}px`,
|
|
height: `${r().height}px`,
|
|
}}
|
|
/>
|
|
)}
|
|
</Show>
|
|
<For each={indices()}>
|
|
{(i) => {
|
|
// 모든 파생값은 반응형으로 — For가 같은 인덱스 값에 대해 DOM을 재사용해도
|
|
// 뷰(폴더) 전환 시 스냅샷이 바뀌면 id/썸네일/선택상태가 즉시 갱신되도록 한다.
|
|
const box = () => {
|
|
const l = layout();
|
|
return l
|
|
? [l.boxes[i * 4], l.boxes[i * 4 + 1], l.boxes[i * 4 + 2], l.boxes[i * 4 + 3]]
|
|
: [0, 0, 0, 0];
|
|
};
|
|
const id = () => {
|
|
const s = snapshot();
|
|
return s ? s.id(i) : -1;
|
|
};
|
|
const version = () => thumbVersions[id()] ?? 0;
|
|
const showImg = () => {
|
|
const s = snapshot();
|
|
return !!s && (s.hasThumb(i) || version() > 0);
|
|
};
|
|
// 썸네일 미준비 상태: 실패(state 2)면 정적 표시, 그 외엔 '생성 중' 스켈레톤.
|
|
const failed = () => {
|
|
const s = snapshot();
|
|
return !!s && s.thumbFailed(i);
|
|
};
|
|
const isVideo = () => {
|
|
const s = snapshot();
|
|
return !!s && s.kind(i) === 1;
|
|
};
|
|
const marked = () => markedIds().has(id());
|
|
// 별점은 스냅샷 flags 비트2~4에서 온다(추가 조회 없음)
|
|
const rating = () => {
|
|
const s = snapshot();
|
|
return s ? s.rating(i) : 0;
|
|
};
|
|
// 좁은 셀일수록 적게 — 나머지는 +N으로 접는다
|
|
const cellTags = () => tagCache[id()] ?? [];
|
|
const tagLimit = () => (box()[2] >= 260 ? 3 : box()[2] >= 180 ? 2 : 1);
|
|
const visibleTags = () => cellTags().slice(0, tagLimit());
|
|
const hiddenTagCount = () => Math.max(0, cellTags().length - tagLimit());
|
|
return (
|
|
<div
|
|
class="grid-cell"
|
|
classList={{
|
|
"is-sel": selected().has(id()),
|
|
"is-marked": marked() && !selected().has(id()),
|
|
}}
|
|
style={{
|
|
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
|
|
width: `${box()[2]}px`,
|
|
height: `${box()[3]}px`,
|
|
}}
|
|
onClick={(e) => {
|
|
const s = snapshot();
|
|
if (s) onCellClick(e, s, i);
|
|
}}
|
|
onDblClick={() => openLightbox(i)}
|
|
onContextMenu={(e) => {
|
|
const s = snapshot();
|
|
if (s) onCellContextMenu(e, s, i);
|
|
}}
|
|
draggable={true}
|
|
onDragStart={(e) => {
|
|
const s = snapshot();
|
|
if (s) onCellDragStart(e, s, i);
|
|
}}
|
|
>
|
|
<Show
|
|
when={showImg() && info()}
|
|
fallback={
|
|
// 준비 전: 실패면 정적 아이콘, 대기면 은은한 스켈레톤(생성 중)
|
|
<Show
|
|
when={!failed()}
|
|
fallback={
|
|
<div class="grid h-full w-full place-items-center text-[var(--text-muted)]">
|
|
<IconImage size={18} class="opacity-60" />
|
|
</div>
|
|
}
|
|
>
|
|
<div class="h-full w-full animate-pulse bg-[var(--grid-skeleton)]" />
|
|
</Show>
|
|
}
|
|
>
|
|
<img
|
|
src={`${thumbUrl(info()!, id())}&v=${version()}`}
|
|
class="h-full w-full object-cover"
|
|
decoding="async"
|
|
draggable={false}
|
|
onLoad={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = "";
|
|
}}
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
</Show>
|
|
<Show when={isVideo()}>
|
|
<span class="chip--on-media absolute bottom-1 right-1">{ko.filter.video}</span>
|
|
</Show>
|
|
{/* 별점 — 매긴 것만 보여준다. 0점까지 빈 별로 그리면 모든 셀에 별 5개가
|
|
깔려서 사진을 가린다(별점은 소수의 항목에만 붙는 표시다). */}
|
|
<Show when={rating() > 0}>
|
|
<span class="cell-stars" title={ko.rating.set(rating())}>
|
|
{"★".repeat(rating())}
|
|
</span>
|
|
</Show>
|
|
{/* 상단 라벨 줄 — 삭제 예정 배지 + 태그.
|
|
셀이 좁으면 태그가 썸네일을 다 덮으므로 일정 폭부터만 보여준다. */}
|
|
<div class="pointer-events-none absolute inset-x-1 top-1 flex flex-wrap gap-0.5">
|
|
<Show when={marked()}>
|
|
<span class="chip--on-media chip--on-media--danger" title={ko.trash.badge}>
|
|
<IconTrash size={11} />
|
|
</span>
|
|
</Show>
|
|
<Show when={box()[2] >= TAG_MIN_CELL_W}>
|
|
<For each={visibleTags()}>
|
|
{(name) => (
|
|
<span class="chip--on-media chip--on-media--tag" title={name}>
|
|
{name}
|
|
</span>
|
|
)}
|
|
</For>
|
|
<Show when={hiddenTagCount() > 0}>
|
|
<span class="chip--on-media chip--on-media--dim">+{hiddenTagCount()}</span>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
|
|
<Show when={showInfo()}>
|
|
<div class="info-overlay">
|
|
<div class="info-overlay__name">{detailsCache[id()]?.name ?? ""}</div>
|
|
<Show when={detailsCache[id()]}>
|
|
<div class="info-overlay__sub">{fmtSize(detailsCache[id()]!.size)}</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|