darktable-inspired reskin + metadata/collections, map, easy mode, select/export
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>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user