Files
photoai/src/renderer/components/GroupsView.tsx
T
koriweb 3e73967c7b 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>
2026-06-01 19:22:19 +09:00

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>
)
}