From d2546f9cbfdbcd7649b319858699196d5cffa333 Mon Sep 17 00:00:00 2001 From: g1nation Date: Tue, 2 Jun 2026 14:47:26 +0900 Subject: [PATCH] Library workspace upgrades: darkroom viewer, mosaic, geotagging, file explorer - darkroom-style fullscreen viewer: left info panel, rating/color-label toolbar, bottom filmstrip, keyboard nav (Esc / arrows), placeholder on load failure - thumbnail density slider (contact-sheet) + photo mosaic generator (target -> tiles) - lighttable-style hover info preview (no click needed) - map drag & drop geotagging (saved to index only; originals untouched) - file explorer: parallel drive scan + timeout, create/delete(trash)/move folders; index reparent on move and cleanup on delete (single source of truth) - library: photos-before-videos ordering; drag range select/deselect; native image drag disabled so sweep-select works - responsive sidebar font scaling; no-wrap filter labels; media protocol CORS + video Range Co-Authored-By: Claude Opus 4.8 --- docs/records/PhotoAI/README.md | 18 + docs/records/PhotoAI/chronicle.config.json | 11 + ...을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md | 19 + ...-정리하는-기능성-유틸리티인데-사진-정리_implementation.md | 23 + ...화-할-수-있는-부분이-있는지-분석해줘_implementation.md | 29 + docs/records/PhotoAI/project-profile.md | 31 + docs/records/PhotoAI/timeline.md | 13 + src/inference/imageLoader.ts | 17 +- src/main/exif.ts | 60 +- src/main/fsExplorer.ts | 94 ++ src/main/indexDb.ts | 198 ++- src/main/indexer.ts | 9 +- src/main/ipc.ts | 51 +- src/main/mediaProtocol.ts | 71 +- src/preload/index.ts | 23 +- src/renderer/App.tsx | 38 +- src/renderer/components/FileExplorer.tsx | 215 +++ src/renderer/components/FolderPicker.tsx | 41 +- src/renderer/components/LibraryView.tsx | 1262 ++++++++++++----- src/renderer/components/MapView.tsx | 129 +- src/renderer/components/MosaicView.tsx | 238 ++++ src/renderer/styles/index.css | 13 +- src/shared/constants.ts | 14 + src/shared/i18n.ts | 107 +- src/shared/types.ts | 68 + 25 files changed, 2358 insertions(+), 434 deletions(-) create mode 100644 docs/records/PhotoAI/README.md create mode 100644 docs/records/PhotoAI/chronicle.config.json create mode 100644 docs/records/PhotoAI/decisions/ADR-0001-기업모드로-다시-너가-준-내용을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md create mode 100644 docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-이-프로젝트를-지금-개발-중이야-사진-관리-정리하는-기능성-유틸리티인데-사진-정리_implementation.md create mode 100644 docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md create mode 100644 docs/records/PhotoAI/project-profile.md create mode 100644 docs/records/PhotoAI/timeline.md create mode 100644 src/main/fsExplorer.ts create mode 100644 src/renderer/components/FileExplorer.tsx create mode 100644 src/renderer/components/MosaicView.tsx diff --git a/docs/records/PhotoAI/README.md b/docs/records/PhotoAI/README.md new file mode 100644 index 0000000..809612e --- /dev/null +++ b/docs/records/PhotoAI/README.md @@ -0,0 +1,18 @@ +# PhotoAI Chronicle Records + +## Project +- ID: photoai +- Root: E:\Wiki\PhotoAI +- Record root: E:\Wiki\PhotoAI\docs\records\PhotoAI +- Detail level: standard + +## Purpose +Auto-created by Project Architecture activation. + +## Folders +- `planning/` +- `discussions/` +- `decisions/` +- `development/` +- `bugs/` +- `retrospectives/` diff --git a/docs/records/PhotoAI/chronicle.config.json b/docs/records/PhotoAI/chronicle.config.json new file mode 100644 index 0000000..1f76b95 --- /dev/null +++ b/docs/records/PhotoAI/chronicle.config.json @@ -0,0 +1,11 @@ +{ + "projectId": "photoai", + "projectName": "PhotoAI", + "projectRoot": "E:\\Wiki\\PhotoAI", + "recordRoot": "E:\\Wiki\\PhotoAI\\docs\\records\\PhotoAI", + "description": "Auto-created by Project Architecture activation.", + "corePurpose": "", + "detailLevel": "standard", + "createdAt": "2026-06-01T04:16:09.722Z", + "updatedAt": "2026-06-02T05:39:25.175Z" +} diff --git a/docs/records/PhotoAI/decisions/ADR-0001-기업모드로-다시-너가-준-내용을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md b/docs/records/PhotoAI/decisions/ADR-0001-기업모드로-다시-너가-준-내용을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md new file mode 100644 index 0000000..203922a --- /dev/null +++ b/docs/records/PhotoAI/decisions/ADR-0001-기업모드로-다시-너가-준-내용을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md @@ -0,0 +1,19 @@ +# ADR: 기업모드로 다시 너가 준 내용을 한번더 다같이 논의 하고 최종 결과물을 줄 수 있어? + +## Status +accepted + +## Context +기업모드로 다시 너가 준 내용을 한번더 다같이 논의 하고 최종 결과물을 줄 수 있어? + +## Decision +결론부터 말씀씀: 네, 가능합니다. 제가 제안한 기능들을 단순한 아이디어 나열이 아니라, '기술적 타당성', '구현 난이도', '기존 아키텍처와의 정합성'을 기준으로 재검토하여, 팀원들과 즉시 논의하고 결정할 수 있는 '의사결정용 검토 안건(Decision Memo)' 형태로 정리해 드릴 수 있습니다. 이렇게 하면 단순한 브레인스토밍이 아니라, 실제 프로젝트의 로드맵에 반영할 수 있는 '확정된 계획'을 만드는 과정이 됩니다. --- 🛠️ 기업 모드 기반: 기능 확장 안건 검토 프로세스 제가 다음과 같은 구조로 내용을 재구성하여 드릴 테니, 이를 바탕으로 팀원들과 논의를 시작하시면 됩니다. 1. [검토 단계] 기술적/비용적 타당성 분석 (Feasibility Study) - 기존 로직과의 충돌 확인: 예를 들어 '자연어 검색'을 도입할 때, 현재의 `inference` 모듈이 CLIP 모델을 감당할 수 있는지, 메모리 점유율은 어떻게 변할지 분석합니다. - 리소스 제약 검토: 로컬 ... + +## Reason +Captured automatically because the conversation contained decision-oriented language. + +## Alternatives +Not captured yet. + +## Consequences +- Future prompts should treat this as project context unless the user changes direction. diff --git a/docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-이-프로젝트를-지금-개발-중이야-사진-관리-정리하는-기능성-유틸리티인데-사진-정리_implementation.md b/docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-이-프로젝트를-지금-개발-중이야-사진-관리-정리하는-기능성-유틸리티인데-사진-정리_implementation.md new file mode 100644 index 0000000..022f58f --- /dev/null +++ b/docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-이-프로젝트를-지금-개발-중이야-사진-관리-정리하는-기능성-유틸리티인데-사진-정리_implementation.md @@ -0,0 +1,23 @@ +# Development Log: E:\Wiki\PhotoAI 이 프로젝트를 지금 개발 중이야. 사진 관리, 정리하는 기능성 유틸리티인데, 사진 정리할때 필요한 기능을 추가 할 ... + +## Purpose +Record the implementation or verification outcome from the current conversation. + +## Implementation Summary +결론부터 말씀씀: 현재 PhotoAI의 핵심 가치인 '로컬 기반 자동화(Face/EXIF)'를 유지하면서도, 사용자의 개입을 최소화하고 정리된 결과물의 품질을 높이는 '선별 보조(Culling Support)'와 (Post-processing) 메타데이터 관리 기능에 집중하는 것이 좋습니다. 현재 프로젝트의 아키텍처와 목적성을 고려할 때, 단순한 기능 확장이 아닌 '정리 효율'을 극대화할 수 있는 방향으로 제안합니다. --- 1. 지능형 선별 및 품질 관리 (Smart Culling & Quality Control) 사진 정리의 핵심은 '좋은 사진을 남기는 것'입니다. - 이미지 품질 점수 자동 부여 (Aesthetic Scoring): 단순한 초점(Blur) 체크를 넘어, AI 모델을 통해 사진의 미적 점수를 매깁니다. 점수가 낮은 사진은 `Unsorted` 폴러로 자동 분류하거나 삭제 대상 후보로 지정합니다. - 중복 및 유사 이미지 클러스터링 (Near-duplicate Det... + +## Architecture +Captured automatically from the assistant response and active project context. + +## Changed Files +- docs/PRD.md +- docs/NEXTGEN_REVIEW.md + +## Dependency Notes +No new dependency note was captured automatically. + +## Bugs +No bugs recorded. + +## Lessons +- Automatic project records should be generated in the background when the turn contains durable project knowledge. diff --git a/docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md b/docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md new file mode 100644 index 0000000..20ef0ca --- /dev/null +++ b/docs/records/PhotoAI/development/2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md @@ -0,0 +1,29 @@ +# Development Log: E:\Wiki\PhotoAI 코딩 리뷰하고 설계적으로 더 최적화 할 수 있는 부분이 있는지 분석해줘. + +## Purpose +Record the implementation or verification outcome from the current conversation. + +## Implementation Summary +현재 정보만으로는 전체 코드의 로직을 모두 파악할 수 없으므로, 제공된 아키텍처 컨텍스트와 파일 구조를 바탕으로 구조적 안정성과 확장성 관점에서 리뷰하겠습니다. 결론부터 말씀씀: 현재 PhotoAI는 3-Tier(Main/Renderer/Inference) 분리 구조를 통해 Electron의 고질적인 문제인 'UI 프리징'을 잘 피하고 있으나, 데이터 흐름의 단일화(Single Source of Truth)와 추론 프로세스의 동기화 관리 측면에서 최적화가 필요합니다. --- 🔍 아키텍처 리뷰 및 최적화 제안 1. 데이터 정합성: Single Source of Truth (SSOT) 강화 현재 `src/main/indexDb.ts`, `src/renderer/store.ts`, `src/main/libraryStore.ts` 등 여러 곳에서 상태를 관리하고 있습니다. 파일 이동(Move)이나 삭제(Delete) 발생 시, DB와 Renderer Store 간의 동기화가 깨질 위험이 ... + +## Architecture +Captured automatically from the assistant response and active project context. + +## Changed Files +- src/main/indexDb.ts +- src/renderer/store.ts +- src/main/libraryStore.ts +- src/main/fileOps.ts +- src/main/inferenceBridge.ts +- src/main/thumbnails.ts +- src/inference/pathToFileUrl.ts +- src/renderer/components/LibraryView.tsx + +## Dependency Notes +No new dependency note was captured automatically. + +## Bugs +No bugs recorded. + +## Lessons +- Automatic project records should be generated in the background when the turn contains durable project knowledge. diff --git a/docs/records/PhotoAI/project-profile.md b/docs/records/PhotoAI/project-profile.md new file mode 100644 index 0000000..2d626fe --- /dev/null +++ b/docs/records/PhotoAI/project-profile.md @@ -0,0 +1,31 @@ +# Project Profile + +## Project Name +PhotoAI + +## Description +Auto-created by Project Architecture activation. + +## Project Root +E:\Wiki\PhotoAI + +## Record Root +E:\Wiki\PhotoAI\docs\records\PhotoAI + +## Core Purpose +Not captured yet. + +## Target Users +Not captured yet. + +## Avoid Directions +Not captured yet. + +## Record Detail Level +standard + +## Created +2026-06-01T04:16:09.722Z + +## Updated +2026-06-02T02:04:14.970Z diff --git a/docs/records/PhotoAI/timeline.md b/docs/records/PhotoAI/timeline.md new file mode 100644 index 0000000..2d2afff --- /dev/null +++ b/docs/records/PhotoAI/timeline.md @@ -0,0 +1,13 @@ +# Project Timeline + +## 2026-06-01 +- Project Chronicle record folder initialized for PhotoAI. + +## 2026-06-02 +- Auto development record created: development\2026-06-02_e-wiki-photoai-이-프로젝트를-지금-개발-중이야-사진-관리-정리하는-기능성-유틸리티인데-사진-정리_implementation.md + +## 2026-06-02 +- Auto decision record created: decisions\ADR-0001-기업모드로-다시-너가-준-내용을-한번더-다같이-논의-하고-최종-결과물을-줄-수-있어.md + +## 2026-06-02 +- Auto development record created: development\2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md diff --git a/src/inference/imageLoader.ts b/src/inference/imageLoader.ts index deafd03..198c353 100644 --- a/src/inference/imageLoader.ts +++ b/src/inference/imageLoader.ts @@ -96,6 +96,10 @@ export async function videoThumbnail( const video = document.createElement('video') video.muted = true video.preload = 'auto' + video.setAttribute('playsinline', '') + // 일부 환경에서 off-DOM 비디오는 프레임 디코딩이 안 되므로 화면 밖에 부착 + video.style.cssText = 'position:fixed;left:-10000px;top:0;width:2px;height:2px;opacity:0;' + document.body.appendChild(video) video.src = pathToFileUrl(path) const withTimeout = (p: Promise, ms: number): Promise => @@ -107,19 +111,21 @@ export async function videoThumbnail( try { await withTimeout( new Promise((res, rej) => { - video.onloadedmetadata = () => res() - video.onerror = () => rej(new Error('영상 로드 실패')) + video.onloadeddata = () => res() + video.onerror = () => rej(new Error('영상 로드/디코딩 실패')) }), 20000 ) - // 너무 앞(검은 화면) 회피 위해 1초 또는 절반 지점으로 seek - const seekTo = Math.min(1, (video.duration || 2) / 2) + // 요청: 약 5초 지점 프레임 사용 (영상이 짧으면 끝부분/절반) + const dur = Number.isFinite(video.duration) ? video.duration : 0 + const seekTo = dur > 5 ? 5 : dur > 0.2 ? Math.max(0, dur - 0.1) : 0 await withTimeout( new Promise((res, rej) => { video.onseeked = () => res() video.onerror = () => rej(new Error('영상 seek 실패')) - video.currentTime = seekTo + if (Math.abs(video.currentTime - seekTo) < 0.001) res() + else video.currentTime = seekTo }), 20000 ) @@ -145,6 +151,7 @@ export async function videoThumbnail( video.src = '' video.removeAttribute('src') video.load() + video.remove() } } diff --git a/src/main/exif.ts b/src/main/exif.ts index 26aaccd..5a5a239 100644 --- a/src/main/exif.ts +++ b/src/main/exif.ts @@ -1,6 +1,64 @@ import exifr from 'exifr' import { stat } from 'node:fs/promises' -import type { CaptureDate } from '@shared/types' +import type { CaptureDate, ExifInfo } from '@shared/types' + +function formatExposure(t: number): string { + if (!t || t <= 0) return '' + return t < 1 ? `1/${Math.round(1 / t)}s` : `${t}s` +} + +/** 상세 EXIF를 온디맨드로 읽어 이미지 정보 패널에 표시 (실패 시 빈 값) */ +export async function readFullExif(path: string): Promise { + let d: Record = {} + try { + d = + (await exifr.parse(path, { + pick: [ + 'DateTimeOriginal', + 'CreateDate', + 'Make', + 'Model', + 'LensModel', + 'FNumber', + 'ExposureTime', + 'ISO', + 'ISOSpeedRatings', + 'FocalLength', + 'ExifImageWidth', + 'ExifImageHeight', + 'PixelXDimension', + 'PixelYDimension' + ] + })) || {} + } catch { + /* no exif */ + } + let gps: ExifInfo['gps'] = null + try { + const g = await exifr.gps(path) + if (g && typeof g.latitude === 'number' && typeof g.longitude === 'number') { + gps = { lat: g.latitude, lon: g.longitude } + } + } catch { + /* no gps */ + } + const dt = (d.DateTimeOriginal ?? d.CreateDate) as Date | string | undefined + const num = (v: unknown): number | null => (typeof v === 'number' ? v : null) + const str = (v: unknown): string | null => (v ? String(v).trim() : null) + return { + dateTime: dt instanceof Date ? dt.toLocaleString() : dt ? String(dt) : null, + make: str(d.Make), + model: str(d.Model), + lens: str(d.LensModel), + fNumber: num(d.FNumber), + exposureTime: typeof d.ExposureTime === 'number' ? formatExposure(d.ExposureTime) : null, + iso: num(d.ISO) ?? num(d.ISOSpeedRatings), + focalLength: num(d.FocalLength), + width: num(d.ExifImageWidth) ?? num(d.PixelXDimension), + height: num(d.ExifImageHeight) ?? num(d.PixelYDimension), + gps + } +} function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate { const year = String(d.getFullYear()) diff --git a/src/main/fsExplorer.ts b/src/main/fsExplorer.ts new file mode 100644 index 0000000..e696791 --- /dev/null +++ b/src/main/fsExplorer.ts @@ -0,0 +1,94 @@ +import { readdir, access, mkdir, rename, cp } from 'node:fs/promises' +import { constants as FS } from 'node:fs' +import { join, basename, dirname } from 'node:path' +import { shell } from 'electron' +import type { FsEntry } from '@shared/types' + +/** 느린(미디어 없는 카드리더/끊긴 네트워크) 드라이브가 전체를 막지 않도록 타임아웃 */ +function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)) + ]) +} + +/** + * 파일 탐색기용 디렉터리 나열. + * - path 없음 → 드라이브 목록(Windows: 접근 가능한 A~Z, 병렬 탐색) + * - path 있음 → 해당 폴더의 하위 디렉터리만 (이름순) + * 권한/오류는 빈 배열로 처리. + */ +export async function listDir(path: string | null): Promise { + if (!path) { + if (process.platform === 'win32') { + // A~Z를 병렬로 검사 → 총 소요시간이 '가장 느린 드라이브 하나'로 수렴 (순차 합산 X) + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + const results = await Promise.all( + letters.map(async (letter) => { + const root = `${letter}:\\` + try { + await withTimeout(access(root, FS.R_OK), 700) + return { name: `${letter}:`, path: root } as FsEntry + } catch { + return null + } + }) + ) + return results.filter((d): d is FsEntry => d !== null) + } + return [{ name: '/', path: '/' }] + } + + try { + const entries = await readdir(path, { withFileTypes: true }) + return entries + .filter((e) => e.isDirectory() && !e.name.startsWith('$')) + .map((e) => ({ name: e.name, path: join(path, e.name) })) + .sort((a, b) => a.name.localeCompare(b.name)) + } catch { + return [] + } +} + +/** 새 폴더 생성. 이미 있으면 throw. 생성된 항목 반환 */ +export async function makeDir(parent: string, name: string): Promise { + const clean = name.trim() + if (!clean || /[\\/:*?"<>|]/.test(clean)) throw new Error('잘못된 폴더 이름') + const target = join(parent, clean) + await mkdir(target) // recursive 아님 → 이미 존재하면 EEXIST throw + return { name: clean, path: target } +} + +/** 폴더를 휴지통으로 이동 (영구 삭제 아님 → 복구 가능) */ +export async function trashDir(path: string): Promise { + await shell.trashItem(path) +} + +/** + * 폴더 이동: src 를 destDir 안으로. 같은 드라이브면 rename(즉시), + * 다른 드라이브면 복사 후 원본 휴지통 이동. + * 자기 자신/하위로의 이동은 거부. + */ +export async function moveDir(src: string, destDir: string): Promise { + const name = basename(src) + const target = join(destDir, name) + const norm = (p: string): string => p.replace(/[\\/]+$/, '').toLowerCase() + if (norm(src) === norm(destDir)) throw new Error('같은 위치') + if (norm(dirname(src)) === norm(destDir)) throw new Error('이미 해당 폴더에 있음') + // destDir 가 src 자신이거나 그 하위면 거부 (무한/자기참조 방지) + if (norm(destDir) === norm(src) || norm(destDir).startsWith(norm(src) + '\\')) { + throw new Error('자기 자신 또는 하위 폴더로는 이동할 수 없습니다') + } + try { + await rename(src, target) + } catch (err) { + // 다른 드라이브(EXDEV) → 재귀 복사 후 원본 삭제(휴지통) + if ((err as NodeJS.ErrnoException).code === 'EXDEV') { + await cp(src, target, { recursive: true, errorOnExist: true, force: false }) + await shell.trashItem(src) + } else { + throw err + } + } + return { name, path: target } +} diff --git a/src/main/indexDb.ts b/src/main/indexDb.ts index 4aeab47..fc0c83e 100644 --- a/src/main/indexDb.ts +++ b/src/main/indexDb.ts @@ -77,6 +77,19 @@ class IndexDb { assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE, vec BLOB ); + CREATE TABLE IF NOT EXISTS tag ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL + ); + CREATE TABLE IF NOT EXISTS asset_tag ( + assetId INTEGER NOT NULL REFERENCES asset(id) ON DELETE CASCADE, + tagId INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE, + PRIMARY KEY (assetId, tagId) + ); + CREATE TABLE IF NOT EXISTS metadata ( + assetId INTEGER PRIMARY KEY REFERENCES asset(id) ON DELETE CASCADE, + title TEXT, description TEXT, creator TEXT + ); CREATE INDEX IF NOT EXISTS idx_asset_hash ON asset(contentHash); CREATE INDEX IF NOT EXISTS idx_asset_path ON asset(path); `) @@ -85,7 +98,8 @@ class IndexDb { this.ensureColumn('asset', 'gpsLat', 'REAL') this.ensureColumn('asset', 'gpsLon', 'REAL') this.ensureColumn('asset', 'camera', 'TEXT') - // metaVersion: 확장 메타(GPS/카메라) 적재 버전. 구버전 행(0/NULL)은 재색인 시 backfill + this.ensureColumn('asset', 'folder', 'TEXT') + // metaVersion: 확장 메타(GPS/카메라/folder) 적재 버전. 구버전 행은 재색인 시 backfill this.ensureColumn('asset', 'metaVersion', 'INTEGER') } @@ -133,6 +147,17 @@ class IndexDb { } } + /** 해당 경로가 라이브러리 인덱스에 등록되어 있는지 (미디어 프로토콜 원본 서빙 허용 판정용) */ + hasPath(path: string): boolean { + const stmt = this.db!.prepare('SELECT 1 FROM asset WHERE path = ? LIMIT 1') + try { + stmt.bind([path]) + return stmt.step() + } finally { + stmt.free() + } + } + /** 이미 색인되었고 mtime이 동일하면 재색인 불필요 */ needsIndex(contentHash: string, mtime: number): boolean { const stmt = this.db!.prepare('SELECT mtime FROM asset WHERE contentHash = ?') @@ -198,9 +223,25 @@ class IndexDb { conds.push('label = ?') params.push(query.label) } + if (query.folder) { + conds.push('folder = ?') + params.push(query.folder) + } + if (query.tag) { + conds.push( + 'id IN (SELECT at.assetId FROM asset_tag at JOIN tag t ON t.id = at.tagId WHERE t.name = ?)' + ) + params.push(query.tag) + } return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', params } } + /** 정렬: 사진 우선 → 영상 나중, 각 그룹 내에서는 최근 색인순 */ + private orderClause(): string { + const v = SUPPORTED_VIDEO_EXTENSIONS.map((e) => `'${e}'`).join(',') + return `ORDER BY (CASE WHEN ext IN (${v}) THEN 1 ELSE 0 END), indexedAt DESC, id DESC` + } + listAssets( offset: number, limit: number, @@ -210,7 +251,7 @@ class IndexDb { const inner = this.innerSelect(th) const { where, params } = this.buildWhere(query) const stmt = this.db!.prepare( - `SELECT * FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC LIMIT ? OFFSET ?` + `SELECT * FROM (${inner}) ${where} ${this.orderClause()} LIMIT ? OFFSET ?` ) const out: IndexedAsset[] = [] try { @@ -227,7 +268,7 @@ class IndexDb { const inner = this.innerSelect(th) const { where, params } = this.buildWhere(query) const stmt = this.db!.prepare( - `SELECT id FROM (${inner}) ${where} ORDER BY indexedAt DESC, id DESC` + `SELECT id FROM (${inner}) ${where} ${this.orderClause()}` ) const out: number[] = [] try { @@ -255,10 +296,87 @@ class IndexDb { ), labels: q( 'SELECT label, COUNT(*) FROM usermeta WHERE label IS NOT NULL GROUP BY label ORDER BY COUNT(*) DESC' + ), + folders: q( + "SELECT folder, COUNT(*) FROM asset WHERE folder IS NOT NULL AND folder <> '' GROUP BY folder ORDER BY folder" ) } } + // ---- 태깅 / 메타데이터 (darktable) ---- + + listTags(): { name: string; count: number }[] { + const res = this.db!.exec( + `SELECT t.name, COUNT(at.assetId) FROM tag t + LEFT JOIN asset_tag at ON at.tagId = t.id + GROUP BY t.id ORDER BY t.name` + ) + if (!res.length) return [] + return res[0].values.map((r) => ({ name: String(r[0]), count: Number(r[1]) })) + } + + getAssetTags(assetId: number): string[] { + const stmt = this.db!.prepare( + 'SELECT t.name FROM asset_tag at JOIN tag t ON t.id = at.tagId WHERE at.assetId = ? ORDER BY t.name' + ) + const out: string[] = [] + try { + stmt.bind([assetId]) + while (stmt.step()) out.push(String((stmt.getAsObject() as { name: string }).name)) + } finally { + stmt.free() + } + return out + } + + attachTag(assetIds: number[], name: string): void { + const tag = name.trim() + if (!tag) return + this.db!.run('INSERT OR IGNORE INTO tag (name) VALUES (?)', [tag]) + const res = this.db!.exec('SELECT id FROM tag WHERE name = ?', [tag]) + if (!res.length) return + const tagId = Number(res[0].values[0][0]) + for (const assetId of assetIds) { + this.db!.run('INSERT OR IGNORE INTO asset_tag (assetId, tagId) VALUES (?, ?)', [ + assetId, + tagId + ]) + } + this.dirty = true + } + + detachTag(assetId: number, name: string): void { + this.db!.run( + 'DELETE FROM asset_tag WHERE assetId = ? AND tagId = (SELECT id FROM tag WHERE name = ?)', + [assetId, name] + ) + this.dirty = true + } + + getMetadata(assetId: number): { title: string; description: string; creator: string } { + const stmt = this.db!.prepare('SELECT title, description, creator FROM metadata WHERE assetId = ?') + try { + stmt.bind([assetId]) + if (!stmt.step()) return { title: '', description: '', creator: '' } + const r = stmt.getAsObject() as { title?: string; description?: string; creator?: string } + return { title: r.title ?? '', description: r.description ?? '', creator: r.creator ?? '' } + } finally { + stmt.free() + } + } + + setMetadata( + assetId: number, + m: { title: string; description: string; creator: string } + ): void { + this.db!.run( + `INSERT INTO metadata (assetId, title, description, creator) VALUES (?, ?, ?, ?) + ON CONFLICT(assetId) DO UPDATE SET title=excluded.title, description=excluded.description, creator=excluded.creator`, + [assetId, m.title, m.description, m.creator] + ) + this.dirty = true + } + setRating(assetId: number, rating: number): void { const r = Math.max(0, Math.min(5, Math.round(rating))) this.db!.run( @@ -333,6 +451,30 @@ class IndexDb { return out } + /** GPS가 없는 자산(지오태깅 드래그 소스용). 이미지 한정, 최근순 */ + assetsWithoutGps(th: QualityThresholds, limit = 200): IndexedAsset[] { + const inner = this.innerSelect(th) + const exts = SUPPORTED_EXTENSIONS.map((e) => `'${e}'`).join(',') + const stmt = this.db!.prepare( + `SELECT * FROM (${inner}) WHERE (gpsLat IS NULL OR gpsLon IS NULL) AND ext IN (${exts}) + ORDER BY indexedAt DESC, id DESC LIMIT ?` + ) + const out: IndexedAsset[] = [] + try { + stmt.bind([limit]) + while (stmt.step()) out.push(stmt.getAsObject() as unknown as IndexedAsset) + } finally { + stmt.free() + } + return out + } + + /** 자산에 GPS 좌표 부여/갱신 (지오태깅). 인덱스 DB에만 저장 — 원본 파일 EXIF는 변경하지 않음 */ + setGps(id: number, lat: number, lon: number): void { + this.db!.run('UPDATE asset SET gpsLat = ?, gpsLon = ? WHERE id = ?', [lat, lon, id]) + this.dirty = true + } + /** 특정 자산의 임베딩 (연관 탐색용) */ getEmbedding(assetId: number): Float32Array | null { const stmt = this.db!.prepare('SELECT vec FROM embedding WHERE assetId = ?') @@ -423,6 +565,49 @@ class IndexDb { this.dirty = true } + /** path가 prefix 폴더 하위인 자산들 (폴더 이동/삭제 시 인덱스 정합용). win은 대소문자 무시 */ + private idsUnderPrefix(prefix: string): { id: number; path: string }[] { + const base = prefix.replace(/[\\/]+$/, '') + const win = process.platform === 'win32' + const lower = (s: string): string => (win ? s.toLowerCase() : s) + const t1 = lower(base + '\\') + const t2 = lower(base + '/') + const stmt = this.db!.prepare('SELECT id, path FROM asset') + const out: { id: number; path: string }[] = [] + try { + while (stmt.step()) { + const r = stmt.getAsObject() as { id: number; path: string } + const lp = lower(r.path) + if (lp.startsWith(t1) || lp.startsWith(t2)) out.push(r) + } + } finally { + stmt.free() + } + return out + } + + /** 폴더 삭제 시: 하위 인덱스 자산 제거 → DB가 사라진 파일을 가리키지 않도록. 제거 수 반환 */ + removeAssetsUnder(prefix: string): number { + const rows = this.idsUnderPrefix(prefix) + for (const r of rows) this.deleteAsset(r.id) + return rows.length + } + + /** 폴더 이동 시: 하위 자산 path/folder 를 새 위치로 갱신. 갱신 수 반환 */ + reparentAssets(oldPrefix: string, newPrefix: string): number { + const rows = this.idsUnderPrefix(oldPrefix) + const oldBase = oldPrefix.replace(/[\\/]+$/, '') + const newBase = newPrefix.replace(/[\\/]+$/, '') + for (const r of rows) { + const np = newBase + r.path.slice(oldBase.length) // 접미부(구분자 포함) 보존 + const sep = Math.max(np.lastIndexOf('\\'), np.lastIndexOf('/')) + const folder = sep > 0 ? np.slice(0, sep) : np + this.db!.run('UPDATE asset SET path = ?, folder = ? WHERE id = ?', [np, folder, r.id]) + } + if (rows.length) this.dirty = true + return rows.length + } + getByHash(contentHash: string): AssetRecord | null { const stmt = this.db!.prepare('SELECT * FROM asset WHERE contentHash = ?') try { @@ -439,14 +624,14 @@ class IndexDb { this.db!.run( `INSERT INTO asset (contentHash, path, ext, sizeBytes, mtime, width, height, exifYear, exifMonth, - gpsLat, gpsLon, camera, metaVersion, indexedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + gpsLat, gpsLon, camera, folder, metaVersion, indexedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) ON CONFLICT(contentHash) DO UPDATE SET path=excluded.path, ext=excluded.ext, sizeBytes=excluded.sizeBytes, mtime=excluded.mtime, width=excluded.width, height=excluded.height, exifYear=excluded.exifYear, exifMonth=excluded.exifMonth, gpsLat=excluded.gpsLat, gpsLon=excluded.gpsLon, camera=excluded.camera, - metaVersion=1, indexedAt=excluded.indexedAt`, + folder=excluded.folder, metaVersion=1, indexedAt=excluded.indexedAt`, [ r.contentHash, r.path, @@ -460,6 +645,7 @@ class IndexDb { r.gpsLat, r.gpsLon, r.camera, + dirname(r.path), r.indexedAt ] ) diff --git a/src/main/indexer.ts b/src/main/indexer.ts index 698b9c5..988a01b 100644 --- a/src/main/indexer.ts +++ b/src/main/indexer.ts @@ -71,8 +71,15 @@ class Indexer { try { const st = await stat(file) const mtime = Math.floor(st.mtimeMs) - // 빠른 스킵: 같은 경로·mtime이면 해시 없이 건너뜀 + const isImageFast = mediaKind(file) === 'image' + // 이미지: 같은 경로·mtime이면 해시 없이 빠른 스킵. + // 영상: 썸네일이 없을 수 있어, 색인됐어도 통과시켜 backfill (해시로 썸네일 존재 확인). + let fastSkip = false if (indexDb.isIndexedPath(file, mtime)) { + if (isImageFast) fastSkip = true + else if (await hasThumb(await contentHash(file))) fastSkip = true + } + if (fastSkip) { skipped++ } else { const hash = await contentHash(file) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8c4cb5d..31f5ff0 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,12 +2,15 @@ import { ipcMain, dialog, BrowserWindow, app, shell } from 'electron' import { writeFile, mkdir } from 'node:fs/promises' import { join, extname, basename } from 'node:path' import { safeCopy } from './fileOps' +import { readFullExif } from './exif' +import { listDir, makeDir, trashDir, moveDir } from './fsExplorer' import type { ProfileInput, JobRequest, ReferenceData, AssetQuery, - ColorLabel + ColorLabel, + AssetMetadata } from '@shared/types' import { IPC, SUPPORTED_EXTENSIONS } from '@shared/constants' import { profileStore } from './profileStore' @@ -175,6 +178,46 @@ export function registerIpc(): void { ipcMain.handle(IPC.INDEX_FACETS, () => indexDb.facets()) + ipcMain.handle(IPC.INDEX_EXIF, async (_e, assetId: number) => { + const a = indexDb.getById(assetId) + if (!a) return await readFullExif('') + return readFullExif(a.path) + }) + + // ---- 태깅 / 메타데이터 ---- + ipcMain.handle(IPC.TAGS_LIST, () => indexDb.listTags()) + ipcMain.handle(IPC.TAGS_FOR_ASSET, (_e, id: number) => indexDb.getAssetTags(id)) + ipcMain.handle(IPC.TAGS_ATTACH, async (_e, ids: number[], name: string) => { + indexDb.attachTag(ids, name) + await indexDb.save() + }) + ipcMain.handle(IPC.TAGS_DETACH, async (_e, id: number, name: string) => { + indexDb.detachTag(id, name) + await indexDb.save() + }) + ipcMain.handle(IPC.META_GET, (_e, id: number) => indexDb.getMetadata(id)) + ipcMain.handle(IPC.META_SET, async (_e, id: number, data: AssetMetadata) => { + indexDb.setMetadata(id, data) + await indexDb.save() + }) + + // ---- 파일 탐색기 ---- + ipcMain.handle(IPC.FS_LIST, (_e, path: string | null) => listDir(path)) + ipcMain.handle(IPC.FS_MKDIR, (_e, parent: string, name: string) => makeDir(parent, name)) + ipcMain.handle(IPC.FS_TRASH, async (_e, path: string) => { + await trashDir(path) + // 인덱스 정합: 삭제된 폴더 하위의 색인 자산 제거 (SSOT — DB가 사라진 파일을 가리키지 않게) + const removed = indexDb.removeAssetsUnder(path) + if (removed > 0) logger.info('폴더 삭제 → 인덱스 자산 제거', { path, removed }) + }) + ipcMain.handle(IPC.FS_MOVE, async (_e, src: string, destDir: string) => { + const res = await moveDir(src, destDir) + // 인덱스 정합: 이동된 폴더 하위 자산의 경로를 새 위치로 갱신 + const moved = indexDb.reparentAssets(src, res.path) + if (moved > 0) logger.info('폴더 이동 → 인덱스 경로 갱신', { from: src, to: res.path, moved }) + return res + }) + ipcMain.handle(IPC.INDEX_EXPORT, async (e, assetIds: number[]) => { const win = BrowserWindow.fromWebContents(e.sender) const r = await dialog.showOpenDialog(win!, { @@ -225,6 +268,12 @@ export function registerIpc(): void { // ---- 지도 / 연관 탐색 (Phase C) ---- ipcMain.handle(IPC.MAP_ASSETS, () => indexDb.assetsWithGps()) ipcMain.handle(IPC.MAP_RELATED, (_e, assetId: number) => relatedAssets(assetId)) + ipcMain.handle(IPC.MAP_UNTAGGED, () => + indexDb.assetsWithoutGps(settingsStore.current().qualityThresholds) + ) + ipcMain.handle(IPC.MAP_SET_GPS, (_e, id: number, lat: number, lon: number) => + indexDb.setGps(id, lat, lon) + ) // ---- 그룹화 / 자가정화 (Phase 3) ---- ipcMain.handle(IPC.GROUPS_BUILD, (_e, threshold: number) => buildGroups(threshold)) diff --git a/src/main/mediaProtocol.ts b/src/main/mediaProtocol.ts index c5be86a..8161843 100644 --- a/src/main/mediaProtocol.ts +++ b/src/main/mediaProtocol.ts @@ -1,9 +1,10 @@ import { protocol } from 'electron' -import { readFile } from 'node:fs/promises' +import { readFile, stat, open } from 'node:fs/promises' import { extname } from 'node:path' import { MEDIA_SCHEME } from '@shared/constants' import { profileStore } from './profileStore' import { presetStore } from './presetStore' +import { indexDb } from './indexDb' import { thumbPath } from './thumbnails' import { logger } from './logger' @@ -11,7 +12,16 @@ const MIME: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', - '.webp': 'image/webp' + '.webp': 'image/webp', + '.gif': 'image/gif', + '.bmp': 'image/bmp', + // 라이브러리 뷰어 영상 재생용 (코덱 미지원 시 렌더러가 placeholder로 폴백) + '.mp4': 'video/mp4', + '.m4v': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo' } /** @@ -54,7 +64,12 @@ export function handleMediaProtocol(): void { const data = await readFile(thumbPath(hash)) return new Response(new Uint8Array(data), { status: 200, - headers: { 'content-type': 'image/webp', 'cache-control': 'no-cache' } + headers: { + 'content-type': 'image/webp', + 'cache-control': 'no-cache', + // 캔버스에서 픽셀 읽기(포토모자이크 색 분석)를 위해 CORS 허용 + 'access-control-allow-origin': '*' + } }) } @@ -65,18 +80,60 @@ export function handleMediaProtocol(): void { if (i < 0) return new Response('missing path', { status: 400 }) const filePath = decodeURIComponent(url.slice(i + marker.length)) - // 등록된 참조 이미지(활성 프로필 또는 프리셋)에 한해 제공 — 임의 파일 읽기 차단 + // 등록된 참조 이미지(활성 프로필/프리셋) 또는 라이브러리 인덱스 등록 경로에 한해 제공 + // → 임의 파일 읽기 차단 const allowed = (await profileStore.isReferenceImage(filePath)) || - (await presetStore.isReferenceImage(filePath)) + (await presetStore.isReferenceImage(filePath)) || + indexDb.hasPath(filePath) if (!allowed) return new Response('forbidden', { status: 403 }) + const mime = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream' + + // 영상은 Range 요청을 지원해 탐색(seek)·스트리밍이 매끄럽게 동작하도록 부분 응답 + const rangeHeader = request.headers.get('range') + if (rangeHeader && mime.startsWith('video/')) { + const { size } = await stat(filePath) + const m = /bytes=(\d*)-(\d*)/.exec(rangeHeader) + const start = m && m[1] ? parseInt(m[1], 10) : 0 + const end = m && m[2] ? Math.min(parseInt(m[2], 10), size - 1) : size - 1 + if (start >= size || start > end) { + return new Response('range not satisfiable', { + status: 416, + headers: { 'content-range': `bytes */${size}` } + }) + } + const fh = await open(filePath, 'r') + try { + const len = end - start + 1 + const buf = Buffer.alloc(len) + await fh.read(buf, 0, len, start) + return new Response(new Uint8Array(buf), { + status: 206, + headers: { + 'content-type': mime, + 'content-range': `bytes ${start}-${end}/${size}`, + 'accept-ranges': 'bytes', + 'content-length': String(len), + 'cache-control': 'no-cache', + 'access-control-allow-origin': '*' + } + }) + } finally { + await fh.close() + } + } + // net.fetch(file://) 대신 fs로 직접 읽어 바이트 반환 → 한글/공백 경로에도 안전 const data = await readFile(filePath) - const mime = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream' return new Response(new Uint8Array(data), { status: 200, - headers: { 'content-type': mime, 'cache-control': 'no-cache' } + headers: { + 'content-type': mime, + 'accept-ranges': mime.startsWith('video/') ? 'bytes' : 'none', + 'cache-control': 'no-cache', + 'access-control-allow-origin': '*' + } }) } catch (err) { logger.error('media 프로토콜 처리 실패', { message: (err as Error).message }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 098f417..80acf08 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import type { ReferenceData, AssetQuery, ColorLabel, + AssetMetadata, RendererEventName, RendererEvents } from '../shared/types' @@ -70,6 +71,7 @@ const api: ExposedApi = { assetIds: (query: AssetQuery) => ipcRenderer.invoke(IPC.INDEX_ASSET_IDS, query), facets: () => ipcRenderer.invoke(IPC.INDEX_FACETS), export: (assetIds: number[]) => ipcRenderer.invoke(IPC.INDEX_EXPORT, assetIds), + exif: (assetId: number) => ipcRenderer.invoke(IPC.INDEX_EXIF, assetId), setRating: (assetId: number, rating: number) => ipcRenderer.invoke(IPC.INDEX_SET_RATING, assetId, rating), setLabel: (assetId: number, label: ColorLabel) => @@ -81,14 +83,33 @@ const api: ExposedApi = { status: () => ipcRenderer.invoke(IPC.SEARCH_STATUS), query: (text: string) => ipcRenderer.invoke(IPC.SEARCH_QUERY, text) }, + tags: { + list: () => ipcRenderer.invoke(IPC.TAGS_LIST), + forAsset: (assetId: number) => ipcRenderer.invoke(IPC.TAGS_FOR_ASSET, assetId), + attach: (assetIds: number[], name: string) => ipcRenderer.invoke(IPC.TAGS_ATTACH, assetIds, name), + detach: (assetId: number, name: string) => ipcRenderer.invoke(IPC.TAGS_DETACH, assetId, name) + }, + meta: { + get: (assetId: number) => ipcRenderer.invoke(IPC.META_GET, assetId), + set: (assetId: number, data: AssetMetadata) => ipcRenderer.invoke(IPC.META_SET, assetId, data) + }, map: { assets: () => ipcRenderer.invoke(IPC.MAP_ASSETS), - related: (assetId: number) => ipcRenderer.invoke(IPC.MAP_RELATED, assetId) + related: (assetId: number) => ipcRenderer.invoke(IPC.MAP_RELATED, assetId), + untagged: () => ipcRenderer.invoke(IPC.MAP_UNTAGGED), + setGps: (id: number, lat: number, lon: number) => + ipcRenderer.invoke(IPC.MAP_SET_GPS, id, lat, lon) }, groups: { build: (threshold: number) => ipcRenderer.invoke(IPC.GROUPS_BUILD, threshold), trash: (assetIds: number[]) => ipcRenderer.invoke(IPC.GROUPS_TRASH, assetIds) }, + fs: { + list: (path: string | null) => ipcRenderer.invoke(IPC.FS_LIST, path), + mkdir: (parent: string, name: string) => ipcRenderer.invoke(IPC.FS_MKDIR, parent, name), + trash: (path: string) => ipcRenderer.invoke(IPC.FS_TRASH, path), + move: (src: string, destDir: string) => ipcRenderer.invoke(IPC.FS_MOVE, src, destDir) + }, // Electron 33: File.path 제거됨 → webUtils로 드롭된 파일의 실제 경로 획득 getPathForFile: (file: unknown) => webUtils.getPathForFile(file as File), on(event: E, cb: (payload: RendererEvents[E]) => void) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8c17ce3..0c805cc 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { LibraryView } from './components/LibraryView' import { SearchView } from './components/SearchView' import { GroupsView } from './components/GroupsView' import { MapView } from './components/MapView' +import { FileExplorer } from './components/FileExplorer' import type { AppView } from './store' export default function App(): JSX.Element { @@ -96,24 +97,31 @@ export default function App(): JSX.Element { )} {view === 'organize' ? ( -
- {/* 좌측: 설정 패널 (자체 스크롤) */} -
- - - -
+
+ {/* 좌측: 파일 탐색기 사이드바 */} + - {/* 우측: 진행/결과 — FileList만 내부 스크롤 */} -
-
- {phase === 'done' ? : } -
- -
+
+ {/* 설정 패널 (자체 스크롤) */} +
+ + + +
+ + {/* 진행/결과 — FileList만 내부 스크롤 */} +
+
+ {phase === 'done' ? : } +
+ +
+
) : view === 'library' ? ( -
+
) : view === 'search' ? ( diff --git a/src/renderer/components/FileExplorer.tsx b/src/renderer/components/FileExplorer.tsx new file mode 100644 index 0000000..a32af50 --- /dev/null +++ b/src/renderer/components/FileExplorer.tsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useState } from 'react' +import { useStore } from '../store' +import { useT } from '../i18n' +import type { FsEntry } from '@shared/types' + +/** 경로의 부모 디렉터리 (Windows/POSIX 겸용) */ +function parentOf(p: string): string { + const idx = Math.max(p.replace(/[\\/]+$/, '').lastIndexOf('\\'), p.replace(/[\\/]+$/, '').lastIndexOf('/')) + return idx > 0 ? p.slice(0, idx) : p +} +function baseOf(p: string): string { + const t = p.replace(/[\\/]+$/, '') + const idx = Math.max(t.lastIndexOf('\\'), t.lastIndexOf('/')) + return idx >= 0 ? t.slice(idx + 1) : t +} + +/** 정리 탭 좌측 파일 탐색기 — 폴더를 드래그/복사/버튼으로 소스·출력 지정, 생성·삭제·이동 */ +export function FileExplorer(): JSX.Element { + const t = useT() + const setSource = useStore((s) => s.setSource) + const setOutput = useStore((s) => s.setOutput) + const [roots, setRoots] = useState([]) + const [loading, setLoading] = useState(true) + const [children, setChildren] = useState>({}) + const [expanded, setExpanded] = useState>(new Set()) + const [selected, setSelected] = useState(null) + const [dropTarget, setDropTarget] = useState(null) + + useEffect(() => { + setLoading(true) + void window.api.fs + .list(null) + .then(setRoots) + .finally(() => setLoading(false)) + }, []) + + /** 해당 폴더의 하위를 다시 읽어 캐시 갱신 */ + const reload = useCallback(async (path: string) => { + const c = await window.api.fs.list(path) + setChildren((prev) => ({ ...prev, [path]: c })) + }, []) + + const toggle = useCallback( + async (path: string) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + if (!children[path]) await reload(path) + }, + [children, reload] + ) + + const copyPath = (p: string) => void navigator.clipboard?.writeText(p) + + const newFolder = async (parent: string) => { + const name = window.prompt(t('explorer.newFolderPrompt')) + if (!name) return + try { + await window.api.fs.mkdir(parent, name) + await reload(parent) + setExpanded((prev) => new Set(prev).add(parent)) + } catch (e) { + window.alert(t('explorer.opFailed', { msg: (e as Error).message })) + } + } + + const deleteFolder = async (path: string) => { + if (!window.confirm(t('explorer.confirmDelete', { name: baseOf(path) }))) return + try { + await window.api.fs.trash(path) + if (selected === path) setSelected(null) + await reload(parentOf(path)) + } catch (e) { + window.alert(t('explorer.opFailed', { msg: (e as Error).message })) + } + } + + const moveFolder = async (src: string, destDir: string) => { + if (!window.confirm(t('explorer.confirmMove', { src: baseOf(src), dest: baseOf(destDir) }))) return + try { + await window.api.fs.move(src, destDir) + if (selected === src) setSelected(null) + await reload(parentOf(src)) + await reload(destDir) + setExpanded((prev) => new Set(prev).add(destDir)) + } catch (e) { + window.alert(t('explorer.opFailed', { msg: (e as Error).message })) + } + } + + const renderNode = (entry: FsEntry, depth: number): JSX.Element => { + const isOpen = expanded.has(entry.path) + const kids = children[entry.path] + const isDrop = dropTarget === entry.path + return ( +
+
{ + e.dataTransfer.setData('text/plain', entry.path) + e.dataTransfer.effectAllowed = 'copyMove' + }} + onDragOver={(e) => { + // 트리 내부 폴더 위로 드래그 → 이동 대상 하이라이트 + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + if (dropTarget !== entry.path) setDropTarget(entry.path) + }} + onDragLeave={() => setDropTarget((d) => (d === entry.path ? null : d))} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + setDropTarget(null) + const src = e.dataTransfer.getData('text/plain') + if (src && src !== entry.path) void moveFolder(src, entry.path) + }} + onClick={() => setSelected(entry.path)} + onDoubleClick={() => void toggle(entry.path)} + title={entry.path} + > + + + {depth === 0 ? '💽' : '📁'} {entry.name} + +
+ {isOpen && kids && kids.map((k) => renderNode(k, depth + 1))} +
+ ) + } + + return ( +
+
+ {t('explorer.title')} + {selected && ( + + )} +
+
+ {loading ? ( +

{t('explorer.loading')}

+ ) : ( + roots.map((r) => renderNode(r, 0)) + )} +
+ {selected && ( +
+
+ {selected} +
+
+ + + +
+
+ + +
+

{t('explorer.hint')}

+
+ )} +
+ ) +} diff --git a/src/renderer/components/FolderPicker.tsx b/src/renderer/components/FolderPicker.tsx index f83d786..b0608e1 100644 --- a/src/renderer/components/FolderPicker.tsx +++ b/src/renderer/components/FolderPicker.tsx @@ -1,7 +1,8 @@ +import { useState } from 'react' import { useStore } from '../store' import { useT } from '../i18n' -/** 소스 폴더 + 출력 루트 선택 */ +/** 소스 폴더 + 출력 루트 선택 (찾기 / 드래그&드롭 / 탐색기 버튼) */ export function FolderPicker(): JSX.Element { const t = useT() const source = useStore((s) => s.source) @@ -22,8 +23,22 @@ export function FolderPicker(): JSX.Element {

{t('folder.section')}

- - + +
) } @@ -34,12 +49,30 @@ function Row(props: { placeholder: string browse: string onPick: () => void + onDropPath: (path: string) => void }): JSX.Element { + const [over, setOver] = useState(false) return (
{props.label}
-
+
{ + e.preventDefault() + setOver(true) + }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault() + setOver(false) + const p = e.dataTransfer.getData('text/plain') + if (p) props.onDropPath(p) + }} + title={props.value ?? ''} + > {props.value ?? props.placeholder}
-
-

