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:
+45
@@ -0,0 +1,45 @@
|
||||
import { onCleanup, onMount, type Component } from "solid-js";
|
||||
import { Sidebar, onLibraryChangedRefresh } from "./shell/Sidebar";
|
||||
import { ContentArea } from "./shell/ContentArea";
|
||||
import { DetailsPanel } from "./shell/DetailsPanel";
|
||||
import { StatusBar } from "./shell/StatusBar";
|
||||
import { Lightbox } from "./features/viewer/Lightbox";
|
||||
import { ContextMenu } from "./features/fileops/ContextMenu";
|
||||
import { MoveDialog } from "./features/fileops/MoveDialog";
|
||||
import { DedupePanel } from "./features/dedupe/DedupePanel";
|
||||
import { RemoteDialog } from "./features/sources/RemoteDialog";
|
||||
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands";
|
||||
import { bumpThumbVersions, loadAppInfo, refreshSnapshot } from "./state/store";
|
||||
|
||||
const App: Component = () => {
|
||||
onMount(() => {
|
||||
void loadAppInfo();
|
||||
void refreshSnapshot();
|
||||
|
||||
const unlisteners: Promise<UnlistenFn>[] = [
|
||||
onThumbsReady((ids) => bumpThumbVersions(ids)),
|
||||
onLibraryChanged(() => onLibraryChangedRefresh()),
|
||||
];
|
||||
onCleanup(() => {
|
||||
for (const u of unlisteners) void u.then((f) => f());
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="grid h-full grid-rows-[1fr_auto]">
|
||||
<div class="grid min-h-0 grid-cols-[240px_1fr_280px]">
|
||||
<Sidebar />
|
||||
<ContentArea />
|
||||
<DetailsPanel />
|
||||
</div>
|
||||
<StatusBar />
|
||||
<Lightbox />
|
||||
<ContextMenu />
|
||||
<MoveDialog />
|
||||
<DedupePanel />
|
||||
<RemoteDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,252 @@
|
||||
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
|
||||
import {
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
cancelDedupe,
|
||||
dismissDupePair,
|
||||
listDupeGroups,
|
||||
resolveDupeGroup,
|
||||
startDedupe,
|
||||
thumbUrl,
|
||||
trashFiles,
|
||||
type DedupeProgress,
|
||||
type DupeGroup,
|
||||
} from "../../ipc/commands";
|
||||
import {
|
||||
closeDedupe,
|
||||
dedupeOpen,
|
||||
info,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
toast,
|
||||
} from "../../state/store";
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" };
|
||||
|
||||
export const DedupePanel: Component = () => {
|
||||
const [running, setRunning] = createSignal(false);
|
||||
const [progress, setProgress] = createSignal<DedupeProgress | undefined>();
|
||||
const [includeVideo, setIncludeVideo] = createSignal(false);
|
||||
const [threshold, setThreshold] = createSignal(5);
|
||||
const [groupsVersion, setGroupsVersion] = createSignal(0);
|
||||
|
||||
const [groups] = createResource(
|
||||
() => (dedupeOpen() ? groupsVersion() : undefined),
|
||||
() => listDupeGroups(),
|
||||
);
|
||||
|
||||
const runScan = async () => {
|
||||
setRunning(true);
|
||||
setProgress(undefined);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
startDedupe(
|
||||
{ imageThreshold: threshold(), includeVideo: includeVideo() },
|
||||
(p) => {
|
||||
setProgress(p);
|
||||
if (p.phase === "done") resolve();
|
||||
},
|
||||
).catch(reject);
|
||||
});
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setProgress(undefined);
|
||||
setGroupsVersion((v) => v + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/** keeper 외 전부 휴지통 */
|
||||
const resolveGroup = async (g: DupeGroup) => {
|
||||
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
|
||||
if (losers.length === 0) {
|
||||
await resolveDupeGroup(g.id);
|
||||
setGroupsVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
const yes = await ask(ko.dedupe.trashLosersConfirm(losers.length), {
|
||||
title: "Archive",
|
||||
kind: "warning",
|
||||
});
|
||||
if (!yes) return;
|
||||
await trashFiles(losers);
|
||||
await resolveDupeGroup(g.id);
|
||||
toast(ko.ops.trashDone(losers.length));
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
setGroupsVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const keepAll = async (g: DupeGroup) => {
|
||||
// 그룹 내 모든 쌍을 dismiss (재탐지 시 제외) + 해결 표시
|
||||
for (let i = 0; i < g.members.length; i++) {
|
||||
for (let j = i + 1; j < g.members.length; j++) {
|
||||
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
|
||||
}
|
||||
}
|
||||
await resolveDupeGroup(g.id);
|
||||
setGroupsVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={dedupeOpen()}>
|
||||
<div class="fixed inset-0 z-40 flex flex-col bg-[var(--bg-app)]">
|
||||
{/* 헤더 */}
|
||||
<div class="flex h-12 shrink-0 items-center gap-4 border-b border-[var(--border)] px-4">
|
||||
<span class="text-sm font-semibold">{ko.dedupe.title}</span>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<label class="flex items-center gap-1.5">
|
||||
{ko.dedupe.threshold}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={12}
|
||||
value={threshold()}
|
||||
class="accent-[var(--accent)]"
|
||||
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="w-4 tabular-nums">{threshold()}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeVideo()}
|
||||
onChange={(e) => setIncludeVideo(e.currentTarget.checked)}
|
||||
/>
|
||||
{ko.dedupe.includeVideo}
|
||||
</label>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<Show when={running()}>
|
||||
<span class="text-[11px] text-[var(--accent)]">
|
||||
{progress()
|
||||
? ko.dedupe.scanning(progress()!.phase, progress()!.current, progress()!.total)
|
||||
: ko.dedupe.starting}
|
||||
</span>
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void cancelDedupe()}
|
||||
>
|
||||
{ko.common.cancel}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={!running()}>
|
||||
<button
|
||||
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
|
||||
onClick={() => void runScan()}
|
||||
>
|
||||
{ko.dedupe.scan}
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
class="rounded px-2 py-1 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={closeDedupe}
|
||||
title={ko.common.close}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹 목록 */}
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<Show
|
||||
when={groups() && groups()!.length > 0}
|
||||
fallback={
|
||||
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
|
||||
{running() ? ko.dedupe.starting : ko.dedupe.empty}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mb-3 text-xs text-[var(--text-muted)]">
|
||||
{ko.dedupe.groupCount(groups()!.length)}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={groups()}>
|
||||
{(g) => (
|
||||
<div class="rounded-lg border border-[var(--border)] bg-[var(--bg-panel)] p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="rounded bg-[var(--bg-panel-raised)] px-2 py-0.5 text-[10px] text-[var(--text-secondary)]">
|
||||
{KIND_LABEL[g.kind]}
|
||||
</span>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{ko.dedupe.memberCount(g.members.length)}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-2 py-0.5 text-[11px] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void keepAll(g)}
|
||||
>
|
||||
{ko.dedupe.keepAll}
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[var(--danger)] px-2 py-0.5 text-[11px] text-white"
|
||||
onClick={() => void resolveGroup(g)}
|
||||
>
|
||||
{ko.dedupe.keepBest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<For each={g.members}>
|
||||
{(m) => (
|
||||
<div
|
||||
class="w-40 overflow-hidden rounded border"
|
||||
classList={{
|
||||
"border-[var(--accent)]": m.isKeeper,
|
||||
"border-[var(--border)]": !m.isKeeper,
|
||||
}}
|
||||
>
|
||||
<div class="relative aspect-square bg-[var(--grid-cell-bg)]">
|
||||
<Show when={info()}>
|
||||
<img
|
||||
src={thumbUrl(info()!, m.fileId)}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={m.isKeeper}>
|
||||
<span class="absolute left-1 top-1 rounded bg-[var(--accent)] px-1.5 py-0.5 text-[10px] text-white">
|
||||
{ko.dedupe.keeper}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<div class="truncate text-[11px]" title={`${m.dir}\\${m.name}`}>
|
||||
{m.name}
|
||||
</div>
|
||||
<div class="text-[10px] text-[var(--text-muted)]">
|
||||
{m.width && m.height ? `${m.width}×${m.height} · ` : ""}
|
||||
{fmtSize(m.size)}
|
||||
{m.distance != null ? ` · 거리 ${m.distance}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
// 그리드 우클릭 컨텍스트 메뉴 — 태그 할당, 이동, 이름변경, 휴지통.
|
||||
import { createResource, createSignal, For, Show, type Component } from "solid-js";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
assignTags,
|
||||
listTags,
|
||||
renameFile,
|
||||
trashFiles,
|
||||
undoLast,
|
||||
} from "../../ipc/commands";
|
||||
import {
|
||||
clearSelection,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
selected,
|
||||
toast,
|
||||
} from "../../state/store";
|
||||
import { openMoveDialog } from "./MoveDialog";
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: number;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
const [menu, setMenu] = createSignal<MenuState | undefined>();
|
||||
export const openContextMenu = (s: MenuState) => setMenu(s);
|
||||
export const closeContextMenu = () => setMenu(undefined);
|
||||
|
||||
const [renameTarget, setRenameTarget] = createSignal<{ id: number; name: string } | undefined>();
|
||||
export const openRenameDialog = (id: number, name: string) => setRenameTarget({ id, name });
|
||||
|
||||
export async function performTrash(): Promise<void> {
|
||||
try {
|
||||
const ids = [...selected()];
|
||||
if (ids.length === 0) return;
|
||||
const yes = await ask(ko.ops.trashConfirm(ids.length), { title: "Archive", kind: "warning" });
|
||||
if (!yes) return;
|
||||
const summary = await trashFiles(ids, (p) => toast(ko.ops.inProgress(p.done, p.total)));
|
||||
toast(ko.ops.trashDone(summary.done));
|
||||
clearSelection();
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
console.error("performTrash", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function performUndo(): Promise<void> {
|
||||
const summary = await undoLast();
|
||||
if (!summary) {
|
||||
toast(ko.ops.nothingToUndo);
|
||||
return;
|
||||
}
|
||||
toast(ko.ops.undone(summary.done));
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
const MenuItem: Component<{ label: string; onClick: () => void }> = (props) => (
|
||||
<div
|
||||
class="cursor-pointer rounded px-3 py-1.5 text-xs hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => {
|
||||
closeContextMenu();
|
||||
props.onClick();
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ContextMenu: Component = () => {
|
||||
const [tags] = createResource(
|
||||
() => (menu() ? 1 : undefined),
|
||||
() => listTags(),
|
||||
);
|
||||
|
||||
const onAssign = async (tagId: number, tagName: string) => {
|
||||
const ids = [...selected()];
|
||||
const r = await assignTags(ids, tagId);
|
||||
toast(ko.tags.assignTo(r.count, tagName));
|
||||
refreshSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={menu()}>
|
||||
{(m) => (
|
||||
<>
|
||||
<div class="fixed inset-0 z-40" onClick={closeContextMenu} onContextMenu={(e) => { e.preventDefault(); closeContextMenu(); }} />
|
||||
<div
|
||||
class="fixed z-50 w-52 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1 shadow-xl"
|
||||
style={{ left: `${Math.min(m().x, window.innerWidth - 220)}px`, top: `${Math.min(m().y, window.innerHeight - 260)}px` }}
|
||||
>
|
||||
<Show when={tags() && tags()!.length > 0}>
|
||||
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]">태그</div>
|
||||
<div class="max-h-36 overflow-y-auto">
|
||||
<For each={tags()}>
|
||||
{(t) => <MenuItem label={`# ${t.name}`} onClick={() => void onAssign(t.id, t.name)} />}
|
||||
</For>
|
||||
</div>
|
||||
<div class="my-1 border-t border-[var(--border)]" />
|
||||
</Show>
|
||||
<MenuItem label={ko.ops.moveTo} onClick={openMoveDialog} />
|
||||
<Show when={selected().size === 1 && m().fileName}>
|
||||
<MenuItem
|
||||
label={ko.common.rename}
|
||||
onClick={() => openRenameDialog(m().fileId, m().fileName!)}
|
||||
/>
|
||||
</Show>
|
||||
<div class="my-1 border-t border-[var(--border)]" />
|
||||
<MenuItem label={`${ko.common.delete} (휴지통)`} onClick={() => void performTrash()} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* 이름 변경 모달 */}
|
||||
<Show when={renameTarget()}>
|
||||
{(t) => {
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
const submit = async () => {
|
||||
const newName = inputRef?.value.trim();
|
||||
setRenameTarget(undefined);
|
||||
if (!newName || newName === t().name) return;
|
||||
try {
|
||||
await renameFile(t().id, newName);
|
||||
await refreshSnapshot();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={() => setRenameTarget(undefined)}>
|
||||
<div
|
||||
class="w-96 rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] p-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="mb-2 text-sm font-semibold">{ko.common.rename}</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={t().name}
|
||||
class="w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1.5 text-xs outline-none focus:border-[var(--accent)]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (e.key === "Escape") setRenameTarget(undefined);
|
||||
}}
|
||||
/>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => setRenameTarget(undefined)}
|
||||
>
|
||||
{ko.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{ko.common.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
// 폴더 선택 모달 — 선택 항목을 인덱싱된 폴더로 이동한다.
|
||||
import { createResource, createSignal, For, Show, type Component } from "solid-js";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
folderTree,
|
||||
listSources,
|
||||
moveFiles,
|
||||
type FolderNode,
|
||||
} from "../../ipc/commands";
|
||||
import {
|
||||
clearSelection,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
selected,
|
||||
toast,
|
||||
} from "../../state/store";
|
||||
|
||||
const [open, setOpen] = createSignal(false);
|
||||
export const openMoveDialog = () => setOpen(true);
|
||||
|
||||
interface TreeNode extends FolderNode {
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
function buildTree(nodes: FolderNode[]): TreeNode[] {
|
||||
const map = new Map<number, TreeNode>();
|
||||
const roots: TreeNode[] = [];
|
||||
for (const n of nodes) map.set(n.id, { ...n, children: [] });
|
||||
for (const n of map.values()) {
|
||||
if (n.parentId != null && map.has(n.parentId)) map.get(n.parentId)!.children.push(n);
|
||||
else roots.push(n);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export async function performMove(destFolderId: number): Promise<void> {
|
||||
const ids = [...selected()];
|
||||
if (ids.length === 0) return;
|
||||
const summary = await moveFiles(ids, destFolderId, (p) =>
|
||||
toast(ko.ops.inProgress(p.done, p.total)),
|
||||
);
|
||||
toast(
|
||||
summary.failed > 0
|
||||
? `${ko.ops.moveDone(summary.done)}, ${ko.ops.moveFailed(summary.failed)}`
|
||||
: ko.ops.moveDone(summary.done),
|
||||
);
|
||||
clearSelection();
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
const FolderPickRow: Component<{
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
onPick: (id: number) => void;
|
||||
}> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(props.depth < 1);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
style={{ "padding-left": `${8 + props.depth * 14}px` }}
|
||||
onClick={() => props.onPick(props.node.id)}
|
||||
>
|
||||
<span
|
||||
class="w-3 shrink-0 text-[var(--text-muted)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded());
|
||||
}}
|
||||
>
|
||||
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
|
||||
</span>
|
||||
<span class="truncate">{props.node.name}</span>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<For each={props.node.children}>
|
||||
{(c) => <FolderPickRow node={c} depth={props.depth + 1} onPick={props.onPick} />}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MoveDialog: Component = () => {
|
||||
const [trees] = createResource(
|
||||
() => open(),
|
||||
async (isOpen) => {
|
||||
if (!isOpen) return [];
|
||||
const sources = await listSources();
|
||||
return Promise.all(
|
||||
sources
|
||||
.filter((s) => s.kind === "local")
|
||||
.map(async (s) => ({
|
||||
source: s,
|
||||
tree: buildTree(await folderTree(s.id)),
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const onPick = async (folderId: number) => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await performMove(folderId);
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={open()}>
|
||||
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
class="flex max-h-[70vh] w-96 flex-col rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="border-b border-[var(--border)] px-4 py-2.5 text-sm font-semibold">
|
||||
{ko.ops.moveTo}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<For each={trees()}>
|
||||
{(entry) => (
|
||||
<div class="mb-2">
|
||||
<div class="px-2 py-1 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
{entry.source.name}
|
||||
</div>
|
||||
<For each={entry.tree}>
|
||||
{(n) => <FolderPickRow node={n} depth={0} onPick={(id) => void onPick(id)} />}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex justify-end border-t border-[var(--border)] px-3 py-2">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{ko.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import type { LayoutResult } from "./layout.worker";
|
||||
import LayoutWorker from "./layout.worker?worker";
|
||||
import {
|
||||
anchorIndex,
|
||||
info,
|
||||
openLightbox,
|
||||
selected,
|
||||
selectSingle,
|
||||
toggleSelect,
|
||||
selectRange,
|
||||
snapshot,
|
||||
thumbVersions,
|
||||
} from "../../state/store";
|
||||
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
openContextMenu,
|
||||
openRenameDialog,
|
||||
performTrash,
|
||||
performUndo,
|
||||
} from "../fileops/ContextMenu";
|
||||
|
||||
const TARGET_ROW_HEIGHT = 200;
|
||||
const GAP = 6;
|
||||
const OVERSCAN_PX = 800;
|
||||
|
||||
/** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */
|
||||
function findRow(rowTops: Float32Array, y: number): number {
|
||||
let lo = 0;
|
||||
let hi = rowTops.length - 2; // 마지막 요소는 전체 높이
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (rowTops[mid] <= y) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
export const JustifiedGrid: Component = () => {
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
const [layout, setLayout] = createSignal<LayoutResult | undefined>();
|
||||
const [scrollTop, setScrollTop] = createSignal(0);
|
||||
const [viewportH, setViewportH] = createSignal(600);
|
||||
const [containerW, setContainerW] = createSignal(0);
|
||||
|
||||
const worker = new LayoutWorker();
|
||||
let layoutSeq = 0;
|
||||
worker.onmessage = (e: MessageEvent<LayoutResult>) => {
|
||||
if (e.data.seq === layoutSeq) setLayout(e.data);
|
||||
};
|
||||
onCleanup(() => worker.terminate());
|
||||
|
||||
// 스냅샷/폭 변경 → 레이아웃 재계산
|
||||
createEffect(() => {
|
||||
const snap = snapshot();
|
||||
const w = containerW();
|
||||
if (!snap || w <= 0) {
|
||||
setLayout(undefined);
|
||||
return;
|
||||
}
|
||||
layoutSeq++;
|
||||
const ratios = snap.aspectRatios();
|
||||
worker.postMessage(
|
||||
{ seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: TARGET_ROW_HEIGHT, gap: GAP },
|
||||
[ratios.buffer],
|
||||
);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const el = containerRef!;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerW(el.clientWidth - 16); // 패딩 보정
|
||||
setViewportH(el.clientHeight);
|
||||
});
|
||||
ro.observe(el);
|
||||
onCleanup(() => ro.disconnect());
|
||||
});
|
||||
|
||||
// rAF 스로틀 스크롤
|
||||
let rafPending = false;
|
||||
const onScroll = () => {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
if (containerRef) setScrollTop(containerRef.scrollTop);
|
||||
});
|
||||
};
|
||||
|
||||
/** 가시 항목 인덱스 범위 */
|
||||
const visibleRange = createMemo<[number, number]>(() => {
|
||||
const l = layout();
|
||||
const snap = snapshot();
|
||||
if (!l || !snap || l.rowFirst.length === 0) return [0, -1];
|
||||
const top = Math.max(0, scrollTop() - OVERSCAN_PX);
|
||||
const bottom = scrollTop() + viewportH() + OVERSCAN_PX;
|
||||
const firstRow = findRow(l.rowTops, top);
|
||||
let lastRow = firstRow;
|
||||
while (lastRow + 1 < l.rowFirst.length && l.rowTops[lastRow + 1] < bottom) lastRow++;
|
||||
const first = l.rowFirst[firstRow];
|
||||
const last =
|
||||
lastRow + 1 < l.rowFirst.length ? l.rowFirst[lastRow + 1] - 1 : snap.count - 1;
|
||||
return [first, last];
|
||||
});
|
||||
|
||||
// 뷰포트 항목 중 썸네일 없는 것 우선 생성 요청 (디바운스 200ms)
|
||||
let prioTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
createEffect(() => {
|
||||
const [first, last] = visibleRange();
|
||||
const snap = snapshot();
|
||||
if (!snap || last < first) return;
|
||||
clearTimeout(prioTimer);
|
||||
prioTimer = setTimeout(() => {
|
||||
const ids: number[] = [];
|
||||
for (let i = first; i <= last && ids.length < 512; i++) {
|
||||
if (!snap.hasThumb(i)) ids.push(snap.id(i));
|
||||
}
|
||||
if (ids.length > 0) void setThumbPriority(ids);
|
||||
}, 200);
|
||||
});
|
||||
onCleanup(() => clearTimeout(prioTimer));
|
||||
|
||||
const indices = createMemo(() => {
|
||||
const [first, last] = visibleRange();
|
||||
const out: number[] = [];
|
||||
for (let i = first; i <= last; i++) out.push(i);
|
||||
return out;
|
||||
});
|
||||
|
||||
const onCellClick = (e: MouseEvent, snap: Snapshot, index: number) => {
|
||||
const id = snap.id(index);
|
||||
if (e.shiftKey) selectRange(snap, index);
|
||||
else if (e.ctrlKey || e.metaKey) toggleSelect(id, index);
|
||||
else selectSingle(id, index);
|
||||
};
|
||||
|
||||
/** 위/아래 화살표: 레이아웃 기하 기반 — 인접 행에서 x중심이 가장 가까운 셀 */
|
||||
const verticalNeighbor = (from: number, dir: -1 | 1): number | undefined => {
|
||||
const l = layout();
|
||||
const snap = snapshot();
|
||||
if (!l || !snap) return undefined;
|
||||
const cx = l.boxes[from * 4] + l.boxes[from * 4 + 2] / 2;
|
||||
const cy = l.boxes[from * 4 + 1];
|
||||
let best: number | undefined;
|
||||
let bestDist = Infinity;
|
||||
// 최대 3행 거리까지 탐색 (마지막 행 채움 편차 대응)
|
||||
const step = dir === 1 ? 1 : -1;
|
||||
for (let i = from + step; i >= 0 && i < snap.count; i += step) {
|
||||
const y = l.boxes[i * 4 + 1];
|
||||
if (dir === 1 ? y <= cy : y >= cy) continue; // 같은 행 스킵
|
||||
const nx = l.boxes[i * 4] + l.boxes[i * 4 + 2] / 2;
|
||||
const d = Math.abs(nx - cx);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = i;
|
||||
} else if (best !== undefined && Math.abs(y - l.boxes[best * 4 + 1]) > 1) {
|
||||
break; // 다음 행으로 넘어가면 종료
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const snap = snapshot();
|
||||
if (!snap || snap.count === 0) return;
|
||||
const anchor = anchorIndex();
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
void performUndo();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (anchor >= 0) {
|
||||
e.preventDefault();
|
||||
openLightbox(anchor);
|
||||
}
|
||||
break;
|
||||
case "Delete":
|
||||
if (selected().size > 0) {
|
||||
e.preventDefault();
|
||||
void performTrash();
|
||||
}
|
||||
break;
|
||||
case "F2":
|
||||
if (anchor >= 0 && selected().size === 1) {
|
||||
e.preventDefault();
|
||||
const id = snap.id(anchor);
|
||||
void fileDetails([id]).then((d) => {
|
||||
if (d[0]) openRenameDialog(id, d[0].name);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (anchor > 0) {
|
||||
e.preventDefault();
|
||||
selectSingle(snap.id(anchor - 1), anchor - 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (anchor >= 0 && anchor < snap.count - 1) {
|
||||
e.preventDefault();
|
||||
selectSingle(snap.id(anchor + 1), anchor + 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
case "ArrowDown": {
|
||||
if (anchor < 0) break;
|
||||
e.preventDefault();
|
||||
const next = verticalNeighbor(anchor, e.key === "ArrowDown" ? 1 : -1);
|
||||
if (next !== undefined) selectSingle(snap.id(next), next);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
|
||||
e.preventDefault();
|
||||
const id = snap.id(index);
|
||||
if (!selected().has(id)) selectSingle(id, index);
|
||||
void fileDetails([id]).then((d) => {
|
||||
openContextMenu({ x: e.clientX, y: e.clientY, fileId: id, fileName: d[0]?.name });
|
||||
});
|
||||
};
|
||||
|
||||
const onCellDragStart = (e: DragEvent, snap: Snapshot, index: number) => {
|
||||
const id = snap.id(index);
|
||||
if (!selected().has(id)) selectSingle(id, index);
|
||||
e.dataTransfer?.setData("application/x-archive-move", "1");
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
tabindex={0}
|
||||
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
|
||||
>
|
||||
<Show
|
||||
when={snapshot() && snapshot()!.count > 0}
|
||||
fallback={
|
||||
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
|
||||
{ko.grid.empty}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="relative" style={{ height: `${layout()?.totalHeight ?? 0}px` }}>
|
||||
<For each={indices()}>
|
||||
{(i) => {
|
||||
const snap = snapshot()!;
|
||||
const l = layout()!;
|
||||
const id = snap.id(i);
|
||||
const x = l.boxes[i * 4];
|
||||
const y = l.boxes[i * 4 + 1];
|
||||
const w = l.boxes[i * 4 + 2];
|
||||
const h = l.boxes[i * 4 + 3];
|
||||
const version = () => thumbVersions[id] ?? 0;
|
||||
const showImg = () => snap.hasThumb(i) || version() > 0;
|
||||
return (
|
||||
<div
|
||||
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]"
|
||||
classList={{
|
||||
"outline outline-2 outline-[var(--accent)]": selected().has(id),
|
||||
}}
|
||||
style={{
|
||||
transform: `translate(${x}px, ${y}px)`,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
}}
|
||||
onClick={(e) => onCellClick(e, snap, i)}
|
||||
onDblClick={() => openLightbox(i)}
|
||||
onContextMenu={(e) => onCellContextMenu(e, snap, i)}
|
||||
draggable={true}
|
||||
onDragStart={(e) => onCellDragStart(e, snap, i)}
|
||||
>
|
||||
<Show when={showImg() && info()}>
|
||||
<img
|
||||
src={`${thumbUrl(info()!, id)}&v=${version()}`}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={snap.kind(i) === 1}>
|
||||
<span class="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[10px] text-white">
|
||||
영상
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
// Justified 레이아웃 계산 워커 — 그리디 행 채우기.
|
||||
// 입력: 종횡비 배열 → 출력: 항목별 [x,y,w,h] + 행 오프셋 인덱스.
|
||||
|
||||
export interface LayoutRequest {
|
||||
seq: number;
|
||||
ratios: Float32Array;
|
||||
containerWidth: number;
|
||||
targetRowHeight: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export interface LayoutResult {
|
||||
seq: number;
|
||||
/** n*4: [x, y, w, h] */
|
||||
boxes: Float32Array;
|
||||
/** 행별 상단 y (마지막 요소 = 전체 높이) */
|
||||
rowTops: Float32Array;
|
||||
/** 행별 첫 항목 인덱스 */
|
||||
rowFirst: Uint32Array;
|
||||
totalHeight: number;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<LayoutRequest>) => {
|
||||
const { seq, ratios, containerWidth, targetRowHeight, gap } = e.data;
|
||||
const n = ratios.length;
|
||||
const boxes = new Float32Array(n * 4);
|
||||
const rowTopsArr: number[] = [];
|
||||
const rowFirstArr: number[] = [];
|
||||
|
||||
// 극단적 종횡비는 레이아웃 파손 방지를 위해 클램프
|
||||
const clamp = (r: number) => Math.min(4, Math.max(0.25, r || 1));
|
||||
|
||||
let y = 0;
|
||||
let rowStart = 0;
|
||||
let ratioSum = 0;
|
||||
|
||||
const flushRow = (end: number, justify: boolean) => {
|
||||
const count = end - rowStart;
|
||||
if (count <= 0) return;
|
||||
const gaps = gap * (count - 1);
|
||||
let h = targetRowHeight;
|
||||
if (justify) {
|
||||
h = (containerWidth - gaps) / ratioSum;
|
||||
} else {
|
||||
// 마지막 행: 목표 높이 유지하되 넘치면 축소
|
||||
const natural = ratioSum * targetRowHeight + gaps;
|
||||
if (natural > containerWidth) h = (containerWidth - gaps) / ratioSum;
|
||||
}
|
||||
let x = 0;
|
||||
for (let i = rowStart; i < end; i++) {
|
||||
const w = clamp(ratios[i]) * h;
|
||||
boxes[i * 4] = x;
|
||||
boxes[i * 4 + 1] = y;
|
||||
boxes[i * 4 + 2] = w;
|
||||
boxes[i * 4 + 3] = h;
|
||||
x += w + gap;
|
||||
}
|
||||
rowTopsArr.push(y);
|
||||
rowFirstArr.push(rowStart);
|
||||
y += h + gap;
|
||||
rowStart = end;
|
||||
ratioSum = 0;
|
||||
};
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
ratioSum += clamp(ratios[i]);
|
||||
const width = ratioSum * targetRowHeight + gap * (i - rowStart);
|
||||
if (width >= containerWidth) {
|
||||
flushRow(i + 1, true);
|
||||
}
|
||||
}
|
||||
flushRow(n, false);
|
||||
|
||||
const totalHeight = y > 0 ? y - gap : 0;
|
||||
rowTopsArr.push(totalHeight); // 마지막 = 전체 높이 (이진탐색 경계)
|
||||
|
||||
const result: LayoutResult = {
|
||||
seq,
|
||||
boxes,
|
||||
rowTops: new Float32Array(rowTopsArr),
|
||||
rowFirst: new Uint32Array(rowFirstArr),
|
||||
totalHeight,
|
||||
};
|
||||
(self as unknown as Worker).postMessage(result, [
|
||||
result.boxes.buffer,
|
||||
result.rowTops.buffer,
|
||||
result.rowFirst.buffer,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
// 원격 소스(SFTP/WebDAV/FTP) 연결 다이얼로그.
|
||||
// 비밀번호는 앱을 통해 OS 키체인/포터블 볼트에 저장되고 SQLite에는 저장되지 않는다.
|
||||
import { createSignal, Show, type Component } from "solid-js";
|
||||
import { ko } from "../../i18n/ko";
|
||||
import {
|
||||
addRemoteSource,
|
||||
connectRemoteSource,
|
||||
scanRemoteSource,
|
||||
type RemoteInput,
|
||||
} from "../../ipc/commands";
|
||||
import { refreshSidebar, refreshSnapshot, toast } from "../../state/store";
|
||||
|
||||
const [open, setOpen] = createSignal(false);
|
||||
export const openRemoteDialog = () => setOpen(true);
|
||||
|
||||
type Kind = "sftp" | "webdav" | "ftp";
|
||||
const DEFAULT_PORT: Record<Kind, number> = { sftp: 22, ftp: 21, webdav: 5006 };
|
||||
|
||||
export const RemoteDialog: Component = () => {
|
||||
const [kind, setKind] = createSignal<Kind>("sftp");
|
||||
const [name, setName] = createSignal("");
|
||||
const [host, setHost] = createSignal("");
|
||||
const [port, setPort] = createSignal<number>(22);
|
||||
const [username, setUsername] = createSignal("");
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [basePath, setBasePath] = createSignal("");
|
||||
const [url, setUrl] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
|
||||
const reset = () => {
|
||||
setName("");
|
||||
setHost("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setBasePath("");
|
||||
setUrl("");
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const input: RemoteInput = {
|
||||
kind: kind(),
|
||||
name: name() || host() || url(),
|
||||
host: host(),
|
||||
port: port(),
|
||||
username: username(),
|
||||
password: password(),
|
||||
basePath: basePath(),
|
||||
url: url(),
|
||||
};
|
||||
const sourceId = await addRemoteSource(input);
|
||||
toast(ko.remote.connecting);
|
||||
await connectRemoteSource(sourceId);
|
||||
toast(ko.remote.indexing);
|
||||
const count = await scanRemoteSource(sourceId);
|
||||
toast(ko.remote.indexed(count));
|
||||
refreshSidebar();
|
||||
await refreshSnapshot();
|
||||
setOpen(false);
|
||||
reset();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const Field: Component<{ label: string; children: any }> = (props) => (
|
||||
<label class="mb-2 block">
|
||||
<div class="mb-0.5 text-[11px] text-[var(--text-muted)]">{props.label}</div>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
const inputClass =
|
||||
"w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1.5 text-xs outline-none focus:border-[var(--accent)]";
|
||||
|
||||
return (
|
||||
<Show when={open()}>
|
||||
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={() => !busy() && setOpen(false)}>
|
||||
<div
|
||||
class="w-[420px] rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] p-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="mb-3 text-sm font-semibold">{ko.remote.title}</div>
|
||||
|
||||
<div class="mb-3 flex gap-1">
|
||||
{(["sftp", "webdav", "ftp"] as Kind[]).map((k) => (
|
||||
<button
|
||||
class="flex-1 rounded border px-2 py-1 text-xs"
|
||||
classList={{
|
||||
"border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]": kind() === k,
|
||||
"border-[var(--border)] text-[var(--text-secondary)]": kind() !== k,
|
||||
}}
|
||||
onClick={() => {
|
||||
setKind(k);
|
||||
setPort(DEFAULT_PORT[k]);
|
||||
}}
|
||||
>
|
||||
{k.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Field label={ko.remote.name}>
|
||||
<input class={inputClass} value={name()} onInput={(e) => setName(e.currentTarget.value)} placeholder={ko.remote.namePlaceholder} />
|
||||
</Field>
|
||||
|
||||
<Show
|
||||
when={kind() === "webdav"}
|
||||
fallback={
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<Field label={ko.remote.host}>
|
||||
<input class={inputClass} value={host()} onInput={(e) => setHost(e.currentTarget.value)} placeholder="192.168.0.10" />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="w-20">
|
||||
<Field label={ko.remote.port}>
|
||||
<input class={inputClass} type="number" value={port()} onInput={(e) => setPort(Number(e.currentTarget.value))} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Field label={ko.remote.url}>
|
||||
<input class={inputClass} value={url()} onInput={(e) => setUrl(e.currentTarget.value)} placeholder="https://nas.example.com:5006/dav" />
|
||||
</Field>
|
||||
</Show>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<Field label={ko.remote.username}>
|
||||
<input class={inputClass} value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Field label={ko.remote.password}>
|
||||
<input class={inputClass} type="password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label={ko.remote.basePath}>
|
||||
<input class={inputClass} value={basePath()} onInput={(e) => setBasePath(e.currentTarget.value)} placeholder="/photo" />
|
||||
</Field>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<span class="text-[10px] text-[var(--text-muted)]">{ko.remote.credNote}</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={busy()}
|
||||
>
|
||||
{ko.common.cancel}
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white disabled:opacity-50"
|
||||
onClick={() => void submit()}
|
||||
disabled={busy()}
|
||||
>
|
||||
{busy() ? ko.remote.connecting : ko.remote.connect}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
// 라이트박스 — 사진 원본/영상 재생 오버레이. Esc 닫기, ←/→ 이동.
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import {
|
||||
closeLightbox,
|
||||
info,
|
||||
lightboxIndex,
|
||||
navLightbox,
|
||||
snapshot,
|
||||
} from "../../state/store";
|
||||
import { fileDetails, mediaUrl, thumbUrl } from "../../ipc/commands";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
|
||||
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
|
||||
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]);
|
||||
|
||||
export const Lightbox: Component = () => {
|
||||
const item = createMemo(() => {
|
||||
const snap = snapshot();
|
||||
const idx = lightboxIndex();
|
||||
if (!snap || idx == null || idx >= snap.count) return undefined;
|
||||
return { index: idx, id: snap.id(idx), kind: snap.kind(idx) };
|
||||
});
|
||||
|
||||
const [details] = createResource(
|
||||
() => item()?.id,
|
||||
async (id) => (await fileDetails([id]))[0],
|
||||
);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (lightboxIndex() == null) return;
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
closeLightbox();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
navLightbox(-1);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
navLightbox(1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", onKey, { capture: true });
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
|
||||
});
|
||||
|
||||
// 배경 스크롤 잠금
|
||||
createEffect(() => {
|
||||
document.body.style.overflow = lightboxIndex() != null ? "hidden" : "";
|
||||
});
|
||||
|
||||
const imgSrc = () => {
|
||||
const it = item();
|
||||
const app = info();
|
||||
const d = details();
|
||||
if (!it || !app) return undefined;
|
||||
const ext = d?.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
// 네이티브 디코드 불가 형식(HEIC/RAW/TIFF)은 프리뷰 썸네일로 폴백
|
||||
return IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={item()}>
|
||||
{(it) => (
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-black/90">
|
||||
<div class="flex h-10 shrink-0 items-center gap-3 px-3">
|
||||
<span class="truncate text-xs text-[var(--text-primary)]">
|
||||
{details()?.name ?? ""}
|
||||
</span>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
|
||||
</span>
|
||||
<button
|
||||
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={closeLightbox}
|
||||
title="닫기 (Esc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 p-2">
|
||||
<Show
|
||||
when={it().kind === 1}
|
||||
fallback={
|
||||
<div class="grid h-full w-full place-items-center">
|
||||
<img
|
||||
src={imgSrc()}
|
||||
class="max-h-full max-w-full object-contain"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
// 원본 디코드 실패 → 그리드 썸네일 폴백
|
||||
const app = info();
|
||||
if (app) (e.currentTarget as HTMLImageElement).src = thumbUrl(app, it().id, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<VideoPlayer fileId={it().id} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
// 비디오 플레이어 — 직접 재생(Range, 네이티브 컨트롤) 또는
|
||||
// ffmpeg 스트림(리먹스/트랜스코딩, 커스텀 스크러버 + seekBase 오프셋).
|
||||
import {
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import {
|
||||
decidePlayback,
|
||||
mediaUrl,
|
||||
playbackInfo,
|
||||
startStream,
|
||||
stopStream,
|
||||
type AudioMode,
|
||||
type PlaybackPlan,
|
||||
type VideoMode,
|
||||
} from "../../ipc/commands";
|
||||
import { info as appInfoSig } from "../../state/store";
|
||||
|
||||
function fmtTime(sec: number): string {
|
||||
const s = Math.max(0, Math.floor(sec));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0
|
||||
? `${h}:${String(m % 60).padStart(2, "0")}:${String(r).padStart(2, "0")}`
|
||||
: `${m}:${String(r).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export const VideoPlayer: Component<{ fileId: number }> = (props) => {
|
||||
let videoRef: HTMLVideoElement | undefined;
|
||||
const [pb] = createResource(() => props.fileId, playbackInfo);
|
||||
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
|
||||
const [src, setSrc] = createSignal<string | undefined>();
|
||||
const [seekBase, setSeekBase] = createSignal(0);
|
||||
const [current, setCurrent] = createSignal(0);
|
||||
const [playing, setPlaying] = createSignal(false);
|
||||
let jobId: number | undefined;
|
||||
|
||||
const durationSec = () => (pb()?.durationMs ?? 0) / 1000;
|
||||
|
||||
const cleanupJob = () => {
|
||||
if (jobId != null) {
|
||||
void stopStream(jobId);
|
||||
jobId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startStreamAt = async (t: number, video: VideoMode, audio: AudioMode) => {
|
||||
cleanupJob();
|
||||
const res = await startStream(props.fileId, t, video, audio);
|
||||
jobId = res.jobId;
|
||||
setSeekBase(t);
|
||||
setSrc(res.url);
|
||||
queueMicrotask(() => videoRef?.play().catch(() => {}));
|
||||
};
|
||||
|
||||
// 파일 변경 → 재생 계획 수립
|
||||
createEffect(() => {
|
||||
const p = pb();
|
||||
const app = appInfoSig();
|
||||
if (!p || !app) return;
|
||||
cleanupJob();
|
||||
setSeekBase(0);
|
||||
setCurrent(0);
|
||||
const decided = decidePlayback(p);
|
||||
setPlan(decided);
|
||||
if (decided.mode === "direct") {
|
||||
setSrc(mediaUrl(app, props.fileId));
|
||||
} else if (decided.mode === "stream") {
|
||||
void startStreamAt(0, decided.video, decided.audio);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(cleanupJob);
|
||||
|
||||
const isStream = () => plan()?.mode === "stream";
|
||||
|
||||
const onSeek = (t: number) => {
|
||||
const p = plan();
|
||||
if (!p) return;
|
||||
if (p.mode === "direct") {
|
||||
if (videoRef) videoRef.currentTime = t;
|
||||
} else if (p.mode === "stream") {
|
||||
void startStreamAt(t, p.video, p.audio);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!videoRef) return;
|
||||
if (videoRef.paused) void videoRef.play().catch(() => {});
|
||||
else videoRef.pause();
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex h-full w-full flex-col items-center justify-center">
|
||||
<Show
|
||||
when={plan()?.mode !== "unsupported"}
|
||||
fallback={
|
||||
<div class="max-w-md text-center text-sm text-[var(--text-muted)]">
|
||||
{(plan() as { reason?: string })?.reason ?? "재생할 수 없는 형식입니다"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src()}
|
||||
class="max-h-[calc(100%-44px)] max-w-full flex-1 bg-black outline-none"
|
||||
controls={!isStream()}
|
||||
autoplay
|
||||
onTimeUpdate={() => setCurrent(seekBase() + (videoRef?.currentTime ?? 0))}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onClick={() => isStream() && togglePlay()}
|
||||
/>
|
||||
{/* 스트림 모드 커스텀 컨트롤 (fMP4 라이브 스트림은 네이티브 시킹 불가) */}
|
||||
<Show when={isStream()}>
|
||||
<div class="flex h-11 w-full max-w-3xl items-center gap-3 px-2">
|
||||
<button
|
||||
class="w-8 rounded px-1 py-0.5 text-lg text-[var(--text-primary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{playing() ? "⏸" : "▶"}
|
||||
</button>
|
||||
<span class="w-14 shrink-0 text-right text-[11px] tabular-nums text-[var(--text-secondary)]">
|
||||
{fmtTime(current())}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
class="h-1 flex-1 accent-[var(--accent)]"
|
||||
min={0}
|
||||
max={durationSec() || 0}
|
||||
step={0.1}
|
||||
value={current()}
|
||||
onChange={(e) => onSeek(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span class="w-14 shrink-0 text-[11px] tabular-nums text-[var(--text-secondary)]">
|
||||
{fmtTime(durationSec())}
|
||||
</span>
|
||||
<span class="shrink-0 rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)]">
|
||||
{plan()?.mode === "stream" && (plan() as { video: string }).video === "transcode"
|
||||
? "변환 재생"
|
||||
: "리먹스 재생"}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// 한국어 UI 문자열 테이블. 모든 사용자 노출 문자열은 여기서만 가져다 쓴다.
|
||||
export const ko = {
|
||||
app: {
|
||||
title: "Archive",
|
||||
loading: "불러오는 중…",
|
||||
},
|
||||
sidebar: {
|
||||
sources: "소스",
|
||||
folders: "폴더",
|
||||
tags: "태그",
|
||||
smartFolders: "스마트 폴더",
|
||||
dedupe: "중복 항목",
|
||||
addSource: "폴더",
|
||||
noSources: "등록된 소스가 없습니다.\n폴더를 추가해 시작하세요.",
|
||||
},
|
||||
onboarding: {
|
||||
title: "Archive에 오신 것을 환영합니다",
|
||||
subtitle: "사진·영상을 빠르게 조회하고 정리하세요.",
|
||||
addLocal: "폴더 추가",
|
||||
addRemote: "네트워크 소스 연결 (NAS)",
|
||||
hint: "수만 개의 파일도 인덱싱 후 즉시 스크롤·검색됩니다.",
|
||||
},
|
||||
grid: {
|
||||
empty: "표시할 항목이 없습니다",
|
||||
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
|
||||
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
|
||||
},
|
||||
details: {
|
||||
title: "상세 정보",
|
||||
noSelection: "항목을 선택하면 정보가 표시됩니다",
|
||||
fileName: "파일명",
|
||||
filePath: "경로",
|
||||
fileSize: "크기",
|
||||
dimensions: "해상도",
|
||||
duration: "재생 시간",
|
||||
codec: "코덱",
|
||||
takenAt: "촬영 일시",
|
||||
modifiedAt: "수정 일시",
|
||||
tags: "태그",
|
||||
},
|
||||
statusBar: {
|
||||
ready: "준비됨",
|
||||
scanning: (dir: string) => `스캔 중: ${dir}`,
|
||||
dataDir: "데이터 위치",
|
||||
portable: "포터블 모드",
|
||||
installed: "일반 모드",
|
||||
},
|
||||
search: {
|
||||
placeholder: "파일명 검색…",
|
||||
},
|
||||
common: {
|
||||
cancel: "취소",
|
||||
confirm: "확인",
|
||||
delete: "삭제",
|
||||
rename: "이름 바꾸기",
|
||||
move: "이동",
|
||||
undo: "실행 취소",
|
||||
create: "만들기",
|
||||
close: "닫기",
|
||||
},
|
||||
tags: {
|
||||
add: "태그 추가…",
|
||||
newPlaceholder: "새 태그 이름",
|
||||
assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`,
|
||||
removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`,
|
||||
deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`,
|
||||
exported: (path: string) => `태그를 내보냈습니다: ${path}`,
|
||||
exportAction: "태그 JSON 내보내기",
|
||||
},
|
||||
ops: {
|
||||
moveTo: "폴더로 이동…",
|
||||
moveDone: (n: number) => `${n}개 이동 완료`,
|
||||
moveFailed: (n: number) => `${n}개 이동 실패`,
|
||||
trashConfirm: (n: number) => `${n}개 항목을 휴지통으로 보낼까요?`,
|
||||
trashDone: (n: number) => `${n}개 항목을 휴지통으로 보냄`,
|
||||
renamePrompt: "새 이름",
|
||||
undone: (n: number) => `${n}개 작업을 되돌렸습니다`,
|
||||
nothingToUndo: "되돌릴 작업이 없습니다",
|
||||
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}…`,
|
||||
},
|
||||
smart: {
|
||||
create: "스마트 폴더 만들기",
|
||||
name: "이름",
|
||||
onlyImages: "사진만",
|
||||
onlyVideos: "영상만",
|
||||
untagged: "태그 없는 항목",
|
||||
withTags: "선택한 태그 포함",
|
||||
},
|
||||
remote: {
|
||||
add: "네트워크 소스",
|
||||
title: "네트워크 소스 연결",
|
||||
name: "이름",
|
||||
namePlaceholder: "예: 시놀로지 사진",
|
||||
host: "호스트",
|
||||
port: "포트",
|
||||
url: "WebDAV URL",
|
||||
username: "사용자",
|
||||
password: "비밀번호",
|
||||
basePath: "기준 경로",
|
||||
connect: "연결 및 스캔",
|
||||
connecting: "연결 중…",
|
||||
indexing: "인덱싱 중…",
|
||||
indexed: (n: number) => `${n.toLocaleString("ko-KR")}개 파일 인덱싱됨`,
|
||||
credNote: "비밀번호는 OS 키체인에 저장됩니다",
|
||||
},
|
||||
dedupe: {
|
||||
open: "중복 찾기",
|
||||
title: "중복 항목 검토",
|
||||
scan: "중복 탐지 시작",
|
||||
starting: "탐지 중…",
|
||||
scanning: (phase: string, cur: number, total: number) => {
|
||||
const labels: Record<string, string> = {
|
||||
exact: "완전 동일 검사",
|
||||
image: "유사 이미지 검사",
|
||||
video: "유사 영상 검사",
|
||||
};
|
||||
return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`;
|
||||
},
|
||||
threshold: "유사도 임계값",
|
||||
includeVideo: "영상 포함",
|
||||
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
|
||||
groupCount: (n: number) => `${n}개 그룹`,
|
||||
memberCount: (n: number) => `${n}개 항목`,
|
||||
keeper: "보관",
|
||||
keepBest: "최적 보관 (나머지 휴지통)",
|
||||
keepAll: "모두 보관 (무시)",
|
||||
trashLosersConfirm: (n: number) => `보관 항목을 제외한 ${n}개를 휴지통으로 보낼까요?`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type Ko = typeof ko;
|
||||
@@ -0,0 +1,400 @@
|
||||
// 유일한 RPC 경계 — Rust 커맨드 호출은 전부 이 모듈을 통해서만 한다.
|
||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export type { UnlistenFn };
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
dataDir: string;
|
||||
portable: boolean;
|
||||
mediaPort: number;
|
||||
mediaToken: string;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
root: string;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
export interface FolderNode {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
name: string;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
export interface FileDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
dir: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
durationMs: number | null;
|
||||
kind: number;
|
||||
takenAt: number | null;
|
||||
}
|
||||
|
||||
export interface ScanProgress {
|
||||
scanId: number;
|
||||
phase: "enumerate" | "metadata" | "done";
|
||||
seen: number;
|
||||
currentDir: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export const appInfo = () => invoke<AppInfo>("app_info");
|
||||
export const listSources = () => invoke<Source[]>("list_sources");
|
||||
export const addLocalSource = (path: string) =>
|
||||
invoke<Source>("add_local_source", { path });
|
||||
export const cancelScan = () => invoke<void>("cancel_scan");
|
||||
export const folderTree = (sourceId: number) =>
|
||||
invoke<FolderNode[]>("folder_tree", { sourceId });
|
||||
export const fileDetails = (ids: number[]) =>
|
||||
invoke<FileDetails[]>("file_details", { ids });
|
||||
export const setThumbPriority = (ids: number[]) =>
|
||||
invoke<void>("set_thumb_priority", { ids });
|
||||
|
||||
export function startScan(
|
||||
sourceId: number,
|
||||
onProgress: (p: ScanProgress) => void,
|
||||
): Promise<void> {
|
||||
const ch = new Channel<ScanProgress>();
|
||||
ch.onmessage = onProgress;
|
||||
return invoke<void>("start_scan", { sourceId, onProgress: ch });
|
||||
}
|
||||
|
||||
/** 스냅샷: 항목당 10바이트 packed 바이너리 (id u32, w u16, h u16, kind u8, flags u8) */
|
||||
export const ITEM_BYTES = 10;
|
||||
|
||||
export class Snapshot {
|
||||
private view: DataView;
|
||||
readonly count: number;
|
||||
|
||||
constructor(buf: ArrayBuffer) {
|
||||
this.view = new DataView(buf);
|
||||
this.count = Math.floor(buf.byteLength / ITEM_BYTES);
|
||||
}
|
||||
id(i: number): number {
|
||||
return this.view.getUint32(i * ITEM_BYTES, true);
|
||||
}
|
||||
width(i: number): number {
|
||||
return this.view.getUint16(i * ITEM_BYTES + 4, true);
|
||||
}
|
||||
height(i: number): number {
|
||||
return this.view.getUint16(i * ITEM_BYTES + 6, true);
|
||||
}
|
||||
kind(i: number): number {
|
||||
return this.view.getUint8(i * ITEM_BYTES + 8);
|
||||
}
|
||||
hasThumb(i: number): boolean {
|
||||
return (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0;
|
||||
}
|
||||
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
|
||||
aspectRatios(): Float32Array {
|
||||
const out = new Float32Array(this.count);
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
const w = this.width(i);
|
||||
const h = this.height(i);
|
||||
out[i] = w > 0 && h > 0 ? w / h : 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export async function folderSnapshot(args: {
|
||||
sourceId?: number;
|
||||
folderId?: number;
|
||||
recursive?: boolean;
|
||||
}): Promise<Snapshot> {
|
||||
const buf = await invoke<ArrayBuffer>("folder_snapshot", {
|
||||
sourceId: args.sourceId ?? null,
|
||||
folderId: args.folderId ?? null,
|
||||
recursive: args.recursive ?? true,
|
||||
});
|
||||
return new Snapshot(buf);
|
||||
}
|
||||
|
||||
export async function searchSnapshot(query: string): Promise<Snapshot> {
|
||||
const buf = await invoke<ArrayBuffer>("search_snapshot", { query });
|
||||
return new Snapshot(buf);
|
||||
}
|
||||
|
||||
// ── 태그 ────────────────────────────────────────────────
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
export interface TagOpResult {
|
||||
batchId: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const listTags = () => invoke<Tag[]>("list_tags");
|
||||
export const createTag = (name: string, color?: string) =>
|
||||
invoke<number>("create_tag", { name, color: color ?? null });
|
||||
export const renameTag = (tagId: number, name: string) =>
|
||||
invoke<void>("rename_tag", { tagId, name });
|
||||
export const deleteTag = (tagId: number) => invoke<void>("delete_tag", { tagId });
|
||||
export const assignTags = (fileIds: number[], tagId: number) =>
|
||||
invoke<TagOpResult>("assign_tags", { fileIds, tagId });
|
||||
export const unassignTags = (fileIds: number[], tagId: number) =>
|
||||
invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
|
||||
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId });
|
||||
export const exportTags = (path?: string) =>
|
||||
invoke<string>("export_tags", { path: path ?? null });
|
||||
|
||||
export async function tagSnapshot(tagId: number): Promise<Snapshot> {
|
||||
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId }));
|
||||
}
|
||||
|
||||
// ── 파일 작업 ───────────────────────────────────────────
|
||||
|
||||
export interface OpSummary {
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
batchId: string;
|
||||
}
|
||||
|
||||
export interface OpProgress {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function moveFiles(
|
||||
fileIds: number[],
|
||||
destFolderId: number,
|
||||
onProgress?: (p: OpProgress) => void,
|
||||
): Promise<OpSummary> {
|
||||
const ch = new Channel<OpProgress>();
|
||||
ch.onmessage = onProgress ?? (() => {});
|
||||
return invoke<OpSummary>("move_files", { fileIds, destFolderId, onProgress: ch });
|
||||
}
|
||||
|
||||
export const renameFile = (fileId: number, newName: string) =>
|
||||
invoke<void>("rename_file", { fileId, newName });
|
||||
|
||||
export function trashFiles(
|
||||
fileIds: number[],
|
||||
onProgress?: (p: OpProgress) => void,
|
||||
): Promise<OpSummary> {
|
||||
const ch = new Channel<OpProgress>();
|
||||
ch.onmessage = onProgress ?? (() => {});
|
||||
return invoke<OpSummary>("trash_files", { fileIds, onProgress: ch });
|
||||
}
|
||||
|
||||
export const undoLast = () => invoke<OpSummary | null>("undo_last");
|
||||
|
||||
// ── 스마트 폴더 ─────────────────────────────────────────
|
||||
|
||||
export interface SmartQuery {
|
||||
kind?: number | null;
|
||||
tagIds?: number[];
|
||||
untagged?: boolean;
|
||||
}
|
||||
|
||||
export interface SmartFolder {
|
||||
id: number;
|
||||
name: string;
|
||||
query: SmartQuery;
|
||||
}
|
||||
|
||||
export const listSmartFolders = () => invoke<SmartFolder[]>("list_smart_folders");
|
||||
export const createSmartFolder = (name: string, query: SmartQuery) =>
|
||||
invoke<number>("create_smart_folder", { name, query });
|
||||
export const deleteSmartFolder = (id: number) =>
|
||||
invoke<void>("delete_smart_folder", { id });
|
||||
|
||||
export async function smartSnapshot(id: number): Promise<Snapshot> {
|
||||
return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id }));
|
||||
}
|
||||
|
||||
// ── 원격 소스 ───────────────────────────────────────────
|
||||
|
||||
export interface RemoteInput {
|
||||
kind: "sftp" | "ftp" | "webdav";
|
||||
name: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
password: string;
|
||||
basePath?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export const addRemoteSource = (input: RemoteInput) =>
|
||||
invoke<number>("add_remote_source", { input });
|
||||
export const connectRemoteSource = (sourceId: number) =>
|
||||
invoke<boolean>("connect_remote_source", { sourceId });
|
||||
export const scanRemoteSource = (sourceId: number) =>
|
||||
invoke<number>("scan_remote_source", { sourceId });
|
||||
export const trashRemoteFiles = (sourceId: number, fileIds: number[]) =>
|
||||
invoke<OpSummary>("trash_remote_files", { sourceId, fileIds });
|
||||
export const unlockVault = (passphrase: string) =>
|
||||
invoke<void>("unlock_vault", { passphrase });
|
||||
|
||||
// ── 중복 탐지 ───────────────────────────────────────────
|
||||
|
||||
export interface DedupeProgress {
|
||||
phase: "exact" | "image" | "video" | "done";
|
||||
current: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DupeMember {
|
||||
fileId: number;
|
||||
name: string;
|
||||
dir: string;
|
||||
size: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
durationMs: number | null;
|
||||
kind: number;
|
||||
isKeeper: boolean;
|
||||
distance: number | null;
|
||||
}
|
||||
|
||||
export interface DupeGroup {
|
||||
id: number;
|
||||
kind: number; // 0 exact, 1 image, 2 video
|
||||
members: DupeMember[];
|
||||
}
|
||||
|
||||
export function startDedupe(
|
||||
args: { sourceId?: number; imageThreshold?: number; includeVideo?: boolean },
|
||||
onProgress: (p: DedupeProgress) => void,
|
||||
): Promise<void> {
|
||||
const ch = new Channel<DedupeProgress>();
|
||||
ch.onmessage = onProgress;
|
||||
return invoke<void>("start_dedupe", {
|
||||
sourceId: args.sourceId ?? null,
|
||||
imageThreshold: args.imageThreshold ?? 5,
|
||||
includeVideo: args.includeVideo ?? false,
|
||||
onProgress: ch,
|
||||
});
|
||||
}
|
||||
|
||||
export const cancelDedupe = () => invoke<void>("cancel_dedupe");
|
||||
export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups");
|
||||
export const resolveDupeGroup = (groupId: number) =>
|
||||
invoke<void>("resolve_dupe_group", { groupId });
|
||||
export const dismissDupePair = (fileA: number, fileB: number) =>
|
||||
invoke<void>("dismiss_dupe_pair", { fileA, fileB });
|
||||
|
||||
export const onDedupeDone = (cb: () => void): Promise<UnlistenFn> =>
|
||||
listen("dedupe-done", () => cb());
|
||||
|
||||
// ── 재생 ────────────────────────────────────────────────
|
||||
|
||||
export interface PlaybackInfo {
|
||||
ext: string | null;
|
||||
vcodec: string | null;
|
||||
acodec: string | null;
|
||||
durationMs: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
streamAvailable: boolean;
|
||||
}
|
||||
|
||||
export type VideoMode = "copy" | "copyhvc1" | "transcode";
|
||||
export type AudioMode = "copy" | "aac";
|
||||
|
||||
export interface StreamStart {
|
||||
jobId: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const playbackInfo = (fileId: number) =>
|
||||
invoke<PlaybackInfo>("playback_info", { fileId });
|
||||
|
||||
export const startStream = (
|
||||
fileId: number,
|
||||
startSeconds: number,
|
||||
video: VideoMode,
|
||||
audio: AudioMode,
|
||||
) => invoke<StreamStart>("start_stream", { fileId, startSeconds, video, audio });
|
||||
|
||||
export const stopStream = (jobId: number) => invoke<void>("stop_stream", { jobId });
|
||||
|
||||
// ── 재생 결정 로직 ──────────────────────────────────────
|
||||
|
||||
const V_MIME: Record<string, string> = {
|
||||
h264: 'video/mp4; codecs="avc1.640028"',
|
||||
hevc: 'video/mp4; codecs="hvc1.1.6.L120.90"',
|
||||
vp9: 'video/webm; codecs="vp09.00.40.08"',
|
||||
vp8: 'video/webm; codecs="vp8"',
|
||||
av1: 'video/mp4; codecs="av01.0.08M.08"',
|
||||
};
|
||||
const A_MIME: Record<string, string> = {
|
||||
aac: 'audio/mp4; codecs="mp4a.40.2"',
|
||||
mp3: 'audio/mpeg',
|
||||
opus: 'audio/webm; codecs="opus"',
|
||||
vorbis: 'audio/webm; codecs="vorbis"',
|
||||
flac: 'audio/mp4; codecs="flac"',
|
||||
};
|
||||
|
||||
const probe = document.createElement("video");
|
||||
const canPlay = (mime: string | undefined): boolean =>
|
||||
!!mime && probe.canPlayType(mime) !== "";
|
||||
|
||||
const DIRECT_CONTAINERS = new Set(["mp4", "m4v", "mov", "webm"]);
|
||||
/** fMP4 컨테이너에 카피해도 안전한 오디오 */
|
||||
const MP4_SAFE_AUDIO = new Set(["aac", "mp3"]);
|
||||
|
||||
export type PlaybackPlan =
|
||||
| { mode: "direct" }
|
||||
| { mode: "stream"; video: VideoMode; audio: AudioMode }
|
||||
| { mode: "unsupported"; reason: string };
|
||||
|
||||
export function decidePlayback(info: PlaybackInfo): PlaybackPlan {
|
||||
const v = info.vcodec ?? "";
|
||||
const a = info.acodec;
|
||||
const vOk = canPlay(V_MIME[v]);
|
||||
const aOk = !a || canPlay(A_MIME[a]);
|
||||
const containerOk = DIRECT_CONTAINERS.has(info.ext ?? "");
|
||||
|
||||
if (containerOk && vOk && aOk) return { mode: "direct" };
|
||||
if (!info.streamAvailable) {
|
||||
return {
|
||||
mode: "unsupported",
|
||||
reason: `이 형식(${info.ext}/${v || "?"})은 ffmpeg 사이드카 없이 재생할 수 없습니다`,
|
||||
};
|
||||
}
|
||||
// 영상: H.264(및 재생 가능한 HEVC)만 카피, 그 외 트랜스코딩
|
||||
const videoMode: VideoMode =
|
||||
v === "h264" && canPlay(V_MIME.h264)
|
||||
? "copy"
|
||||
: v === "hevc" && canPlay(V_MIME.hevc)
|
||||
? "copyhvc1"
|
||||
: "transcode";
|
||||
const audioMode: AudioMode = !a || MP4_SAFE_AUDIO.has(a) ? "copy" : "aac";
|
||||
return { mode: "stream", video: videoMode, audio: audioMode };
|
||||
}
|
||||
|
||||
// ── 이벤트 ──────────────────────────────────────────────
|
||||
|
||||
export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
|
||||
listen<number[]>("thumbs-ready", (e) => cb(e.payload));
|
||||
|
||||
export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
|
||||
listen<number>("library-changed", (e) => cb(e.payload));
|
||||
|
||||
// ── 미디어 URL ──────────────────────────────────────────
|
||||
|
||||
export function thumbUrl(info: AppInfo, fileId: number, sizeClass: 0 | 1 = 0): string {
|
||||
return `http://127.0.0.1:${info.mediaPort}/thumb/${info.mediaToken}/${fileId}?s=${sizeClass}`;
|
||||
}
|
||||
|
||||
export function mediaUrl(info: AppInfo, fileId: number): string {
|
||||
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
import "./styles/app.css";
|
||||
|
||||
render(() => <App />, document.getElementById("root")!);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createResource, createSignal, onCleanup, Show, type Component } from "solid-js";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { ko } from "../i18n/ko";
|
||||
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
|
||||
import { refreshSidebar, setView, snapshot, updateScanProgress, view } from "../state/store";
|
||||
import { addLocalSource, listSources, startScan } from "../ipc/commands";
|
||||
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
||||
|
||||
export const ContentArea: Component = () => {
|
||||
const [q, setQ] = createSignal("");
|
||||
let debounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const onInput = (value: string) => {
|
||||
setQ(value);
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(() => {
|
||||
const query = value.trim();
|
||||
if (query.length > 0) void setView({ type: "search", query });
|
||||
else if (view().type === "search") void setView({ type: "all" });
|
||||
}, 250);
|
||||
};
|
||||
onCleanup(() => clearTimeout(debounce));
|
||||
|
||||
// 소스가 하나도 없으면 온보딩 표시
|
||||
const [sources] = createResource(listSources);
|
||||
const isEmpty = () => sources() && sources()!.length === 0;
|
||||
|
||||
const addLocal = async () => {
|
||||
const dir = await open({ directory: true, multiple: false, title: ko.onboarding.addLocal });
|
||||
if (typeof dir !== "string") return;
|
||||
const src = await addLocalSource(dir);
|
||||
refreshSidebar();
|
||||
await startScan(src.id, updateScanProgress);
|
||||
};
|
||||
|
||||
return (
|
||||
<main class="flex min-h-0 flex-col bg-[var(--bg-app)]">
|
||||
<div class="flex h-11 shrink-0 items-center gap-3 border-b border-[var(--border)] px-3">
|
||||
<input
|
||||
type="search"
|
||||
value={q()}
|
||||
onInput={(e) => onInput(e.currentTarget.value)}
|
||||
placeholder={ko.search.placeholder}
|
||||
class="h-7 w-64 rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
|
||||
/>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">
|
||||
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Show
|
||||
when={isEmpty()}
|
||||
fallback={<JustifiedGrid />}
|
||||
>
|
||||
<div class="grid flex-1 place-items-center">
|
||||
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
|
||||
<div class="text-lg font-semibold text-[var(--text-primary)]">{ko.onboarding.title}</div>
|
||||
<div class="text-sm text-[var(--text-secondary)]">{ko.onboarding.subtitle}</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
class="rounded-md bg-[var(--accent)] px-4 py-2 text-xs text-white"
|
||||
onClick={() => void addLocal()}
|
||||
>
|
||||
{ko.onboarding.addLocal}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={openRemoteDialog}
|
||||
>
|
||||
{ko.onboarding.addRemote}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-[var(--text-muted)]">{ko.onboarding.hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
type Component,
|
||||
} from "solid-js";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { ko } from "../i18n/ko";
|
||||
import {
|
||||
addLocalSource,
|
||||
createSmartFolder,
|
||||
createTag,
|
||||
deleteSmartFolder,
|
||||
deleteTag,
|
||||
exportTags,
|
||||
folderTree,
|
||||
listSmartFolders,
|
||||
listSources,
|
||||
listTags,
|
||||
startScan,
|
||||
type FolderNode,
|
||||
type Source,
|
||||
} from "../ipc/commands";
|
||||
import {
|
||||
openDedupe,
|
||||
refreshSidebar,
|
||||
refreshSnapshot,
|
||||
setView,
|
||||
sidebarVersion,
|
||||
toast,
|
||||
updateScanProgress,
|
||||
view,
|
||||
} from "../state/store";
|
||||
import { performMove } from "../features/fileops/MoveDialog";
|
||||
import { openRemoteDialog } from "../features/sources/RemoteDialog";
|
||||
|
||||
const SectionHeader: Component<{ label: string; action?: () => void; actionLabel?: string }> = (
|
||||
props,
|
||||
) => (
|
||||
<div class="flex items-center justify-between px-3 pt-4 pb-1.5">
|
||||
<span class="text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
|
||||
{props.label}
|
||||
</span>
|
||||
<Show when={props.action}>
|
||||
<button
|
||||
class="rounded px-1 text-[13px] leading-none text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
|
||||
onClick={props.action}
|
||||
title={props.actionLabel}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TreeNode extends FolderNode {
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
function buildTree(nodes: FolderNode[]): TreeNode[] {
|
||||
const map = new Map<number, TreeNode>();
|
||||
const roots: TreeNode[] = [];
|
||||
for (const n of nodes) map.set(n.id, { ...n, children: [] });
|
||||
for (const n of map.values()) {
|
||||
if (n.parentId != null && map.has(n.parentId)) map.get(n.parentId)!.children.push(n);
|
||||
else roots.push(n);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
const FolderRow: Component<{ node: TreeNode; depth: number }> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(props.depth === 0);
|
||||
const [dragOver, setDragOver] = createSignal(false);
|
||||
const isActive = () => {
|
||||
const v = view();
|
||||
return v.type === "folder" && v.folderId === props.node.id;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)]": isActive(),
|
||||
"outline outline-1 outline-[var(--accent)]": dragOver(),
|
||||
}}
|
||||
style={{ "padding-left": `${8 + props.depth * 14}px` }}
|
||||
onClick={() => void setView({ type: "folder", folderId: props.node.id, recursive: true })}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types.includes("application/x-archive-move")) {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
void performMove(props.node.id).catch((err) => toast(String(err)));
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="w-3 shrink-0 text-[var(--text-muted)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded());
|
||||
}}
|
||||
>
|
||||
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
|
||||
</span>
|
||||
<span class="truncate text-[var(--text-primary)]">{props.node.name}</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
|
||||
{props.node.fileCount > 0 ? props.node.fileCount.toLocaleString("ko-KR") : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<For each={props.node.children}>
|
||||
{(c) => <FolderRow node={c} depth={props.depth + 1} />}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SourceRow: Component<{ source: Source }> = (props) => {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
const [tree] = createResource(
|
||||
() => (expanded() ? `${props.source.id}:${sidebarVersion()}` : undefined),
|
||||
async () => buildTree(await folderTree(props.source.id)),
|
||||
);
|
||||
const isActive = () => {
|
||||
const v = view();
|
||||
return v.type === "source" && v.sourceId === props.source.id;
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
classList={{ "bg-[var(--bg-active)]": isActive() }}
|
||||
onClick={() => void setView({ type: "source", sourceId: props.source.id })}
|
||||
>
|
||||
<span
|
||||
class="w-3 shrink-0 text-[var(--text-muted)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded());
|
||||
}}
|
||||
>
|
||||
{expanded() ? "▾" : "▸"}
|
||||
</span>
|
||||
<span class="truncate font-medium text-[var(--text-primary)]" title={props.source.root}>
|
||||
{props.source.name}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
|
||||
{props.source.fileCount.toLocaleString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={expanded() && tree()}>
|
||||
<For each={tree()}>{(n) => <FolderRow node={n} depth={1} />}</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar: Component = () => {
|
||||
const [sources, { refetch }] = createResource(
|
||||
() => sidebarVersion(),
|
||||
() => listSources(),
|
||||
);
|
||||
const [tags] = createResource(
|
||||
() => sidebarVersion(),
|
||||
() => listTags(),
|
||||
);
|
||||
const [smartFolders] = createResource(
|
||||
() => sidebarVersion(),
|
||||
() => listSmartFolders(),
|
||||
);
|
||||
const [newTagMode, setNewTagMode] = createSignal(false);
|
||||
|
||||
const onAddSource = async () => {
|
||||
const dir = await open({ directory: true, multiple: false, title: ko.sidebar.addSource });
|
||||
if (typeof dir !== "string") return;
|
||||
try {
|
||||
const src = await addLocalSource(dir);
|
||||
void refetch();
|
||||
await startScan(src.id, updateScanProgress);
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateTag = async (name: string) => {
|
||||
setNewTagMode(false);
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
await createTag(trimmed);
|
||||
refreshSidebar();
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteTag = async (id: number, name: string) => {
|
||||
const yes = await ask(ko.tags.deleteConfirm(name), { title: "Archive", kind: "warning" });
|
||||
if (!yes) return;
|
||||
await deleteTag(id);
|
||||
refreshSidebar();
|
||||
if (view().type === "tag") void setView({ type: "all" });
|
||||
};
|
||||
|
||||
const onCreateSmart = async (name: string, query: Parameters<typeof createSmartFolder>[1]) => {
|
||||
await createSmartFolder(name, query);
|
||||
refreshSidebar();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside class="flex min-h-0 flex-col overflow-y-auto border-r border-[var(--border)] bg-[var(--bg-panel)] pb-4">
|
||||
<div class="flex items-center justify-between pr-2">
|
||||
<SectionHeader label={ko.sidebar.sources} />
|
||||
<div class="mt-2 flex gap-1">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void onAddSource()}
|
||||
>
|
||||
+ {ko.sidebar.addSource}
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={openRemoteDialog}
|
||||
>
|
||||
+ {ko.remote.add}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mx-1 cursor-pointer rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
classList={{ "bg-[var(--bg-active)]": view().type === "all" }}
|
||||
onClick={() => void setView({ type: "all" })}
|
||||
>
|
||||
전체 항목
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={sources() && sources()!.length > 0}
|
||||
fallback={
|
||||
<div class="whitespace-pre-line px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
|
||||
{ko.sidebar.noSources}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="mx-1">
|
||||
<For each={sources()}>{(s) => <SourceRow source={s} />}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* 태그 */}
|
||||
<SectionHeader label={ko.sidebar.tags} action={() => setNewTagMode(true)} actionLabel={ko.tags.newPlaceholder} />
|
||||
<Show when={newTagMode()}>
|
||||
<input
|
||||
class="mx-3 mb-1 rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
|
||||
placeholder={ko.tags.newPlaceholder}
|
||||
ref={(el) => queueMicrotask(() => el.focus())}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void onCreateTag(e.currentTarget.value);
|
||||
if (e.key === "Escape") setNewTagMode(false);
|
||||
}}
|
||||
onBlur={() => setNewTagMode(false)}
|
||||
/>
|
||||
</Show>
|
||||
<div class="mx-1">
|
||||
<For each={tags()}>
|
||||
{(t) => (
|
||||
<div
|
||||
class="group flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)]": view().type === "tag" && (view() as { tagId: number }).tagId === t.id,
|
||||
}}
|
||||
onClick={() => void setView({ type: "tag", tagId: t.id })}
|
||||
>
|
||||
<span class="text-[var(--accent)]">#</span>
|
||||
<span class="truncate">{t.name}</span>
|
||||
<span class="ml-auto text-[10px] text-[var(--text-muted)]">
|
||||
{t.fileCount.toLocaleString("ko-KR")}
|
||||
</span>
|
||||
<button
|
||||
class="hidden rounded px-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--danger)] group-hover:block"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void onDeleteTag(t.id, t.name);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={(tags()?.length ?? 0) > 0}>
|
||||
<button
|
||||
class="mx-3 mt-1 self-start rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() =>
|
||||
void exportTags().then((p) => toast(ko.tags.exported(p))).catch((e) => toast(String(e)))
|
||||
}
|
||||
>
|
||||
{ko.tags.exportAction}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* 스마트 폴더 */}
|
||||
<SectionHeader label={ko.sidebar.smartFolders} />
|
||||
<div class="mx-1">
|
||||
<For each={smartFolders()}>
|
||||
{(sf) => (
|
||||
<div
|
||||
class="group flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
|
||||
classList={{
|
||||
"bg-[var(--bg-active)]":
|
||||
view().type === "smart" && (view() as { smartId: number }).smartId === sf.id,
|
||||
}}
|
||||
onClick={() => void setView({ type: "smart", smartId: sf.id })}
|
||||
>
|
||||
<span class="text-[var(--text-muted)]">⚡</span>
|
||||
<span class="truncate">{sf.name}</span>
|
||||
<button
|
||||
class="ml-auto hidden rounded px-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--danger)] group-hover:block"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteSmartFolder(sf.id).then(refreshSidebar);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={(smartFolders()?.length ?? 0) === 0}>
|
||||
<div class="flex flex-wrap gap-1 px-2 py-1">
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void onCreateSmart(ko.smart.onlyVideos, { kind: 1 })}
|
||||
>
|
||||
+ {ko.smart.onlyVideos}
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void onCreateSmart(ko.smart.onlyImages, { kind: 0 })}
|
||||
>
|
||||
+ {ko.smart.onlyImages}
|
||||
</button>
|
||||
<button
|
||||
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={() => void onCreateSmart(ko.smart.untagged, { untagged: true })}
|
||||
>
|
||||
+ {ko.smart.untagged}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<SectionHeader label={ko.sidebar.dedupe} />
|
||||
<button
|
||||
class="mx-3 mt-1 self-start rounded border border-[var(--border)] px-2 py-1 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
|
||||
onClick={openDedupe}
|
||||
>
|
||||
{ko.dedupe.open}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
// 스캔 완료 후 사이드바+그리드 새로고침을 위해 외부에서 호출
|
||||
export function onLibraryChangedRefresh(): void {
|
||||
refreshSidebar();
|
||||
void refreshSnapshot();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Show, type Component } from "solid-js";
|
||||
import { ko } from "../i18n/ko";
|
||||
import { info, scanProgress, selected, snapshot, statusMessage } from "../state/store";
|
||||
|
||||
export const StatusBar: Component = () => {
|
||||
return (
|
||||
<footer class="flex h-6 items-center gap-3 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 text-[11px] text-[var(--text-secondary)]">
|
||||
<Show when={statusMessage()}>
|
||||
<span class="text-[var(--accent)]">{statusMessage()}</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={scanProgress()}
|
||||
fallback={
|
||||
<span>
|
||||
{snapshot() ? ko.grid.items(snapshot()!.count) : ko.statusBar.ready}
|
||||
<Show when={selected().size > 0}>
|
||||
<span class="ml-2 text-[var(--accent)]">{ko.grid.selected(selected().size)}</span>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(p) => (
|
||||
<span class="text-[var(--accent)]">
|
||||
{ko.statusBar.scanning(p().currentDir)} — {p().seen.toLocaleString("ko-KR")}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={info()}>
|
||||
{(i) => (
|
||||
<>
|
||||
<span class="ml-auto truncate text-[var(--text-muted)]" title={i().dataDir}>
|
||||
{i().portable ? ko.statusBar.portable : ko.statusBar.installed}
|
||||
</span>
|
||||
<span class="text-[var(--text-muted)]">v{i().version}</span>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
// 전역 상태 — Solid 시그널 기반 단일 스토어 모듈.
|
||||
import { createSignal } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import {
|
||||
appInfo,
|
||||
folderSnapshot,
|
||||
searchSnapshot,
|
||||
smartSnapshot,
|
||||
tagSnapshot,
|
||||
Snapshot,
|
||||
type AppInfo,
|
||||
type ScanProgress,
|
||||
} from "../ipc/commands";
|
||||
|
||||
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
|
||||
const [info, setInfo] = createSignal<AppInfo | undefined>();
|
||||
export { info };
|
||||
|
||||
export async function loadAppInfo(): Promise<void> {
|
||||
try {
|
||||
setInfo(await appInfo());
|
||||
} catch (e) {
|
||||
console.error("app_info 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 현재 뷰 ─────────────────────────────────────────────
|
||||
export type View =
|
||||
| { type: "all" }
|
||||
| { type: "source"; sourceId: number }
|
||||
| { type: "folder"; folderId: number; recursive: boolean }
|
||||
| { type: "search"; query: string }
|
||||
| { type: "tag"; tagId: number }
|
||||
| { type: "smart"; smartId: number };
|
||||
|
||||
const [view, setViewSignal] = createSignal<View>({ type: "all" });
|
||||
const [snapshot, setSnapshot] = createSignal<Snapshot | undefined>();
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
export { view, snapshot, loading };
|
||||
|
||||
let viewSeq = 0;
|
||||
|
||||
export async function setView(v: View): Promise<void> {
|
||||
setViewSignal(v);
|
||||
clearSelection();
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
export async function refreshSnapshot(): Promise<void> {
|
||||
const v = view();
|
||||
const seq = ++viewSeq;
|
||||
setLoading(true);
|
||||
try {
|
||||
let snap: Snapshot;
|
||||
switch (v.type) {
|
||||
case "all":
|
||||
snap = await folderSnapshot({});
|
||||
break;
|
||||
case "source":
|
||||
snap = await folderSnapshot({ sourceId: v.sourceId });
|
||||
break;
|
||||
case "folder":
|
||||
snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive });
|
||||
break;
|
||||
case "search":
|
||||
snap = await searchSnapshot(v.query);
|
||||
break;
|
||||
case "tag":
|
||||
snap = await tagSnapshot(v.tagId);
|
||||
break;
|
||||
case "smart":
|
||||
snap = await smartSnapshot(v.smartId);
|
||||
break;
|
||||
}
|
||||
if (seq === viewSeq) setSnapshot(snap);
|
||||
} finally {
|
||||
if (seq === viewSeq) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 선택 ────────────────────────────────────────────────
|
||||
const [selected, setSelected] = createSignal<ReadonlySet<number>>(new Set());
|
||||
const [anchorIndex, setAnchorIndex] = createSignal<number>(-1);
|
||||
export { selected, anchorIndex };
|
||||
|
||||
export function selectSingle(id: number, index: number): void {
|
||||
setSelected(new Set([id]));
|
||||
setAnchorIndex(index);
|
||||
}
|
||||
|
||||
export function toggleSelect(id: number, index: number): void {
|
||||
const next = new Set<number>(selected());
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelected(next);
|
||||
setAnchorIndex(index);
|
||||
}
|
||||
|
||||
export function selectRange(snap: Snapshot, toIndex: number): void {
|
||||
const from = anchorIndex() < 0 ? toIndex : anchorIndex();
|
||||
const [a, b] = from <= toIndex ? [from, toIndex] : [toIndex, from];
|
||||
const next = new Set<number>();
|
||||
for (let i = a; i <= b; i++) next.add(snap.id(i));
|
||||
setSelected(next);
|
||||
}
|
||||
|
||||
export function clearSelection(): void {
|
||||
setSelected(new Set<number>());
|
||||
setAnchorIndex(-1);
|
||||
}
|
||||
|
||||
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
|
||||
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
|
||||
export { thumbVersions };
|
||||
|
||||
export function bumpThumbVersions(ids: number[]): void {
|
||||
setThumbVersions(
|
||||
produce((s) => {
|
||||
for (const id of ids) s[id] = (s[id] ?? 0) + 1;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 스캔 진행 ───────────────────────────────────────────
|
||||
const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>();
|
||||
export { scanProgress };
|
||||
|
||||
export function updateScanProgress(p: ScanProgress): void {
|
||||
setScanProgress(p.done ? undefined : p);
|
||||
}
|
||||
|
||||
// ── 사이드바 새로고침 트리거 ─────────────────────────────
|
||||
const [sidebarVersion, setSidebarVersion] = createSignal(0);
|
||||
export { sidebarVersion };
|
||||
export const refreshSidebar = () => setSidebarVersion((v) => v + 1);
|
||||
|
||||
// ── 중복 검토 패널 ───────────────────────────────────────
|
||||
const [dedupeOpen, setDedupeOpen] = createSignal(false);
|
||||
export { dedupeOpen };
|
||||
export const openDedupe = () => setDedupeOpen(true);
|
||||
export const closeDedupe = () => setDedupeOpen(false);
|
||||
|
||||
// ── 상태 메시지 (토스트) ─────────────────────────────────
|
||||
const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
|
||||
export { statusMessage };
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
export function toast(msg: string): void {
|
||||
setStatusMessage(msg);
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => setStatusMessage(undefined), 4000);
|
||||
}
|
||||
|
||||
// ── 라이트박스 ──────────────────────────────────────────
|
||||
const [lightboxIndex, setLightboxIndex] = createSignal<number | null>(null);
|
||||
export { lightboxIndex };
|
||||
|
||||
export function openLightbox(index: number): void {
|
||||
const snap = snapshot();
|
||||
if (!snap || index < 0 || index >= snap.count) return;
|
||||
setLightboxIndex(index);
|
||||
}
|
||||
|
||||
export function closeLightbox(): void {
|
||||
setLightboxIndex(null);
|
||||
}
|
||||
|
||||
export function navLightbox(delta: number): void {
|
||||
const snap = snapshot();
|
||||
const cur = lightboxIndex();
|
||||
if (!snap || cur == null) return;
|
||||
const next = Math.min(snap.count - 1, Math.max(0, cur + delta));
|
||||
setLightboxIndex(next);
|
||||
// 라이트박스 이동 시 선택도 따라간다
|
||||
selectSingle(snap.id(next), next);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 다크 테마 팔레트 — 모든 색은 변수로만 사용한다 */
|
||||
:root {
|
||||
--bg-app: #1b1d21;
|
||||
--bg-panel: #232529;
|
||||
--bg-panel-raised: #2a2d33;
|
||||
--bg-hover: #33363d;
|
||||
--bg-active: #3b3f48;
|
||||
--border: #34373e;
|
||||
--border-strong: #45494f;
|
||||
--text-primary: #e6e8eb;
|
||||
--text-secondary: #a3a8b0;
|
||||
--text-muted: #6f747d;
|
||||
--accent: #4f8cff;
|
||||
--accent-soft: rgba(79, 140, 255, 0.18);
|
||||
--danger: #ff5f57;
|
||||
--grid-cell-bg: #2a2d33;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
font-family:
|
||||
"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* 스크롤바 (WebView2/WKWebView 공통) */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 5px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user