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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user