{t('lib.hint')}

- - {libraries.length === 0 ? ( -

{t('lib.empty')}

- ) : ( -
    +
    + {/* ===== 좌측 패널 ===== */} +
- )} -
+
+ + {!running ? ( + + ) : ( + + )} +
+ {running && ( +
+
+
+
+
+ {pct}% · {progress?.current ?? ''} +
+
+ )} +
+ - {/* 색인 제어 + 진행률 */} -
-
- {!running ? ( - - ) : ( - - )} - {summary && !running && ( - - {t('lib.assets', { n: summary.assets })} - - )} -
+
+ baseName(v) || v} + onPick={(v) => setFolder(folder === v ? null : v)} + /> +
- {running && ( - <> -
-
+ {(facets?.years.length ?? 0) > 0 && ( +
+
{t('col.year')}
+ setYear(year === v ? null : v)} />
-
- - {progress?.current ?? t('lib.indexing')} - - {pct}% + )} + {(facets?.cameras.length ?? 0) > 0 && ( +
+
{t('col.camera')}
+ setCamera(camera === v ? null : v)} + />
- - )} - - {summary && !running && ( -

- {t('lib.doneSummary', { - indexed: summary.indexed, - skipped: summary.skipped, - failed: summary.failed, - assets: summary.assets - })} -

- )} -
- - {/* 그리드 + 컬링 */} -
-
-
-

