즐겨찾기 기능 구현

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

@@ -12,7 +12,7 @@
const pages = [
{ path: "/home", label: "홈", icon: IconHome },
{ path: "/directory", label: "파일", icon: IconFolder },
{ path: "/favorite", label: "즐겨찾기", icon: IconFavorite },
{ path: "/favorites", label: "즐겨찾기", icon: IconFavorite },
{ path: "/category", label: "카테고리", icon: IconCategory },
{ path: "/menu", label: "전체", icon: IconMenu },
];

View File

@@ -23,6 +23,7 @@
requestFileUpload,
requestEntryRename,
requestEntryDeletion,
requestFavoriteToggle,
} from "./service.svelte";
import IconSearch from "~icons/material-symbols/search";
@@ -45,7 +46,7 @@
let isEntryDeleteModalOpen = $state(false);
let showParentEntry = $derived(
["file", "search"].includes(page.url.searchParams.get("from") ?? ""),
["file", "search", "favorite"].includes(page.url.searchParams.get("from") ?? ""),
);
let showBackButton = $derived(data.id !== "root" || showParentEntry);
@@ -194,6 +195,12 @@
isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true;
}}
onFavoriteClick={async () => {
if (await requestFavoriteToggle(context.selectedEntry!)) {
isEntryMenuBottomSheetOpen = false;
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}
}}
/>
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}

View File

@@ -19,7 +19,13 @@
let thumbnail = $derived(getFileThumbnail(info));
const action = (callback: typeof onclick) => {
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
callback({
type: "file",
id: info.id,
dataKey: info.dataKey,
name: info.name,
isFavorite: info.isFavorite ?? false,
});
};
</script>

View File

@@ -15,7 +15,13 @@
let { info, onclick, onOpenMenuClick }: Props = $props();
const action = (callback: typeof onclick) => {
callback({ type: "directory", id: info.id, dataKey: info.dataKey, name: info.name });
callback({
type: "directory",
id: info.id,
dataKey: info.dataKey,
name: info.name,
isFavorite: info.isFavorite ?? false,
});
};
</script>

View File

@@ -3,24 +3,35 @@
import { DirectoryEntryLabel, IconEntryButton } from "$lib/components/molecules";
import { useContext } from "./service.svelte";
import IconFavorite from "~icons/material-symbols/favorite";
import IconFavoriteBorder from "~icons/material-symbols/favorite-outline";
import IconEdit from "~icons/material-symbols/edit";
import IconDelete from "~icons/material-symbols/delete";
interface Props {
isOpen: boolean;
onDeleteClick: () => void;
onFavoriteClick: () => void;
onRenameClick: () => void;
}
let { isOpen = $bindable(), onDeleteClick, onRenameClick }: Props = $props();
let { isOpen = $bindable(), onDeleteClick, onFavoriteClick, onRenameClick }: Props = $props();
let context = useContext();
</script>
{#if context.selectedEntry}
{@const { name, type } = context.selectedEntry}
{@const { name, type, isFavorite } = context.selectedEntry}
<BottomSheet bind:isOpen class="p-4">
<DirectoryEntryLabel {type} {name} class="h-12 p-2" textClass="!font-semibold" />
<div class="my-2 h-px w-full bg-gray-200"></div>
<IconEntryButton
icon={isFavorite ? IconFavorite : IconFavoriteBorder}
onclick={onFavoriteClick}
class="h-12 w-full"
iconClass={isFavorite ? "text-red-500" : ""}
>
{isFavorite ? "즐겨찾기에서 해제하기" : "즐겨찾기에 추가하기"}
</IconEntryButton>
<IconEntryButton icon={IconEdit} onclick={onRenameClick} class="h-12 w-full">
이름 바꾸기
</IconEntryButton>

View File

@@ -17,6 +17,7 @@ export interface SelectedEntry {
id: number;
dataKey: DataKey | undefined;
name: string;
isFavorite: boolean;
}
export const createContext = () => {
@@ -149,3 +150,25 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => {
return false;
}
};
export const requestFavoriteToggle = async (entry: SelectedEntry) => {
try {
if (entry.type === "directory") {
if (entry.isFavorite) {
await trpc().favorites.removeDirectory.mutate({ id: entry.id });
} else {
await trpc().favorites.addDirectory.mutate({ id: entry.id });
}
} else {
if (entry.isFavorite) {
await trpc().favorites.removeFile.mutate({ id: entry.id });
} else {
await trpc().favorites.addFile.mutate({ id: entry.id });
}
}
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,3 +0,0 @@
<div class="flex h-full items-center justify-center p-4">
<p class="text-gray-500">아직 개발 중이에요.</p>
</div>

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;
}
};