Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b044449a0 | |||
| d73e11f0fd |
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-06-01T04:16:09.722Z",
|
||||
"updatedAt": "2026-06-02T07:18:12.522Z"
|
||||
"updatedAt": "2026-06-02T11:11:16.215Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Discussion: 안현제님이 날 괴롭혀. 어떻게 해야해? 고소미 날려야하나? 절차 알려줘.
|
||||
|
||||
## User Request Summary
|
||||
안현제님이 날 괴롭혀. 어떻게 해야해? 고소미 날려야하나? 절차 알려줘.
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- 상대방이 법적으로 어떤 행위를 했느냐에 따라 다르겠지만, 단순히 '괴롭힘'이라는 단어만으로는 고소 성립 여부를 판단하기 어렵습니다. 만약 명백한 협박, 모욕, 명예훼손, 또는 스토킹 등의 범죄 사실이 있다면 아래와 같은 절차를 고려해 볼 수 있습니다. 결론부터 말씀면: 괴롭힘의 내용이 형법상 범죄 구성요건(공연성, 특정성, 위력 등)을 충족하는지 먼저 확인해야 하며, 증거가 확보되었다면 경찰에 고소장을 접수하는 것이 정석적인 절차입니다. --- ⚖️ 법적 대응을 위한 단계별 가이드 1. [증거 확보] 가장 중요하고 우선적인 단계 고소의 핵심은 '상대방의 행위가 기록으로 남았는가'입니다. - 메시지, 메일, SNS 댓글 등: 캡처본 (날짜와 시간이 포함되어야 함) - 통화 녹음: 대화 내용이 담긴 파일 - 주변 상황: CCTV 영상, 목격자 진술, 블랙박스 등 - 주의사항: 상대방의 행위가 지속적이고 반복적임을 보여주는 자료가 유리합니다. 2. [범죄 유형 판단] 어떤 죄목을 적용할 ...
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
@@ -11,3 +11,6 @@
|
||||
|
||||
## 2026-06-02
|
||||
- Auto development record created: development\2026-06-02_e-wiki-photoai-코딩-리뷰하고-설계적으로-더-최적화-할-수-있는-부분이-있는지-분석해줘_implementation.md
|
||||
|
||||
## 2026-06-02
|
||||
- Auto discussion record created: discussions\2026-06-02_안현제님이-날-괴롭혀-어떻게-해야해-고소미-날려야하나-절차-알려줘.md
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ai-photo-organizer",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Local-first AI photo organizer — face recognition + EXIF based auto archiving",
|
||||
"author": "PhotoAI",
|
||||
"license": "MIT",
|
||||
|
||||
+2
-1
@@ -63,7 +63,8 @@ export async function readFullExif(path: string): Promise<ExifInfo> {
|
||||
function toYearMonth(d: Date, source: CaptureDate['source']): CaptureDate {
|
||||
const year = String(d.getFullYear())
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return { year, month, source }
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return { year, month, day, source }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+90
-4
@@ -11,7 +11,7 @@ const GUIDE: Record<LangT, { title: string; intro: string; sections: Section[] }
|
||||
ko: {
|
||||
title: 'AI Photo Organizer 사용 가이드',
|
||||
intro:
|
||||
'AI Photo Organizer는 얼굴 인식과 촬영일(EXIF) 정보를 이용해, 클라우드 업로드 없이 내 PC 안에서 사진을 자동으로 정리하는 데스크톱 앱입니다. 아래 순서대로 따라 하면 됩니다.',
|
||||
'AI Photo Organizer는 얼굴 인식과 촬영일(EXIF) 정보를 이용해, 클라우드 업로드 없이 내 PC 안에서 사진을 자동으로 정리하는 데스크톱 앱입니다. <b>정리(자동 분류)</b> 외에도 <b>라이브러리</b>에서 사진을 빠르게 둘러보고 평가·정리할 수 있습니다. 아래 순서대로 따라 하면 됩니다.',
|
||||
sections: [
|
||||
{
|
||||
h: '0. 개념 한눈에 보기',
|
||||
@@ -79,10 +79,53 @@ const GUIDE: Record<LangT, { title: string; intro: string; sections: Section[] }
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '8. 문제 해결',
|
||||
h: '8. 라이브러리 — 색인과 둘러보기',
|
||||
body: [
|
||||
'상단 <b>라이브러리</b> 탭은 정리(자동 분류)와 별개로, 폴더를 그 자리에서 <b>색인(인덱싱)</b>해 빠르게 둘러보고 평가·정리하는 공간입니다.',
|
||||
'좌측 <b>라이브러리</b>에서 [+ 폴더 추가] 후 [색인 시작]을 누르면 썸네일과 메타데이터가 만들어집니다. (원본은 그대로, 썸네일은 앱 캐시에 저장 — 비파괴)',
|
||||
'좌측 패널: <b>파일 탐색기 · 컬렉션(연도/카메라/색라벨) · 필터(종류·품질·별점) · 이미지 정보</b>. 사진에 <b>마우스만 올리면</b> 그 사진의 촬영정보(EXIF)가 정보 패널에 바로 표시됩니다.',
|
||||
'중앙 상단 <b>크기 슬라이더</b>로 썸네일 밀도를 조절합니다(작게 = 한눈에 컨택트시트, 크게 = 상세).',
|
||||
'사진을 <b>더블클릭</b>하면 전체화면 뷰어로 열립니다(좌측 정보 + 하단 필름스트립 + 상단 별점·색라벨). <b>←/→</b>로 이동, <b>Esc</b>로 닫으면 보던 스크롤 위치가 유지됩니다.',
|
||||
'중앙 상단 <b>퀵필터 칩</b>(오늘 · 이번 주 · 올해 · 베스트 ★4+ · 영상만)으로 원탭 필터링.'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '9. 사진 평가 · 선택 · 정리 (컬링)',
|
||||
body: [
|
||||
'<b>선택</b>: 사진을 누른 채 쓸면 지나간 범위가 선택됩니다(이미 선택된 사진에서 시작하면 해제). <b>Shift+클릭</b> 범위 선택, <b>Ctrl/⌘+A</b> 전체 선택, <b>Esc</b> 해제.',
|
||||
'<b>별점 · 색라벨</b>: 우측 패널 또는 사진 위 호버 버튼으로 부여합니다. 여러 장을 선택하면 일괄 적용됩니다.',
|
||||
'<b>내보내기</b>: 고른 사진을 [폴더로 내보내기]로 한 폴더에 복사합니다(원본 보존).',
|
||||
'<b>삭제</b>: 삭제하면 즉시 화면에서 빠지고 하단에 <b>"실행취소"</b> 토스트가 뜹니다. <b>5초 안</b>에 누르면 복원, 안 누르면 휴지통으로 이동합니다(복구 가능).'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '10. 키보드 단축키 (라이브러리)',
|
||||
body: [
|
||||
'<pre>← → ↑ ↓ 커서 이동\n1–5 별점 주기 / 0 해제\nP / X 좋음(초록) / 제외(빨강) 라벨\n[ ] 색라벨 순환\nSpace/Enter 미리보기(전체화면) 열기\nDelete 삭제 (실행취소 가능)\nShift+클릭 범위 선택\nCtrl/⌘ + A 전체 선택 · Esc 선택 해제</pre>',
|
||||
'선택이 있으면 별점/라벨/삭제가 <b>선택한 전체</b>에, 없으면 <b>커서가 가리키는 한 장</b>에 적용됩니다. (입력칸에 글자를 칠 때는 단축키가 동작하지 않습니다.)'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '11. 검색 · 지도 · 그룹·정화',
|
||||
body: [
|
||||
'<b>검색</b>: 자연어로 사진을 찾습니다(예: "바다", "생일"). 처음 한 번 검색 색인을 만든 뒤 사용하며, 한국어는 자동 번역되어 매칭됩니다.',
|
||||
'<b>지도</b>: GPS가 있는 사진이 지도에 표시됩니다. 아래 스트립의 <b>위치 없는 사진을 지도 위로 끌어다 놓으면 위치가 지정</b>됩니다(색인에만 저장 — 원본 파일 EXIF는 변경하지 않음).',
|
||||
'<b>그룹·정화</b>: 비슷한 사진을 자동으로 묶어 보여줘 중복을 골라 정리하도록 돕습니다.'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '12. 포토모자이크',
|
||||
body: [
|
||||
'라이브러리에서 대표 사진을 한 장 고른 뒤 우측 <b>🧩 이 사진으로 모자이크</b>를 누르면, 라이브러리 사진들이 작은 타일이 되어 그 사진을 재현합니다.',
|
||||
'<b>해상도</b>(타일 수) · <b>색 보정</b> · <b>격자선</b>으로 느낌을 조절하고, 모자이크의 <b>타일을 클릭하면</b> 그 자리에 실제로 쓰인 원본 사진을 볼 수 있습니다. [PNG 저장]으로 내보냅니다. (사진이 많을수록 색 매칭이 정교해집니다.)'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '13. 문제 해결',
|
||||
body: [
|
||||
'<b>썸네일/사진이 안 보임</b>: 참조 사진 원본 파일이 이동/삭제되지 않았는지 확인하세요.',
|
||||
'<b>모두 Unsorted로 감</b>: 프로필에 얼굴(참조 사진)이 등록되었는지 확인하세요.',
|
||||
'<b>라이브러리가 비어 있음</b>: 폴더를 추가하고 [색인 시작]을 실행했는지 확인하세요.',
|
||||
'설정(언어/테마)은 메뉴 <b>보기</b>에서 언제든 바꿀 수 있습니다.'
|
||||
]
|
||||
}
|
||||
@@ -91,7 +134,7 @@ const GUIDE: Record<LangT, { title: string; intro: string; sections: Section[] }
|
||||
en: {
|
||||
title: 'AI Photo Organizer — User Guide',
|
||||
intro:
|
||||
'AI Photo Organizer sorts your photos automatically using face recognition and capture date (EXIF), entirely on your own PC with no cloud upload. Follow the steps below.',
|
||||
'AI Photo Organizer sorts your photos automatically using face recognition and capture date (EXIF), entirely on your own PC with no cloud upload. Beyond <b>Organize</b> (auto-sorting), the <b>Library</b> lets you browse, rate and cull photos fast. Follow the steps below.',
|
||||
sections: [
|
||||
{
|
||||
h: '0. Key concepts',
|
||||
@@ -159,10 +202,53 @@ const GUIDE: Record<LangT, { title: string; intro: string; sections: Section[] }
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '8. Troubleshooting',
|
||||
h: '8. Library — indexing & browsing',
|
||||
body: [
|
||||
'The <b>Library</b> tab is separate from Organize: it <b>indexes</b> a folder in place so you can browse, rate and cull quickly.',
|
||||
'In the left <b>Library</b> panel, click [+ Add folder] then [Start indexing] to build thumbnails and metadata. (Originals untouched; thumbnails are cached by the app — non-destructive.)',
|
||||
'Left panels: <b>File explorer · Collections (year/camera/color label) · Filters (type·quality·rating) · Image information</b>. Just <b>hover a photo</b> to see its EXIF in the info panel.',
|
||||
'Use the <b>Size slider</b> (top center) to change thumbnail density (small = whole-folder contact sheet, large = detail).',
|
||||
'<b>Double-click</b> a photo to open the fullscreen viewer (left info + bottom filmstrip + top rating/labels). Move with <b>←/→</b>; <b>Esc</b> returns with your scroll position preserved.',
|
||||
'One-tap <b>quick-filter chips</b> (Today · This week · This year · Best ★4+ · Videos) at the top center.'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '9. Rate · select · cull',
|
||||
body: [
|
||||
'<b>Select</b>: press and sweep across photos to select a range (start on a selected one to deselect). <b>Shift+click</b> for range, <b>Ctrl/⌘+A</b> select all, <b>Esc</b> to clear.',
|
||||
'<b>Rating · color labels</b>: apply from the right panel or the hover buttons on a photo. Select many to apply in bulk.',
|
||||
'<b>Export</b>: copy the chosen photos into one folder with [Export to folder] (originals preserved).',
|
||||
'<b>Delete</b>: removed from view immediately with an <b>"Undo"</b> toast. Click it within <b>5 seconds</b> to restore; otherwise it moves to the Recycle Bin (recoverable).'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '10. Keyboard shortcuts (Library)',
|
||||
body: [
|
||||
'<pre>← → ↑ ↓ move cursor\n1–5 set rating / 0 clears\nP / X pick (green) / reject (red) label\n[ ] cycle color label\nSpace/Enter open fullscreen preview\nDelete trash (undoable)\nShift+click range select\nCtrl/⌘ + A select all · Esc clear</pre>',
|
||||
'Rating/label/delete apply to the <b>whole selection</b> if any, otherwise to the <b>single cursor</b> tile. (Shortcuts are off while typing in a text field.)'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '11. Search · Map · Groups',
|
||||
body: [
|
||||
'<b>Search</b>: find photos by natural language (e.g. "beach", "birthday"). Build the search index once, then use it; Korean is auto-translated for matching.',
|
||||
'<b>Map</b>: geotagged photos appear on the map. <b>Drag a photo without GPS from the strip onto the map</b> to set its location (saved to the index only — original EXIF is not changed).',
|
||||
'<b>Groups</b>: automatically clusters similar photos so you can pick duplicates and clean up.'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '12. Photo mosaic',
|
||||
body: [
|
||||
'Pick one target photo in the Library, then click <b>🧩 Make mosaic</b> on the right — your library photos become tiles that recreate that picture.',
|
||||
'Tune it with <b>Resolution</b> (tile count) · <b>Color blend</b> · <b>Grid lines</b>, and <b>click a tile</b> to see the source photo placed there. Export with [Save PNG]. (More photos = finer color matching.)'
|
||||
]
|
||||
},
|
||||
{
|
||||
h: '13. Troubleshooting',
|
||||
body: [
|
||||
'<b>Thumbnails/photos not showing</b>: make sure the original reference files were not moved or deleted.',
|
||||
'<b>Everything goes to Unsorted</b>: check that the profile actually has reference faces registered.',
|
||||
'<b>Library is empty</b>: make sure you added a folder and ran [Start indexing].',
|
||||
'Language/theme can be changed anytime from the <b>View</b> menu.'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -233,6 +233,14 @@ class IndexDb {
|
||||
)
|
||||
params.push(query.tag)
|
||||
}
|
||||
if (query.dateFrom != null) {
|
||||
conds.push('mtime >= ?')
|
||||
params.push(query.dateFrom)
|
||||
}
|
||||
if (query.dateTo != null) {
|
||||
conds.push('mtime <= ?')
|
||||
params.push(query.dateTo)
|
||||
}
|
||||
return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
|
||||
+62
-17
@@ -4,15 +4,19 @@ import type {
|
||||
FileProcessed,
|
||||
ProgressEvent,
|
||||
Report,
|
||||
ProfileMatch
|
||||
ProfileMatch,
|
||||
Profile,
|
||||
Anniversary,
|
||||
CaptureDate
|
||||
} from '@shared/types'
|
||||
import { IPC } from '@shared/constants'
|
||||
import { scan, countMedia, defaultSkipDirs, mediaKind } from './scanner'
|
||||
import { getCaptureDate, getMtimeDate } from './exif'
|
||||
import { buildTargetPath } from './pathBuilder'
|
||||
import { MOVIE_FOLDER } from '@shared/constants'
|
||||
import { buildTargetPath, buildCollectionPath } from './pathBuilder'
|
||||
import { MOVIE_FOLDER, BIRTHDAY_FOLDER, ANNIVERSARY_FOLDER } from '@shared/constants'
|
||||
import { safeMove, safeCopy } from './fileOps'
|
||||
import { profileStore } from './profileStore'
|
||||
import { settingsStore } from './settingsStore'
|
||||
import { inferenceBridge } from './inferenceBridge'
|
||||
import { Reporter } from './reporter'
|
||||
import { createLimiter } from './concurrency'
|
||||
@@ -50,6 +54,7 @@ class Orchestrator {
|
||||
|
||||
try {
|
||||
const profiles = await profileStore.list() // order asc 정렬됨
|
||||
const anniversaries = settingsStore.current().anniversaries ?? []
|
||||
// 추론 엔진 준비 + 매처 구성
|
||||
await inferenceBridge.whenReady()
|
||||
await inferenceBridge.initMatcher(profiles, req.options)
|
||||
@@ -73,7 +78,7 @@ class Orchestrator {
|
||||
const progress: ProgressEvent = { done, total, current: file }
|
||||
send(IPC.JOB_PROGRESS, progress)
|
||||
|
||||
const result = await this.processFile(req, file, profiles)
|
||||
const result = await this.processFile(req, file, profiles, anniversaries)
|
||||
reporter.record(result)
|
||||
done++
|
||||
send(IPC.JOB_FILE_PROCESSED, result)
|
||||
@@ -96,22 +101,61 @@ class Orchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
/** 파일 1건 처리: 인식 → 날짜 → 이동/복사 */
|
||||
/**
|
||||
* 생일/기념일 컬렉션 폴더로 추가 복사.
|
||||
* @param primaryDest 이미 출력에 기록된(이동/복사된) 파일 — 복사 소스로 사용
|
||||
* @param persons 매칭된 인물(이름+생일). 영상/미검출은 빈 배열 → 기념일만 적용
|
||||
*/
|
||||
private async copyCollections(
|
||||
req: JobRequest,
|
||||
sourceFile: string,
|
||||
primaryDest: string,
|
||||
date: CaptureDate,
|
||||
persons: { name: string; birthday?: string | null }[],
|
||||
anniversaries: Anniversary[]
|
||||
): Promise<string[]> {
|
||||
const mmdd = `${date.month}-${date.day}`
|
||||
const extra: string[] = []
|
||||
// 생일: 매칭 인물의 생일과 같은 날
|
||||
for (const p of persons) {
|
||||
if (p.birthday && p.birthday === mmdd) {
|
||||
const dest = await safeCopy(
|
||||
primaryDest,
|
||||
buildCollectionPath(req.outputRoot, BIRTHDAY_FOLDER, p.name, date.year, sourceFile)
|
||||
)
|
||||
extra.push(dest)
|
||||
}
|
||||
}
|
||||
// 기념일: 같은 날짜의 모든 기념일 (인물 무관)
|
||||
for (const a of anniversaries) {
|
||||
if (a.date && a.date === mmdd) {
|
||||
const dest = await safeCopy(
|
||||
primaryDest,
|
||||
buildCollectionPath(req.outputRoot, ANNIVERSARY_FOLDER, a.label, date.year, sourceFile)
|
||||
)
|
||||
extra.push(dest)
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
/** 파일 1건 처리: 인식 → 날짜 → 이동/복사 (+ 생일/기념일 컬렉션 복사) */
|
||||
private async processFile(
|
||||
req: JobRequest,
|
||||
file: string,
|
||||
profilesOrdered: { id: string; name: string; order: number }[]
|
||||
profiles: Profile[],
|
||||
anniversaries: Anniversary[]
|
||||
): Promise<FileProcessed> {
|
||||
void profilesOrdered
|
||||
try {
|
||||
// 영상은 얼굴인식 없이 날짜 기준으로 Movie 폴더로 이동
|
||||
// 영상은 얼굴인식 없이 날짜 기준으로 Movie 폴더로 이동 (인물 없음 → 기념일만)
|
||||
if (mediaKind(file) === 'video') {
|
||||
const vdate = await getMtimeDate(file)
|
||||
const dest = await safeMove(
|
||||
file,
|
||||
buildTargetPath(req.outputRoot, MOVIE_FOLDER, vdate, file)
|
||||
)
|
||||
return { file, kind: 'movie', targets: [dest], matchedNames: [], date: vdate }
|
||||
const extra = await this.copyCollections(req, file, dest, vdate, [], anniversaries)
|
||||
return { file, kind: 'movie', targets: [dest, ...extra], matchedNames: [], date: vdate }
|
||||
}
|
||||
|
||||
// 얼굴 인식 + 날짜 추출 병렬
|
||||
@@ -120,16 +164,11 @@ class Orchestrator {
|
||||
getCaptureDate(file)
|
||||
])
|
||||
|
||||
// 매칭 인물 없음 → [미정]
|
||||
// 매칭 인물 없음 → [미정] (기념일만 적용)
|
||||
if (!match.matched || match.matched.length === 0) {
|
||||
const dest = await safeMove(file, buildTargetPath(req.outputRoot, null, date, file))
|
||||
return {
|
||||
file,
|
||||
kind: 'unmatched',
|
||||
targets: [dest],
|
||||
matchedNames: [],
|
||||
date
|
||||
}
|
||||
const extra = await this.copyCollections(req, file, dest, date, [], anniversaries)
|
||||
return { file, kind: 'unmatched', targets: [dest, ...extra], matchedNames: [], date }
|
||||
}
|
||||
|
||||
// 등록 순서(order asc) 정렬 → 1순위 이동, 나머지 복사
|
||||
@@ -153,6 +192,12 @@ class Orchestrator {
|
||||
targets.push(copyDest)
|
||||
}
|
||||
|
||||
// 생일/기념일 컬렉션 (매칭 인물의 생일 + 전체 기념일)
|
||||
const bdById = new Map(profiles.map((p) => [p.id, p.birthday]))
|
||||
const persons = ordered.map((m) => ({ name: m.name, birthday: bdById.get(m.profileId) }))
|
||||
const extra = await this.copyCollections(req, file, movedDest, date, persons, anniversaries)
|
||||
targets.push(...extra)
|
||||
|
||||
return {
|
||||
file,
|
||||
kind: 'moved',
|
||||
|
||||
@@ -19,6 +19,25 @@ export function buildTargetPath(
|
||||
return join(outputRoot, folder, date.year, date.month, filename)
|
||||
}
|
||||
|
||||
/** 폴더명으로 안전하지 않은 문자 제거 (라벨에 / : * 등이 들어오는 경우 대비) */
|
||||
function safeFolder(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]/g, '_').trim() || '_'
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬렉션(생일/기념일) 폴더 경로: <출력>/<카테고리>/<하위>/<연도>/<파일명>
|
||||
* 예: 출력/Birthdays/Alex/2024/IMG_0001.jpg, 출력/Anniversaries/결혼기념일/2024/IMG_0001.jpg
|
||||
*/
|
||||
export function buildCollectionPath(
|
||||
outputRoot: string,
|
||||
category: string,
|
||||
sub: string,
|
||||
year: string,
|
||||
sourceFile: string
|
||||
): string {
|
||||
return join(outputRoot, category, safeFolder(sub), year, basename(sourceFile))
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일명 충돌 시 사용할 후보 경로를 생성 (name_1.ext, name_2.ext ...).
|
||||
* @param index 1부터 시작하는 충돌 회피 인덱스
|
||||
|
||||
@@ -52,6 +52,7 @@ class ProfileStore {
|
||||
if (!existing) throw new Error(`프로필을 찾을 수 없음: ${input.id}`)
|
||||
existing.name = input.name
|
||||
existing.order = input.order
|
||||
if (input.birthday !== undefined) existing.birthday = input.birthday
|
||||
await this.persist()
|
||||
return existing
|
||||
}
|
||||
@@ -62,6 +63,7 @@ class ProfileStore {
|
||||
id: cryptoRandomId(),
|
||||
name: input.name,
|
||||
order: input.order,
|
||||
birthday: input.birthday ?? null,
|
||||
referenceImages: [],
|
||||
descriptors: []
|
||||
}
|
||||
|
||||
+11
-2
@@ -5,7 +5,9 @@ import {
|
||||
SUPPORTED_VIDEO_EXTENSIONS,
|
||||
LOG_FOLDER,
|
||||
UNMATCHED_FOLDER,
|
||||
MOVIE_FOLDER
|
||||
MOVIE_FOLDER,
|
||||
BIRTHDAY_FOLDER,
|
||||
ANNIVERSARY_FOLDER
|
||||
} from '@shared/constants'
|
||||
|
||||
const IMAGE_EXT_SET = new Set<string>(SUPPORTED_EXTENSIONS)
|
||||
@@ -73,5 +75,12 @@ export async function countMedia(
|
||||
|
||||
/** 출력물 재처리 방지를 위한 기본 제외 디렉터리 집합 */
|
||||
export function defaultSkipDirs(profileNames: string[]): Set<string> {
|
||||
return new Set<string>([LOG_FOLDER, UNMATCHED_FOLDER, MOVIE_FOLDER, ...profileNames])
|
||||
return new Set<string>([
|
||||
LOG_FOLDER,
|
||||
UNMATCHED_FOLDER,
|
||||
MOVIE_FOLDER,
|
||||
BIRTHDAY_FOLDER,
|
||||
ANNIVERSARY_FOLDER,
|
||||
...profileNames
|
||||
])
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ const DEFAULTS: Settings = {
|
||||
theme: 'dark', // 기본 다크모드
|
||||
onboarded: false,
|
||||
qualityThresholds: { ...QUALITY_THRESHOLDS },
|
||||
easyMode: false
|
||||
easyMode: false,
|
||||
anniversaries: []
|
||||
}
|
||||
|
||||
/** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useStore, wireEvents } from './store'
|
||||
import { useT } from './i18n'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
import { ProfileManager } from './components/ProfileManager'
|
||||
import { AnniversaryManager } from './components/AnniversaryManager'
|
||||
import { FolderPicker } from './components/FolderPicker'
|
||||
import { RunControl } from './components/RunControl'
|
||||
import { ProgressView } from './components/ProgressView'
|
||||
@@ -13,6 +14,7 @@ import { SearchView } from './components/SearchView'
|
||||
import { GroupsView } from './components/GroupsView'
|
||||
import { MapView } from './components/MapView'
|
||||
import { FileExplorer } from './components/FileExplorer'
|
||||
import { Overlays } from './overlays'
|
||||
import type { AppView } from './store'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
@@ -107,6 +109,7 @@ export default function App(): JSX.Element {
|
||||
{/* 설정 패널 (자체 스크롤) */}
|
||||
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
|
||||
<ProfileManager />
|
||||
<AnniversaryManager />
|
||||
<FolderPicker />
|
||||
<RunControl />
|
||||
</section>
|
||||
@@ -137,6 +140,9 @@ export default function App(): JSX.Element {
|
||||
<GroupsView />
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* 토스트 / 확인 모달 호스트 (전역) */}
|
||||
<Overlays />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import type { Anniversary } from '@shared/types'
|
||||
|
||||
/** 기념일(앱 전체 공통) 등록/삭제. 해당 날짜 사진을 Anniversaries/이름/연도 폴더에 모음 */
|
||||
export function AnniversaryManager(): JSX.Element {
|
||||
const t = useT()
|
||||
const anniversaries = useStore((s) => s.anniversaries)
|
||||
const updateSettings = useStore((s) => s.updateSettings)
|
||||
const [label, setLabel] = useState('')
|
||||
const [date, setDate] = useState('') // YYYY-MM-DD
|
||||
|
||||
const add = async () => {
|
||||
const l = label.trim()
|
||||
if (!l || !date) return
|
||||
const item: Anniversary = { id: crypto.randomUUID(), label: l, date: date.slice(5) } // MM-DD
|
||||
await updateSettings({ anniversaries: [...anniversaries, item] })
|
||||
setLabel('')
|
||||
setDate('')
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
await updateSettings({ anniversaries: anniversaries.filter((a) => a.id !== id) })
|
||||
}
|
||||
|
||||
// MM-DD → 보기용 "MM.DD"
|
||||
const fmt = (mmdd: string): string => mmdd.replace('-', '.')
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
|
||||
<h2 className="font-semibold dark:text-slate-100 mb-1">🎉 {t('anniv.section')}</h2>
|
||||
<p className="text-[11px] text-slate-400 mb-3">{t('anniv.hint')}</p>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
className="flex-1 border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder={t('anniv.labelPlaceholder')}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-2 py-2 text-sm"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
|
||||
onClick={add}
|
||||
disabled={!label.trim() || !date}
|
||||
>
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{anniversaries.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">{t('anniv.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{anniversaries.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 pl-3 pr-2 py-1 rounded-full border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/50"
|
||||
>
|
||||
<span className="text-xs font-medium dark:text-slate-200">{a.label}</span>
|
||||
<span className="text-[11px] text-slate-400 tabular-nums">{fmt(a.date)}</span>
|
||||
<button
|
||||
className="text-slate-400 hover:text-red-500 text-xs leading-none"
|
||||
onClick={() => remove(a.id)}
|
||||
title="삭제"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import { toast, confirm, promptText } from '../overlays'
|
||||
import type { FsEntry } from '@shared/types'
|
||||
|
||||
/** 경로의 부모 디렉터리 (Windows/POSIX 겸용) */
|
||||
@@ -56,30 +57,30 @@ export function FileExplorer(): JSX.Element {
|
||||
const copyPath = (p: string) => void navigator.clipboard?.writeText(p)
|
||||
|
||||
const newFolder = async (parent: string) => {
|
||||
const name = window.prompt(t('explorer.newFolderPrompt'))
|
||||
const name = await promptText(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 }))
|
||||
toast(t('explorer.opFailed', { msg: (e as Error).message }), { tone: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFolder = async (path: string) => {
|
||||
if (!window.confirm(t('explorer.confirmDelete', { name: baseOf(path) }))) return
|
||||
if (!(await confirm(t('explorer.confirmDelete', { name: baseOf(path) }), { danger: true, confirmLabel: t('explorer.delete') }))) 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 }))
|
||||
toast(t('explorer.opFailed', { msg: (e as Error).message }), { tone: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const moveFolder = async (src: string, destDir: string) => {
|
||||
if (!window.confirm(t('explorer.confirmMove', { src: baseOf(src), dest: baseOf(destDir) }))) return
|
||||
if (!(await confirm(t('explorer.confirmMove', { src: baseOf(src), dest: baseOf(destDir) })))) return
|
||||
try {
|
||||
await window.api.fs.move(src, destDir)
|
||||
if (selected === src) setSelected(null)
|
||||
@@ -87,7 +88,7 @@ export function FileExplorer(): JSX.Element {
|
||||
await reload(destDir)
|
||||
setExpanded((prev) => new Set(prev).add(destDir))
|
||||
} catch (e) {
|
||||
window.alert(t('explorer.opFailed', { msg: (e as Error).message }))
|
||||
toast(t('explorer.opFailed', { msg: (e as Error).message }), { tone: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useT } from '../i18n'
|
||||
import { toast, confirm } from '../overlays'
|
||||
import { thumbUrl, baseName } from '../media'
|
||||
import type { AssetGroup, IndexedAsset } from '@shared/types'
|
||||
|
||||
@@ -45,7 +46,7 @@ export function GroupsView(): JSX.Element {
|
||||
|
||||
const trash = async () => {
|
||||
if (selected.size === 0) return
|
||||
if (!window.confirm(t('groups.confirmTrash', { n: selected.size }))) return
|
||||
if (!(await confirm(t('groups.confirmTrash', { n: selected.size }), { danger: true }))) return
|
||||
setTrashing(true)
|
||||
try {
|
||||
const ids = [...selected]
|
||||
@@ -61,7 +62,7 @@ export function GroupsView(): JSX.Element {
|
||||
.filter((g) => g.members.length > 1)
|
||||
)
|
||||
setSelected(new Set())
|
||||
window.alert(t('groups.trashed', { n }))
|
||||
toast(t('groups.trashed', { n }), { tone: 'success' })
|
||||
} finally {
|
||||
setTrashing(false)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactNode, MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useT } from '../i18n'
|
||||
import { thumbUrl, mediaUrl, baseName } from '../media'
|
||||
import { MosaicView } from './MosaicView'
|
||||
import { toast } from '../overlays'
|
||||
import type {
|
||||
IndexedAsset,
|
||||
QualityFilter,
|
||||
@@ -75,6 +76,13 @@ export function LibraryView(): JSX.Element {
|
||||
const [tags, setTags] = useState<TagItem[]>([])
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 스마트 퀵필터: 시간 칩(오늘/이번주/올해)이 설정하는 mtime 범위 + 활성 칩 표시
|
||||
const [dateRange, setDateRange] = useState<{ from: number; to: number } | null>(null)
|
||||
const [timeChip, setTimeChip] = useState<'today' | 'week' | 'year' | null>(null)
|
||||
// 키보드 컬링 커서(그리드 내 인덱스) + 선택 앵커(Shift 범위선택용)
|
||||
const [cursor, setCursor] = useState(-1)
|
||||
const anchorRef = useRef<number>(-1)
|
||||
const gridScrollRef = useRef<HTMLDivElement>(null)
|
||||
// 그리드 밀도(열 수). 작을수록 큰 썸네일, 클수록 고밀도 컨택트시트
|
||||
const [columns, setColumns] = useState(6)
|
||||
// 전체화면 뷰어(라이트박스) 대상. null이면 닫힘
|
||||
@@ -97,8 +105,19 @@ export function LibraryView(): JSX.Element {
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const query = useMemo<AssetQuery>(
|
||||
() => ({ filter, kind, ratingMin, year, camera, label: labelFilter, folder, tag: tagFilter }),
|
||||
[filter, kind, ratingMin, year, camera, labelFilter, folder, tagFilter]
|
||||
() => ({
|
||||
filter,
|
||||
kind,
|
||||
ratingMin,
|
||||
year,
|
||||
camera,
|
||||
label: labelFilter,
|
||||
folder,
|
||||
tag: tagFilter,
|
||||
dateFrom: dateRange?.from ?? null,
|
||||
dateTo: dateRange?.to ?? null
|
||||
}),
|
||||
[filter, kind, ratingMin, year, camera, labelFilter, folder, tagFilter, dateRange]
|
||||
)
|
||||
|
||||
const loadAssets = useCallback(async (offset: number, q: AssetQuery) => {
|
||||
@@ -225,16 +244,36 @@ export function LibraryView(): JSX.Element {
|
||||
return () => window.removeEventListener('mouseup', onUp)
|
||||
}, [])
|
||||
|
||||
const onTileClick = (a: IndexedAsset) => {
|
||||
// 범위 선택(Shift+클릭): 앵커~현재 사이를 모두 선택
|
||||
const selectRange = (from: number, to: number) => {
|
||||
const lo = Math.min(from, to)
|
||||
const hi = Math.max(from, to)
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const id = assets[i]?.id
|
||||
if (id != null) next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const onTileClick = (a: IndexedAsset, idx: number, e: ReactMouseEvent) => {
|
||||
if (suppressClickRef.current) {
|
||||
// 방금 드래그 선택을 마침 → 뒤따르는 click 토글 무시
|
||||
suppressClickRef.current = false
|
||||
return
|
||||
}
|
||||
setFocused(a)
|
||||
setCursor(idx)
|
||||
// Shift+클릭 → 앵커부터 범위 선택 (토글 아님)
|
||||
if (e.shiftKey && anchorRef.current >= 0) {
|
||||
selectRange(anchorRef.current, idx)
|
||||
return
|
||||
}
|
||||
anchorRef.current = idx
|
||||
if (clickTimer.current != null) return // 더블클릭 진행 중 — 두 번째 클릭 무시
|
||||
clickTimer.current = window.setTimeout(() => {
|
||||
clickTimer.current = null
|
||||
setFocused(a)
|
||||
toggleSelect(a)
|
||||
}, 200)
|
||||
}
|
||||
@@ -279,6 +318,163 @@ export function LibraryView(): JSX.Element {
|
||||
const targetIds = (): number[] =>
|
||||
selected.size > 0 ? [...selected] : focused?.id != null ? [focused.id] : []
|
||||
|
||||
// 스마트 시간 칩(오늘/이번 주/올해) — mtime 범위로 필터
|
||||
const applyTimeChip = (which: 'today' | 'week' | 'year') => {
|
||||
if (timeChip === which) {
|
||||
setTimeChip(null)
|
||||
setDateRange(null)
|
||||
return
|
||||
}
|
||||
const now = new Date()
|
||||
const to = now.getTime()
|
||||
const start = new Date(now)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
if (which === 'week') start.setDate(start.getDate() - 6)
|
||||
else if (which === 'year') {
|
||||
start.setMonth(0, 1)
|
||||
}
|
||||
setTimeChip(which)
|
||||
setDateRange({ from: start.getTime(), to })
|
||||
}
|
||||
|
||||
// 키보드 컬링 대상: 선택이 있으면 선택 전체, 없으면 커서(또는 포커스) 1장
|
||||
const cullTargets = useCallback((): IndexedAsset[] => {
|
||||
if (selected.size > 0) return assets.filter((a) => a.id != null && selected.has(a.id))
|
||||
const a = cursor >= 0 ? assets[cursor] : focused
|
||||
return a ? [a] : []
|
||||
}, [selected, assets, cursor, focused])
|
||||
const applyRating = useCallback(
|
||||
async (rating: number) => {
|
||||
for (const a of cullTargets()) {
|
||||
if (a.id == null) continue
|
||||
await window.api.index.setRating(a.id, rating)
|
||||
patchAsset(a.id, { rating })
|
||||
}
|
||||
},
|
||||
[cullTargets]
|
||||
)
|
||||
const applyLabelSet = useCallback(
|
||||
async (label: ColorLabel) => {
|
||||
for (const a of cullTargets()) {
|
||||
if (a.id == null) continue
|
||||
await window.api.index.setLabel(a.id, label)
|
||||
patchAsset(a.id, { label })
|
||||
}
|
||||
void refreshFacets()
|
||||
},
|
||||
[cullTargets, refreshFacets]
|
||||
)
|
||||
const cycleLabel = useCallback(
|
||||
async (dir: 1 | -1) => {
|
||||
const order: Exclude<ColorLabel, null>[] = LABEL_COLORS.map((c) => c.id)
|
||||
for (const a of cullTargets()) {
|
||||
if (a.id == null) continue
|
||||
const i = a.label ? order.indexOf(a.label) : -1
|
||||
const ni = a.label == null ? (dir > 0 ? 0 : order.length - 1) : i + dir
|
||||
const next: ColorLabel = ni < 0 || ni >= order.length ? null : order[ni]
|
||||
await window.api.index.setLabel(a.id, next)
|
||||
patchAsset(a.id, { label: next })
|
||||
}
|
||||
void refreshFacets()
|
||||
},
|
||||
[cullTargets, refreshFacets]
|
||||
)
|
||||
|
||||
// 삭제: 즉시 UI에서 빼고 5초 뒤 실제 휴지통 이동 — 그 사이 토스트의 '실행취소'로 되돌림 (Gmail식)
|
||||
const trashIds = useCallback(
|
||||
(ids: number[]) => {
|
||||
if (ids.length === 0) return
|
||||
const removed = new Set(ids)
|
||||
setAssets((prev) => prev.filter((x) => x.id == null || !removed.has(x.id)))
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
ids.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
let undone = false
|
||||
const timer = window.setTimeout(() => {
|
||||
if (undone) return
|
||||
void window.api.groups.trash(ids).then(() => refreshFacets())
|
||||
}, 5000)
|
||||
toast(t('sel.trashedSoft', { n: ids.length }), {
|
||||
action: {
|
||||
label: t('action.undo'),
|
||||
onClick: () => {
|
||||
undone = true
|
||||
clearTimeout(timer)
|
||||
void loadAssets(0, query) // 아직 DB엔 남아있으므로 다시 불러오면 복원
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[refreshFacets, loadAssets, query, t]
|
||||
)
|
||||
|
||||
// 그리드 전역 키보드 컬링 (뷰어/모자이크 열림 시·입력창 포커스 시 비활성)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (viewer || mosaicTarget) return
|
||||
const tag = (e.target as HTMLElement | null)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (e.key === 'a' || e.key === 'A') {
|
||||
e.preventDefault()
|
||||
void selectAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
const n = assets.length
|
||||
if (n === 0) return
|
||||
const cur = cursor < 0 ? 0 : cursor
|
||||
const moveTo = (idx: number) => {
|
||||
const c = Math.max(0, Math.min(n - 1, idx))
|
||||
setCursor(c)
|
||||
setFocused(assets[c])
|
||||
anchorRef.current = c
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'ArrowRight': e.preventDefault(); moveTo(cur + 1); break
|
||||
case 'ArrowLeft': e.preventDefault(); moveTo(cur - 1); break
|
||||
case 'ArrowDown': e.preventDefault(); moveTo(cur + columns); break
|
||||
case 'ArrowUp': e.preventDefault(); moveTo(cur - columns); break
|
||||
case 'Escape': clearSelection(); break
|
||||
case ' ':
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const a = assets[cur]
|
||||
if (a) { setFocused(a); setViewer(a) }
|
||||
break
|
||||
}
|
||||
case 'Delete':
|
||||
case 'Backspace': {
|
||||
e.preventDefault()
|
||||
const ids = selected.size > 0 ? [...selected] : assets[cur]?.id != null ? [assets[cur].id!] : []
|
||||
trashIds(ids)
|
||||
break
|
||||
}
|
||||
case 'p': case 'P': e.preventDefault(); void applyLabelSet('green'); break
|
||||
case 'x': case 'X': e.preventDefault(); void applyLabelSet('red'); break
|
||||
case '[': e.preventDefault(); void cycleLabel(-1); break
|
||||
case ']': e.preventDefault(); void cycleLabel(1); break
|
||||
default:
|
||||
if (e.key >= '0' && e.key <= '5') {
|
||||
e.preventDefault()
|
||||
void applyRating(Number(e.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewer, mosaicTarget, assets, cursor, columns, selected, applyRating, applyLabelSet, cycleLabel])
|
||||
|
||||
// 커서 이동 시 해당 타일을 보이도록 스크롤
|
||||
useEffect(() => {
|
||||
if (cursor < 0) return
|
||||
const el = gridScrollRef.current?.querySelector(`[data-idx="${cursor}"]`)
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [cursor])
|
||||
|
||||
// 일괄 평가 (선택 대상)
|
||||
const bulkRate = async (rating: number) => {
|
||||
const ids = targetIds()
|
||||
@@ -292,34 +488,20 @@ export function LibraryView(): JSX.Element {
|
||||
void refreshFacets()
|
||||
}
|
||||
|
||||
// 내보내기 / 삭제
|
||||
// 내보내기
|
||||
const exportSelected = async () => {
|
||||
if (selected.size === 0) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await window.api.index.export([...selected])
|
||||
if (r) window.alert(t('sel.exported', { n: r.count, dest: r.dest }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const deleteSelected = async () => {
|
||||
if (selected.size === 0) return
|
||||
if (!window.confirm(t('sel.confirmDelete', { n: selected.size }))) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const ids = [...selected]
|
||||
const n = await window.api.groups.trash(ids)
|
||||
const removed = new Set(ids)
|
||||
setAssets((prev) => prev.filter((x) => x.id == null || !removed.has(x.id)))
|
||||
clearSelection()
|
||||
void refreshFacets()
|
||||
window.alert(t('sel.deleted', { n }))
|
||||
if (r) toast(t('sel.exportedShort', { n: r.count, dest: baseName(r.dest) }), { tone: 'success' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSelected = () => trashIds([...selected])
|
||||
|
||||
// 태깅 / 메타데이터
|
||||
const attachTag = async () => {
|
||||
const name = tagInput.trim()
|
||||
@@ -339,7 +521,7 @@ export function LibraryView(): JSX.Element {
|
||||
const saveMeta = async () => {
|
||||
if (focused?.id == null) return
|
||||
await window.api.meta.set(focused.id, meta)
|
||||
window.alert(t('meta.saved'))
|
||||
toast(t('meta.saved'), { tone: 'success' })
|
||||
}
|
||||
|
||||
const running = indexPhase === 'running'
|
||||
@@ -531,10 +713,18 @@ export function LibraryView(): JSX.Element {
|
||||
|
||||
{/* ===== 중앙: 그리드 ===== */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<div className="h-9 px-3 flex items-center gap-3 border-b border-slate-200 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 shrink-0">
|
||||
<span>{t('lib.grid')}</span>
|
||||
<span>· {assets.length}</span>
|
||||
{selected.size > 0 && <span className="text-brand">· {t('sel.count', { n: selected.size })}</span>}
|
||||
<div className="h-9 px-3 flex items-center gap-2 border-b border-slate-200 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 shrink-0">
|
||||
<span className="shrink-0">{t('lib.grid')}</span>
|
||||
<span className="shrink-0">· {assets.length}</span>
|
||||
{selected.size > 0 && <span className="text-brand shrink-0">· {t('sel.count', { n: selected.size })}</span>}
|
||||
{/* 스마트 퀵필터 칩 */}
|
||||
<div className="flex items-center gap-1 ml-1 overflow-x-auto">
|
||||
<Chip active={timeChip === 'today'} onClick={() => applyTimeChip('today')}>{t('chip.today')}</Chip>
|
||||
<Chip active={timeChip === 'week'} onClick={() => applyTimeChip('week')}>{t('chip.week')}</Chip>
|
||||
<Chip active={timeChip === 'year'} onClick={() => applyTimeChip('year')}>{t('chip.year')}</Chip>
|
||||
<Chip active={ratingMin >= 4} onClick={() => setRatingMin(ratingMin >= 4 ? 0 : 4)}>{t('chip.best')}</Chip>
|
||||
<Chip active={kind === 'video'} onClick={() => setKind(kind === 'video' ? 'all' : 'video')}>{t('chip.video')}</Chip>
|
||||
</div>
|
||||
{/* 밀도(썸네일 크기) 슬라이더 — 작게=컨택트시트, 크게=상세 */}
|
||||
<label
|
||||
className="ml-auto flex items-center gap-1.5 select-none"
|
||||
@@ -553,7 +743,7 @@ export function LibraryView(): JSX.Element {
|
||||
<span className="text-[10px] text-slate-400 w-6 tabular-nums">{columns}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div ref={gridScrollRef} className="flex-1 overflow-y-auto p-2">
|
||||
{assets.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 p-3">{t('lib.gridEmpty')}</p>
|
||||
) : (
|
||||
@@ -570,12 +760,14 @@ export function LibraryView(): JSX.Element {
|
||||
{assets.map((a, i) => (
|
||||
<AssetTile
|
||||
key={a.contentHash}
|
||||
index={i}
|
||||
asset={a}
|
||||
flagLabel={a.flag ? t(`flag.${a.flag}`) : ''}
|
||||
selected={a.id != null && selected.has(a.id)}
|
||||
focused={focused?.contentHash === a.contentHash}
|
||||
cursor={cursor === i}
|
||||
compact={columns >= 9}
|
||||
onClickTile={() => onTileClick(a)}
|
||||
onClickTile={(e) => onTileClick(a, i, e)}
|
||||
onDoubleTile={() => onTileDouble(a)}
|
||||
onHover={() => {
|
||||
setHovered(a)
|
||||
@@ -587,6 +779,9 @@ export function LibraryView(): JSX.Element {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 dark:text-slate-500 text-center mt-3 select-none">
|
||||
{t('kbd.hint')}
|
||||
</p>
|
||||
{hasMore && (
|
||||
<div className="text-center mt-3">
|
||||
<button
|
||||
@@ -956,6 +1151,22 @@ function FacetList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 스마트 퀵필터 칩 버튼 */
|
||||
function Chip(props: { active: boolean; onClick: () => void; children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
onClick={props.onClick}
|
||||
className={`whitespace-nowrap rounded-full px-2.5 py-0.5 text-[11px] border transition-colors ${
|
||||
props.active
|
||||
? 'border-brand bg-brand text-white'
|
||||
: 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand'
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterLabel(props: { children: ReactNode; className?: string }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
@@ -1059,19 +1270,21 @@ function ThresholdSlider(props: {
|
||||
}
|
||||
|
||||
function AssetTile(props: {
|
||||
index: number
|
||||
asset: IndexedAsset
|
||||
flagLabel: string
|
||||
selected: boolean
|
||||
focused: boolean
|
||||
cursor: boolean
|
||||
compact: boolean
|
||||
onClickTile: () => void
|
||||
onClickTile: (e: ReactMouseEvent) => void
|
||||
onDoubleTile: () => void
|
||||
onHover: () => void
|
||||
onMouseDownTile: () => void
|
||||
onRate: (rating: number) => void
|
||||
onLabel: (label: Exclude<ColorLabel, null>) => void
|
||||
}): JSX.Element {
|
||||
const { asset: a, selected, focused, compact } = props
|
||||
const { asset: a, selected, focused, cursor, compact } = props
|
||||
const isVideo = VIDEO_EXTS.includes(a.ext)
|
||||
const labelColor = a.label ? LABEL_COLORS.find((c) => c.id === a.label)?.cls : null
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation()
|
||||
@@ -1080,15 +1293,19 @@ function AssetTile(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-idx={props.index}
|
||||
className={`relative aspect-square overflow-hidden bg-slate-100 dark:bg-slate-700 group cursor-pointer ${
|
||||
compact ? '' : 'rounded-sm'
|
||||
} ${
|
||||
// ring은 box-shadow 기반 → 레이아웃에 영향 없이 타일이 맞붙은 채로 강조
|
||||
// ring은 box-shadow 기반 → 레이아웃에 영향 없이 타일이 맞붙은 채로 강조.
|
||||
// 키보드 커서는 흰색 링으로 선택/포커스와 구분.
|
||||
selected
|
||||
? 'ring-2 ring-brand ring-inset z-10'
|
||||
: focused
|
||||
? 'ring-1 ring-slate-400 ring-inset z-10'
|
||||
: ''
|
||||
: cursor
|
||||
? 'ring-2 ring-white ring-inset z-10'
|
||||
: focused
|
||||
? 'ring-1 ring-slate-400 ring-inset z-10'
|
||||
: ''
|
||||
}`}
|
||||
title={a.path}
|
||||
draggable={false}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function ProfileManager(): JSX.Element {
|
||||
const profiles = useStore((s) => s.profiles)
|
||||
const refreshProfiles = useStore((s) => s.refreshProfiles)
|
||||
const [name, setName] = useState('')
|
||||
const [birthday, setBirthday] = useState('') // YYYY-MM-DD (date input). MM-DD만 저장
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
@@ -70,8 +71,13 @@ export function ProfileManager(): JSX.Element {
|
||||
if (!trimmed) return
|
||||
setError(null)
|
||||
try {
|
||||
await window.api.profiles.upsert({ name: trimmed, order: profiles.length })
|
||||
await window.api.profiles.upsert({
|
||||
name: trimmed,
|
||||
order: profiles.length,
|
||||
birthday: birthday ? birthday.slice(5) : null // "YYYY-MM-DD" → "MM-DD"
|
||||
})
|
||||
setName('')
|
||||
setBirthday('')
|
||||
await refreshProfiles()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
@@ -163,7 +169,7 @@ export function ProfileManager(): JSX.Element {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
className="flex-1 border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder={t('profile.namePlaceholder')}
|
||||
@@ -172,6 +178,14 @@ export function ProfileManager(): JSX.Element {
|
||||
onKeyDown={(e) => e.key === 'Enter' && addProfile()}
|
||||
disabled={profiles.length >= MAX_PROFILES}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className="border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded-lg px-2 py-2 text-sm"
|
||||
title={t('profile.birthday')}
|
||||
value={birthday}
|
||||
onChange={(e) => setBirthday(e.target.value)}
|
||||
disabled={profiles.length >= MAX_PROFILES}
|
||||
/>
|
||||
<button
|
||||
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
|
||||
onClick={addProfile}
|
||||
@@ -180,6 +194,7 @@ export function ProfileManager(): JSX.Element {
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mb-3">🎂 {t('profile.birthday')} (선택) · {t('profile.birthdayHint', { name: name.trim() || 'Alex' })}</p>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400 mb-2">{error}</p>}
|
||||
|
||||
@@ -275,6 +290,10 @@ function ProfileCard(props: {
|
||||
await window.api.profiles.remove(p.id)
|
||||
await props.onRefresh()
|
||||
}
|
||||
const updateBirthday = async (mmdd: string | null) => {
|
||||
await window.api.profiles.upsert({ id: p.id, name: p.name, order: p.order, birthday: mmdd })
|
||||
await props.onRefresh()
|
||||
}
|
||||
const removeReference = async (imagePath: string) => {
|
||||
await window.api.profiles.removeReference(p.id, imagePath)
|
||||
await props.onRefresh()
|
||||
@@ -327,6 +346,28 @@ function ProfileCard(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 생일(월·일) */}
|
||||
<div className="flex items-center gap-2 mb-2 text-xs">
|
||||
<span className="text-slate-500 dark:text-slate-400">🎂 {t('profile.birthday')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className="border border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 rounded px-2 py-1 text-xs"
|
||||
value={p.birthday ? `2000-${p.birthday}` : ''}
|
||||
onChange={(e) => updateBirthday(e.target.value ? e.target.value.slice(5) : null)}
|
||||
/>
|
||||
{p.birthday ? (
|
||||
<button
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
onClick={() => updateBirthday(null)}
|
||||
title={t('profile.birthdayNone')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-400">{t('profile.birthdayNone')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 참조 이미지 썸네일 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{p.referenceImages.map((img) => (
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/* ===================== Toast (with optional Undo action) ===================== */
|
||||
|
||||
export type ToastTone = 'info' | 'success' | 'error'
|
||||
export type ToastAction = { label: string; onClick: () => void }
|
||||
export type ToastItem = { id: number; message: string; tone: ToastTone; action?: ToastAction }
|
||||
|
||||
type ToastState = {
|
||||
toasts: ToastItem[]
|
||||
push: (
|
||||
message: string,
|
||||
opts?: { tone?: ToastTone; action?: ToastAction; duration?: number }
|
||||
) => number
|
||||
dismiss: (id: number) => void
|
||||
}
|
||||
|
||||
let seq = 1
|
||||
const useToastStore = create<ToastState>((set, get) => ({
|
||||
toasts: [],
|
||||
push: (message, opts) => {
|
||||
const id = seq++
|
||||
set((s) => ({
|
||||
toasts: [...s.toasts, { id, message, tone: opts?.tone ?? 'info', action: opts?.action }]
|
||||
}))
|
||||
const duration = opts?.duration ?? (opts?.action ? 6000 : 3200)
|
||||
if (duration > 0) window.setTimeout(() => get().dismiss(id), duration)
|
||||
return id
|
||||
},
|
||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) }))
|
||||
}))
|
||||
|
||||
/** 어디서든 호출 가능한 토스트 (훅 아님). action을 주면 Undo 등 버튼이 붙는다. */
|
||||
export function toast(
|
||||
message: string,
|
||||
opts?: { tone?: ToastTone; action?: ToastAction; duration?: number }
|
||||
): number {
|
||||
return useToastStore.getState().push(message, opts)
|
||||
}
|
||||
|
||||
/* ===================== Confirm (promise-based, replaces window.confirm) ===================== */
|
||||
|
||||
type ConfirmReq = {
|
||||
id: number
|
||||
message: string
|
||||
confirmLabel: string
|
||||
danger: boolean
|
||||
resolve: (ok: boolean) => void
|
||||
}
|
||||
type ConfirmState = {
|
||||
current: ConfirmReq | null
|
||||
ask: (message: string, opts?: { confirmLabel?: string; danger?: boolean }) => Promise<boolean>
|
||||
answer: (ok: boolean) => void
|
||||
}
|
||||
|
||||
const useConfirmStore = create<ConfirmState>((set, get) => ({
|
||||
current: null,
|
||||
ask: (message, opts) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
set({
|
||||
current: {
|
||||
id: seq++,
|
||||
message,
|
||||
confirmLabel: opts?.confirmLabel ?? '확인',
|
||||
danger: opts?.danger ?? false,
|
||||
resolve
|
||||
}
|
||||
})
|
||||
}),
|
||||
answer: (ok) => {
|
||||
const cur = get().current
|
||||
if (cur) cur.resolve(ok)
|
||||
set({ current: null })
|
||||
}
|
||||
}))
|
||||
|
||||
/** await confirm('정말 삭제할까요?', { danger:true }) → boolean */
|
||||
export function confirm(
|
||||
message: string,
|
||||
opts?: { confirmLabel?: string; danger?: boolean }
|
||||
): Promise<boolean> {
|
||||
return useConfirmStore.getState().ask(message, opts)
|
||||
}
|
||||
|
||||
/* ===================== Prompt (text input, replaces window.prompt) ===================== */
|
||||
|
||||
type PromptReq = {
|
||||
id: number
|
||||
message: string
|
||||
value: string
|
||||
placeholder: string
|
||||
resolve: (value: string | null) => void
|
||||
}
|
||||
type PromptState = {
|
||||
current: PromptReq | null
|
||||
ask: (message: string, opts?: { initial?: string; placeholder?: string }) => Promise<string | null>
|
||||
setValue: (v: string) => void
|
||||
answer: (value: string | null) => void
|
||||
}
|
||||
|
||||
const usePromptStore = create<PromptState>((set, get) => ({
|
||||
current: null,
|
||||
ask: (message, opts) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
set({
|
||||
current: {
|
||||
id: seq++,
|
||||
message,
|
||||
value: opts?.initial ?? '',
|
||||
placeholder: opts?.placeholder ?? '',
|
||||
resolve
|
||||
}
|
||||
})
|
||||
}),
|
||||
setValue: (v) => set((s) => (s.current ? { current: { ...s.current, value: v } } : s)),
|
||||
answer: (value) => {
|
||||
const cur = get().current
|
||||
if (cur) cur.resolve(value)
|
||||
set({ current: null })
|
||||
}
|
||||
}))
|
||||
|
||||
/** await promptText('새 폴더 이름:') → string | null */
|
||||
export function promptText(
|
||||
message: string,
|
||||
opts?: { initial?: string; placeholder?: string }
|
||||
): Promise<string | null> {
|
||||
return usePromptStore.getState().ask(message, opts)
|
||||
}
|
||||
|
||||
/* ===================== Host component (mount once in App) ===================== */
|
||||
|
||||
const TONE_CLS: Record<ToastTone, string> = {
|
||||
info: 'border-slate-600',
|
||||
success: 'border-emerald-500/60',
|
||||
error: 'border-red-500/60'
|
||||
}
|
||||
|
||||
export function Overlays(): JSX.Element {
|
||||
const toasts = useToastStore((s) => s.toasts)
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
const confirmReq = useConfirmStore((s) => s.current)
|
||||
const answer = useConfirmStore((s) => s.answer)
|
||||
const promptReq = usePromptStore((s) => s.current)
|
||||
const setPromptValue = usePromptStore((s) => s.setValue)
|
||||
const promptAnswer = usePromptStore((s) => s.answer)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 토스트 스택 (하단 중앙) */}
|
||||
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 pointer-events-none">
|
||||
{toasts.map((tt) => (
|
||||
<div
|
||||
key={tt.id}
|
||||
className={`pointer-events-auto flex items-center gap-3 bg-neutral-800/95 backdrop-blur text-slate-100 text-sm rounded-lg pl-4 pr-2 py-2 shadow-2xl border ${TONE_CLS[tt.tone]} animate-[fadeIn_120ms_ease-out]`}
|
||||
>
|
||||
<span>{tt.message}</span>
|
||||
{tt.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
tt.action!.onClick()
|
||||
dismiss(tt.id)
|
||||
}}
|
||||
className="text-brand font-semibold hover:text-brand-dark px-2 py-1 rounded hover:bg-white/5"
|
||||
>
|
||||
{tt.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => dismiss(tt.id)}
|
||||
className="text-slate-400 hover:text-white px-1.5 leading-none"
|
||||
aria-label="dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 확인 모달 */}
|
||||
{confirmReq && (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] bg-black/50 flex items-center justify-center p-4"
|
||||
onClick={() => answer(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl border border-slate-200 dark:border-neutral-700 max-w-sm w-full p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-200 whitespace-pre-line mb-4">
|
||||
{confirmReq.message}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => answer(false)}
|
||||
className="text-sm px-3 py-1.5 rounded border border-slate-300 dark:border-neutral-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
autoFocus
|
||||
onClick={() => answer(true)}
|
||||
className={`text-sm px-3 py-1.5 rounded text-white font-medium ${
|
||||
confirmReq.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-brand hover:bg-brand-dark'
|
||||
}`}
|
||||
>
|
||||
{confirmReq.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 입력 모달 */}
|
||||
{promptReq && (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] bg-black/50 flex items-center justify-center p-4"
|
||||
onClick={() => promptAnswer(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-neutral-800 rounded-xl shadow-2xl border border-slate-200 dark:border-neutral-700 max-w-sm w-full p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-200 mb-3">{promptReq.message}</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={promptReq.value}
|
||||
placeholder={promptReq.placeholder}
|
||||
onChange={(e) => setPromptValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') promptAnswer(promptReq.value)
|
||||
else if (e.key === 'Escape') promptAnswer(null)
|
||||
}}
|
||||
className="w-full border border-slate-300 dark:border-neutral-600 dark:bg-neutral-700 dark:text-slate-100 rounded px-2 py-1.5 text-sm mb-4"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => promptAnswer(null)}
|
||||
className="text-sm px-3 py-1.5 rounded border border-slate-300 dark:border-neutral-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => promptAnswer(promptReq.value)}
|
||||
className="text-sm px-3 py-1.5 rounded text-white font-medium bg-brand hover:bg-brand-dark"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
Settings,
|
||||
Theme,
|
||||
QualityThresholds,
|
||||
Anniversary,
|
||||
IndexProgress,
|
||||
IndexSummary,
|
||||
SearchProgress,
|
||||
@@ -83,6 +84,7 @@ interface AppState {
|
||||
onboarded: boolean
|
||||
qualityThresholds: QualityThresholds
|
||||
easyMode: boolean
|
||||
anniversaries: Anniversary[]
|
||||
initSettings: () => Promise<void>
|
||||
updateSettings: (patch: Partial<Settings>) => Promise<void>
|
||||
|
||||
@@ -168,6 +170,7 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
onboarded: false,
|
||||
qualityThresholds: { focus: 60, exposure: 0.35, eyes: 0.18 },
|
||||
easyMode: false,
|
||||
anniversaries: [],
|
||||
initSettings: async () => {
|
||||
const s = await window.api.settings.get()
|
||||
applyTheme(s.theme)
|
||||
@@ -177,7 +180,8 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds,
|
||||
easyMode: s.easyMode
|
||||
easyMode: s.easyMode,
|
||||
anniversaries: s.anniversaries ?? []
|
||||
})
|
||||
},
|
||||
updateSettings: async (patch) => {
|
||||
@@ -189,7 +193,8 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds,
|
||||
easyMode: s.easyMode
|
||||
easyMode: s.easyMode,
|
||||
anniversaries: s.anniversaries ?? []
|
||||
})
|
||||
},
|
||||
|
||||
@@ -209,7 +214,8 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
theme: s.theme,
|
||||
onboarded: s.onboarded,
|
||||
qualityThresholds: s.qualityThresholds,
|
||||
easyMode: s.easyMode
|
||||
easyMode: s.easyMode,
|
||||
anniversaries: s.anniversaries ?? []
|
||||
})
|
||||
},
|
||||
_onIndexProgress: (p: IndexProgress) => set({ indexProgress: p }),
|
||||
|
||||
@@ -34,6 +34,18 @@ html.dark body {
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* 토스트 등장 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* darktable식 얇은 스크롤바 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
|
||||
@@ -19,6 +19,12 @@ export const UNMATCHED_FOLDER = 'Unsorted'
|
||||
/** 영상 파일이 들어가는 폴더명 (얼굴인식 없이 날짜 기준 이동) */
|
||||
export const MOVIE_FOLDER = 'Movie'
|
||||
|
||||
/** 생일에 찍힌 사진을 모으는 최상위 폴더명 (하위: 인물/연도) */
|
||||
export const BIRTHDAY_FOLDER = 'Birthdays'
|
||||
|
||||
/** 기념일에 찍힌 사진을 모으는 최상위 폴더명 (하위: 라벨/연도) */
|
||||
export const ANNIVERSARY_FOLDER = 'Anniversaries'
|
||||
|
||||
/** 로컬 참조 이미지를 UI 창에 안전하게 표시하기 위한 커스텀 프로토콜 스킴 */
|
||||
export const MEDIA_SCHEME = 'photoai-media'
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ export const MESSAGES: Table = {
|
||||
ko: '인물 이름 (예: Alex)',
|
||||
en: 'Person name (e.g. Alex)'
|
||||
},
|
||||
'profile.birthday': { ko: '생일', en: 'Birthday' },
|
||||
'profile.birthdayHint': {
|
||||
ko: '생일(월·일)을 정하면, 그 날짜에 찍힌 이 인물의 사진이 Birthdays/{name}/연도 폴더에도 모입니다.',
|
||||
en: "Set a birthday (month·day) and this person's photos taken on that date are also collected into Birthdays/{name}/year."
|
||||
},
|
||||
'profile.birthdayNone': { ko: '생일 미설정', en: 'No birthday' },
|
||||
// 기념일 (앱 전체 공통)
|
||||
'anniv.section': { ko: '기념일', en: 'Anniversaries' },
|
||||
'anniv.hint': {
|
||||
ko: '기념일 날짜에 찍힌 모든 사진(인물 무관)이 Anniversaries/이름/연도 폴더에 모입니다.',
|
||||
en: 'Every photo taken on an anniversary date is collected into Anniversaries/label/year.'
|
||||
},
|
||||
'anniv.labelPlaceholder': { ko: '이름 (예: 결혼기념일)', en: 'Label (e.g. Wedding)' },
|
||||
'anniv.empty': { ko: '등록된 기념일이 없습니다.', en: 'No anniversaries yet.' },
|
||||
'profile.dndHint': {
|
||||
ko: '타일 클릭 · 드래그&드롭 · 붙여넣기(Ctrl+V)로 추가',
|
||||
en: 'Add by click, drag & drop, or paste (Ctrl+V)'
|
||||
@@ -323,10 +337,23 @@ export const MESSAGES: Table = {
|
||||
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.trashedSoft': { ko: '{n}장 삭제됨', en: 'Deleted {n}' },
|
||||
'sel.exportedShort': { ko: '{n}장 내보냄 → {dest}', en: 'Exported {n} → {dest}' },
|
||||
'sel.exported': {
|
||||
ko: '{n}개를 내보냈습니다.\n{dest}',
|
||||
en: 'Exported {n} items.\n{dest}'
|
||||
},
|
||||
'action.undo': { ko: '실행취소', en: 'Undo' },
|
||||
// 스마트 퀵필터 칩
|
||||
'chip.today': { ko: '오늘', en: 'Today' },
|
||||
'chip.week': { ko: '이번 주', en: 'This week' },
|
||||
'chip.year': { ko: '올해', en: 'This year' },
|
||||
'chip.best': { ko: '베스트 ★4+', en: 'Best ★4+' },
|
||||
'chip.video': { ko: '영상만', en: 'Videos' },
|
||||
'kbd.hint': {
|
||||
ko: '키보드: ←→↑↓ 이동 · 1-5 별점 · P/X 좋음·제외 · [ ] 색라벨 · Space 미리보기 · Del 삭제',
|
||||
en: 'Keys: ←→↑↓ move · 1-5 rate · P/X pick·reject · [ ] color · Space preview · Del trash'
|
||||
},
|
||||
|
||||
// 미디어 종류 (사진/영상 분리)
|
||||
'media.all': { ko: '전체', en: 'All' },
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Settings {
|
||||
qualityThresholds: QualityThresholds
|
||||
/** 4050 쉬운 모드(대형 UI/구어체) */
|
||||
easyMode: boolean
|
||||
/** 기념일 목록(앱 전체 공통) — 해당 날짜 사진을 기념일 폴더에 모음 */
|
||||
anniversaries?: Anniversary[]
|
||||
}
|
||||
|
||||
/** 등록된 인물 프로필 */
|
||||
@@ -31,6 +33,8 @@ export interface Profile {
|
||||
name: string
|
||||
/** 이동/복사 우선순위. 작을수록 1순위(=이동 대상). PRD: 첫 프로필 기준 이동 */
|
||||
order: number
|
||||
/** 생일(월-일, "MM-DD"). 이 날짜에 찍힌 그 인물 사진은 생일 폴더에도 모음 */
|
||||
birthday?: string | null
|
||||
/** 참조 이미지 절대 경로 목록 */
|
||||
referenceImages: string[]
|
||||
/** 참조 이미지로부터 계산된 128-d descriptor 들 (number[] 직렬화 형태) */
|
||||
@@ -42,6 +46,15 @@ export interface ProfileInput {
|
||||
id?: string
|
||||
name: string
|
||||
order: number
|
||||
birthday?: string | null
|
||||
}
|
||||
|
||||
/** 기념일(앱 전체 공통). 해당 날짜에 찍힌 모든 사진을 기념일 폴더에 모음 */
|
||||
export interface Anniversary {
|
||||
id: string
|
||||
label: string
|
||||
/** 월-일 "MM-DD" */
|
||||
date: string
|
||||
}
|
||||
|
||||
/** 프리셋: 저장된 인물(라이브러리). 클릭하면 활성 프로필로 불러온다. 로컬 전용. */
|
||||
@@ -220,6 +233,9 @@ export interface AssetQuery {
|
||||
folder?: string | null
|
||||
/** 태그 필터 */
|
||||
tag?: string | null
|
||||
/** 파일시각(mtime, epoch ms) 범위 — 스마트 칩(오늘/이번 주/올해)용 */
|
||||
dateFrom?: number | null
|
||||
dateTo?: number | null
|
||||
}
|
||||
|
||||
/** 컬렉션 패싯 한 항목 */
|
||||
@@ -280,6 +296,7 @@ export interface SearchStatus {
|
||||
export interface CaptureDate {
|
||||
year: string // "2024"
|
||||
month: string // "03"
|
||||
day: string // "15"
|
||||
/** EXIF에서 왔는지 mtime 폴백인지 */
|
||||
source: 'exif' | 'mtime'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user