{t('lib.grid')}

- {/* 미디어 종류(사진/영상 분리) */} -
- {MEDIA.map((m) => ( - - ))} -
- {/* 품질 컬링(좋은 사진/걸러낼 사진) */} -
- {FILTERS.map((f) => ( - - ))} -
- {/* 별점 최소 필터 */} -
- {[1, 2, 3, 4, 5].map((n) => ( - - ))} -
-
-
- - {assets.length} -
-
- - {/* 컬렉션 패싯: 타임라인(연도) + 카메라 + 색라벨 */} - {facets && (facets.years.length > 0 || facets.cameras.length > 0) && ( -
- {facets.years.length > 0 && ( -
- {t('col.year')} - {facets.years.map((y) => ( - setYear(year === y.value ? null : y.value)} - /> - ))} -
- )} - {facets.cameras.length > 0 && ( -
- {t('col.camera')} - {facets.cameras.slice(0, 8).map((c) => ( - setCamera(camera === c.value ? null : c.value)} - /> - ))} -
- )} - {facets.labels.length > 0 && ( -
- {t('col.label')} + )} + {(facets?.labels.length ?? 0) > 0 && ( +
+
{t('col.label')}
+
{LABEL_COLORS.map((c) => { - const f = facets.labels.find((l) => l.value === c.id) + const f = facets?.labels.find((l) => l.value === c.id) if (!f) return null return ( ) })}
- )} -
- )} - - {/* 임계값 패널 */} - {showThresholds && ( -
-
- setLocalTh({ ...localThRef.current, focus: v })} - onCommit={() => commitThresholds(localThRef.current)} - /> - setLocalTh({ ...localThRef.current, exposure: v })} - onCommit={() => commitThresholds(localThRef.current)} - /> - setLocalTh({ ...localThRef.current, eyes: v })} - onCommit={() => commitThresholds(localThRef.current)} - />
-
-

