리다이렉트 경로 처리, 파일 정보 변환, 상태 표시 UI 등 중복된 코드를 통합하고 모듈 간 순환 의존성 및 계층 위반 해소

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 08:51:07 +09:00
1 parent 9119c21881
commit 5b8621e524
96 files changed
+452 -548

No files matched your search

+8 -8
View File
@@ -1,6 +1,8 @@
import {
getDirectoryInfo,
getFileInfo,
isExistingFileInfo,
isLocalDirectoryInfo,
setDirectoryFavorite,
setFileFavorite,
type LocalDirectoryInfo,
@@ -29,18 +31,16 @@ export const getFavoriteEntries = async () => {
]);
return [
...sortEntries(
directories.map((directory): FavoriteEntry => ({
directories.filter(isLocalDirectoryInfo).map((directory): FavoriteEntry => ({
type: "directory",
name: directory.name!,
details: directory as LocalDirectoryInfo,
name: directory.name,
details: directory,
})),
),
...sortEntries(
files.map((file): FavoriteEntry => ({
type: "file",
name: file.name!,
details: file as SummarizedFileInfo,
})),
files
.filter(isExistingFileInfo)
.map((file): FavoriteEntry => ({ type: "file", name: file.name, details: file })),
),
];
};
+11 -28
View File
@@ -2,12 +2,8 @@ import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { invalidateFile } from "./file.ts";
import {
decryptCategoryMetadata,
decryptFileMetadata,
encryptName,
generateEntryKeys,
} from "./metadata.ts";
import { toCategoryInfo, toFileInfo } from "./dto.ts";
import { encryptName, generateEntryKeys } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types.ts";
@@ -60,38 +56,25 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
try {
const category = await trpc.category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(category.subCategories.map(toCategoryInfo)),
Promise.all(
category.subCategories.map(async (category) => ({
id: category.id,
parentId: id,
...(await decryptCategoryMetadata(category)),
(category.files ?? []).map(async (file) => ({
...(await toFileInfo(file)),
isRecursive: file.isRecursive,
})),
),
category.files &&
Promise.all(
category.files.map(async (file) => ({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
isFavorite: file.isFavorite,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file)),
})),
),
category.metadata && decryptCategoryMetadata(category.metadata),
category.metadata && toCategoryInfo(category.metadata),
]);
return storeToIndexedDB(
id !== "root"
metadata
? {
id,
parentId: category.metadata!.parent,
...metadata,
subCategories,
files: files!,
files,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
...metadata!,
}
: { id, subCategories },
: { id: "root", subCategories },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
@@ -1,6 +1,7 @@
<script lang="ts">
import { getFileThumbnail } from "#lib/client/cache/index.ts";
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.ts";
import { afterPress } from "#lib/ui/index.ts";
import IconFavorite from "~icons/material-symbols/favorite";
@@ -15,7 +16,7 @@
</script>
<button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
onclick={afterPress(onclick && (() => onclick(info)))}
class="relative aspect-square overflow-hidden rounded-sm transition active:scale-95 active:brightness-90"
>
{#if thumbnail?.url}
+14 -58
View File
@@ -2,12 +2,8 @@ import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/ind
import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import {
decryptDirectoryMetadata,
decryptFileMetadata,
encryptName,
generateEntryKeys,
} from "./metadata.ts";
import { toDirectoryInfo, toFileInfo, type DirectoryDto } from "./dto.ts";
import { encryptName, generateEntryKeys } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types.ts";
@@ -43,37 +39,13 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
try {
const directory = await trpc.directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(
directory.subDirectories.map(async (directory) => ({
id: directory.id,
parentId: id,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory)),
})),
),
Promise.all(
directory.files.map(async (file) => ({
id: file.id,
parentId: id,
contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file)),
})),
),
directory.metadata && decryptDirectoryMetadata(directory.metadata),
Promise.all(directory.subDirectories.map(toDirectoryInfo)),
Promise.all(directory.files.map(toFileInfo)),
directory.metadata && toDirectoryInfo(directory.metadata),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: directory.metadata!.parent,
subDirectories,
files,
isFavorite: directory.metadata!.isFavorite,
...metadata!,
}
: { id, subDirectories, files },
metadata ? { ...metadata, subDirectories, files } : { id: "root", subDirectories, files },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
@@ -109,35 +81,19 @@ const storeToIndexedDB = (info: DirectoryInfo) => {
return { ...info, exists: true as const };
};
export const getDirectoryInfo = (
id: DirectoryId,
options?: {
serverResponse?: {
parent: DirectoryId;
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
isFavorite: boolean;
};
},
) => {
// Uses `serverResponse` instead of fetching the directory from the server if given
export const getDirectoryInfo = (id: DirectoryId, options?: { serverResponse?: DirectoryDto }) => {
const serverResponse = options?.serverResponse;
return cache.get(id, {
fetchFromServer:
options?.serverResponse &&
(async (cachedValue) => {
const metadata = await decryptDirectoryMetadata(options!.serverResponse!);
return storeToIndexedDB({
serverResponse &&
(async (cachedValue) =>
storeToIndexedDB({
subDirectories: [],
files: [],
...cachedValue,
id: id as number,
parentId: options!.serverResponse!.parent,
isFavorite: options!.serverResponse!.isFavorite,
...metadata,
});
}),
...(await toDirectoryInfo(serverResponse)),
})),
});
};
+46
View File
@@ -0,0 +1,46 @@
import type { RouterOutputs } from "#trpc/router.server.ts";
import {
decryptCategoryMetadata,
decryptDirectoryMetadata,
decryptFileMetadata,
} from "./metadata.ts";
import type { FileInfo, SubCategoryInfo, SubDirectoryInfo, SummarizedFileInfo } from "./types.ts";
// Shapes of filesystem entries returned by the server
export type DirectoryDto = RouterOutputs["directory"]["get"]["subDirectories"][number];
export type FileDto = RouterOutputs["directory"]["get"]["files"][number];
export type CategoryDto = RouterOutputs["category"]["get"]["subCategories"][number];
export type FileWithCategoriesDto = RouterOutputs["file"]["get"];
export const toDirectoryInfo = async (directory: DirectoryDto): Promise<SubDirectoryInfo> => ({
id: directory.id,
parentId: directory.parent,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory)),
});
export const toFileInfo = async (file: FileDto): Promise<SummarizedFileInfo> => ({
id: file.id,
isLegacy: file.isLegacy,
parentId: file.parent,
contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file)),
});
export const toCategoryInfo = async (category: CategoryDto): Promise<SubCategoryInfo> => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
});
export const toFileWithCategoriesInfo = async ({
categories,
...file
}: FileWithCategoriesDto): Promise<FileInfo> => {
const [fileInfo, categoryInfos] = await Promise.all([
toFileInfo(file),
Promise.all(categories.map(toCategoryInfo)),
]);
return { ...fileInfo, categories: categoryInfos };
};
+51 -118
View File
@@ -3,24 +3,24 @@ import * as IndexedDB from "#lib/client/storage/indexed-db/index.ts";
import { isTRPCClientError, trpc } from "#trpc/client.ts";
import { FilesystemCache } from "./cache.svelte.ts";
import { invalidateDirectory } from "./directory.ts";
import { decryptCategoryMetadata, decryptFileMetadata, encryptName } from "./metadata.ts";
import { toFileInfo, toFileWithCategoriesInfo, type FileDto } from "./dto.ts";
import { encryptName } from "./metadata.ts";
import { useResource } from "./resource.svelte.ts";
import type { DataKey, FileInfo, MaybeFileInfo } from "./types.ts";
// Categories of files stored in IndexedDB, which may have been deleted
const getFileCategoryInfos = async (categoryIds: number[] | undefined) => {
const categories = await Promise.all(
(categoryIds ?? []).map((id) => IndexedDB.getCategoryInfo(id)),
);
return categories
.filter((category) => !!category)
.map(({ id, parentId, name }) => ({ id, parentId, name }));
};
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
const categories = file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined;
if (file) {
return {
id,
@@ -30,7 +30,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories?.filter((category) => !!category) ?? [],
categories: await getFileCategoryInfos(file.categoryIds),
isFavorite: file.isFavorite,
};
}
@@ -38,30 +38,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromServer(id) {
try {
const file = await trpc.file.get.query({ id });
const [categories, metadata] = await Promise.all([
Promise.all(
file.categories.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
})),
),
decryptFileMetadata(file),
]);
return storeToIndexedDB({
id,
isLegacy: file.isLegacy,
parentId: file.parent,
dataKey: metadata.dataKey,
contentType: file.contentType,
name: metadata.name,
createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt,
categories,
isFavorite: file.isFavorite,
});
return storeToIndexedDB(await toFileWithCategoriesInfo(await trpc.file.get.query({ id })));
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
@@ -72,33 +49,21 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
},
async bulkFetchFromIndexedDB(ids) {
const files = await IndexedDB.bulkGetFileInfos([...ids]);
const categories = await Promise.all(
files.map(async (file) =>
file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined,
),
);
const files = (await IndexedDB.bulkGetFileInfos([...ids])).filter((file) => !!file);
return new Map(
files
.filter((file) => !!file)
.map((file, index) => [
file.id,
{
...file,
exists: true,
categories: categories[index]?.filter((category) => !!category) ?? [],
},
]),
await Promise.all(
files.map(
async (file) =>
[
file.id,
{
...file,
exists: true as const,
categories: await getFileCategoryInfos(file.categoryIds),
},
] as const,
),
),
);
},
@@ -106,31 +71,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
const idsArray = [...ids.keys()];
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
const [categories, metadata] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
})),
),
decryptFileMetadata(metadataRaw),
]);
return {
id,
exists: true as const,
isLegacy: metadataRaw.isLegacy,
parentId: metadataRaw.parent,
contentType: metadataRaw.contentType,
categories,
isFavorite: metadataRaw.isFavorite,
...metadata,
};
}),
);
const files = await Promise.all(filesRaw.map(toFileWithCategoriesInfo));
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
@@ -175,40 +116,18 @@ const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
};
export const getFileInfo = (
id: number,
options?: {
serverResponse?: {
parent: DirectoryId;
mekVersion: number;
dek: string;
dekVersion: Date;
contentType: string;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
isFavorite: boolean;
};
},
) => {
// Uses `serverResponse` instead of fetching the file from the server if given
export const getFileInfo = (id: number, options?: { serverResponse?: FileDto }) => {
const serverResponse = options?.serverResponse;
return cache.get(id, {
fetchFromServer:
options?.serverResponse &&
(async (cachedValue) => {
const metadata = await decryptFileMetadata(options!.serverResponse!);
return storeToIndexedDB({
serverResponse &&
(async (cachedValue) =>
storeToIndexedDB({
categories: [],
...cachedValue,
id,
parentId: options!.serverResponse!.parent,
contentType: options!.serverResponse!.contentType,
isFavorite: options!.serverResponse!.isFavorite,
...metadata,
});
}),
...(await toFileInfo(serverResponse)),
})),
});
};
@@ -261,3 +180,17 @@ export const setFileFavorite = async (
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
// The most recently created media files among the files stored locally
export const getRecentMediaFileInfos = async (limit: number) => {
const files = await IndexedDB.getAllFileInfos();
return files
.filter(
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
)
.sort(
(a, b) =>
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
)
.slice(0, limit);
};
+7
View File
@@ -77,3 +77,10 @@ export type SubCategoryInfo = Omit<
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
export const isLocalDirectoryInfo = (
info: MaybeDirectoryInfo,
): info is LocalDirectoryInfo & { exists: true } => info.exists && info.id !== "root";
export const isExistingFileInfo = (info: MaybeFileInfo): info is FileInfo & { exists: true } =>
info.exists;
+6 -1
View File
@@ -1,6 +1,8 @@
import {
getDirectoryInfo,
getFileInfo,
isExistingFileInfo,
isLocalDirectoryInfo,
type FileInfo,
type LocalCategoryInfo,
type LocalDirectoryInfo,
@@ -37,5 +39,8 @@ export const search = async (filter: SearchFilter) => {
),
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
]);
return { directories, files } as SearchResult;
return {
directories: directories.filter(isLocalDirectoryInfo),
files: files.filter(isExistingFileInfo),
} satisfies SearchResult;
};
@@ -0,0 +1,12 @@
<script lang="ts">
import { formatNetworkSpeed } from "#lib/utils/index.ts";
interface Props {
progress?: number;
rate?: number; // Bytes per second
}
let { progress, rate }: Props = $props();
</script>
전송됨 {Math.floor((progress ?? 0) * 100)}% · {formatNetworkSpeed((rate ?? 0) * 8)}
@@ -0,0 +1,26 @@
<script lang="ts">
import type { FileUploadState } from "#lib/client/transfer/index.ts";
import TransferProgress from "./TransferProgress.svelte";
interface Props {
state: FileUploadState;
}
let { state }: Props = $props();
const statusTexts = {
queued: "대기 중",
"encryption-pending": "준비 중",
encrypting: "암호화하는 중",
"upload-pending": "업로드를 기다리는 중",
uploaded: "업로드 완료",
canceled: "",
error: "업로드 실패",
} satisfies Record<Exclude<FileUploadState["status"], "uploading">, string>;
</script>
{#if state.status === "uploading"}
<TransferProgress progress={state.progress} rate={state.rate} />
{:else}
{statusTexts[state.status]}
{/if}
@@ -0,0 +1,2 @@
export { default as TransferProgress } from "./TransferProgress.svelte";
export { default as UploadStatusText } from "./UploadStatusText.svelte";