Compare commits

..

3 Commits

Author SHA1 Message Date
koriweb 70078b5510 chore(records): update PhotoAI Chronicle records (timeline, bugs, discussions)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:14:25 +09:00
koriweb 9b044449a0 Birthday & anniversary photo collections
- profiles get an optional birthday (MM-DD); photos of that person taken on the
  date are also copied into Birthdays/<person>/<year>/
- app-wide anniversaries (label + MM-DD); any photo taken on the date is copied
  into Anniversaries/<label>/<year>/ (including faceless photos and videos)
- copy (not move) so normal person/date sorting is preserved
- CaptureDate gains day; new collection path builder; scanner skips the new folders
- UI: birthday input in profile create/edit + new Anniversaries manager

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:11:29 +09:00
koriweb d73e11f0fd v0.3.0: keyboard culling, toasts+undo, smart chips, selection gestures, guide
- keyboard-first culling in the library grid: cursor nav (arrows), 1-5 rating,
  P/X pick/reject labels, [ ] cycle color, Space preview, Delete trash
- toast + in-app confirm/prompt overlays replace all native alert/confirm/prompt;
  delete is now Gmail-style deferred trash with Undo
- smart quick-filter chips (today / this week / this year / best 4+ / videos),
  backed by a new mtime date-range filter in AssetQuery
