3e73967c7b
UI overhaul to a darktable tone-and-manner and a set of features adapted from
darktable's proven patterns (reimplemented in our Electron/TS stack; no GPL code).
Design reskin:
- Dark neutral-gray palette + amber accent, flat/squared corners, no card shadows,
compact darktable-style top bar (logo + pipe-separated view tabs), denser 15px base
- Done via design tokens (Tailwind slate/brand/radius/shadow remap) — minimal churn
Metadata & collections (Phase A/B):
- exifr now captures GPS + camera; asset table ALTER-migrated (gpsLat/gpsLon/camera,
metaVersion backfill on re-index)
- Collection facet bar (year timeline / camera / color-label) filters the grid
Map & relation finder (Phase C):
- Leaflet + online OSM map tab; geotagged photos as markers
- relationService: related photos by place (GPS<1km) + time (+/-2d) + CLIP similarity
Easy mode (Phase D):
- easyMode setting (menu / onboarding); scales the whole UI (rem) + bigger thumbnails
+ large icon nav with plain labels (4050 accessibility)
Library usability:
- Video thumbnails (representative frame capture in the inference worker)
- Media filter (All / Photos / Videos) to separate them
- Clearer culling labels ("Good shots" / "To cull") + explanation tooltip
- Multi-select tiles -> Export selected to a folder (copy, best-cut extraction) and
Delete to Recycle Bin (shell.trashItem) behind a confirm dialog
- ONNX Runtime wasm bundled locally (offline) via copy-ort-wasm + asarUnpack
Docs: DARKTABLE_REVIEW (feasibility + roadmap A->D). All typecheck/tests/build green;
boot smoke verified each phase.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
import { useState } from 'react'
|
|
import { useT } from '../i18n'
|
|
import { thumbUrl, baseName } from '../media'
|
|
import type { AssetGroup, IndexedAsset } from '@shared/types'
|
|
|
|
const VIDEO_EXTS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']
|
|
|
|
/** 스마트 그룹화 + 자가정화 (Phase 3) */
|
|
export function GroupsView(): JSX.Element {
|
|
const t = useT()
|
|
const [threshold, setThreshold] = useState(0.92)
|
|
const [groups, setGroups] = useState<AssetGroup[]>([])
|
|
const [finding, setFinding] = useState(false)
|
|
const [searched, setSearched] = useState(false)
|
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
|
const [trashing, setTrashing] = useState(false)
|
|
|
|
const find = async () => {
|
|
setFinding(true)
|
|
setSearched(true)
|
|
try {
|
|
const g = await window.api.groups.build(threshold)
|
|
setGroups(g)
|
|
// 기본 선택: 각 그룹에서 보관 추천을 제외한 나머지(중복 후보)
|
|
const pre = new Set<number>()
|
|
for (const grp of g) {
|
|
for (const m of grp.members) {
|
|
if (m.id != null && m.id !== grp.bestId) pre.add(m.id)
|
|
}
|
|
}
|
|
setSelected(pre)
|
|
} finally {
|
|
setFinding(false)
|
|
}
|
|
}
|
|
|
|
const toggle = (id: number) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const trash = async () => {
|
|
if (selected.size === 0) return
|
|
if (!window.confirm(t('groups.confirmTrash', { n: selected.size }))) return
|
|
setTrashing(true)
|
|
try {
|
|
const ids = [...selected]
|
|
const n = await window.api.groups.trash(ids)
|
|
// 휴지통으로 보낸 항목 제거
|
|
const removed = new Set(ids)
|
|
setGroups((prev) =>
|
|
prev
|
|
.map((g) => ({
|
|
...g,
|
|
members: g.members.filter((m) => m.id == null || !removed.has(m.id))
|
|
}))
|
|
.filter((g) => g.members.length > 1)
|
|
)
|
|
setSelected(new Set())
|
|
window.alert(t('groups.trashed', { n }))
|
|
} finally {
|
|
setTrashing(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-5xl mx-auto w-full flex flex-col gap-4">
|
|
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<h2 className="font-semibold dark:text-slate-100">{t('groups.section')}</h2>
|
|
{searched && !finding && (
|
|
<span className="text-xs text-slate-400">{t('groups.count', { n: groups.length })}</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[11px] text-slate-400 mb-3">{t('groups.hint')}</p>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<label className="text-sm flex-1 max-w-xs">
|
|
<span className="block text-xs text-slate-500 dark:text-slate-400 mb-1">
|
|
{t('groups.threshold', { v: threshold.toFixed(2) })}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={0.8}
|
|
max={0.99}
|
|
step={0.01}
|
|
value={threshold}
|
|
onChange={(e) => setThreshold(Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</label>
|
|
<button
|
|
className="bg-brand hover:bg-brand-dark text-white rounded-lg px-4 py-2 font-semibold disabled:opacity-40"
|
|
onClick={find}
|
|
disabled={finding}
|
|
>
|
|
{finding ? t('groups.finding') : t('groups.find')}
|
|
</button>
|
|
{selected.size > 0 && (
|
|
<button
|
|
className="bg-red-500 hover:bg-red-600 text-white rounded-lg px-4 py-2 font-semibold disabled:opacity-40 ml-auto"
|
|
onClick={trash}
|
|
disabled={trashing}
|
|
>
|
|
🗑 {t('groups.trashSelected', { n: selected.size })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{searched && !finding && groups.length === 0 && (
|
|
<p className="text-sm text-slate-400">{t('groups.empty')}</p>
|
|
)}
|
|
|
|
{groups.map((g, gi) => (
|
|
<div
|
|
key={gi}
|
|
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"
|
|
>
|
|
<div className="text-xs text-slate-400 mb-2">
|
|
{t('groups.groupSize', { n: g.members.length })}
|
|
</div>
|
|
<div className="grid grid-cols-6 gap-2">
|
|
{g.members.map((m) => (
|
|
<GroupTile
|
|
key={m.contentHash}
|
|
asset={m}
|
|
isBest={m.id === g.bestId}
|
|
checked={m.id != null && selected.has(m.id)}
|
|
keepLabel={t('groups.keep')}
|
|
onToggle={() => m.id != null && toggle(m.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GroupTile(props: {
|
|
asset: IndexedAsset
|
|
isBest: boolean
|
|
checked: boolean
|
|
keepLabel: string
|
|
onToggle: () => void
|
|
}): JSX.Element {
|
|
const { asset: a, isBest, checked } = props
|
|
const isVideo = VIDEO_EXTS.includes(a.ext)
|
|
return (
|
|
<div
|
|
className={`relative aspect-square rounded-md overflow-hidden border-2 bg-slate-100 dark:bg-slate-700 ${
|
|
isBest ? 'border-emerald-500' : checked ? 'border-red-400' : 'border-transparent'
|
|
}`}
|
|
title={a.path}
|
|
>
|
|
{isVideo ? (
|
|
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">▶</div>
|
|
) : (
|
|
<img
|
|
src={thumbUrl(a.contentHash)}
|
|
alt={baseName(a.path)}
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
)}
|
|
{isBest ? (
|
|
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-semibold text-white rounded px-1 py-0.5 bg-emerald-500/90">
|
|
★ {props.keepLabel}
|
|
</span>
|
|
) : (
|
|
<button
|
|
className="absolute top-1 right-1 w-5 h-5 rounded bg-black/50 flex items-center justify-center"
|
|
onClick={props.onToggle}
|
|
title={a.path}
|
|
>
|
|
<span className={`text-xs ${checked ? 'text-red-400' : 'text-white/60'}`}>
|
|
{checked ? '✓' : '○'}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|