{t('cull.thresholdHint')}

- -
-
- )} + )} + - {/* 선택 액션바 */} -
- {selected.size > 0 ? ( - <> - - {t('sel.count', { n: selected.size })} - +
+ {t('filter.kind')} + + {t('filter.quality')} +
+ +
+ {t('cull.ratingMin')} +
+ {[1, 2, 3, 4, 5].map((n) => ( - - - + ))} +
+
+ +
+ setLocalTh({ ...localThRef.current, focus: v })} + onCommit={() => commitThresholds(localThRef.current)} + /> + setLocalTh({ ...localThRef.current, exposure: v })} + onCommit={() => commitThresholds(localThRef.current)} + /> + setLocalTh({ ...localThRef.current, eyes: v })} + onCommit={() => commitThresholds(localThRef.current)} + /> +
+ +
+ {!infoAsset ? ( +

{t('info.none')}

+ ) : ( +
+ + + + + + + + + +
+ )} +
+ + + {/* ===== 중앙: 그리드 ===== */} +
+
+ {t('lib.grid')} + · {assets.length} + {selected.size > 0 && · {t('sel.count', { n: selected.size })}} + {/* 밀도(썸네일 크기) 슬라이더 — 작게=컨택트시트, 크게=상세 */} + +
+
+ {assets.length === 0 ? ( +

{t('lib.gridEmpty')}

) : ( <> - - {t('sel.hint')} + {assets.map((a, i) => ( + = 9} + onClickTile={() => onTileClick(a)} + onDoubleTile={() => onTileDouble(a)} + onHover={() => { + setHovered(a) + dragPaint(i) + }} + onMouseDownTile={() => dragStart(i)} + onRate={(r) => setRating(a, r)} + onLabel={(l) => setLabel(a, l)} + /> + ))} +
+ {hasMore && ( +
+ +
+ )} )}
+
- {assets.length === 0 ? ( -

{t('lib.gridEmpty')}

- ) : ( - <> -
- {assets.map((a) => ( - a.id != null && toggleSelect(a.id)} - onRate={(r) => setRating(a, r)} - onLabel={(l) => setLabel(a, l)} - /> - ))} -
- {hasMore && ( -
- + {/* ===== 우측 패널 ===== */} + + + {/* ===== 전체화면 뷰어(라이트박스) — 그리드 위 오버레이라 스크롤 위치 보존 ===== */} + {viewer && ( + x.contentHash === viewer.contentHash)} + t={t} + onClose={() => setViewer(null)} + onPrev={() => navigateViewer(-1)} + onNext={() => navigateViewer(1)} + onJump={(a) => { + setFocused(a) + setViewer(a) + }} + onRate={(n) => setRating(viewer, n)} + onLabel={(c) => setLabel(viewer, c)} + /> + )} + + {/* ===== 포토모자이크 ===== */} + {mosaicTarget && ( + setMosaicTarget(null)} + /> + )} +
+ ) +} + +/* ---------- 하위 컴포넌트 ---------- */ + +/** darktable darkroom식 전체화면 뷰어 — 좌측 정보패널 + 상단 평가툴바 + 하단 필름스트립 */ +function Lightbox(props: { + asset: IndexedAsset + exif: ExifInfo | null + assets: IndexedAsset[] + index: number + t: (k: string, p?: Record) => string + onClose: () => void + onPrev: () => void + onNext: () => void + onJump: (a: IndexedAsset) => void + onRate: (rating: number) => void + onLabel: (label: Exclude) => void +}): JSX.Element { + const { exif, assets, t } = props + // 평가가 바뀌어도 최신 상태를 반영하도록 목록에서 실시간 자산을 다시 조회 + const a = assets.find((x) => x.contentHash === props.asset.contentHash) ?? props.asset + const isVideo = VIDEO_EXTS.includes(a.ext) + const [failed, setFailed] = useState(false) + const stripRef = useRef(null) + const activeThumbRef = useRef(null) + const src = mediaUrl(a.path) + const folder = baseName(a.path.slice(0, a.path.length - baseName(a.path).length)) + + // 자산 변경 시 로드 실패 상태 초기화 + 필름스트립에서 현재 항목을 보이게 스크롤 + useEffect(() => setFailed(false), [a.contentHash]) + useEffect(() => { + activeThumbRef.current?.scrollIntoView({ block: 'nearest', inline: 'center' }) + }, [a.contentHash]) + + const info: { label: string; value: string | null | undefined }[] = [ + { label: t('info.file'), value: baseName(a.path) }, + { label: t('info.date'), value: exif?.dateTime ?? `${a.exifYear ?? ''}.${a.exifMonth ?? ''}` }, + { label: t('info.camera'), value: [exif?.make, exif?.model].filter(Boolean).join(' ') || a.camera }, + { label: t('info.lens'), value: exif?.lens }, + { + label: t('info.exposure'), + value: [exif?.fNumber ? `f/${exif.fNumber}` : null, exif?.exposureTime, exif?.focalLength ? `${exif.focalLength}mm` : null] + .filter(Boolean) + .join(' · ') + }, + { label: t('info.iso'), value: exif?.iso ? String(exif.iso) : null }, + { label: t('info.size'), value: a.width && a.height ? `${a.width}×${a.height}` : null }, + { label: t('info.gps'), value: exif?.gps ? `${exif.gps.lat.toFixed(4)}, ${exif.gps.lon.toFixed(4)}` : null }, + { label: t('info.folder'), value: folder } + ] + + return ( +
+ {/* 상단 툴바: 뒤로 · 파일명 · 별점 · 색라벨 · 카운터 */} +
+ + + {baseName(a.path)} + + {/* 별점 */} +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ {/* 색 라벨 */} +
+ {LABEL_COLORS.map((c) => ( +
+ + {props.index + 1} / {assets.length} + +
+ + {/* 본문: 좌측 정보패널 + 중앙 이미지 */} +
+ {/* 좌측 이미지 정보 패널 */} + + + {/* 중앙 이미지/영상 */} +
+ + + + {failed ? ( +
+ {baseName(a.path)} + {isVideo &&

{t('viewer.videoUnsupported')}

} +
+ ) : isVideo ? ( +
+
+ + {/* 하단 필름스트립 */} +
+ {assets.map((x) => { + const active = x.contentHash === a.contentHash + return ( + + ) + })}
) } -function FacetChip(props: { - label: string - count: number - active: boolean - onClick: () => void +function Section(props: { title: string; defaultOpen?: boolean; children: ReactNode }): JSX.Element { + const [open, setOpen] = useState(props.defaultOpen ?? true) + return ( +
+ + {open &&
{props.children}
} +
+ ) +} + +function FacetList(props: { + items: { value: string; count: number }[] + active: string | null + display?: (v: string) => string + onPick: (v: string) => void }): JSX.Element { return ( - + ))} +
+ ) +} + +function FilterLabel(props: { children: ReactNode; className?: string }): JSX.Element { + return ( +
- {props.label} {props.count} - + {props.children} +
+ ) +} + +function ToggleRow(props: { + label: string + items: { id: T; key: string }[] + value: T + onPick: (v: T) => void + t: (k: string) => string +}): JSX.Element { + return ( +
+ {props.items.map((it) => ( + + ))} +
+ ) +} + +function InfoRow(props: { label: string; value: string | null | undefined }): JSX.Element | null { + if (!props.value) return null + return ( +
+
{props.label}
+
{props.value}
+
+ ) +} + +function MetaField(props: { + label: string + value: string + onChange: (v: string) => void + multiline?: boolean +}): JSX.Element { + return ( +