- selection gestures: shift-click range, Ctrl/Cmd+A select-all, Esc clear
- mosaic: grid lines, lower default density/blend, click tile to view source
- user guide updated: library, culling, shortcuts, search/map/groups, mosaic

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:56:27 +09:00
27 changed files with 993 additions and 76 deletions
@@ -0,0 +1,16 @@
# Bug: 이메일 내용 한번 검토 해줘. 개선이 필요한게 맞는지. 모바일 앱 보안 솔루션 최종 견적 검토 및 적용 범위 논의 결과 공유의 건이고. 이사님께...
## Date
2026-06-04
## Symptom
이메일 내용 한번 검토 해줘. 개선이 필요한게 맞는지. 모바일 앱 보안 솔루션 최종 견적 검토 및 적용 범위 논의 결과 공유의 건이고. 이사님께서 시뮬레이션 값이 있었으면 좋겠다는 의견을 주심. 안녕하세요? 칼리버스 플랫폼개발실 PM팀 김성환입니다. 3Dism 모바일 앱의 보안 강화를 위해 진행한 두 업체(락인컴퍼니, 스틸리언)의 기술 검토(PoC) 결과 및 최종 견적 기반의 도입 방안을 공유해 드립니다. 본 검토는 기술적 우위를 넘어, '앱 안정성 및 긴급 배포 효율성'과 '사용자 증가에 따른 비용 리스크' 등 실용적 관점을 중심으로 정리하였습니다. 1. 보안 솔루션 도입 검토 안 (요약) 구분 1안 2안 조합 구성 락인컴퍼니(보안 솔루션+화면 캡처 방지) + 스틸리언(AppSuit Unit: SDK 라이브러리 난독화) 스틸리언(AppSuit Premium: 위변조+난독화) + 락인 컴퍼니(LISS: 화면 캡처 방지) 기본 연간 비용 (VAT별도) 2,700만 원 (락인 2,000...
## Cause
Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
## Fix
이메일의 내용은 기술적 검토(PoC)와 비용 시뮬레이션, 그리고 운영 리스크까지 매우 논리적이고 체계적으로 잘 작성되었습니다. 특히 이사님께서 요청하신 '시뮬레이션 값'이 2번 항목에 구체적인 수치(디바이스 규모별 예상 비용)로 포함되어 있어, 의사결정권자에게 가장 필요한 정보를 정확히 제공하고 있습니다. 결론부터 말씀드리면: 현재 이메일은 완성도가 매우 높으며, 그대로 발송해도 무방한 수준입니다. 다만, 보고를 받는 상급자가 '그래서 결론이 무엇인가(최종 추천안)'를 더 빠르게 파악할 수 있도록 구조를 살짝 보강한다면 훨씬 강력한 보고서가 될 것입니다. --- 🔍 이메일 상세 리뷰 및 개선 제안 1. [강점] 유지해야 할 부분 - 이사님 요청 사항 반영: 디바이스 규모(100만~1,000만)에 따른 연간 추가 비용을 산출하여, '비용 리스크'를 정량적으로 보여준 점이 매우 탁합니다. 이는 단순한 추측이 아닌 데이터 기반의 의사결정을 유도합니다. - 대조적 구조: 1안과 2안의 장...
## Prevention
Keep automatic records tied to the active project and verify the relevant test or reproduction path.
@@ -0,0 +1,16 @@
# Bug: ㅇ이거 전체적인 이메일을 상사에게 사업적으로 어떤 안으로 갈것인지를 결정할 수 있게 이메일을 보내야 하거든. 다시 한번 써줘. 비개발자가 읽고 ...
## Date
2026-06-04
## Symptom
ㅇ이거 전체적인 이메일을 상사에게 사업적으로 어떤 안으로 갈것인지를 결정할 수 있게 이메일을 보내야 하거든. 다시 한번 써줘. 비개발자가 읽고 쉽게 이 해 할 수 있게. [침고 내용] 안녕하세요? 칼리버스 플랫폼개발실 PM팀 김성환입니다. 3Dism 모바일 앱의 보안 강화를 위해 진행한 두 업체(락인컴퍼니, 스틸리언)의 기술 검토(PoC) 결과 및 최종 견적 기반의 도입 방안을 공유해 드립니다. 본 검토는 기술적 우위를 넘어, '앱 안정성 및 긴급 배포 효율성'과 '사용자 증가에 따른 비용 리스크' 등 실용적 관점을 중심으로 정리하였습니다. 1. 보안 솔루션 도입 검토 안 (요약) 구분 1안 (요약할 우 있는 keyword로 설명이 있으면 좋겠어요) 2안 (요약할 우 있는 keyword로 설명이 있으면 좋겠어요) 조합 구성 락인컴퍼니(보안 솔루션+화면 캡처 방지) + 스틸리언(SDK 라이브러리 난독화) 스틸리언(명칭 통일 필요 ) + 락인 컴퍼니(LISS: 화면 캡처 방지) 기본 연...
## Cause
Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
## Fix
상대방이 비개발자(의사결정권자)라는 점과 '사업적 결정'을 이끌어내야 한다는 목적에 맞춰, 기술적인 용어는 최대한 걷어내고 [비용 / 리스크 / 운영 효율]이라는 경영적 가치 중심으로 재구성했습니다. 결론부터 말씀드리면: 복잡한 기술 구조를 설명하기보다, "연간 800만 원을 더 투자해서 '서비스 중단 리스크'를 막을 것인가, 아니면 비용을 아끼는 대신 '배포 지연 리스크'를 감수할 것인가"라는 선택지를 명확히 던지는 것이 핵심입니다. --- 💡 이메일 작성 전략 (비개발자 대상) 1. 용어 순화: '난독화', 'PoC', '크래시' 대신 '보안 코드 보호', '기술 검증', '앱 오류' 등으로 풀어서 설명하거나 경영적 가치로 치환합니다. 2. 의사결정 포인트 명확화: "점검 시 바로 패치가 가능한가?"라는 질문을 통해, 800만 원의 가치가 '긴급 대응력'에 있음을 강조합니다. 3. 구조적 요약(Executive Summary): 서두에 결론을 배치하여 바쁜 상사가 첫 문장만 읽...
## Prevention
Keep automatic records tied to the active project and verify the relevant test or reproduction path.
+1 -1
View File
@@ -7,5 +7,5 @@
"corePurpose": "",
"detailLevel": "standard",
"createdAt": "2026-06-01T04:16:09.722Z",
"updatedAt": "2026-06-02T07:18:12.522Z"
"updatedAt": "2026-06-04T06:36:29.810Z"
}
@@ -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. [명예훼손죄 성립 여부 확인] 상대방의 '음해'가 법적으로 인정되려면 다음 세 가지 요건이 충격적으로 충족되어야 합니다. - 내용의 허위성: 상대방이 한 말이 단순한 의견이 아니라, '사실 혹은 거짓된 사실'을 바탕으로 질문자님을 공격해야 합니다. - 공연성(Publicity): 그 말을 혼자서만 한 것이 아니라, 다른 사람이나 커뮤니티, 단톡방 등 제3자가 알 수 있는 상태에서 전달되었어야 합니다. - 특정성(Identification): 그...
## Decisions
No decisions captured yet.
@@ -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.
+12
View File
@@ -11,3 +11,15 @@
## 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
## 2026-06-02
- Auto discussion record created: discussions\2026-06-02_나한테-거짓말하고-날-음해하고-있어.md
## 2026-06-04
- Auto bug record created: bugs\BUG-0001-이메일-내용-한번-검토-해줘-개선이-필요한게-맞는지-모바일-앱-보안-솔루션-최종-견적-검토-및-적용-범위-논.md
## 2026-06-04
- Auto bug record created: bugs\BUG-0002-이거-전체적인-이메일을-상사에게-사업적으로-어떤-안으로-갈것인지를-결정할-수-있게-이메일을-보내야-하거든-다.md
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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\n15 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.'
]
}
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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부터 시작하는 충돌 회피 인덱스
+2
View File
@@ -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
View File
@@ -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
])
}
+2 -1
View File
@@ -10,7 +10,8 @@ const DEFAULTS: Settings = {
theme: 'dark', // 기본 다크모드
onboarded: false,
qualityThresholds: { ...QUALITY_THRESHOLDS },
easyMode: false
easyMode: false,
anniversaries: []
}
/** 앱 설정(언어/테마/온보딩) 영속화. userData/settings.json */
+6
View File
@@ -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>
)
}
+7 -6
View File
@@ -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' })
}
}
+3 -2
View File
@@ -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)
}
+253 -36
View File
@@ -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}
+43 -2
View File
@@ -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) => (
+255
View File
@@ -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>
)}
</>
)
}
+9 -3
View File
@@ -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 }),
+12
View File
@@ -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;
+6
View File
@@ -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'
+27
View File
@@ -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' },
+17
View File
@@ -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'
}