Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램

Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼
사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드,
인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포.

- archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram
- archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트)
- archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수
- archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡
- 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널

Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
import { createResource, createSignal, For, Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import {
assignTags,
createTag,
fileDetails,
fileTagsOf,
listTags,
unassignTags,
type FileDetails,
} from "../ipc/commands";
import { refreshSidebar, selected, toast, view } from "../state/store";
function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)} KB`;
return `${bytes} B`;
}
function fmtDate(ms: number | null): string {
if (!ms) return "—";
return new Date(ms).toLocaleString("ko-KR");
}
const Row: Component<{ label: string; value: string }> = (props) => (
<div class="px-3 py-1">
<div class="text-[10px] text-[var(--text-muted)]">{props.label}</div>
<div class="break-all text-xs text-[var(--text-primary)]" title={props.value}>
{props.value}
</div>
</div>
);
/** 선택 항목 태그 편집기 (단일/다중 선택 공용) */
const TagEditor: Component = () => {
const [tagVersion, setTagVersion] = createSignal(0);
const singleId = () => {
const s = selected();
return s.size === 1 ? [...s][0] : undefined;
};
const [currentTags] = createResource(
() => (singleId() != null ? `${singleId()}:${tagVersion()}` : undefined),
async () => fileTagsOf(singleId()!),
);
const [allTags] = createResource(
() => tagVersion(),
() => listTags(),
);
const addTag = async (name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
const ids = [...selected()];
if (ids.length === 0) return;
try {
const existing = (allTags() ?? []).find((t) => t.name === trimmed);
const tagId = existing ? existing.id : await createTag(trimmed);
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, trimmed));
setTagVersion((v) => v + 1);
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
const removeTag = async (tagId: number, name: string) => {
const ids = [...selected()];
const r = await unassignTags(ids, tagId);
toast(ko.tags.removedFrom(r.count, name));
setTagVersion((v) => v + 1);
refreshSidebar();
};
return (
<div class="px-3 py-1">
<div class="text-[10px] text-[var(--text-muted)]">{ko.details.tags}</div>
<div class="mt-1 flex flex-wrap gap-1">
<Show when={singleId() != null}>
<For each={currentTags()}>
{(t) => (
<span class="flex items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
{t.name}
<button
class="hover:text-[var(--danger)]"
onClick={() => void removeTag(t.id, t.name)}
>
×
</button>
</span>
)}
</For>
</Show>
</div>
<input
class="mt-1.5 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-[11px] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
placeholder={ko.tags.add}
list="all-tags-datalist"
onKeyDown={(e) => {
if (e.key === "Enter") {
void addTag(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
<datalist id="all-tags-datalist">
<For each={allTags()}>{(t) => <option value={t.name} />}</For>
</datalist>
</div>
);
};
export const DetailsPanel: Component = () => {
const singleId = () => {
const s = selected();
return s.size === 1 ? [...s][0] : undefined;
};
const [details] = createResource(singleId, async (id) => {
const list = await fileDetails([id]);
return list[0] as FileDetails | undefined;
});
// 뷰 변경 시 details 리소스가 재평가되도록 view 추적
void view;
return (
<aside class="flex min-h-0 flex-col overflow-y-auto border-l border-[var(--border)] bg-[var(--bg-panel)]">
<div class="px-3 pt-4 pb-1.5 text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
{ko.details.title}
</div>
<Show
when={details()}
fallback={
<div class="px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
{selected().size > 1
? ko.grid.selected(selected().size)
: ko.details.noSelection}
</div>
}
>
{(d) => (
<>
<Row label={ko.details.fileName} value={d().name} />
<Row label={ko.details.filePath} value={d().dir} />
<Row label={ko.details.fileSize} value={fmtSize(d().size)} />
<Show when={d().width && d().height}>
<Row label={ko.details.dimensions} value={`${d().width} × ${d().height}`} />
</Show>
<Show when={d().durationMs}>
<Row
label={ko.details.duration}
value={`${Math.round((d().durationMs ?? 0) / 1000)}`}
/>
</Show>
<Row label={ko.details.takenAt} value={fmtDate(d().takenAt)} />
<Row label={ko.details.modifiedAt} value={fmtDate(d().mtimeMs)} />
</>
)}
</Show>
<Show when={selected().size > 0}>
<TagEditor />
</Show>
</aside>
);
};