즐겨찾기 기능 구현

This commit is contained in:
static
2026-01-17 19:41:52 +09:00
parent befa535526
commit 420e30f677
24 changed files with 605 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const favorites = await createCaller(event).favorites.get();
return { favorites };
};

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { RowVirtualizer } from "$lib/components/atoms";
import { masterKeyStore } from "$lib/stores";
import Directory from "./Directory.svelte";
import File from "./File.svelte";
import { requestFavoriteEntries, requestRemoveFavorite, type FavoriteEntry } from "./service";
let { data } = $props();
let entries: FavoriteEntry[] = $state([]);
let isLoading = $state(true);
onMount(async () => {
const masterKey = $masterKeyStore?.get(1)?.key;
if (masterKey) {
entries = await requestFavoriteEntries(data.favorites, masterKey);
}
isLoading = false;
});
const handleRemove = async (entry: FavoriteEntry) => {
if (await requestRemoveFavorite(entry.type, entry.details.id)) {
entries = entries.filter(
(e) => !(e.type === entry.type && e.details.id === entry.details.id),
);
}
};
const handleClick = (entry: FavoriteEntry) => {
goto(
entry.type === "file"
? `/file/${entry.details.id}?from=favorite`
: `/directory/${entry.details.id}?from=favorite`,
);
};
</script>
<svelte:head>
<title>즐겨찾기</title>
</svelte:head>
<div class="flex h-full flex-col p-4">
{#if isLoading}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">
{#if data.favorites.files.length === 0 && data.favorites.directories.length === 0}
즐겨찾기한 항목이 없어요.
{:else}
로딩 중...
{/if}
</p>
</div>
{:else if entries.length === 0}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">즐겨찾기한 항목이 없어요.</p>
</div>
{:else}
<RowVirtualizer
count={entries.length}
getItemKey={(index) => `${entries[index]!.type}-${entries[index]!.details.id}`}
estimateItemHeight={() => 56}
itemGap={4}
>
{#snippet item(index)}
{@const entry = entries[index]!}
{#if entry.type === "directory"}
<Directory
info={entry.details}
onclick={() => handleClick(entry)}
onRemoveClick={() => handleRemove(entry)}
/>
{:else}
<File
info={entry.details}
onclick={() => handleClick(entry)}
onRemoveClick={() => handleRemove(entry)}
/>
{/if}
{/snippet}
</RowVirtualizer>
{/if}
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { SubDirectoryInfo } from "$lib/modules/filesystem";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: SubDirectoryInfo;
onclick: () => void;
onRemoveClick: () => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={IconClose}
onActionButtonClick={onRemoveClick}
>
<DirectoryEntryLabel type="directory" name={info.name} />
</ActionEntryButton>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: SummarizedFileInfo;
onclick: () => void;
onRemoveClick: () => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={IconClose}
onActionButtonClick={onRemoveClick}
>
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
</ActionEntryButton>

View File

@@ -0,0 +1,86 @@
import {
decryptDirectoryMetadata,
decryptFileMetadata,
getFileInfo,
type SummarizedFileInfo,
type SubDirectoryInfo,
} from "$lib/modules/filesystem";
import { HybridPromise, sortEntries } from "$lib/utils";
import { trpc } from "$trpc/client";
import type { RouterOutputs } from "$trpc/router.server";
export type FavoriteEntry =
| { type: "directory"; name: string; details: SubDirectoryInfo }
| { type: "file"; name: string; details: SummarizedFileInfo };
export const requestFavoriteEntries = async (
favorites: RouterOutputs["favorites"]["get"],
masterKey: CryptoKey,
): Promise<FavoriteEntry[]> => {
const directories: FavoriteEntry[] = await Promise.all(
favorites.directories.map(async (dir) => {
const metadata = await decryptDirectoryMetadata(dir, masterKey);
return {
type: "directory" as const,
name: metadata.name,
details: {
id: dir.id,
parentId: dir.parent,
isFavorite: true,
dataKey: metadata.dataKey,
name: metadata.name,
} as SubDirectoryInfo,
};
}),
);
const fileResults = await Promise.all(
favorites.files.map(async (file) => {
const result = await HybridPromise.resolve(
getFileInfo(file.id, masterKey, {
async fetchFromServer(id, cachedInfo) {
const metadata = await decryptFileMetadata(file, masterKey);
return {
categories: [],
...cachedInfo,
id: id as number,
exists: true,
parentId: file.parent,
contentType: file.contentType,
isFavorite: true,
...metadata,
};
},
}),
);
if (result?.exists) {
return {
type: "file" as const,
name: result.name,
details: result as SummarizedFileInfo,
};
}
return null;
}),
);
const files = fileResults.filter(
(f): f is { type: "file"; name: string; details: SummarizedFileInfo } => f !== null,
);
return [...sortEntries(directories), ...sortEntries(files)];
};
export const requestRemoveFavorite = async (type: "file" | "directory", id: number) => {
try {
if (type === "directory") {
await trpc().favorites.removeDirectory.mutate({ id });
} else {
await trpc().favorites.removeFile.mutate({ id });
}
return true;
} catch {
// TODO: Error Handling
return false;
}
};