파일 시스템 관련 상태와 변경 로직을 도메인 모듈로 통합하여 캐시가 자동으로 갱신되도록 개선하고 오류를 토스트로 표시하도록 변경

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:22:25 +09:00
1 parent 2ce73aa331
commit 4e82c5e556
67 files changed
+1091 -1082

No files matched your search

+60 -48
View File
@@ -5,20 +5,22 @@
import { page } from "$app/state";
import { FullscreenDiv } from "#lib/components/atoms/index.js";
import { Categories, IconEntryButton, TopBar } from "#lib/components/molecules/index.js";
import { getFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { captureVideoThumbnail } from "#lib/client/media/thumbnail.js";
import { getFileDownloadState } from "#lib/client/transfer/index.js";
import { HybridPromise } from "#lib/utils/index.js";
import { showErrorToast, tryAction } from "#lib/client/toast.svelte.js";
import {
addFileToCategory,
getFileContent,
getFileStreamUrl,
removeFileFromCategory,
setFileFavorite,
uploadFileThumbnail,
useFile,
type DataKey,
type FileInfo,
} from "#lib/features/filesystem/index.js";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte";
import {
requestFileRemovalFromCategory,
requestFileDownload,
requestThumbnailUpload,
requestFileAdditionToCategory,
requestVideoStream,
requestFavoriteToggle,
} from "./service";
import TopBarMenu from "./TopBarMenu.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
@@ -28,7 +30,8 @@
let { data } = $props();
let info: MaybeFileInfo | undefined = $state();
const file = useFile(() => data.id);
let info = $derived(file.current);
let downloadState = $derived(getFileDownloadState(data.id));
let isMenuOpen = $state(false);
@@ -59,33 +62,43 @@
);
};
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
const thumbnail = await captureVideoThumbnail(videoElement!);
await requestThumbnailUpload(data.id, thumbnail, dataKey, dataKeyVersion);
};
const updateThumbnail = (dataKey: DataKey) =>
tryAction(async () => {
const thumbnail = await captureVideoThumbnail(videoElement!);
await uploadFileThumbnail(data.id, thumbnail, dataKey);
}, "썸네일을 설정하지 못했어요.");
const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false;
void getFileInfo(data.id); // TODO: FIXME
if (
await tryAction(
() => addFileToCategory(data.id, categoryId),
"카테고리에 파일을 추가하지 못했어요.",
)
) {
isAddToCategoryBottomSheetOpen = false;
}
};
const removeFromCategory = async (categoryId: number) => {
await requestFileRemovalFromCategory(data.id, categoryId);
void getFileInfo(data.id); // TODO: FIXME
};
const removeFromCategory = (categoryId: number) =>
tryAction(
() => removeFileFromCategory(data.id, categoryId),
"카테고리에서 파일을 제거하지 못했어요.",
);
const toggleFavorite = async () => {
await requestFavoriteToggle(data.id, !!info?.isFavorite);
void getFileInfo(data.id); // TODO: FIXME
};
const toggleFavorite = () =>
tryAction(
() => setFileFavorite(info as FileInfo, !info?.isFavorite),
"즐겨찾기를 변경하지 못했어요.",
);
$effect(() => {
HybridPromise.resolve(getFileInfo(data.id)).then((result) => {
if (data.id === result.id) {
info = result;
}
const loadContent = (dataKey: CryptoKey, isLegacy: boolean) =>
getFileContent(data.id, dataKey, isLegacy).catch((e) => {
showErrorToast(e, "파일을 불러오지 못했어요.");
throw e;
});
$effect.pre(() => {
void data.id; // Reset the viewer when navigating to another file
isDownloadRequested = false;
viewerType = undefined;
});
@@ -103,25 +116,24 @@
if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true;
if (viewerType === "video" && !info!.isLegacy) {
requestVideoStream(data.id, info!.dataKey!.key, contentType).then((streamUrl) => {
if (streamUrl) {
const { key } = info!.dataKey!;
const isLegacy = !!info!.isLegacy;
if (viewerType === "video" && !isLegacy) {
getFileStreamUrl(data.id, key, contentType)
.then((streamUrl) => {
videoStreamUrl = streamUrl;
} else {
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then((buffer) =>
updateViewer(buffer, contentType),
);
})
.catch(async () => {
// Fall back to downloading the whole file
await updateViewer(await loadContent(key, isLegacy), contentType);
});
} else {
void loadContent(key, isLegacy).then(async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, info!.name);
}
});
} else {
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, info!.name);
}
},
);
}
}
});
@@ -192,7 +204,7 @@
></video>
<IconEntryButton
icon={IconCamera}
onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
onclick={() => updateThumbnail(info!.dataKey!)}
class="w-full"
>
이 장면을 썸네일로 설정하기
@@ -2,9 +2,8 @@
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "#lib/components/atoms/index.js";
import { SubCategories } from "#lib/components/molecules/index.js";
import { CategoryCreateModal } from "#lib/components/organisms/index.js";
import { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js";
import { HybridPromise } from "#lib/utils/index.js";
import { requestCategoryCreation } from "./service";
import { tryAction } from "#lib/client/toast.svelte.js";
import { createCategory, useCategory } from "#lib/features/filesystem/index.js";
interface Props {
onAddToCategoryClick: (categoryId: number) => void;
@@ -13,13 +12,15 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let categoryInfo: MaybeCategoryInfo | undefined = $state();
let categoryId: CategoryId = $state("root");
const category = useCategory(() => categoryId);
let categoryInfo = $derived(category.current);
let isCategoryCreateModalOpen = $state(false);
$effect(() => {
$effect.pre(() => {
if (isOpen) {
HybridPromise.resolve(getCategoryInfo("root")).then((result) => (categoryInfo = result));
categoryId = "root";
}
});
</script>
@@ -30,8 +31,7 @@
<SubCategories
class="py-4"
info={categoryInfo}
onSubCategoryClick={({ id }) =>
HybridPromise.resolve(getCategoryInfo(id)).then((result) => (categoryInfo = result))}
onSubCategoryClick={({ id }) => (categoryId = id)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
subCategoryCreatePosition="top"
/>
@@ -48,11 +48,6 @@
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, categoryInfo!.id)) {
void getCategoryInfo(categoryInfo!.id); // TODO: FIXME
return true;
}
return false;
}}
onCreateClick={(name: string) =>
tryAction(() => createCategory(categoryId, name), "카테고리를 만들지 못했어요.")}
/>
@@ -1,64 +0,0 @@
import { storeFileThumbnailCache } from "#lib/client/cache/index.js";
import { prepareFileDecryption, getDecryptedFileUrl } from "#lib/client/serviceWorker.js";
import { requestFileThumbnailUpload } from "#lib/services/file.js";
import { trpc } from "#trpc/client.js";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "#lib/services/category.js";
export { requestFileDownload } from "#lib/services/file.js";
export const requestVideoStream = async (
fileId: number,
dataKey: CryptoKey,
contentType: string,
) => {
const res = await fetch(`/api/file/${fileId}/download`, { method: "HEAD" });
if (!res.ok) return null;
const encContentSize = parseInt(res.headers.get("Content-Length") ?? "0", 10);
if (encContentSize <= 0) return null;
try {
await prepareFileDecryption(fileId, { isLegacy: false, dataKey, encContentSize, contentType });
return getDecryptedFileUrl(fileId);
} catch {
// TODO: Error Handling
return null;
}
};
export const requestThumbnailUpload = async (
fileId: number,
thumbnail: Blob,
dataKey: CryptoKey,
dataKeyVersion: Date,
) => {
const res = await requestFileThumbnailUpload(fileId, thumbnail, dataKey, dataKeyVersion);
if (!res) return false;
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileId, buffer));
return true;
};
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
try {
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
return true;
} catch {
// TODO: Error Handling
return false;
}
};
export const requestFavoriteToggle = async (fileId: number, isFavorite: boolean) => {
try {
if (isFavorite) {
await trpc.favorites.removeFile.mutate({ id: fileId });
} else {
await trpc.favorites.addFile.mutate({ id: fileId });
}
return true;
} catch {
// TODO: Error Handling
return false;
}
};
@@ -7,7 +7,7 @@
clearDownloadedFiles,
type FileDownloadState,
} from "#lib/client/transfer/index.js";
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/features/filesystem/index.js";
import File from "./File.svelte";
let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]);
@@ -1,6 +1,6 @@
<script lang="ts">
import type { FileDownloadState } from "#lib/client/transfer/index.js";
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
import { formatNetworkSpeed } from "#lib/utils/index.js";
import IconCloud from "~icons/material-symbols/cloud";