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:
@@ -98,6 +98,9 @@ export const IPC = {
|
||||
INDEX_PROGRESS: 'index:progress',
|
||||
INDEX_DONE: 'index:done',
|
||||
INDEX_ASSETS: 'index:assets',
|
||||
INDEX_ASSET_IDS: 'index:assetIds',
|
||||
INDEX_FACETS: 'index:facets',
|
||||
INDEX_EXPORT: 'index:export',
|
||||
INDEX_SET_RATING: 'index:setRating',
|
||||
INDEX_SET_LABEL: 'index:setLabel',
|
||||
// 검색 (Phase 2)
|
||||
@@ -107,6 +110,12 @@ export const IPC = {
|
||||
SEARCH_QUERY: 'search:query',
|
||||
SEARCH_PROGRESS: 'search:progress',
|
||||
SEARCH_DONE: 'search:done',
|
||||
// 그룹화 / 자가정화 (Phase 3)
|
||||
GROUPS_BUILD: 'groups:build',
|
||||
GROUPS_TRASH: 'groups:trash',
|
||||
// 지도 / 연관 탐색 (Phase C)
|
||||
MAP_ASSETS: 'map:assets',
|
||||
MAP_RELATED: 'map:related',
|
||||
// Main → UI (send)
|
||||
JOB_PROGRESS: 'job:progress',
|
||||
JOB_FILE_PROCESSED: 'job:fileProcessed',
|
||||
|
||||
+84
-4
@@ -33,6 +33,9 @@ export const MESSAGES: Table = {
|
||||
'onboard.theme': { ko: '테마', en: 'Theme' },
|
||||
'onboard.dark': { ko: '다크', en: 'Dark' },
|
||||
'onboard.light': { ko: '라이트', en: 'Light' },
|
||||
'onboard.easy': { ko: '화면 크기', en: 'Display size' },
|
||||
'onboard.easyOn': { ko: '크게 (쉬운 모드)', en: 'Large (Easy)' },
|
||||
'onboard.easyOff': { ko: '보통', en: 'Normal' },
|
||||
'onboard.start': { ko: '시작하기', en: 'Get Started' },
|
||||
|
||||
// 1. 프로필
|
||||
@@ -145,6 +148,52 @@ export const MESSAGES: Table = {
|
||||
'nav.organize': { ko: '정리', en: 'Organize' },
|
||||
'nav.library': { ko: '라이브러리', en: 'Library' },
|
||||
'nav.search': { ko: '검색', en: 'Search' },
|
||||
'nav.groups': { ko: '그룹·정화', en: 'Groups' },
|
||||
'nav.map': { ko: '지도', en: 'Map' },
|
||||
// 쉬운 모드 대형 네비(구어체)
|
||||
'easynav.organize': { ko: '사진 정리', en: 'Organize' },
|
||||
'easynav.library': { ko: '내 사진', en: 'My Photos' },
|
||||
'easynav.search': { ko: '사진 찾기', en: 'Find' },
|
||||
'easynav.map': { ko: '지도로 보기', en: 'Map' },
|
||||
'easynav.groups': { ko: '중복 정리', en: 'Cleanup' },
|
||||
|
||||
// 지도 / 연관 탐색 (Phase C)
|
||||
'map.section': { ko: '지도 (촬영 장소)', en: 'Map (photo locations)' },
|
||||
'map.count': { ko: 'GPS 사진 {n}장', en: '{n} geotagged' },
|
||||
'map.empty': {
|
||||
ko: 'GPS 정보가 있는 사진이 없습니다. (라이브러리에서 재색인하면 GPS가 채워집니다)',
|
||||
en: 'No geotagged photos yet. (Re-index in Library to populate GPS)'
|
||||
},
|
||||
'map.related': { ko: '연관 사진', en: 'Related photos' },
|
||||
'map.relatedHint': {
|
||||
ko: '지도의 사진을 클릭하면 같은 장소·시기·비슷한 장면의 사진을 모아 보여줍니다.',
|
||||
en: 'Click a photo on the map to see ones from the same place, time, and similar scenes.'
|
||||
},
|
||||
'map.selectHint': { ko: '지도에서 사진을 클릭하세요.', en: 'Click a photo on the map.' },
|
||||
'map.loading': { ko: '불러오는 중…', en: 'Loading…' },
|
||||
|
||||
// 그룹화 / 자가정화 (Phase 3)
|
||||
'groups.section': { ko: '유사 사진 그룹 · 자가정화', en: 'Similar groups · Cleanup' },
|
||||
'groups.hint': {
|
||||
ko: '검색 색인(임베딩)을 기반으로 비슷한 사진을 묶습니다. 각 그룹의 보관 추천(가장 선명)만 남기고 나머지를 휴지통으로 보낼 수 있습니다.',
|
||||
en: 'Groups similar photos using the search embeddings. Keep the recommended (sharpest) one per group and send the rest to the trash.'
|
||||
},
|
||||
'groups.threshold': { ko: '유사도 ({v})', en: 'Similarity ({v})' },
|
||||
'groups.find': { ko: '그룹 찾기', en: 'Find groups' },
|
||||
'groups.finding': { ko: '찾는 중…', en: 'Finding…' },
|
||||
'groups.empty': {
|
||||
ko: '유사 그룹이 없습니다. (먼저 검색 탭에서 임베딩 색인을 생성하세요)',
|
||||
en: 'No similar groups. (Build the embedding index in the Search tab first)'
|
||||
},
|
||||
'groups.count': { ko: '{n}개 그룹', en: '{n} groups' },
|
||||
'groups.keep': { ko: '추천 보관', en: 'Keep' },
|
||||
'groups.groupSize': { ko: '유사 {n}장', en: '{n} similar' },
|
||||
'groups.trashSelected': { ko: '선택 {n}개 휴지통으로', en: 'Trash {n} selected' },
|
||||
'groups.confirmTrash': {
|
||||
ko: '선택한 {n}개 사진을 휴지통(복구 가능)으로 보냅니다. 계속할까요?',
|
||||
en: 'Move {n} selected photos to the trash (recoverable)? Continue?'
|
||||
},
|
||||
'groups.trashed': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to trash.' },
|
||||
|
||||
// 검색 (Phase 2)
|
||||
'search.section': { ko: '검색 색인', en: 'Search index' },
|
||||
@@ -191,11 +240,37 @@ export const MESSAGES: Table = {
|
||||
},
|
||||
'lib.loadMore': { ko: '더 보기', en: 'Load more' },
|
||||
|
||||
// 선택 / 내보내기 / 삭제 (Library)
|
||||
'sel.count': { ko: '{n}개 선택', en: '{n} selected' },
|
||||
'sel.selectAll': { ko: '전체 선택', en: 'Select all' },
|
||||
'sel.clear': { ko: '선택 해제', en: 'Clear' },
|
||||
'sel.export': { ko: '폴더로 내보내기', en: 'Export to folder' },
|
||||
'sel.delete': { ko: '삭제', en: 'Delete' },
|
||||
'sel.hint': { ko: '사진을 클릭해 선택하세요.', en: 'Click photos to select.' },
|
||||
'sel.confirmDelete': {
|
||||
ko: '선택한 {n}개를 휴지통으로 보냅니다.\n(원본 파일이 휴지통으로 이동되며 복구할 수 있습니다)\n계속할까요?',
|
||||
en: 'Move {n} selected items to the Recycle Bin?\n(Originals are moved to the trash and can be restored.)\nContinue?'
|
||||
},
|
||||
'sel.deleted': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to the trash.' },
|
||||
'sel.exported': {
|
||||
ko: '{n}개를 내보냈습니다.\n{dest}',
|
||||
en: 'Exported {n} items.\n{dest}'
|
||||
},
|
||||
|
||||
// 미디어 종류 (사진/영상 분리)
|
||||
'media.all': { ko: '전체', en: 'All' },
|
||||
'media.image': { ko: '사진', en: 'Photos' },
|
||||
'media.video': { ko: '영상', en: 'Videos' },
|
||||
|
||||
// 컬링 필터 / 품질 플래그 (Phase 1)
|
||||
'cull.all': { ko: '전체', en: 'All' },
|
||||
'cull.candidate': { ko: '고품질 후보', en: 'Candidates' },
|
||||
'cull.rejected': { ko: '제외 후보', en: 'Rejected' },
|
||||
'flag.candidate': { ko: '후보', en: 'Keep' },
|
||||
'cull.all': { ko: '품질 전체', en: 'All' },
|
||||
'cull.candidate': { ko: '잘 나온 사진', en: 'Good shots' },
|
||||
'cull.rejected': { ko: '걸러낼 사진', en: 'To cull' },
|
||||
'cull.help': {
|
||||
ko: '흐리거나 눈 감은·노출 나쁜 사진을 자동으로 표시해, 잘 나온 사진만 빠르게 고를 수 있게 도와줍니다.',
|
||||
en: 'Auto-flags blurry / closed-eye / badly-exposed shots so you can quickly keep the good ones.'
|
||||
},
|
||||
'flag.candidate': { ko: '좋음', en: 'Good' },
|
||||
'flag.blurry': { ko: '흐림', en: 'Blurry' },
|
||||
'flag.eyesClosed': { ko: '눈감음', en: 'Eyes closed' },
|
||||
'flag.badExposure': { ko: '노출', en: 'Exposure' },
|
||||
@@ -209,6 +284,10 @@ export const MESSAGES: Table = {
|
||||
},
|
||||
'cull.reset': { ko: '기본값', en: 'Reset' },
|
||||
'cull.ratingMin': { ko: '별점', en: 'Rating' },
|
||||
// 컬렉션 패싯 (Phase B)
|
||||
'col.year': { ko: '연도', en: 'Year' },
|
||||
'col.camera': { ko: '카메라', en: 'Camera' },
|
||||
'col.label': { ko: '색라벨', en: 'Label' },
|
||||
|
||||
// 메뉴
|
||||
'menu.file': { ko: '파일', en: 'File' },
|
||||
@@ -235,6 +314,7 @@ export const MESSAGES: Table = {
|
||||
'menu.theme.dark': { ko: '다크 모드', en: 'Dark' },
|
||||
'menu.theme.light': { ko: '라이트 모드', en: 'Light' },
|
||||
'menu.language': { ko: '언어', en: 'Language' },
|
||||
'menu.easyMode': { ko: '쉬운 모드 (큰 화면)', en: 'Easy mode (large)' },
|
||||
'menu.guide': { ko: '사용 방법 가이드', en: 'User Guide' },
|
||||
'menu.about': { ko: '정보', en: 'About' }
|
||||
}
|
||||
|
||||
+68
-1
@@ -20,6 +20,8 @@ export interface Settings {
|
||||
onboarded: boolean
|
||||
/** 컬링 품질 임계값 */
|
||||
qualityThresholds: QualityThresholds
|
||||
/** 4050 쉬운 모드(대형 UI/구어체) */
|
||||
easyMode: boolean
|
||||
}
|
||||
|
||||
/** 등록된 인물 프로필 */
|
||||
@@ -98,6 +100,11 @@ export interface AssetRecord {
|
||||
height: number | null
|
||||
exifYear: string | null
|
||||
exifMonth: string | null
|
||||
/** GPS 좌표 (없으면 null) */
|
||||
gpsLat: number | null
|
||||
gpsLon: number | null
|
||||
/** 카메라 모델 (없으면 null) */
|
||||
camera: string | null
|
||||
indexedAt: number
|
||||
}
|
||||
|
||||
@@ -159,11 +166,35 @@ export type QualityFilter =
|
||||
| 'eyesClosed'
|
||||
| 'badExposure'
|
||||
|
||||
/** 미디어 종류 필터 (사진/영상 분리) */
|
||||
export type MediaFilter = 'all' | 'image' | 'video'
|
||||
|
||||
/** 자산 조회 옵션 */
|
||||
export interface AssetQuery {
|
||||
filter: QualityFilter
|
||||
/** 미디어 종류 */
|
||||
kind: MediaFilter
|
||||
/** 최소 별점 (0이면 무시) */
|
||||
ratingMin: number
|
||||
/** 연도 필터 (예: "2024") */
|
||||
year?: string | null
|
||||
/** 카메라 모델 필터 */
|
||||
camera?: string | null
|
||||
/** 색라벨 필터 */
|
||||
label?: ColorLabel
|
||||
}
|
||||
|
||||
/** 컬렉션 패싯 한 항목 */
|
||||
export interface FacetItem {
|
||||
value: string
|
||||
count: number
|
||||
}
|
||||
|
||||
/** 컬렉션 필터용 패싯 집계 */
|
||||
export interface Facets {
|
||||
years: FacetItem[]
|
||||
cameras: FacetItem[]
|
||||
labels: FacetItem[]
|
||||
}
|
||||
|
||||
/** 검색 색인(임베딩) 생성 진행률 */
|
||||
@@ -182,6 +213,22 @@ export interface SearchSummary {
|
||||
count: number
|
||||
}
|
||||
|
||||
/** 지도 마커용 GPS 자산 */
|
||||
export interface GpsAsset {
|
||||
id: number
|
||||
contentHash: string
|
||||
path: string
|
||||
gpsLat: number
|
||||
gpsLon: number
|
||||
}
|
||||
|
||||
/** 유사 이미지 그룹 (스마트 그룹화 / 근접 중복 정화) */
|
||||
export interface AssetGroup {
|
||||
/** 보관 추천(품질 최고) 자산 id */
|
||||
bestId: number
|
||||
members: IndexedAsset[]
|
||||
}
|
||||
|
||||
/** 검색 색인 상태 */
|
||||
export interface SearchStatus {
|
||||
/** 임베딩 보유 수 */
|
||||
@@ -314,8 +361,14 @@ export interface ExposedApi {
|
||||
index: {
|
||||
run(): Promise<void>
|
||||
cancel(): Promise<void>
|
||||
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점 필터) */
|
||||
/** 색인된 자산 목록 (최근순, 페이지네이션, 품질/별점/연도/카메라/라벨 필터) */
|
||||
assets(offset: number, limit: number, query: AssetQuery): Promise<IndexedAsset[]>
|
||||
/** 쿼리에 매칭되는 전체 자산 id (전체 선택용) */
|
||||
assetIds(query: AssetQuery): Promise<number[]>
|
||||
/** 컬렉션 패싯(연도/카메라/색라벨 집계) */
|
||||
facets(): Promise<Facets>
|
||||
/** 선택 자산을 폴더로 내보내기(복사). 폴더 선택 다이얼로그 후 복사. 취소 시 null */
|
||||
export(assetIds: number[]): Promise<{ count: number; dest: string } | null>
|
||||
/** 별점(0~5) 설정 */
|
||||
setRating(assetId: number, rating: number): Promise<void>
|
||||
/** 색라벨 설정 */
|
||||
@@ -330,6 +383,20 @@ export interface ExposedApi {
|
||||
/** 자연어 쿼리 → 유사도 상위 결과 */
|
||||
query(text: string): Promise<IndexedAsset[]>
|
||||
}
|
||||
/** 지도 / 연관 탐색 (Phase C) */
|
||||
map: {
|
||||
/** GPS 좌표가 있는 자산 목록(지도 마커) */
|
||||
assets(): Promise<GpsAsset[]>
|
||||
/** 특정 사진과 관련된 사진(장소+시간+유사도) */
|
||||
related(assetId: number): Promise<IndexedAsset[]>
|
||||
}
|
||||
/** 스마트 그룹화 / 자가정화 (Phase 3) */
|
||||
groups: {
|
||||
/** 임베딩 유사도(threshold) 이상으로 묶인 그룹(크기 2+) 반환 */
|
||||
build(threshold: number): Promise<AssetGroup[]>
|
||||
/** 선택 자산을 OS 휴지통으로 이동하고 인덱스에서 제거. 처리 수 반환 */
|
||||
trash(assetIds: number[]): Promise<number>
|
||||
}
|
||||
/** 드롭된 File의 로컬 절대경로 반환(Electron webUtils). 경로가 없으면 빈 문자열. */
|
||||
getPathForFile(file: unknown): string
|
||||
on<E extends RendererEventName>(
|
||||
|
||||
Reference in New Issue
Block a user