From 409ae09f4fb290bcf4049aebc4d0bc15d39f53f5 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 30 Dec 2025 17:21:54 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=EC=9D=98=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/RowVirtualizer.svelte | 6 +- src/lib/indexedDB/filesystem.ts | 4 - src/lib/modules/file/index.ts | 2 +- .../file/{upload.ts => upload.svelte.ts} | 108 +++++----- src/lib/modules/filesystem.ts | 116 ----------- src/lib/modules/filesystem2.svelte.ts | 191 ++++++++++++++++++ src/lib/server/db/category.ts | 2 - src/lib/server/db/file.ts | 2 - src/lib/stores/file.ts | 24 --- src/lib/types/filesystem.d.ts | 2 + src/lib/utils/index.ts | 1 + src/lib/utils/promise.ts | 16 ++ src/lib/utils/sort.ts | 3 +- .../(fullscreen)/file/uploads/+page.svelte | 17 +- .../(fullscreen)/file/uploads/File.svelte | 37 ++-- .../(main)/directory/[[id]]/+page.svelte | 189 +++++++++-------- .../DirectoryEntries/DirectoryEntries.svelte | 117 +++-------- .../[[id]]/DirectoryEntries/File.svelte | 72 +++---- .../DirectoryEntries/SubDirectory.svelte | 43 ++-- .../DirectoryEntries/UploadingFile.svelte | 51 +++-- .../[[id]]/DirectoryEntries/service.ts | 1 - .../directory/[[id]]/UploadStatusCard.svelte | 20 +- .../(main)/directory/[[id]]/service.svelte.ts | 15 +- src/routes/+layout.svelte | 5 +- src/trpc/routers/directory.ts | 23 ++- 25 files changed, 507 insertions(+), 560 deletions(-) rename src/lib/modules/file/{upload.ts => upload.svelte.ts} (77%) create mode 100644 src/lib/modules/filesystem2.svelte.ts create mode 100644 src/lib/types/filesystem.d.ts create mode 100644 src/lib/utils/promise.ts delete mode 100644 src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index e821c5f..55dc7a1 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -1,6 +1,6 @@
diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index cf60b93..107009d 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -1,7 +1,5 @@ import { Dexie, type EntityTable } from "dexie"; -export type DirectoryId = "root" | number; - interface DirectoryInfo { id: number; parentId: DirectoryId; @@ -18,8 +16,6 @@ interface FileInfo { categoryIds: number[]; } -export type CategoryId = "root" | number; - interface CategoryInfo { id: number; parentId: CategoryId; diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index 42a5613..3b99989 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,3 +1,3 @@ export * from "./cache"; export * from "./download"; -export * from "./upload"; +export * from "./upload.svelte"; diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.svelte.ts similarity index 77% rename from src/lib/modules/file/upload.ts rename to src/lib/modules/file/upload.svelte.ts index 31aabd8..da205f3 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -1,7 +1,6 @@ import axios from "axios"; import ExifReader from "exifreader"; import { limitFunction } from "p-limit"; -import { writable, type Writable } from "svelte/store"; import { encodeToBase64, generateDataKey, @@ -17,14 +16,45 @@ import type { FileUploadRequest, FileUploadResponse, } from "$lib/server/schemas"; -import { - fileUploadStatusStore, - type MasterKey, - type HmacSecret, - type FileUploadStatus, -} from "$lib/stores"; +import type { MasterKey, HmacSecret } from "$lib/stores"; import { trpc } from "$trpc/client"; +export interface FileUploadState { + name: string; + parentId: DirectoryId; + status: + | "encryption-pending" + | "encrypting" + | "upload-pending" + | "uploading" + | "uploaded" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; +} + +export type LiveFileUploadState = FileUploadState & { + status: "encryption-pending" | "encrypting" | "upload-pending" | "uploading"; +}; + +let uploadingFiles: FileUploadState[] = $state([]); + +const isFileUploading = (status: FileUploadState["status"]) => + ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); + +export const getUploadingFiles = (parentId?: DirectoryId) => { + return uploadingFiles.filter( + (file): file is LiveFileUploadState => + (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status), + ); +}; + +export const clearUploadedFiles = () => { + uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status)); +}; + const requestDuplicateFileScan = limitFunction( async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise) => { const fileBuffer = await file.arrayBuffer(); @@ -76,16 +106,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => { }; const encryptFile = limitFunction( - async ( - status: Writable, - file: File, - fileBuffer: ArrayBuffer, - masterKey: MasterKey, - ) => { - status.update((value) => { - value.status = "encrypting"; - return value; - }); + async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => { + state.status = "encrypting"; const fileType = getFileType(file); @@ -109,10 +131,7 @@ const encryptFile = limitFunction( const thumbnailBuffer = await thumbnail?.arrayBuffer(); const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); - status.update((value) => { - value.status = "upload-pending"; - return value; - }); + state.status = "upload-pending"; return { dataKeyWrapped, @@ -130,20 +149,14 @@ const encryptFile = limitFunction( ); const requestFileUpload = limitFunction( - async (status: Writable, form: FormData, thumbnailForm: FormData | null) => { - status.update((value) => { - value.status = "uploading"; - return value; - }); + async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => { + state.status = "uploading"; const res = await axios.post("/api/file/upload", form, { onUploadProgress: ({ progress, rate, estimated }) => { - status.update((value) => { - value.progress = progress; - value.rate = rate; - value.estimated = estimated; - return value; - }); + state.progress = progress; + state.rate = rate; + state.estimated = estimated; }, }); const { file }: FileUploadResponse = res.data; @@ -157,10 +170,7 @@ const requestFileUpload = limitFunction( } } - status.update((value) => { - value.status = "uploaded"; - return value; - }); + state.status = "uploaded"; return { fileId: file }; }, @@ -176,15 +186,12 @@ export const uploadFile = async ( ): Promise< { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined > => { - const status = writable({ + uploadingFiles.push({ name: file.name, parentId, status: "encryption-pending", }); - fileUploadStatusStore.update((value) => { - value.push(status); - return value; - }); + const state = uploadingFiles.at(-1)!; try { const { fileBuffer, fileSigned } = await requestDuplicateFileScan( @@ -193,14 +200,8 @@ export const uploadFile = async ( onDuplicate, ); if (!fileBuffer || !fileSigned) { - status.update((value) => { - value.status = "canceled"; - return value; - }); - fileUploadStatusStore.update((value) => { - value = value.filter((v) => v !== status); - return value; - }); + state.status = "canceled"; + uploadingFiles = uploadingFiles.filter((file) => file !== state); return undefined; } @@ -214,7 +215,7 @@ export const uploadFile = async ( createdAtEncrypted, lastModifiedAtEncrypted, thumbnail, - } = await encryptFile(status, file, fileBuffer, masterKey); + } = await encryptFile(state, file, fileBuffer, masterKey); const form = new FormData(); form.set( @@ -252,13 +253,10 @@ export const uploadFile = async ( thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); } - const { fileId } = await requestFileUpload(status, form, thumbnailForm); + const { fileId } = await requestFileUpload(state, form, thumbnailForm); return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; } catch (e) { - status.update((value) => { - value.status = "error"; - return value; - }); + state.status = "error"; throw e; } }; diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts index f2995ef..e01145b 100644 --- a/src/lib/modules/filesystem.ts +++ b/src/lib/modules/filesystem.ts @@ -1,10 +1,5 @@ import { get, writable, type Writable } from "svelte/store"; import { - getDirectoryInfos as getDirectoryInfosFromIndexedDB, - getDirectoryInfo as getDirectoryInfoFromIndexedDB, - storeDirectoryInfo, - deleteDirectoryInfo, - getFileInfos as getFileInfosFromIndexedDB, getFileInfo as getFileInfoFromIndexedDB, storeFileInfo, deleteFileInfo, @@ -13,32 +8,10 @@ import { storeCategoryInfo, updateCategoryInfo as updateCategoryInfoInIndexedDB, deleteCategoryInfo, - type DirectoryId, - type CategoryId, } from "$lib/indexedDB"; import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; import { trpc, isTRPCClientError } from "$trpc/client"; -export type DirectoryInfo = - | { - id: "root"; - parentId?: undefined; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subDirectoryIds: number[]; - fileIds: number[]; - } - | { - id: number; - parentId: DirectoryId; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - name: string; - subDirectoryIds: number[]; - fileIds: number[]; - }; - export interface FileInfo { id: number; parentId: DirectoryId; @@ -72,98 +45,9 @@ export type CategoryInfo = isFileRecursive: boolean; }; -const directoryInfoStore = new Map>(); const fileInfoStore = new Map>(); const categoryInfoStore = new Map>(); -const fetchDirectoryInfoFromIndexedDB = async ( - id: DirectoryId, - info: Writable, -) => { - if (get(info)) return; - - const [directory, subDirectories, files] = await Promise.all([ - id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined, - getDirectoryInfosFromIndexedDB(id), - getFileInfosFromIndexedDB(id), - ]); - const subDirectoryIds = subDirectories.map(({ id }) => id); - const fileIds = files.map(({ id }) => id); - - if (id === "root") { - info.set({ id, subDirectoryIds, fileIds }); - } else { - if (!directory) return; - info.set({ - id, - parentId: directory.parentId, - name: directory.name, - subDirectoryIds, - fileIds, - }); - } -}; - -const fetchDirectoryInfoFromServer = async ( - id: DirectoryId, - info: Writable, - masterKey: CryptoKey, -) => { - let data; - try { - data = await trpc().directory.get.query({ id }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteDirectoryInfo(id as number); - return; - } - throw new Error("Failed to fetch directory information"); - } - - const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data; - - if (id === "root") { - info.set({ id, subDirectoryIds, fileIds }); - } else { - const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); - const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); - - info.set({ - id, - parentId: metadata!.parent, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name, - subDirectoryIds, - fileIds, - }); - await storeDirectoryInfo({ id, parentId: metadata!.parent, name }); - } -}; - -const fetchDirectoryInfo = async ( - id: DirectoryId, - info: Writable, - masterKey: CryptoKey, -) => { - await fetchDirectoryInfoFromIndexedDB(id, info); - await fetchDirectoryInfoFromServer(id, info, masterKey); -}; - -export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = directoryInfoStore.get(id); - if (!info) { - info = writable(null); - directoryInfoStore.set(id, info); - } - - fetchDirectoryInfo(id, info, masterKey); // Intended - return info; -}; - const fetchFileInfoFromIndexedDB = async (id: number, info: Writable) => { if (get(info)) return; diff --git a/src/lib/modules/filesystem2.svelte.ts b/src/lib/modules/filesystem2.svelte.ts new file mode 100644 index 0000000..ade5342 --- /dev/null +++ b/src/lib/modules/filesystem2.svelte.ts @@ -0,0 +1,191 @@ +import { + getDirectoryInfos as getDirectoryInfosFromIndexedDB, + getDirectoryInfo as getDirectoryInfoFromIndexedDB, + storeDirectoryInfo, + deleteDirectoryInfo, + getFileInfos as getFileInfosFromIndexedDB, + getFileInfo as getFileInfoFromIndexedDB, + storeFileInfo, + deleteFileInfo, + getCategoryInfos as getCategoryInfosFromIndexedDB, + getCategoryInfo as getCategoryInfoFromIndexedDB, + storeCategoryInfo, + updateCategoryInfo as updateCategoryInfoInIndexedDB, + deleteCategoryInfo, +} from "$lib/indexedDB"; +import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; +import { monotonicResolve } from "$lib/utils"; +import { trpc, isTRPCClientError } from "$trpc/client"; + +type DataKey = { key: CryptoKey; version: Date }; + +interface LocalDirectoryInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + name: string; + subDirectories: SubDirectoryInfo[]; + files: SummarizedFileInfo[]; +} + +interface RootDirectoryInfo { + id: "root"; + parentId?: undefined; + dataKey?: undefined; + dataKeyVersion?: undefined; + name?: undefined; + subDirectories: SubDirectoryInfo[]; + files: SummarizedFileInfo[]; +} + +export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; +export type SubDirectoryInfo = Omit; + +interface FileInfo { + id: number; + parentId: DirectoryId; + dataKey?: DataKey; + contentType: string; + contentIv: string | undefined; + name: string; + createdAt?: Date; + lastModifiedAt: Date; + categories: { id: number; name: string }[]; +} + +export type SummarizedFileInfo = Omit; + +interface LocalCategoryInfo { + id: number; + dataKey: DataKey | undefined; + name: string; + subCategories: Omit[]; + files: { id: number; name: string; isRecursive: boolean }[]; + isFileRecursive: boolean; +} + +interface RootCategoryInfo { + id: "root"; + dataKey?: undefined; + name?: undefined; + subCategories: Omit[]; + files?: undefined; +} + +export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo; + +const directoryInfoCache = new Map>(); + +export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { + const info = directoryInfoCache.get(id); + if (info instanceof Promise) { + return info; + } + + const { promise, resolve } = Promise.withResolvers(); + if (!info) { + directoryInfoCache.set(id, promise); + } + + monotonicResolve( + [!info && fetchDirectoryInfoFromIndexedDB(id), fetchDirectoryInfoFromServer(id, masterKey)], + (directoryInfo) => { + let info = directoryInfoCache.get(id); + if (info instanceof Promise) { + const state = $state(directoryInfo); + directoryInfoCache.set(id, state); + resolve(state); + } else { + Object.assign(info!, directoryInfo); + resolve(info!); + } + }, + ); + return info ?? promise; +}; + +const fetchDirectoryInfoFromIndexedDB = async ( + id: DirectoryId, +): Promise => { + const [directory, subDirectories, files] = await Promise.all([ + id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined, + getDirectoryInfosFromIndexedDB(id), + getFileInfosFromIndexedDB(id), + ]); + + if (id === "root") { + return { id, subDirectories, files }; + } else if (directory) { + return { id, parentId: directory.parentId, name: directory.name, subDirectories, files }; + } +}; + +const fetchDirectoryInfoFromServer = async ( + id: DirectoryId, + masterKey: CryptoKey, +): Promise => { + try { + const { + metadata, + subDirectories: subDirectoriesRaw, + files: filesRaw, + } = await trpc().directory.get.query({ id }); + const [subDirectories, files] = await Promise.all([ + Promise.all( + subDirectoriesRaw.map(async (directory) => { + const { dataKey } = await unwrapDataKey(directory.dek, masterKey); + const name = await decryptString(directory.name, directory.nameIv, dataKey); + return { + id: directory.id, + dataKey: { key: dataKey, version: directory.dekVersion }, + name, + }; + }), + ), + Promise.all( + filesRaw.map(async (file) => { + const { dataKey } = await unwrapDataKey(file.dek, masterKey); + const [name, createdAt, lastModifiedAt] = await Promise.all([ + decryptString(file.name, file.nameIv, dataKey), + file.createdAt ? decryptDate(file.createdAt, file.createdAtIv!, dataKey) : undefined, + decryptDate(file.lastModifiedAt, file.lastModifiedAtIv, dataKey), + ]); + return { + id: file.id, + dataKey: { key: dataKey, version: file.dekVersion }, + contentType: file.contentType, + name, + createdAt, + lastModifiedAt, + }; + }), + ), + ]); + + if (id === "root") { + return { id, subDirectories, files }; + } else { + const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); + const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); + return { + id, + parentId: metadata!.parent, + dataKey: { key: dataKey, version: metadata!.dekVersion }, + name, + subDirectories, + files, + }; + } + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + directoryInfoCache.delete(id); + await deleteDirectoryInfo(id as number); + return; + } + throw new Error("Failed to fetch directory information"); + } +}; + +const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { + return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); +}; diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/category.ts index f5c22ff..e20138c 100644 --- a/src/lib/server/db/category.ts +++ b/src/lib/server/db/category.ts @@ -2,8 +2,6 @@ import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; -export type CategoryId = "root" | number; - interface Category { id: number; parentId: CategoryId; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index c3169fc..a524ff4 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -4,8 +4,6 @@ import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; -export type DirectoryId = "root" | number; - interface Directory { id: number; parentId: DirectoryId; diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts index 61db95d..0aab6d1 100644 --- a/src/lib/stores/file.ts +++ b/src/lib/stores/file.ts @@ -1,21 +1,5 @@ import { writable, type Writable } from "svelte/store"; -export interface FileUploadStatus { - name: string; - parentId: "root" | number; - status: - | "encryption-pending" - | "encrypting" - | "upload-pending" - | "uploading" - | "uploaded" - | "canceled" - | "error"; - progress?: number; - rate?: number; - estimated?: number; -} - export interface FileDownloadStatus { id: number; status: @@ -32,16 +16,8 @@ export interface FileDownloadStatus { result?: ArrayBuffer; } -export const fileUploadStatusStore = writable[]>([]); - export const fileDownloadStatusStore = writable[]>([]); -export const isFileUploading = ( - status: FileUploadStatus["status"], -): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => { - return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); -}; - export const isFileDownloading = ( status: FileDownloadStatus["status"], ): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => { diff --git a/src/lib/types/filesystem.d.ts b/src/lib/types/filesystem.d.ts new file mode 100644 index 0000000..2cb91a7 --- /dev/null +++ b/src/lib/types/filesystem.d.ts @@ -0,0 +1,2 @@ +type DirectoryId = "root" | number; +type CategoryId = "root" | number; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1db9577..9dc3631 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from "./format"; export * from "./gotoStateful"; +export * from "./promise"; export * from "./sort"; diff --git a/src/lib/utils/promise.ts b/src/lib/utils/promise.ts new file mode 100644 index 0000000..a4b0fb2 --- /dev/null +++ b/src/lib/utils/promise.ts @@ -0,0 +1,16 @@ +export const monotonicResolve = ( + promises: (Promise | false)[], + callback: (value: T) => void, +) => { + let latestResolvedIndex = -1; + + promises.forEach((promise, index) => { + if (!promise) return; + promise.then((value) => { + if (value !== undefined && index > latestResolvedIndex) { + latestResolvedIndex = index; + callback(value); + } + }); + }); +}; diff --git a/src/lib/utils/sort.ts b/src/lib/utils/sort.ts index 2385e55..a92c444 100644 --- a/src/lib/utils/sort.ts +++ b/src/lib/utils/sort.ts @@ -32,7 +32,7 @@ const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => { const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b); -export const sortEntries = (entries: T[], sortBy: SortBy) => { +export const sortEntries = (entries: T[], sortBy = SortBy.NAME_ASC) => { let sortFunc: SortFunc; switch (sortBy) { @@ -54,4 +54,5 @@ export const sortEntries = (entries: T[], sortBy: SortBy) = } entries.sort(sortFunc); + return entries; }; diff --git a/src/routes/(fullscreen)/file/uploads/+page.svelte b/src/routes/(fullscreen)/file/uploads/+page.svelte index d456322..687b72b 100644 --- a/src/routes/(fullscreen)/file/uploads/+page.svelte +++ b/src/routes/(fullscreen)/file/uploads/+page.svelte @@ -1,19 +1,10 @@ @@ -23,8 +14,8 @@
- {#each uploadingFiles as status} - + {#each getUploadingFiles() as file} + {/each}
diff --git a/src/routes/(fullscreen)/file/uploads/File.svelte b/src/routes/(fullscreen)/file/uploads/File.svelte index 2435240..7b40ac5 100644 --- a/src/routes/(fullscreen)/file/uploads/File.svelte +++ b/src/routes/(fullscreen)/file/uploads/File.svelte @@ -1,6 +1,5 @@
- {#if $status.status === "encryption-pending"} + {#if state.status === "encryption-pending"} - {:else if $status.status === "encrypting"} + {:else if state.status === "encrypting"} - {:else if $status.status === "upload-pending"} + {:else if state.status === "upload-pending"} - {:else if $status.status === "uploading"} + {:else if state.status === "uploading"} - {:else if $status.status === "uploaded"} + {:else if state.status === "uploaded"} - {:else if $status.status === "error"} + {:else if state.status === "error"} {/if}
-

- {$status.name} +

+ {state.name}

- {#if $status.status === "encryption-pending"} + {#if state.status === "encryption-pending"} 준비 중 - {:else if $status.status === "encrypting"} + {:else if state.status === "encrypting"} 암호화하는 중 - {:else if $status.status === "upload-pending"} + {:else if state.status === "upload-pending"} 업로드를 기다리는 중 - {:else if $status.status === "uploading"} + {:else if state.status === "uploading"} 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {:else if $status.status === "uploaded"} + {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} + {:else if state.status === "uploaded"} 업로드 완료 - {:else if $status.status === "error"} + {:else if state.status === "error"} 업로드 실패 {/if}

diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index a4edf30..1bd2c5c 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -1,11 +1,10 @@ @@ -89,106 +88,106 @@ -
- {#if showTopBar} - - {/if} - {#if $info} -
-
- goto("/file/uploads")} /> - goto("/file/downloads")} /> -
- {#key $info} +{#await infoPromise then info} + {#if info} +
+ {#if showTopBar} + + {/if} +
+
+ goto("/file/uploads")} /> + goto("/file/downloads")} /> +
goto(`/${type}/${id}`)} onEntryMenuClick={(entry) => { context.selectedEntry = entry; isEntryMenuBottomSheetOpen = true; }} - showParentEntry={isFromFilePage && $info.parentId !== undefined} + showParentEntry={isFromFilePage && info.parentId !== undefined} onParentClick={() => goto( - $info.parentId === "root" + info.parentId === "root" ? "/directory?from=file" - : `/directory/${$info.parentId}?from=file`, + : `/directory/${info.parentId}?from=file`, )} /> - {/key} +
+ + { + isEntryCreateBottomSheetOpen = true; + }} + class="bottom-24 right-4" + /> + { + isEntryCreateBottomSheetOpen = false; + isDirectoryCreateModalOpen = true; + }} + onFileUploadClick={() => { + isEntryCreateBottomSheetOpen = false; + fileInput?.click(); + }} + /> + { + if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + { + resolveForDuplicateFileModal?.(false); + isDuplicateFileModalOpen = false; + }} + onUploadClick={() => { + resolveForDuplicateFileModal?.(true); + isDuplicateFileModalOpen = false; + }} + /> + + { + isEntryMenuBottomSheetOpen = false; + isEntryRenameModalOpen = true; + }} + onDeleteClick={() => { + isEntryMenuBottomSheetOpen = false; + isEntryDeleteModalOpen = true; + }} + /> + { + if (await requestEntryRename(context.selectedEntry!, newName)) { + infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + { + if (await requestEntryDeletion(context.selectedEntry!)) { + infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> {/if} -
- - { - isEntryCreateBottomSheetOpen = true; - }} - class="bottom-24 right-4" -/> - { - isEntryCreateBottomSheetOpen = false; - isDirectoryCreateModalOpen = true; - }} - onFileUploadClick={() => { - isEntryCreateBottomSheetOpen = false; - fileInput?.click(); - }} -/> - { - if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - { - resolveForDuplicateFileModal?.(false); - isDuplicateFileModalOpen = false; - }} - onUploadClick={() => { - resolveForDuplicateFileModal?.(true); - isDuplicateFileModalOpen = false; - }} -/> - - { - isEntryMenuBottomSheetOpen = false; - isEntryRenameModalOpen = true; - }} - onDeleteClick={() => { - isEntryMenuBottomSheetOpen = false; - isEntryDeleteModalOpen = true; - }} -/> - { - if (await requestEntryRename(context.selectedEntry!, newName)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - { - if (await requestEntryDeletion(context.selectedEntry!)) { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> +{/await} diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index 527bd1b..b761176 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -1,21 +1,9 @@ {#if subDirectories.length + files.length > 0 || showParentEntry} @@ -124,8 +55,8 @@ {/if} - {#each subDirectories as { info }} - + {#each subDirectories as subDirectory} + {/each} {#if files.length > 0} {#if file.type === "file"} - + {:else} - + {/if}
{/snippet} diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index 67d7e36..fdc225c 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -1,66 +1,46 @@ -{#if $info} - + action(onclick)} + actionButtonIcon={IconMoreVert} + onActionButtonClick={() => action(onOpenMenuClick)} +> + {#await thumbnailPromise then thumbnail} - -{/if} + {/await} + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte index 5454695..0d65cc2 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte @@ -1,44 +1,29 @@ -{#if $info} - - - -{/if} + action(onclick)} + actionButtonIcon={IconMoreVert} + onActionButtonClick={() => action(onOpenMenuClick)} +> + + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte index bf5e85a..0ec7263 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte @@ -1,38 +1,35 @@ -{#if isFileUploading($status.status)} -
-
- -
-
-

- {$status.name} -

-

- {#if $status.status === "encryption-pending"} - 준비 중 - {:else if $status.status === "encrypting"} - 암호화하는 중 - {:else if $status.status === "upload-pending"} - 업로드를 기다리는 중 - {:else if $status.status === "uploading"} - 전송됨 {Math.floor(($status.progress ?? 0) * 100)}% · - {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {/if} -

-
+
+
+
-{/if} +
+

+ {state.name} +

+

+ {#if state.status === "encryption-pending"} + 준비 중 + {:else if state.status === "encrypting"} + 암호화하는 중 + {:else if state.status === "upload-pending"} + 업로드를 기다리는 중 + {:else if state.status === "uploading"} + 전송됨 {Math.floor((state.progress ?? 0) * 100)}% · + {formatNetworkSpeed((state.rate ?? 0) * 8)} + {/if} +

+
+
diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts b/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts deleted file mode 100644 index d4b47f8..0000000 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/service.ts +++ /dev/null @@ -1 +0,0 @@ -export { requestFileThumbnailDownload } from "$lib/services/file"; diff --git a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte index 1ac40b3..578c368 100644 --- a/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte +++ b/src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte @@ -1,7 +1,5 @@ {#if uploadingFiles.length > 0} diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index c94cc1e..db1f114 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -14,8 +14,7 @@ import { trpc } from "$trpc/client"; export interface SelectedEntry { type: "directory" | "file"; id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; + dataKey: { key: CryptoKey; version: Date } | undefined; name: string; } @@ -97,20 +96,26 @@ export const requestFileUpload = async ( }; export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { - const newNameEncrypted = await encryptString(newName, entry.dataKey); + if (!entry.dataKey) { + // TODO: Error Handling + console.log("hi"); + return false; + } + + const newNameEncrypted = await encryptString(newName, entry.dataKey.key); try { if (entry.type === "directory") { await trpc().directory.rename.mutate({ id: entry.id, - dekVersion: entry.dataKeyVersion, + dekVersion: entry.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); } else { await trpc().file.rename.mutate({ id: entry.id, - dekVersion: entry.dataKeyVersion, + dekVersion: entry.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e4bca97..612cfe4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,10 +2,9 @@ import { onMount } from "svelte"; import { get } from "svelte/store"; import { goto as svelteGoto } from "$app/navigation"; + import { getUploadingFiles } from "$lib/modules/file"; import { - fileUploadStatusStore, fileDownloadStatusStore, - isFileUploading, isFileDownloading, clientKeyStore, masterKeyStore, @@ -16,7 +15,7 @@ const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => { if ( - $fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) || + getUploadingFiles().length > 0 || $fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status)) ) { e.preventDefault(); diff --git a/src/trpc/routers/directory.ts b/src/trpc/routers/directory.ts index e060c23..6e1e358 100644 --- a/src/trpc/routers/directory.ts +++ b/src/trpc/routers/directory.ts @@ -32,8 +32,27 @@ const directoryRouter = router({ name: directory.encName.ciphertext, nameIv: directory.encName.iv, }, - subDirectories: directories.map(({ id }) => id), - files: files.map(({ id }) => id), + subDirectories: directories.map((directory) => ({ + id: directory.id, + mekVersion: directory.mekVersion, + dek: directory.encDek, + dekVersion: directory.dekVersion, + name: directory.encName.ciphertext, + nameIv: directory.encName.iv, + })), + files: files.map((file) => ({ + id: file.id, + mekVersion: file.mekVersion, + dek: file.encDek, + dekVersion: file.dekVersion, + contentType: file.contentType, + name: file.encName.ciphertext, + nameIv: file.encName.iv, + createdAt: file.encCreatedAt?.ciphertext, + createdAtIv: file.encCreatedAt?.iv, + lastModifiedAt: file.encLastModifiedAt.ciphertext, + lastModifiedAtIv: file.encLastModifiedAt.iv, + })), }; }), From 1e57941f4c2fbdab43b702150449f66133aeddb6 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 30 Dec 2025 18:44:46 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=EB=8F=84=20?= =?UTF-8?q?=EA=B0=80=EC=83=81=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/RowVirtualizer.svelte | 18 ++++- .../(fullscreen)/file/[id]/+page.svelte | 4 +- src/routes/(fullscreen)/gallery/+page.svelte | 2 +- .../DirectoryEntries/DirectoryEntries.svelte | 79 ++++++++++--------- 4 files changed, 60 insertions(+), 43 deletions(-) diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index 55dc7a1..cd7f35f 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -13,24 +13,38 @@ let { class: className, count, item, itemHeight, placeholder }: Props = $props(); + let element: HTMLElement | undefined = $state(); + let scrollMargin = $state(0); + const virtualizer = $derived( createWindowVirtualizer({ count, estimateSize: itemHeight, + scrollMargin, }), ); const measureItem = (node: HTMLElement) => { $effect(() => $virtualizer.measureElement(node)); }; + + $effect(() => { + if (!element) return; + + const observer = new ResizeObserver(() => { + scrollMargin = element!.getBoundingClientRect().top + window.scrollY; + }); + observer.observe(element.parentElement!); + return () => observer.disconnect(); + }); -
+
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 3249bf2..9e0ddc0 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -145,7 +145,9 @@ diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 1826c47..b6f8239 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -22,5 +22,5 @@ - goto(`/file/${id}`)} /> + goto(`/file/${id}?from=gallery`)} /> diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index b761176..530cb97 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -25,56 +25,57 @@ showParentEntry = false, }: Props = $props(); - type FileEntry = + type Entry = + | { type: "parent" } + | { type: "directory"; name: string; details: (typeof info.subDirectories)[number] } | { type: "file"; name: string; details: (typeof info.files)[number] } | { type: "uploading-file"; name: string; details: LiveFileUploadState }; - const toFileEntry = - (type: T) => - (details: Extract["details"]) => ({ + const toEntry = + >(type: T) => + (details: Extract["details"]) => ({ type, name: details.name, details, }); - const subDirectories = $derived( - sortEntries(structuredClone($state.snapshot(info.subDirectories))), - ); - const files = $derived( - sortEntries([ - ...info.files.map(toFileEntry("file")), - ...getUploadingFiles(info.id).map(toFileEntry("uploading-file")), + const entries = $derived([ + ...(showParentEntry ? ([{ type: "parent" }] as const) : []), + ...sortEntries(info.subDirectories.map(toEntry("directory"))), + ...sortEntries([ + ...info.files.map(toEntry("file")), + ...getUploadingFiles(info.id).map(toEntry("uploading-file")), ]), - ); + ]); -{#if subDirectories.length + files.length > 0 || showParentEntry} -
- {#if showParentEntry} - - - - {/if} - {#each subDirectories as subDirectory} - - {/each} - {#if files.length > 0} - 56 + (index + 1 < files.length ? 4 : 0)} - > - {#snippet item(index)} - {@const file = files[index]!} -
- {#if file.type === "file"} - - {:else} - - {/if} -
- {/snippet} -
- {/if} +{#if entries.length > 0} +
+ 56 + (index + 1 < entries.length ? 4 : 0)} + > + {#snippet item(index)} + {@const entry = entries[index]!} +
+ {#if entry.type === "parent"} + + + + {:else if entry.type === "directory"} + + {:else if entry.type === "file"} + + {:else} + + {/if} +
+ {/snippet} +
{:else}
From b5522a4c6d0e55e76b1498fdaacea9500040c939 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 30 Dec 2025 20:53:20 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=EC=9D=98=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/RowVirtualizer.svelte | 2 +- .../molecules/Categories/Categories.svelte | 44 +---- .../molecules/Categories/Category.svelte | 37 ++-- .../molecules/Categories/service.ts | 3 +- .../components/molecules/SubCategories.svelte | 26 +-- .../organisms/Category/Category.svelte | 87 +++------- .../components/organisms/Category/File.svelte | 71 +++----- .../components/organisms/Category/service.ts | 4 - src/lib/modules/filesystem.ts | 147 ---------------- src/lib/modules/filesystem2.svelte.ts | 158 +++++++++++++++++- src/lib/server/db/file.ts | 48 ++++-- .../(fullscreen)/file/[id]/+page.svelte | 21 +-- .../file/[id]/AddToCategoryBottomSheet.svelte | 71 ++++---- .../(main)/category/[[id]]/+page.svelte | 146 ++++++++-------- .../(main)/category/[[id]]/service.svelte.ts | 9 +- .../DirectoryEntries/DirectoryEntries.svelte | 2 +- .../[[id]]/DirectoryEntries/File.svelte | 8 +- .../(main)/directory/[[id]]/service.svelte.ts | 1 - src/trpc/routers/category.ts | 52 +++--- 19 files changed, 416 insertions(+), 521 deletions(-) diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index cd7f35f..aa68bd2 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -16,7 +16,7 @@ let element: HTMLElement | undefined = $state(); let scrollMargin = $state(0); - const virtualizer = $derived( + let virtualizer = $derived( createWindowVirtualizer({ count, estimateSize: itemHeight, diff --git a/src/lib/components/molecules/Categories/Categories.svelte b/src/lib/components/molecules/Categories/Categories.svelte index 54368c6..a4a123b 100644 --- a/src/lib/components/molecules/Categories/Categories.svelte +++ b/src/lib/components/molecules/Categories/Categories.svelte @@ -1,59 +1,29 @@ {#if categoriesWithName.length > 0}
- {#each categoriesWithName as { info }} + {#each categoriesWithName as category} import type { Component } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; - import type { Writable } from "svelte/store"; import { ActionEntryButton } from "$lib/components/atoms"; import { CategoryLabel } from "$lib/components/molecules"; - import type { CategoryInfo } from "$lib/modules/filesystem"; + import type { SubCategoryInfo } from "$lib/modules/filesystem2.svelte"; import type { SelectedCategory } from "./service"; interface Props { - info: Writable; + info: SubCategoryInfo; menuIcon?: Component; onclick: (category: SelectedCategory) => void; onMenuClick?: (category: SelectedCategory) => void; } let { info, menuIcon, onclick, onMenuClick }: Props = $props(); - - const openCategory = () => { - const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo; - if (!dataKey || !dataKeyVersion) return; // TODO: Error handling - - onclick({ id, dataKey, dataKeyVersion, name }); - }; - - const openMenu = () => { - const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo; - if (!dataKey || !dataKeyVersion) return; // TODO: Error handling - - onMenuClick!({ id, dataKey, dataKeyVersion, name }); - }; -{#if $info} - - - -{/if} + onclick(info)} + actionButtonIcon={menuIcon} + onActionButtonClick={() => onMenuClick?.(info)} +> + + diff --git a/src/lib/components/molecules/Categories/service.ts b/src/lib/components/molecules/Categories/service.ts index 08c41db..683d516 100644 --- a/src/lib/components/molecules/Categories/service.ts +++ b/src/lib/components/molecules/Categories/service.ts @@ -1,6 +1,5 @@ export interface SelectedCategory { id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; + dataKey?: { key: CryptoKey; version: Date }; name: string; } diff --git a/src/lib/components/molecules/SubCategories.svelte b/src/lib/components/molecules/SubCategories.svelte index 9c84a89..a271309 100644 --- a/src/lib/components/molecules/SubCategories.svelte +++ b/src/lib/components/molecules/SubCategories.svelte @@ -1,10 +1,8 @@
@@ -53,14 +43,12 @@ {#if subCategoryCreatePosition === "top"} {@render subCategoryCreate()} {/if} - {#key info} - - {/key} + {#if subCategoryCreatePosition === "bottom"} {@render subCategoryCreate()} {/if} diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte index b42aeef..4824f82 100644 --- a/src/lib/components/organisms/Category/Category.svelte +++ b/src/lib/components/organisms/Category/Category.svelte @@ -1,11 +1,8 @@
@@ -89,26 +58,24 @@

하위 카테고리의 파일

- {#key info} - 48 + (index + 1 < files.length ? 4 : 0)} - > - {#snippet item(index)} - {@const { info, isRecursive } = files[index]!} -
- -
- {/snippet} - {#snippet placeholder()} -

이 카테고리에 추가된 파일이 없어요.

- {/snippet} -
- {/key} + 48 + (index + 1 < files.length ? 4 : 0)} + > + {#snippet item(index)} + {@const { details } = files[index]!} +
+ +
+ {/snippet} + {#snippet placeholder()} +

이 카테고리에 추가된 파일이 없어요.

+ {/snippet} +
{/if}
diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte index 8e3fc12..88e821a 100644 --- a/src/lib/components/organisms/Category/File.svelte +++ b/src/lib/components/organisms/Category/File.svelte @@ -1,59 +1,38 @@ -{#if $info} - - - -{/if} + onclick(info)} + actionButtonIcon={onRemoveClick && IconClose} + onActionButtonClick={() => onRemoveClick?.(info)} +> + {#await thumbnailPromise} + + {:then thumbnail} + + {/await} + diff --git a/src/lib/components/organisms/Category/service.ts b/src/lib/components/organisms/Category/service.ts index fb6e640..3c78d2f 100644 --- a/src/lib/components/organisms/Category/service.ts +++ b/src/lib/components/organisms/Category/service.ts @@ -1,8 +1,4 @@ -export { requestFileThumbnailDownload } from "$lib/services/file"; - export interface SelectedFile { id: number; - dataKey: CryptoKey; - dataKeyVersion: Date; name: string; } diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts index e01145b..5020793 100644 --- a/src/lib/modules/filesystem.ts +++ b/src/lib/modules/filesystem.ts @@ -3,11 +3,6 @@ import { getFileInfo as getFileInfoFromIndexedDB, storeFileInfo, deleteFileInfo, - getCategoryInfos as getCategoryInfosFromIndexedDB, - getCategoryInfo as getCategoryInfoFromIndexedDB, - storeCategoryInfo, - updateCategoryInfo as updateCategoryInfoInIndexedDB, - deleteCategoryInfo, } from "$lib/indexedDB"; import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; import { trpc, isTRPCClientError } from "$trpc/client"; @@ -25,28 +20,7 @@ export interface FileInfo { categoryIds: number[]; } -export type CategoryInfo = - | { - id: "root"; - dataKey?: undefined; - dataKeyVersion?: undefined; - name?: undefined; - subCategoryIds: number[]; - files?: undefined; - isFileRecursive?: undefined; - } - | { - id: number; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - name: string; - subCategoryIds: number[]; - files: { id: number; isRecursive: boolean }[]; - isFileRecursive: boolean; - }; - const fileInfoStore = new Map>(); -const categoryInfoStore = new Map>(); const fetchFileInfoFromIndexedDB = async (id: number, info: Writable) => { if (get(info)) return; @@ -130,124 +104,3 @@ export const getFileInfo = (fileId: number, masterKey: CryptoKey) => { fetchFileInfo(fileId, info, masterKey); // Intended return info; }; - -const fetchCategoryInfoFromIndexedDB = async ( - id: CategoryId, - info: Writable, -) => { - if (get(info)) return; - - const [category, subCategories] = await Promise.all([ - id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined, - getCategoryInfosFromIndexedDB(id), - ]); - const subCategoryIds = subCategories.map(({ id }) => id); - - if (id === "root") { - info.set({ id, subCategoryIds }); - } else { - if (!category) return; - info.set({ - id, - name: category.name, - subCategoryIds, - files: category.files, - isFileRecursive: category.isFileRecursive, - }); - } -}; - -const fetchCategoryInfoFromServer = async ( - id: CategoryId, - info: Writable, - masterKey: CryptoKey, -) => { - let data; - try { - data = await trpc().category.get.query({ id }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteCategoryInfo(id as number); - return; - } - throw new Error("Failed to fetch category information"); - } - - const { metadata, subCategories } = data; - - if (id === "root") { - info.set({ id, subCategoryIds: subCategories }); - } else { - const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); - const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); - - let files; - try { - files = await trpc().category.files.query({ id, recurse: true }); - } catch { - throw new Error("Failed to fetch category files"); - } - - const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive })); - let isFileRecursive: boolean | undefined = undefined; - - info.update((value) => { - const newValue = { - isFileRecursive: false, - ...value, - id, - dataKey, - dataKeyVersion: new Date(metadata!.dekVersion), - name, - subCategoryIds: subCategories, - files: filesMapped, - }; - isFileRecursive = newValue.isFileRecursive; - return newValue; - }); - await storeCategoryInfo({ - id, - parentId: metadata!.parent, - name, - files: filesMapped, - isFileRecursive: isFileRecursive!, - }); - } -}; - -const fetchCategoryInfo = async ( - id: CategoryId, - info: Writable, - masterKey: CryptoKey, -) => { - await fetchCategoryInfoFromIndexedDB(id, info); - await fetchCategoryInfoFromServer(id, info, masterKey); -}; - -export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = categoryInfoStore.get(categoryId); - if (!info) { - info = writable(null); - categoryInfoStore.set(categoryId, info); - } - - fetchCategoryInfo(categoryId, info, masterKey); // Intended - return info; -}; - -export const updateCategoryInfo = async ( - categoryId: number, - changes: { isFileRecursive?: boolean }, -) => { - await updateCategoryInfoInIndexedDB(categoryId, changes); - categoryInfoStore.get(categoryId)?.update((value) => { - if (!value) return value; - if (changes.isFileRecursive !== undefined) { - value.isFileRecursive = changes.isFileRecursive; - } - return value; - }); -}; diff --git a/src/lib/modules/filesystem2.svelte.ts b/src/lib/modules/filesystem2.svelte.ts index ade5342..01514cb 100644 --- a/src/lib/modules/filesystem2.svelte.ts +++ b/src/lib/modules/filesystem2.svelte.ts @@ -54,13 +54,14 @@ interface FileInfo { } export type SummarizedFileInfo = Omit; +export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; interface LocalCategoryInfo { id: number; - dataKey: DataKey | undefined; + dataKey?: DataKey | undefined; name: string; - subCategories: Omit[]; - files: { id: number; name: string; isRecursive: boolean }[]; + subCategories: SubCategoryInfo[]; + files: CategoryFileInfo[]; isFileRecursive: boolean; } @@ -68,13 +69,19 @@ interface RootCategoryInfo { id: "root"; dataKey?: undefined; name?: undefined; - subCategories: Omit[]; + subCategories: SubCategoryInfo[]; files?: undefined; + isFileRecursive?: undefined; } export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo; +export type SubCategoryInfo = Omit< + LocalCategoryInfo, + "subCategories" | "files" | "isFileRecursive" +>; const directoryInfoCache = new Map>(); +const categoryInfoCache = new Map>(); export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { const info = directoryInfoCache.get(id); @@ -189,3 +196,146 @@ const fetchDirectoryInfoFromServer = async ( const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); }; + +export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { + const info = categoryInfoCache.get(id); + if (info instanceof Promise) { + return info; + } + + const { promise, resolve } = Promise.withResolvers(); + if (!info) { + categoryInfoCache.set(id, promise); + const categoryInfo = await fetchCategoryInfoFromIndexedDB(id); + if (categoryInfo) { + const state = $state(categoryInfo); + categoryInfoCache.set(id, state); + resolve(state); + } + } + + fetchCategoryInfoFromServer(id, masterKey).then((categoryInfo) => { + if (!categoryInfo) return; + + let info = categoryInfoCache.get(id); + if (info instanceof Promise) { + const state = $state(categoryInfo); + categoryInfoCache.set(id, state); + resolve(state); + } else { + Object.assign(info!, categoryInfo); + resolve(info!); + } + }); + + return info ?? promise; +}; + +const fetchCategoryInfoFromIndexedDB = async ( + id: CategoryId, +): Promise => { + const [category, subCategories] = await Promise.all([ + id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined, + getCategoryInfosFromIndexedDB(id), + ]); + const files = category + ? await Promise.all( + category.files.map(async (file) => { + const fileInfo = await getFileInfoFromIndexedDB(file.id); + return fileInfo + ? { + id: file.id, + contentType: fileInfo.contentType, + name: fileInfo.name, + createdAt: fileInfo.createdAt, + lastModifiedAt: fileInfo.lastModifiedAt, + isRecursive: file.isRecursive, + } + : undefined; + }), + ) + : undefined; + + if (id === "root") { + return { id, subCategories }; + } else if (category) { + return { + id, + name: category.name, + subCategories, + files: files!.filter((file) => !!file), + isFileRecursive: category.isFileRecursive, + }; + } +}; + +const fetchCategoryInfoFromServer = async ( + id: CategoryId, + masterKey: CryptoKey, +): Promise => { + try { + const { + metadata, + subCategories: subCategoriesRaw, + files: filesRaw, + } = await trpc().category.get.query({ id, recurse: true }); + const [subCategories, files] = await Promise.all([ + Promise.all( + subCategoriesRaw.map(async (category) => { + const { dataKey } = await unwrapDataKey(category.dek, masterKey); + const name = await decryptString(category.name, category.nameIv, dataKey); + return { + id: category.id, + dataKey: { key: dataKey, version: category.dekVersion }, + name, + }; + }), + ), + id !== "root" + ? Promise.all( + filesRaw!.map(async (file) => { + const { dataKey } = await unwrapDataKey(file.dek, masterKey); + const [name, createdAt, lastModifiedAt] = await Promise.all([ + decryptString(file.name, file.nameIv, dataKey), + file.createdAt + ? decryptDate(file.createdAt, file.createdAtIv!, dataKey) + : undefined, + decryptDate(file.lastModifiedAt, file.lastModifiedAtIv, dataKey), + ]); + return { + id: file.id, + dataKey: { key: dataKey, version: file.dekVersion }, + contentType: file.contentType, + name, + createdAt, + lastModifiedAt, + isRecursive: file.isRecursive, + }; + }), + ) + : undefined, + ]); + + if (id === "root") { + return { id, subCategories }; + } else { + const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey); + const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey); + return { + id, + dataKey: { key: dataKey, version: metadata!.dekVersion }, + name, + subCategories, + files: files!, + isFileRecursive: false, + }; + } + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + categoryInfoCache.delete(id); + await deleteCategoryInfo(id as number); + return; + } + throw new Error("Failed to fetch category information"); + } +}; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index a524ff4..7bea6db 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -304,39 +304,51 @@ export const getAllFilesByCategory = async ( recurse: boolean, ) => { const files = await db - .withRecursive("cte", (db) => + .withRecursive("category_tree", (db) => db .selectFrom("category") - .leftJoin("file_category", "category.id", "file_category.category_id") - .select(["id", "parent_id", "user_id", "file_category.file_id"]) - .select(sql`0`.as("depth")) + .select(["id", sql`0`.as("depth")]) .where("id", "=", categoryId) + .where("user_id", "=", userId) .$if(recurse, (qb) => qb.unionAll((db) => db .selectFrom("category") - .leftJoin("file_category", "category.id", "file_category.category_id") - .innerJoin("cte", "category.parent_id", "cte.id") - .select([ - "category.id", - "category.parent_id", - "category.user_id", - "file_category.file_id", - ]) - .select(sql`cte.depth + 1`.as("depth")), + .innerJoin("category_tree", "category.parent_id", "category_tree.id") + .select(["category.id", sql`depth + 1`.as("depth")]), ), ), ) - .selectFrom("cte") + .selectFrom("category_tree") + .innerJoin("file_category", "category_tree.id", "file_category.category_id") + .innerJoin("file", "file_category.file_id", "file.id") .select(["file_id", "depth"]) + .selectAll("file") .distinctOn("file_id") - .where("user_id", "=", userId) - .where("file_id", "is not", null) - .$narrowType<{ file_id: NotNull }>() .orderBy("file_id") .orderBy("depth") .execute(); - return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 })); + return files.map( + (file) => + ({ + id: file.file_id, + parentId: file.parent_id ?? "root", + userId: file.user_id, + path: file.path, + mekVersion: file.master_encryption_key_version, + encDek: file.encrypted_data_encryption_key, + dekVersion: file.data_encryption_key_version, + hskVersion: file.hmac_secret_key_version, + contentHmac: file.content_hmac, + contentType: file.content_type, + encContentIv: file.encrypted_content_iv, + encContentHash: file.encrypted_content_hash, + encName: file.encrypted_name, + encCreatedAt: file.encrypted_created_at, + encLastModifiedAt: file.encrypted_last_modified_at, + isRecursive: file.depth > 0, + }) satisfies File & { isRecursive: boolean }, + ); }; export const getAllFileIds = async (userId: number) => { diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 9e0ddc0..ab85dc7 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -6,12 +6,7 @@ import { page } from "$app/state"; import { FullscreenDiv } from "$lib/components/atoms"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; - import { - getFileInfo, - getCategoryInfo, - type FileInfo, - type CategoryInfo, - } from "$lib/modules/filesystem"; + import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; @@ -32,7 +27,7 @@ let { data } = $props(); let info: Writable | undefined = $state(); - let categories: Writable[] = $state([]); + // let categories: Writable[] = $state([]); let isMenuOpen = $state(false); let isAddToCategoryBottomSheetOpen = $state(false); @@ -90,10 +85,10 @@ viewerType = undefined; }); - $effect(() => { - categories = - $info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? []; - }); + // $effect(() => { + // categories = + // $info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? []; + // }); $effect(() => { if ($info && $info.dataKey && $info.contentIv) { @@ -190,12 +185,12 @@

카테고리

- goto(`/category/${id}`)} onCategoryMenuClick={({ id }) => removeFromCategory(id)} - /> + /> --> (isAddToCategoryBottomSheetOpen = true)} diff --git a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte index f1d0200..91dc7e5 100644 --- a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte +++ b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte @@ -1,9 +1,8 @@ -{#if $category} - - - - (category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - subCategoryCreatePosition="top" - /> - {#if $category.id !== "root"} - - - - {/if} - - -{/if} +{#await categoryInfoPromise then categoryInfo} + {#if categoryInfo} + + + + (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + subCategoryCreatePosition="top" + /> + {#if categoryInfo.id !== "root"} + + + + {/if} + + - { - if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) { - category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> + { + if (await requestCategoryCreation(name, categoryInfo.id, $masterKeyStore?.get(1)!)) { + categoryInfoPromise = getCategoryInfo(categoryInfo.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + {/if} +{/await} diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte index 9b3e195..4a038d9 100644 --- a/src/routes/(main)/category/[[id]]/+page.svelte +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -1,9 +1,8 @@ @@ -50,68 +34,70 @@ 카테고리 -{#if data.id !== "root"} - -{/if} -
- {#if $info && isFileRecursive !== undefined} - goto(`/file/${id}?from=category`)} - onFileRemoveClick={async ({ id }) => { - await requestFileRemovalFromCategory(id, data.id as number); - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME +{#await infoPromise then info} + {#if info} + {#if info.id !== "root"} + + {/if} +
+ goto(`/file/${id}?from=category`)} + onFileRemoveClick={async ({ id }) => { + await requestFileRemovalFromCategory(id, data.id as number); + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + }} + onSubCategoryClick={({ id }) => goto(`/category/${id}`)} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + onSubCategoryMenuClick={(subCategory) => { + context.selectedCategory = subCategory; + isCategoryMenuBottomSheetOpen = true; + }} + /> +
+ + { + if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; }} - onSubCategoryClick={({ id }) => goto(`/category/${id}`)} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - onSubCategoryMenuClick={(subCategory) => { - context.selectedCategory = subCategory; - isCategoryMenuBottomSheetOpen = true; + /> + + { + isCategoryMenuBottomSheetOpen = false; + isCategoryRenameModalOpen = true; + }} + onDeleteClick={() => { + isCategoryMenuBottomSheetOpen = false; + isCategoryDeleteModalOpen = true; + }} + /> + { + if (await requestCategoryRename(context.selectedCategory!, newName)) { + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} + /> + { + if (await requestCategoryDeletion(context.selectedCategory!)) { + infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; }} /> {/if} -
- - { - if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - - { - isCategoryMenuBottomSheetOpen = false; - isCategoryRenameModalOpen = true; - }} - onDeleteClick={() => { - isCategoryMenuBottomSheetOpen = false; - isCategoryDeleteModalOpen = true; - }} -/> - { - if (await requestCategoryRename(context.selectedCategory!, newName)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> - { - if (await requestCategoryDeletion(context.selectedCategory!)) { - info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} -/> +{/await} diff --git a/src/routes/(main)/category/[[id]]/service.svelte.ts b/src/routes/(main)/category/[[id]]/service.svelte.ts index 18f68fd..c415cf5 100644 --- a/src/routes/(main)/category/[[id]]/service.svelte.ts +++ b/src/routes/(main)/category/[[id]]/service.svelte.ts @@ -17,12 +17,17 @@ export const useContext = () => { }; export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { - const newNameEncrypted = await encryptString(newName, category.dataKey); + if (!category.dataKey) { + // TODO: Error Handling + return false; + } + + const newNameEncrypted = await encryptString(newName, category.dataKey.key); try { await trpc().category.rename.mutate({ id: category.id, - dekVersion: category.dataKeyVersion, + dekVersion: category.dataKey.version, name: newNameEncrypted.ciphertext, nameIv: newNameEncrypted.iv, }); diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index 530cb97..5375574 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -39,7 +39,7 @@ details, }); - const entries = $derived([ + let entries = $derived([ ...(showParentEntry ? ([{ type: "parent" }] as const) : []), ...sortEntries(info.subDirectories.map(toEntry("directory"))), ...sortEntries([ diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index fdc225c..9a972aa 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -35,7 +35,13 @@ actionButtonIcon={IconMoreVert} onActionButtonClick={() => action(onOpenMenuClick)} > - {#await thumbnailPromise then thumbnail} + {#await thumbnailPromise} + + {:then thumbnail} { if (!entry.dataKey) { // TODO: Error Handling - console.log("hi"); return false; } diff --git a/src/trpc/routers/category.ts b/src/trpc/routers/category.ts index 2be80c8..9b2567a 100644 --- a/src/trpc/routers/category.ts +++ b/src/trpc/routers/category.ts @@ -9,6 +9,7 @@ const categoryRouter = router({ .input( z.object({ id: categoryIdSchema, + recurse: z.boolean().default(false), }), ) .query(async ({ ctx, input }) => { @@ -20,7 +21,12 @@ const categoryRouter = router({ throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); } - const categories = await CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id); + const [categories, files] = await Promise.all([ + CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id), + input.id !== "root" + ? FileRepo.getAllFilesByCategory(ctx.session.userId, input.id, input.recurse) + : undefined, + ]); return { metadata: category && { parent: category.parentId, @@ -30,7 +36,28 @@ const categoryRouter = router({ name: category.encName.ciphertext, nameIv: category.encName.iv, }, - subCategories: categories.map(({ id }) => id), + subCategories: categories.map((category) => ({ + id: category.id, + mekVersion: category.mekVersion, + dek: category.encDek, + dekVersion: category.dekVersion, + name: category.encName.ciphertext, + nameIv: category.encName.iv, + })), + files: files?.map((file) => ({ + id: file.id, + mekVersion: file.mekVersion, + dek: file.encDek, + dekVersion: file.dekVersion, + contentType: file.contentType, + name: file.encName.ciphertext, + nameIv: file.encName.iv, + createdAt: file.encCreatedAt?.ciphertext, + createdAtIv: file.encCreatedAt?.iv, + lastModifiedAt: file.encLastModifiedAt.ciphertext, + lastModifiedAtIv: file.encLastModifiedAt.iv, + isRecursive: file.isRecursive, + })), }; }), @@ -113,27 +140,6 @@ const categoryRouter = router({ } }), - files: roleProcedure["activeClient"] - .input( - z.object({ - id: z.int().positive(), - recurse: z.boolean().default(false), - }), - ) - .query(async ({ ctx, input }) => { - const category = await CategoryRepo.getCategory(ctx.session.userId, input.id); - if (!category) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); - } - - const files = await FileRepo.getAllFilesByCategory( - ctx.session.userId, - input.id, - input.recurse, - ); - return files.map(({ id, isRecursive }) => ({ file: id, isRecursive })); - }), - addFile: roleProcedure["activeClient"] .input( z.object({ From e4413ddbf64a7f8d2f5bd574f2d4eb2d5411a03a Mon Sep 17 00:00:00 2001 From: static Date: Tue, 30 Dec 2025 23:30:50 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=EC=9D=98=20=EB=84=A4=ED=8A=B8?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=20=ED=98=B8=EC=B6=9C=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../atoms/buttons/FileThumbnailButton.svelte | 48 ++-- src/lib/components/organisms/Gallery.svelte | 124 +++------- src/lib/modules/file/download.svelte.ts | 97 ++++++++ src/lib/modules/file/download.ts | 84 ------- src/lib/modules/file/index.ts | 2 +- src/lib/modules/filesystem.ts | 106 --------- src/lib/modules/filesystem2.svelte.ts | 99 +++++++- src/lib/server/db/file.ts | 13 +- src/lib/stores/file.ts | 25 -- src/lib/stores/index.ts | 1 - .../(fullscreen)/file/[id]/+page.svelte | 215 +++++++++--------- .../file/[id]/DownloadStatus.svelte | 21 +- .../(fullscreen)/file/downloads/+page.svelte | 28 +-- .../(fullscreen)/file/downloads/File.svelte | 94 ++++---- src/routes/(fullscreen)/gallery/+page.svelte | 17 +- .../(fullscreen)/settings/cache/+page.svelte | 18 +- .../(fullscreen)/settings/cache/File.svelte | 11 +- .../settings/thumbnail/+page.svelte | 21 +- .../settings/thumbnail/File.svelte | 36 ++- .../settings/thumbnail/service.svelte.ts | 14 +- .../[[id]]/DownloadStatusCard.svelte | 22 +- src/routes/(main)/home/+page.svelte | 19 +- src/routes/+layout.svelte | 15 +- src/trpc/routers/file.ts | 9 +- 24 files changed, 521 insertions(+), 618 deletions(-) create mode 100644 src/lib/modules/file/download.svelte.ts delete mode 100644 src/lib/modules/file/download.ts delete mode 100644 src/lib/modules/filesystem.ts delete mode 100644 src/lib/stores/file.ts diff --git a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte index c18101c..c71ff6b 100644 --- a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte +++ b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte @@ -1,42 +1,34 @@ -{#if $info} - -{/if} + {/await} + diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte index 1fcb4ff..fb96775 100644 --- a/src/lib/components/organisms/Gallery.svelte +++ b/src/lib/components/organisms/Gallery.svelte @@ -1,98 +1,48 @@ @@ -101,8 +51,8 @@ itemHeight={(index) => rows[index]!.type === "header" ? 28 - : Math.ceil(rows[index]!.items.length / 4) * 181 + - (Math.ceil(rows[index]!.items.length / 4) - 1) * 4 + + : Math.ceil(rows[index]!.files.length / 4) * 181 + + (Math.ceil(rows[index]!.files.length / 4) - 1) * 4 + 16} class="flex flex-grow flex-col" > @@ -112,8 +62,8 @@

{row.label}

{:else}
- {#each row.items as { info }} - + {#each row.files as file} + {/each}
{/if} @@ -123,8 +73,6 @@

{#if files.length === 0} 업로드된 파일이 없어요. - {:else if filesWithDate.length === 0} - 파일 목록을 불러오고 있어요. {:else} 사진 또는 동영상이 없어요. {/if} diff --git a/src/lib/modules/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts new file mode 100644 index 0000000..4c53ed0 --- /dev/null +++ b/src/lib/modules/file/download.svelte.ts @@ -0,0 +1,97 @@ +import axios from "axios"; +import { limitFunction } from "p-limit"; +import { decryptData } from "$lib/modules/crypto"; + +export interface FileDownloadState { + id: number; + status: + | "download-pending" + | "downloading" + | "decryption-pending" + | "decrypting" + | "decrypted" + | "canceled" + | "error"; + progress?: number; + rate?: number; + estimated?: number; + result?: ArrayBuffer; +} + +export type LiveFileDownloadState = FileDownloadState & { + status: "download-pending" | "downloading" | "decryption-pending" | "decrypting"; +}; + +let downloadingFiles: FileDownloadState[] = $state([]); + +export const isFileDownloading = ( + status: FileDownloadState["status"], +): status is LiveFileDownloadState["status"] => + ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status); + +export const getFileDownloadState = (fileId: number) => { + return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status)); +}; + +export const getDownloadingFiles = () => { + return downloadingFiles.filter((file): file is LiveFileDownloadState => + isFileDownloading(file.status), + ); +}; + +export const clearDownloadedFiles = () => { + downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status)); +}; + +const requestFileDownload = limitFunction( + async (state: FileDownloadState, id: number) => { + state.status = "download-pending"; + + const res = await axios.get(`/api/file/${id}/download`, { + responseType: "arraybuffer", + onDownloadProgress: ({ progress, rate, estimated }) => { + state.progress = progress; + state.rate = rate; + state.estimated = estimated; + }, + }); + const fileEncrypted: ArrayBuffer = res.data; + + state.status = "decryption-pending"; + return fileEncrypted; + }, + { concurrency: 1 }, +); + +const decryptFile = limitFunction( + async ( + state: FileDownloadState, + fileEncrypted: ArrayBuffer, + fileEncryptedIv: string, + dataKey: CryptoKey, + ) => { + state.status = "decrypting"; + + const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); + + state.status = "decrypted"; + state.result = fileBuffer; + return fileBuffer; + }, + { concurrency: 4 }, +); + +export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { + downloadingFiles.push({ + id, + status: "download-pending", + }); + const state = downloadingFiles.at(-1)!; + + try { + return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey); + } catch (e) { + state.status = "error"; + throw e; + } +}; diff --git a/src/lib/modules/file/download.ts b/src/lib/modules/file/download.ts deleted file mode 100644 index b0efb30..0000000 --- a/src/lib/modules/file/download.ts +++ /dev/null @@ -1,84 +0,0 @@ -import axios from "axios"; -import { limitFunction } from "p-limit"; -import { writable, type Writable } from "svelte/store"; -import { decryptData } from "$lib/modules/crypto"; -import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores"; - -const requestFileDownload = limitFunction( - async (status: Writable, id: number) => { - status.update((value) => { - value.status = "downloading"; - return value; - }); - - const res = await axios.get(`/api/file/${id}/download`, { - responseType: "arraybuffer", - onDownloadProgress: ({ progress, rate, estimated }) => { - status.update((value) => { - value.progress = progress; - value.rate = rate; - value.estimated = estimated; - return value; - }); - }, - }); - const fileEncrypted: ArrayBuffer = res.data; - - status.update((value) => { - value.status = "decryption-pending"; - return value; - }); - return fileEncrypted; - }, - { concurrency: 1 }, -); - -const decryptFile = limitFunction( - async ( - status: Writable, - fileEncrypted: ArrayBuffer, - fileEncryptedIv: string, - dataKey: CryptoKey, - ) => { - status.update((value) => { - value.status = "decrypting"; - return value; - }); - - const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); - - status.update((value) => { - value.status = "decrypted"; - value.result = fileBuffer; - return value; - }); - return fileBuffer; - }, - { concurrency: 4 }, -); - -export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { - const status = writable({ - id, - status: "download-pending", - }); - fileDownloadStatusStore.update((value) => { - value.push(status); - return value; - }); - - try { - return await decryptFile( - status, - await requestFileDownload(status, id), - fileEncryptedIv, - dataKey, - ); - } catch (e) { - status.update((value) => { - value.status = "error"; - return value; - }); - throw e; - } -}; diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index 3b99989..871d299 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,3 +1,3 @@ export * from "./cache"; -export * from "./download"; +export * from "./download.svelte"; export * from "./upload.svelte"; diff --git a/src/lib/modules/filesystem.ts b/src/lib/modules/filesystem.ts deleted file mode 100644 index 5020793..0000000 --- a/src/lib/modules/filesystem.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { get, writable, type Writable } from "svelte/store"; -import { - getFileInfo as getFileInfoFromIndexedDB, - storeFileInfo, - deleteFileInfo, -} from "$lib/indexedDB"; -import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; -import { trpc, isTRPCClientError } from "$trpc/client"; - -export interface FileInfo { - id: number; - parentId: DirectoryId; - dataKey?: CryptoKey; - dataKeyVersion?: Date; - contentType: string; - contentIv?: string; - name: string; - createdAt?: Date; - lastModifiedAt: Date; - categoryIds: number[]; -} - -const fileInfoStore = new Map>(); - -const fetchFileInfoFromIndexedDB = async (id: number, info: Writable) => { - if (get(info)) return; - - const file = await getFileInfoFromIndexedDB(id); - if (!file) return; - - info.set(file); -}; - -const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { - return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); -}; - -const fetchFileInfoFromServer = async ( - id: number, - info: Writable, - masterKey: CryptoKey, -) => { - let metadata; - try { - metadata = await trpc().file.get.query({ id }); - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - info.set(null); - await deleteFileInfo(id); - return; - } - throw new Error("Failed to fetch file information"); - } - const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); - - const name = await decryptString(metadata.name, metadata.nameIv, dataKey); - const createdAt = - metadata.createdAt && metadata.createdAtIv - ? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey) - : undefined; - const lastModifiedAt = await decryptDate( - metadata.lastModifiedAt, - metadata.lastModifiedAtIv, - dataKey, - ); - - info.set({ - id, - parentId: metadata.parent, - dataKey, - dataKeyVersion: new Date(metadata.dekVersion), - contentType: metadata.contentType, - contentIv: metadata.contentIv, - name, - createdAt, - lastModifiedAt, - categoryIds: metadata.categories, - }); - await storeFileInfo({ - id, - parentId: metadata.parent, - name, - contentType: metadata.contentType, - createdAt, - lastModifiedAt, - categoryIds: metadata.categories, - }); -}; - -const fetchFileInfo = async (id: number, info: Writable, masterKey: CryptoKey) => { - await fetchFileInfoFromIndexedDB(id, info); - await fetchFileInfoFromServer(id, info, masterKey); -}; - -export const getFileInfo = (fileId: number, masterKey: CryptoKey) => { - // TODO: MEK rotation - - let info = fileInfoStore.get(fileId); - if (!info) { - info = writable(null); - fileInfoStore.set(fileId, info); - } - - fetchFileInfo(fileId, info, masterKey); // Intended - return info; -}; diff --git a/src/lib/modules/filesystem2.svelte.ts b/src/lib/modules/filesystem2.svelte.ts index 01514cb..eeb3766 100644 --- a/src/lib/modules/filesystem2.svelte.ts +++ b/src/lib/modules/filesystem2.svelte.ts @@ -41,12 +41,12 @@ interface RootDirectoryInfo { export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; export type SubDirectoryInfo = Omit; -interface FileInfo { +export interface FileInfo { id: number; parentId: DirectoryId; dataKey?: DataKey; contentType: string; - contentIv: string | undefined; + contentIv?: string; name: string; createdAt?: Date; lastModifiedAt: Date; @@ -81,6 +81,7 @@ export type SubCategoryInfo = Omit< >; const directoryInfoCache = new Map>(); +const fileInfoCache = new Map>(); const categoryInfoCache = new Map>(); export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { @@ -197,6 +198,100 @@ const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) = return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); }; +export const getFileInfo = async (id: number, masterKey: CryptoKey) => { + const info = fileInfoCache.get(id); + if (info instanceof Promise) { + return info; + } + + const { promise, resolve } = Promise.withResolvers(); + if (!info) { + fileInfoCache.set(id, promise); + } + + monotonicResolve( + [!info && fetchFileInfoFromIndexedDB(id), fetchFileInfoFromServer(id, masterKey)], + (fileInfo) => { + let info = fileInfoCache.get(id); + if (info instanceof Promise) { + const state = $state(fileInfo); + fileInfoCache.set(id, state); + resolve(state); + } else { + Object.assign(info!, fileInfo); + resolve(info!); + } + }, + ); + return info ?? promise; +}; + +const fetchFileInfoFromIndexedDB = async (id: number): Promise => { + const file = await getFileInfoFromIndexedDB(id); + const categories = await Promise.all( + file?.categoryIds.map(async (categoryId) => { + const categoryInfo = await getCategoryInfoFromIndexedDB(categoryId); + return categoryInfo ? { id: categoryId, name: categoryInfo.name } : undefined; + }) ?? [], + ); + + if (file) { + return { + id, + parentId: file.parentId, + contentType: file.contentType, + name: file.name, + createdAt: file.createdAt, + lastModifiedAt: file.lastModifiedAt, + categories: categories.filter((category) => !!category), + }; + } +}; + +const fetchFileInfoFromServer = async ( + id: number, + masterKey: CryptoKey, +): Promise => { + try { + const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id }); + const categories = await Promise.all( + categoriesRaw.map(async (category) => { + const { dataKey } = await unwrapDataKey(category.dek, masterKey); + const name = await decryptString(category.name, category.nameIv, dataKey); + return { id: category.id, name }; + }), + ); + + const { dataKey } = await unwrapDataKey(metadata.dek, masterKey); + const [name, createdAt, lastModifiedAt] = await Promise.all([ + decryptString(metadata.name, metadata.nameIv, dataKey), + metadata.createdAt + ? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey) + : undefined, + decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey), + ]); + + return { + id, + parentId: metadata.parent, + dataKey: { key: dataKey, version: new Date(metadata.dekVersion) }, + contentType: metadata.contentType, + contentIv: metadata.contentIv, + name, + createdAt, + lastModifiedAt, + categories, + }; + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + fileInfoCache.delete(id); + await deleteFileInfo(id); + return; + } + throw new Error("Failed to fetch file information"); + } +}; + export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { const info = categoryInfoCache.get(id); if (info instanceof Promise) { diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 7bea6db..45ae0f4 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,4 +1,4 @@ -import { sql, type NotNull } from "kysely"; +import { sql } from "kysely"; import pg from "pg"; import { IntegrityError } from "./error"; import db from "./kysely"; @@ -486,10 +486,17 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => { export const getAllFileCategories = async (fileId: number) => { const categories = await db .selectFrom("file_category") - .select("category_id") + .innerJoin("category", "file_category.category_id", "category.id") + .selectAll("category") .where("file_id", "=", fileId) .execute(); - return categories.map(({ category_id }) => ({ id: category_id })); + return categories.map((category) => ({ + id: category.id, + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: category.data_encryption_key_version, + encName: category.encrypted_name, + })); }; export const removeFileFromCategory = async (fileId: number, categoryId: number) => { diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts deleted file mode 100644 index 0aab6d1..0000000 --- a/src/lib/stores/file.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { writable, type Writable } from "svelte/store"; - -export interface FileDownloadStatus { - id: number; - status: - | "download-pending" - | "downloading" - | "decryption-pending" - | "decrypting" - | "decrypted" - | "canceled" - | "error"; - progress?: number; - rate?: number; - estimated?: number; - result?: ArrayBuffer; -} - -export const fileDownloadStatusStore = writable[]>([]); - -export const isFileDownloading = ( - status: FileDownloadStatus["status"], -): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => { - return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status); -}; diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 537209a..668f46f 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -1,2 +1 @@ -export * from "./file"; export * from "./key"; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index ab85dc7..d5ba57c 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -1,14 +1,14 @@ -{#if $status && isFileDownloading($status.status)} +{#if isFileDownloading(state.status)}

- {#if $status.status === "download-pending"} + {#if state.status === "download-pending"} 다운로드를 기다리는 중 - {:else if $status.status === "downloading"} + {:else if state.status === "downloading"} 다운로드하는 중 - {:else if $status.status === "decryption-pending"} + {:else if state.status === "decryption-pending"} 복호화를 기다리는 중 - {:else if $status.status === "decrypting"} + {:else if state.status === "decrypting"} 복호화하는 중 {/if}

- {#if $status.status === "downloading"} + {#if state.status === "downloading"} 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} + {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} {/if}

diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte index e1bad0e..f9bfa84 100644 --- a/src/routes/(fullscreen)/file/downloads/+page.svelte +++ b/src/routes/(fullscreen)/file/downloads/+page.svelte @@ -1,19 +1,21 @@ @@ -22,9 +24,9 @@ -
- {#each downloadingFiles as status} - + {#await downloadingFilesPromise then downloadingFiles} + {#each downloadingFiles as { state, fileInfo }} + {/each} -
+ {/await}
diff --git a/src/routes/(fullscreen)/file/downloads/File.svelte b/src/routes/(fullscreen)/file/downloads/File.svelte index 3bfe292..6a2a0ca 100644 --- a/src/routes/(fullscreen)/file/downloads/File.svelte +++ b/src/routes/(fullscreen)/file/downloads/File.svelte @@ -1,7 +1,6 @@ -{#if $fileInfo} -
-
- {#if $status.status === "download-pending"} - - {:else if $status.status === "downloading"} - - {:else if $status.status === "decryption-pending"} - - {:else if $status.status === "decrypting"} - - {:else if $status.status === "decrypted"} - - {:else if $status.status === "error"} - - {/if} -
-
-

- {$fileInfo.name} -

-

- {#if $status.status === "download-pending"} - 다운로드를 기다리는 중 - {:else if $status.status === "downloading"} - 전송됨 - {Math.floor(($status.progress ?? 0) * 100)}% · - {formatNetworkSpeed(($status.rate ?? 0) * 8)} - {:else if $status.status === "decryption-pending"} - 복호화를 기다리는 중 - {:else if $status.status === "decrypting"} - 복호화하는 중 - {:else if $status.status === "decrypted"} - 다운로드 완료 - {:else if $status.status === "error"} - 다운로드 실패 - {/if} -

-
+
+
+ {#if state.status === "download-pending"} + + {:else if state.status === "downloading"} + + {:else if state.status === "decryption-pending"} + + {:else if state.status === "decrypting"} + + {:else if state.status === "decrypted"} + + {:else if state.status === "error"} + + {/if}
-{/if} +
+

+ {info.name} +

+

+ {#if state.status === "download-pending"} + 다운로드를 기다리는 중 + {:else if state.status === "downloading"} + 전송됨 + {Math.floor((state.progress ?? 0) * 100)}% · + {formatNetworkSpeed((state.rate ?? 0) * 8)} + {:else if state.status === "decryption-pending"} + 복호화를 기다리는 중 + {:else if state.status === "decrypting"} + 복호화하는 중 + {:else if state.status === "decrypted"} + 다운로드 완료 + {:else if state.status === "error"} + 다운로드 실패 + {/if} +

+
+
diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index b6f8239..ab73c70 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -1,18 +1,20 @@ @@ -22,5 +24,8 @@ - goto(`/file/${id}?from=gallery`)} /> + !!file)} + onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)} + /> diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index cf8192d..271ae96 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -1,18 +1,17 @@
- {#if $info} + {#if info}
@@ -28,8 +27,8 @@
{/if}
- {#if $info} -

{$info.name}

+ {#if info} +

{info.name}

{:else}

삭제된 파일

{/if} diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index d9cd692..8830133 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -5,7 +5,7 @@ import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; - import { getFileInfo } from "$lib/modules/filesystem"; + import { getFileInfo } from "$lib/modules/filesystem2.svelte"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; import { @@ -20,19 +20,20 @@ const generateAllThumbnails = () => { persistentStates.files.forEach(({ info }) => { - const fileInfo = get(info); - if (fileInfo) { - requestThumbnailGeneration(fileInfo); + if (info) { + requestThumbnailGeneration(info); } }); }; - onMount(() => { - persistentStates.files = data.files.map((fileId) => ({ - id: fileId, - info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), - status: getGenerationStatus(fileId), - })); + onMount(async () => { + persistentStates.files = await Promise.all( + data.files.map(async (fileId) => ({ + id: fileId, + info: await getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), + status: getGenerationStatus(fileId), + })), + ); }); diff --git a/src/routes/(fullscreen)/settings/thumbnail/File.svelte b/src/routes/(fullscreen)/settings/thumbnail/File.svelte index 93c23ad..e5699e2 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/File.svelte @@ -13,34 +13,32 @@ import type { Writable } from "svelte/store"; import { ActionEntryButton } from "$lib/components/atoms"; import { DirectoryEntryLabel } from "$lib/components/molecules"; - import type { FileInfo } from "$lib/modules/filesystem"; + import type { FileInfo } from "$lib/modules/filesystem2.svelte"; import { formatDateTime } from "$lib/utils"; import type { GenerationStatus } from "./service.svelte"; import IconCamera from "~icons/material-symbols/camera"; interface Props { - info: Writable; - onclick: (selectedFile: FileInfo) => void; - onGenerateThumbnailClick: (selectedFile: FileInfo) => void; + info: FileInfo; + onclick: (file: FileInfo) => void; + onGenerateThumbnailClick: (file: FileInfo) => void; generationStatus?: Writable; } let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props(); -{#if $info} - onclick($info)} - actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} - onActionButtonClick={() => onGenerateThumbnailClick($info)} - actionButtonClass="text-gray-800" - > - {@const subtext = - $generationStatus && $generationStatus !== "uploaded" - ? subtexts[$generationStatus] - : formatDateTime($info.createdAt ?? $info.lastModifiedAt)} - - -{/if} + onclick(info)} + actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} + onActionButtonClick={() => onGenerateThumbnailClick(info)} + actionButtonClass="text-gray-800" +> + {@const subtext = + $generationStatus && $generationStatus !== "uploaded" + ? subtexts[$generationStatus] + : formatDateTime(info.createdAt ?? info.lastModifiedAt)} + + diff --git a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts b/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts index d8f288c..6b0acb2 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts @@ -2,7 +2,7 @@ import { limitFunction } from "p-limit"; import { get, writable, type Writable } from "svelte/store"; import { encryptData } from "$lib/modules/crypto"; import { storeFileThumbnailCache } from "$lib/modules/file"; -import type { FileInfo } from "$lib/modules/filesystem"; +import type { FileInfo } from "$lib/modules/filesystem2.svelte"; import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; @@ -17,7 +17,7 @@ export type GenerationStatus = interface File { id: number; - info: Writable; + info: FileInfo; status?: Writable; } @@ -129,7 +129,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { let fileSize = 0; try { - const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!); + const file = await requestFileDownload( + fileInfo.id, + fileInfo.contentIv!, + fileInfo.dataKey?.key!, + ); fileSize = file.byteLength; memoryUsage += fileSize; @@ -141,11 +145,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { status, file, fileInfo.contentType, - fileInfo.dataKey!, + fileInfo.dataKey?.key!, ); if ( !thumbnail || - !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail)) + !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail)) ) { status.set("error"); } diff --git a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte index 18bb159..590cb8f 100644 --- a/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte +++ b/src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte @@ -1,7 +1,5 @@ {#if downloadingFiles.length > 0} diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index 0ace1ab..21c3695 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -1,17 +1,18 @@ @@ -28,7 +29,9 @@ {#if mediaFiles.length > 0}
{#each mediaFiles as file} - goto(`/file/${id}`)} /> + {#if file} + goto(`/file/${id}`)} /> + {/if} {/each}
{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 612cfe4..9aadffd 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,23 +1,14 @@ @@ -14,7 +16,7 @@
- {#each getUploadingFiles() as file} + {#each uploadingFiles as file} {/each}
From 182ec18a2b47ed9e1d1fb98e7f3d98da6fff77d0 Mon Sep 17 00:00:00 2001 From: static Date: Wed, 31 Dec 2025 02:43:07 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../molecules/Categories/service.ts | 4 +++- src/lib/modules/file/download.svelte.ts | 6 ++---- src/lib/modules/filesystem/internal.svelte.ts | 4 +++- src/lib/modules/filesystem/types.ts | 5 ++--- src/lib/utils/promise.ts | 20 +++++++++---------- .../(main)/directory/[[id]]/service.svelte.ts | 3 ++- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/lib/components/molecules/Categories/service.ts b/src/lib/components/molecules/Categories/service.ts index 683d516..2a2a3e0 100644 --- a/src/lib/components/molecules/Categories/service.ts +++ b/src/lib/components/molecules/Categories/service.ts @@ -1,5 +1,7 @@ +import type { DataKey } from "$lib/modules/filesystem"; + export interface SelectedCategory { id: number; - dataKey?: { key: CryptoKey; version: Date }; + dataKey?: DataKey; name: string; } diff --git a/src/lib/modules/file/download.svelte.ts b/src/lib/modules/file/download.svelte.ts index e80fe31..bea8316 100644 --- a/src/lib/modules/file/download.svelte.ts +++ b/src/lib/modules/file/download.svelte.ts @@ -18,7 +18,7 @@ export interface FileDownloadState { result?: ArrayBuffer; } -export type LiveFileDownloadState = FileDownloadState & { +type LiveFileDownloadState = FileDownloadState & { status: "download-pending" | "downloading" | "decryption-pending" | "decrypting"; }; @@ -34,9 +34,7 @@ export const getFileDownloadState = (fileId: number) => { }; export const getDownloadingFiles = () => { - return downloadingFiles.filter((file): file is LiveFileDownloadState => - isFileDownloading(file.status), - ); + return downloadingFiles.filter((file) => isFileDownloading(file.status)); }; export const clearDownloadedFiles = () => { diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index 7bfe60d..f5e5e1f 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -3,7 +3,7 @@ import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; export class FilesystemCache { private map = new Map>(); - get(key: K, loader: (isInitial: boolean, resolve: (value: RV) => void) => void) { + get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) { const info = this.map.get(key); if (info instanceof Promise) { return info; @@ -15,6 +15,8 @@ export class FilesystemCache { } loader(!info, (loadedInfo) => { + if (!loadedInfo) return; + let info = this.map.get(key)!; if (info instanceof Promise) { const state = $state(loadedInfo); diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts index fe3254f..6374474 100644 --- a/src/lib/modules/filesystem/types.ts +++ b/src/lib/modules/filesystem/types.ts @@ -1,4 +1,4 @@ -type DataKey = { key: CryptoKey; version: Date }; +export type DataKey = { key: CryptoKey; version: Date }; interface LocalDirectoryInfo { id: number; @@ -13,7 +13,6 @@ interface RootDirectoryInfo { id: "root"; parentId?: undefined; dataKey?: undefined; - dataKeyVersion?: undefined; name?: undefined; subDirectories: SubDirectoryInfo[]; files: SummarizedFileInfo[]; @@ -39,7 +38,7 @@ export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; interface LocalCategoryInfo { id: number; - dataKey?: DataKey | undefined; + dataKey?: DataKey; name: string; subCategories: SubCategoryInfo[]; files: CategoryFileInfo[]; diff --git a/src/lib/utils/promise.ts b/src/lib/utils/promise.ts index a4b0fb2..9e841c8 100644 --- a/src/lib/utils/promise.ts +++ b/src/lib/utils/promise.ts @@ -1,16 +1,16 @@ export const monotonicResolve = ( - promises: (Promise | false)[], + promises: (Promise | false)[], callback: (value: T) => void, ) => { let latestResolvedIndex = -1; - - promises.forEach((promise, index) => { - if (!promise) return; - promise.then((value) => { - if (value !== undefined && index > latestResolvedIndex) { - latestResolvedIndex = index; - callback(value); - } + promises + .filter((promise) => !!promise) + .forEach((promise, index) => { + promise.then((value) => { + if (index > latestResolvedIndex) { + latestResolvedIndex = index; + callback(value); + } + }); }); - }); }; diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index 0dc69d9..f83bbaf 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -8,13 +8,14 @@ import { deleteFileThumbnailCache, uploadFile, } from "$lib/modules/file"; +import type { DataKey } from "$lib/modules/filesystem"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { trpc } from "$trpc/client"; export interface SelectedEntry { type: "directory" | "file"; id: number; - dataKey: { key: CryptoKey; version: Date } | undefined; + dataKey: DataKey | undefined; name: string; } From 841c57e8fcba62f615b90e206dec5e34c944d63e Mon Sep 17 00:00:00 2001 From: static Date: Thu, 1 Jan 2026 21:41:53 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=90=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=98=20=EC=BA=90=EC=8B=9C=EA=B0=80=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=ED=95=98=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=EC=9D=B4=20=EB=81=9D=EB=82=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/filesystem/category.ts | 21 +++++++++---- src/lib/modules/filesystem/directory.ts | 31 ++++++++++++++----- src/lib/modules/filesystem/file.ts | 9 +++--- src/lib/modules/filesystem/internal.svelte.ts | 4 --- src/lib/modules/filesystem/types.ts | 10 ++++++ .../(fullscreen)/file/[id]/+page.svelte | 8 +++-- .../file/[id]/AddToCategoryBottomSheet.svelte | 6 ++-- .../(fullscreen)/file/downloads/+page.svelte | 4 ++- src/routes/(fullscreen)/gallery/+page.svelte | 6 ++-- .../(fullscreen)/settings/cache/+page.svelte | 10 +++--- .../(fullscreen)/settings/cache/File.svelte | 8 ++--- .../settings/thumbnail/+page.svelte | 18 ++++++----- .../settings/thumbnail/service.svelte.ts | 4 +-- .../(main)/category/[[id]]/+page.svelte | 6 ++-- .../(main)/directory/[[id]]/+page.svelte | 6 ++-- src/routes/(main)/home/+page.svelte | 6 ++-- 16 files changed, 98 insertions(+), 59 deletions(-) diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index 491d43d..310a220 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -1,9 +1,9 @@ import * as IndexedDB from "$lib/indexedDB"; import { trpc, isTRPCClientError } from "$trpc/client"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; -import type { CategoryInfo } from "./types"; +import type { MaybeCategoryInfo } from "./types"; -const cache = new FilesystemCache>(); +const cache = new FilesystemCache>(); const fetchFromIndexedDB = async (id: CategoryId) => { const [category, subCategories] = await Promise.all([ @@ -29,10 +29,15 @@ const fetchFromIndexedDB = async (id: CategoryId) => { : undefined; if (id === "root") { - return { id, subCategories }; + return { + id, + exists: true as const, + subCategories, + }; } else if (category) { return { id, + exists: true as const, name: category.name, subCategories, files: files!.filter((file) => !!file), @@ -68,10 +73,15 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { ]); if (id === "root") { - return { id, subCategories }; + return { + id, + exists: true as const, + subCategories, + }; } else { return { id, + exists: true as const, subCategories, files, ...(await decryptCategoryMetadata(metadata!, masterKey)), @@ -79,9 +89,8 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { } } catch (e) { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - cache.delete(id); await IndexedDB.deleteCategoryInfo(id as number); - return; + return { id, exists: false as const }; } throw e; } diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index b4f9aac..6417758 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -2,9 +2,9 @@ import * as IndexedDB from "$lib/indexedDB"; import { monotonicResolve } from "$lib/utils"; import { trpc, isTRPCClientError } from "$trpc/client"; import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte"; -import type { DirectoryInfo } from "./types"; +import type { MaybeDirectoryInfo } from "./types"; -const cache = new FilesystemCache(); +const cache = new FilesystemCache(); const fetchFromIndexedDB = async (id: DirectoryId) => { const [directory, subDirectories, files] = await Promise.all([ @@ -14,9 +14,21 @@ const fetchFromIndexedDB = async (id: DirectoryId) => { ]); if (id === "root") { - return { id, subDirectories, files }; + return { + id, + exists: true as const, + subDirectories, + files, + }; } else if (directory) { - return { id, parentId: directory.parentId, name: directory.name, subDirectories, files }; + return { + id, + exists: true as const, + parentId: directory.parentId, + name: directory.name, + subDirectories, + files, + }; } }; @@ -44,10 +56,16 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { ]); if (id === "root") { - return { id, subDirectories, files }; + return { + id, + exists: true as const, + subDirectories, + files, + }; } else { return { id, + exists: true as const, parentId: metadata!.parent, subDirectories, files, @@ -56,9 +74,8 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { } } catch (e) { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - cache.delete(id); await IndexedDB.deleteDirectoryInfo(id as number); - return; + return { id, exists: false as const }; } throw e; } diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index 450ef41..fc6f53c 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -2,9 +2,9 @@ import * as IndexedDB from "$lib/indexedDB"; import { monotonicResolve } from "$lib/utils"; import { trpc, isTRPCClientError } from "$trpc/client"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; -import type { FileInfo } from "./types"; +import type { MaybeFileInfo } from "./types"; -const cache = new FilesystemCache(); +const cache = new FilesystemCache(); const fetchFromIndexedDB = async (id: number) => { const file = await IndexedDB.getFileInfo(id); @@ -20,6 +20,7 @@ const fetchFromIndexedDB = async (id: number) => { if (file) { return { id, + exists: true as const, parentId: file.parentId, contentType: file.contentType, name: file.name, @@ -44,6 +45,7 @@ const fetchFromServer = async (id: number, masterKey: CryptoKey) => { return { id, + exists: true as const, parentId: metadata.parent, contentType: metadata.contentType, contentIv: metadata.contentIv, @@ -52,9 +54,8 @@ const fetchFromServer = async (id: number, masterKey: CryptoKey) => { }; } catch (e) { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - cache.delete(id); await IndexedDB.deleteFileInfo(id); - return; + return { id, exists: false as const }; } throw e; } diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index f5e5e1f..f98320d 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -30,10 +30,6 @@ export class FilesystemCache { return info ?? promise; } - - delete(key: K) { - this.map.delete(key); - } } export const decryptDirectoryMetadata = async ( diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts index 6374474..15b0e93 100644 --- a/src/lib/modules/filesystem/types.ts +++ b/src/lib/modules/filesystem/types.ts @@ -1,4 +1,5 @@ export type DataKey = { key: CryptoKey; version: Date }; +type AllUndefined = { [K in keyof T]?: undefined }; interface LocalDirectoryInfo { id: number; @@ -20,6 +21,9 @@ interface RootDirectoryInfo { export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; export type SubDirectoryInfo = Omit; +export type MaybeDirectoryInfo = + | (DirectoryInfo & { exists: true }) + | ({ id: DirectoryId; exists: false } & AllUndefined>); export interface FileInfo { id: number; @@ -35,6 +39,9 @@ export interface FileInfo { export type SummarizedFileInfo = Omit; export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; +export type MaybeFileInfo = + | (FileInfo & { exists: true }) + | ({ id: number; exists: false } & AllUndefined>); interface LocalCategoryInfo { id: number; @@ -59,3 +66,6 @@ export type SubCategoryInfo = Omit< LocalCategoryInfo, "subCategories" | "files" | "isFileRecursive" >; +export type MaybeCategoryInfo = + | (CategoryInfo & { exists: true }) + | ({ id: CategoryId; exists: false } & AllUndefined>); diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 113ddbe..7a04a7f 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -5,7 +5,7 @@ import { page } from "$app/state"; import { FullscreenDiv } from "$lib/components/atoms"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; - import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; + import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { getFileDownloadState } from "$lib/modules/file"; import { masterKeyStore } from "$lib/stores"; @@ -26,7 +26,7 @@ let { data } = $props(); - let infoPromise: Promise | undefined = $state(); + let infoPromise: Promise | undefined = $state(); let info: FileInfo | null = $state(null); let downloadState = $derived(getFileDownloadState(data.id)); @@ -75,7 +75,9 @@ $effect(() => { infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => { - info = fileInfo; + if (fileInfo.exists) { + info = fileInfo; + } return fileInfo; }); info = null; diff --git a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte index b39ef72..5d89512 100644 --- a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte +++ b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte @@ -2,7 +2,7 @@ import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms"; import { SubCategories } from "$lib/components/molecules"; import { CategoryCreateModal } from "$lib/components/organisms"; - import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; + import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import { requestCategoryCreation } from "./service"; @@ -13,7 +13,7 @@ let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); - let categoryInfoPromise: Promise | undefined = $state(); + let categoryInfoPromise: Promise | undefined = $state(); let isCategoryCreateModalOpen = $state(false); @@ -25,7 +25,7 @@ {#await categoryInfoPromise then categoryInfo} - {#if categoryInfo} + {#if categoryInfo?.exists} {#each files as file, index} - + {#if file.exists} + + {/if} {/each}
{/await} diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 4408cc1..39ae445 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -4,12 +4,12 @@ import { FullscreenDiv } from "$lib/components/atoms"; import { TopBar } from "$lib/components/molecules"; import { Gallery } from "$lib/components/organisms"; - import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; + import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; let { data } = $props(); - let files: (FileInfo | null)[] = $state([]); + let files: MaybeFileInfo[] = $state([]); onMount(async () => { files = await Promise.all( @@ -25,7 +25,7 @@ !!file)} + files={files.filter((file) => file?.exists)} onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)} /> diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index 2812b93..b37701f 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -4,14 +4,14 @@ import { TopBar } from "$lib/components/molecules"; import type { FileCacheIndex } from "$lib/indexedDB"; import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; - import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; + import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import { formatFileSize } from "$lib/utils"; import File from "./File.svelte"; interface FileCache { index: FileCacheIndex; - fileInfo: FileInfo | null; + info: MaybeFileInfo; } let fileCache: FileCache[] | undefined = $state(); @@ -26,7 +26,7 @@ fileCache = await Promise.all( getFileCacheIndex().map(async (index) => ({ index, - fileInfo: await getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), + info: await getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), })), ); fileCache.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); @@ -55,8 +55,8 @@

캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.

- {#each fileCache as { index, fileInfo }} - + {#each fileCache as { index, info }} + {/each}
diff --git a/src/routes/(fullscreen)/settings/cache/File.svelte b/src/routes/(fullscreen)/settings/cache/File.svelte index 5ae7794..2727381 100644 --- a/src/routes/(fullscreen)/settings/cache/File.svelte +++ b/src/routes/(fullscreen)/settings/cache/File.svelte @@ -1,6 +1,6 @@
- {#if info} + {#if info.exists}
@@ -27,7 +27,7 @@
{/if}
- {#if info} + {#if info.exists}

{info.name}

{:else}

삭제된 파일

diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index 6cc0aa8..127f5eb 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -1,6 +1,5 @@ diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index b37701f..7884b20 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -4,7 +4,7 @@ import { TopBar } from "$lib/components/molecules"; import type { FileCacheIndex } from "$lib/indexedDB"; import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; - import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import { formatFileSize } from "$lib/utils"; import File from "./File.svelte"; @@ -23,13 +23,17 @@ }; onMount(async () => { - fileCache = await Promise.all( - getFileCacheIndex().map(async (index) => ({ - index, - info: await getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), - })), + const indexes = getFileCacheIndex(); + const infos = await bulkGetFileInfo( + indexes.map(({ fileId }) => fileId), + $masterKeyStore?.get(1)?.key!, ); - fileCache.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); + fileCache = indexes + .map((index, i) => ({ + index, + info: infos.get(index.fileId)!, + })) + .sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); }); $effect(() => { diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index 127f5eb..a5b658e 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -4,7 +4,7 @@ import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; - import { getFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; import { @@ -14,7 +14,6 @@ } from "./service.svelte"; import IconDelete from "~icons/material-symbols/delete"; - import { file } from "zod"; let { data } = $props(); @@ -27,13 +26,12 @@ }; onMount(async () => { - persistentStates.files = await Promise.all( - data.files.map(async (fileId) => ({ - id: fileId, - info: await getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), - status: getGenerationStatus(fileId), - })), - ); + const fileInfos = await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!); + persistentStates.files = persistentStates.files.map(({ id, status }) => ({ + id, + info: fileInfos.get(id)!, + status, + })); }); diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index 07c601d..bf94bad 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { EntryButton, FileThumbnailButton } from "$lib/components/atoms"; - import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import { requestFreshMediaFilesRetrieval } from "./service"; @@ -10,8 +10,13 @@ onMount(async () => { const files = await requestFreshMediaFilesRetrieval(); - mediaFiles = await Promise.all( - files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)), + mediaFiles = Array.from( + ( + await bulkGetFileInfo( + files.map(({ id }) => id), + $masterKeyStore?.get(1)?.key!, + ) + ).values(), ); }); diff --git a/src/trpc/routers/file.ts b/src/trpc/routers/file.ts index a3ac5f6..b08bbf2 100644 --- a/src/trpc/routers/file.ts +++ b/src/trpc/routers/file.ts @@ -42,6 +42,39 @@ const fileRouter = router({ }; }), + bulkGet: roleProcedure["activeClient"] + .input( + z.object({ + ids: z.number().positive().array(), + }), + ) + .query(async ({ ctx, input }) => { + const files = await FileRepo.getFilesWithCategories(ctx.session.userId, input.ids); + return files.map((file) => ({ + id: file.id, + parent: file.parentId, + mekVersion: file.mekVersion, + dek: file.encDek, + dekVersion: file.dekVersion, + contentType: file.contentType, + contentIv: file.encContentIv, + name: file.encName.ciphertext, + nameIv: file.encName.iv, + createdAt: file.encCreatedAt?.ciphertext, + createdAtIv: file.encCreatedAt?.iv, + lastModifiedAt: file.encLastModifiedAt.ciphertext, + lastModifiedAtIv: file.encLastModifiedAt.iv, + categories: file.categories.map((category) => ({ + id: category.id, + mekVersion: category.mekVersion, + dek: category.encDek, + dekVersion: category.dekVersion, + name: category.encName.ciphertext, + nameIv: category.encName.iv, + })), + })); + }), + list: roleProcedure["activeClient"].query(async ({ ctx }) => { return await FileRepo.getAllFileIds(ctx.session.userId); }), From 2e3cd4f8a2f19a13489d6307855aea25e42b5018 Mon Sep 17 00:00:00 2001 From: static Date: Thu, 1 Jan 2026 23:52:47 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EA=B2=B0=EA=B3=BC=EA=B0=80=20IndexedDB?= =?UTF-8?q?=EC=97=90=20=EC=BA=90=EC=8B=9C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organisms/Category/Category.svelte | 17 ++++ src/lib/modules/filesystem/category.ts | 83 +++++++++++++++---- src/lib/modules/filesystem/directory.ts | 52 +++++++++--- src/lib/modules/filesystem/file.ts | 42 ++++++++-- 4 files changed, 158 insertions(+), 36 deletions(-) diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte index 81471e6..3728f5f 100644 --- a/src/lib/components/organisms/Category/Category.svelte +++ b/src/lib/components/organisms/Category/Category.svelte @@ -1,6 +1,7 @@
diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index 310a220..dc25506 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -52,25 +52,76 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { metadata, subCategories: subCategoriesRaw, files: filesRaw, - } = await trpc().category.get.query({ id }); - const [subCategories, files] = await Promise.all([ - Promise.all( - subCategoriesRaw.map(async (category) => ({ + } = await trpc().category.get.query({ id, recurse: true }); + const subCategories = await Promise.all( + subCategoriesRaw.map(async (category) => { + const decrypted = await decryptCategoryMetadata(category, masterKey); + const existing = await IndexedDB.getCategoryInfo(category.id); + await IndexedDB.storeCategoryInfo({ id: category.id, - ...(await decryptCategoryMetadata(category, masterKey)), - })), - ), - filesRaw - ? Promise.all( - filesRaw.map(async (file) => ({ + parentId: id, + name: decrypted.name, + files: existing?.files ?? [], + isFileRecursive: existing?.isFileRecursive ?? false, + }); + return { + id: category.id, + ...decrypted, + }; + }), + ); + + const existingFiles = filesRaw + ? await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id)) + : []; + const files = filesRaw + ? await Promise.all( + filesRaw.map(async (file, index) => { + const decrypted = await decryptFileMetadata(file, masterKey); + const existing = existingFiles[index]; + if (existing) { + const categoryIds = file.isRecursive + ? existing.categoryIds + : Array.from(new Set([...existing.categoryIds, id as number])); + await IndexedDB.storeFileInfo({ + id: file.id, + parentId: existing.parentId, + contentType: file.contentType, + name: decrypted.name, + createdAt: decrypted.createdAt, + lastModifiedAt: decrypted.lastModifiedAt, + categoryIds, + }); + } + return { id: file.id, contentType: file.contentType, isRecursive: file.isRecursive, - ...(await decryptFileMetadata(file, masterKey)), - })), - ) - : undefined, - ]); + ...decrypted, + }; + }), + ) + : undefined; + + const decryptedMetadata = metadata + ? await decryptCategoryMetadata(metadata, masterKey) + : undefined; + if (id !== "root" && metadata && decryptedMetadata) { + const existingCategory = await IndexedDB.getCategoryInfo(id); + await IndexedDB.storeCategoryInfo({ + id: id as number, + parentId: metadata.parent, + name: decryptedMetadata.name, + files: + files?.map((file) => ({ + id: file.id, + isRecursive: file.isRecursive, + })) ?? + existingCategory?.files ?? + [], + isFileRecursive: existingCategory?.isFileRecursive ?? false, + }); + } if (id === "root") { return { @@ -84,7 +135,7 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { exists: true as const, subCategories, files, - ...(await decryptCategoryMetadata(metadata!, masterKey)), + ...decryptedMetadata!, }; } } catch (e) { diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index 6417758..6449480 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -39,22 +39,52 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { subDirectories: subDirectoriesRaw, files: filesRaw, } = await trpc().directory.get.query({ id }); - const [subDirectories, files] = await Promise.all([ + const existingFiles = await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id)); + const [subDirectories, files, decryptedMetadata] = await Promise.all([ Promise.all( - subDirectoriesRaw.map(async (directory) => ({ - id: directory.id, - ...(await decryptDirectoryMetadata(directory, masterKey)), - })), + subDirectoriesRaw.map(async (directory) => { + const decrypted = await decryptDirectoryMetadata(directory, masterKey); + await IndexedDB.storeDirectoryInfo({ + id: directory.id, + parentId: id, + name: decrypted.name, + }); + return { + id: directory.id, + ...decrypted, + }; + }), ), Promise.all( - filesRaw.map(async (file) => ({ - id: file.id, - contentType: file.contentType, - ...(await decryptFileMetadata(file, masterKey)), - })), + filesRaw.map(async (file, index) => { + const decrypted = await decryptFileMetadata(file, masterKey); + await IndexedDB.storeFileInfo({ + id: file.id, + parentId: id, + contentType: file.contentType, + name: decrypted.name, + createdAt: decrypted.createdAt, + lastModifiedAt: decrypted.lastModifiedAt, + categoryIds: existingFiles[index]?.categoryIds ?? [], + }); + return { + id: file.id, + contentType: file.contentType, + ...decrypted, + }; + }), ), + metadata ? decryptDirectoryMetadata(metadata, masterKey) : undefined, ]); + if (id !== "root" && metadata && decryptedMetadata) { + await IndexedDB.storeDirectoryInfo({ + id, + parentId: metadata.parent, + name: decryptedMetadata.name, + }); + } + if (id === "root") { return { id, @@ -69,7 +99,7 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { parentId: metadata!.parent, subDirectories, files, - ...(await decryptDirectoryMetadata(metadata!, masterKey)), + ...decryptedMetadata!, }; } } catch (e) { diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index d8411bf..45fef78 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -66,15 +66,26 @@ const bulkFetchFromIndexedDB = async (ids: number[]) => { const fetchFromServer = async (id: number, masterKey: CryptoKey) => { try { const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id }); - const [categories] = await Promise.all([ + const [categories, decryptedMetadata] = await Promise.all([ Promise.all( categoriesRaw.map(async (category) => ({ id: category.id, ...(await decryptCategoryMetadata(category, masterKey)), })), ), + decryptFileMetadata(metadata, masterKey), ]); + await IndexedDB.storeFileInfo({ + id, + parentId: metadata.parent, + contentType: metadata.contentType, + name: decryptedMetadata.name, + createdAt: decryptedMetadata.createdAt, + lastModifiedAt: decryptedMetadata.lastModifiedAt, + categoryIds: categories.map((category) => category.id), + }); + return { id, exists: true as const, @@ -82,7 +93,7 @@ const fetchFromServer = async (id: number, masterKey: CryptoKey) => { contentType: metadata.contentType, contentIv: metadata.contentIv, categories, - ...(await decryptFileMetadata(metadata, masterKey)), + ...decryptedMetadata, }; } catch (e) { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { @@ -97,12 +108,25 @@ const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => { const filesRaw = await trpc().file.bulkGet.query({ ids }); const files = await Promise.all( filesRaw.map(async (file) => { - const categories = await Promise.all( - file.categories.map(async (category) => ({ - id: category.id, - ...(await decryptCategoryMetadata(category, masterKey)), - })), - ); + const [categories, decryptedMetadata] = await Promise.all([ + Promise.all( + file.categories.map(async (category) => ({ + id: category.id, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + decryptFileMetadata(file, masterKey), + ]); + + await IndexedDB.storeFileInfo({ + id: file.id, + parentId: file.parent, + contentType: file.contentType, + name: decryptedMetadata.name, + createdAt: decryptedMetadata.createdAt, + lastModifiedAt: decryptedMetadata.lastModifiedAt, + categoryIds: categories.map((category) => category.id), + }); return { id: file.id, exists: true as const, @@ -110,7 +134,7 @@ const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => { contentType: file.contentType, contentIv: file.contentIv, categories, - ...(await decryptFileMetadata(file, masterKey)), + ...decryptedMetadata, }; }), ); From d1f9018213f6550f36ba7150c822a22b285a45d7 Mon Sep 17 00:00:00 2001 From: static Date: Fri, 2 Jan 2026 00:31:58 +0900 Subject: [PATCH 11/12] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/RowVirtualizer.svelte | 4 +- .../organisms/Category/Category.svelte | 17 +++---- src/lib/modules/file/upload.svelte.ts | 2 +- .../(fullscreen)/file/downloads/+page.svelte | 44 ++++++++++-------- .../(fullscreen)/file/downloads/File.svelte | 4 +- .../DirectoryEntries/DirectoryEntries.svelte | 45 ++++++++----------- 6 files changed, 56 insertions(+), 60 deletions(-) diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index aa68bd2..444b9c7 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -8,10 +8,11 @@ count: number; item: Snippet<[index: number]>; itemHeight: (index: number) => number; + itemGap?: number; placeholder?: Snippet; } - let { class: className, count, item, itemHeight, placeholder }: Props = $props(); + let { class: className, count, item, itemHeight, itemGap, placeholder }: Props = $props(); let element: HTMLElement | undefined = $state(); let scrollMargin = $state(0); @@ -20,6 +21,7 @@ createWindowVirtualizer({ count, estimateSize: itemHeight, + gap: itemGap, scrollMargin, }), ); diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte index 3728f5f..6482a16 100644 --- a/src/lib/components/organisms/Category/Category.svelte +++ b/src/lib/components/organisms/Category/Category.svelte @@ -75,19 +75,14 @@

하위 카테고리의 파일

- 48 + (index + 1 < files.length ? 4 : 0)} - > + 48} itemGap={4}> {#snippet item(index)} {@const { details } = files[index]!} -
- -
+ {/snippet} {#snippet placeholder()}

이 카테고리에 추가된 파일이 없어요.

diff --git a/src/lib/modules/file/upload.svelte.ts b/src/lib/modules/file/upload.svelte.ts index da205f3..679de5b 100644 --- a/src/lib/modules/file/upload.svelte.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -46,7 +46,7 @@ const isFileUploading = (status: FileUploadState["status"]) => export const getUploadingFiles = (parentId?: DirectoryId) => { return uploadingFiles.filter( - (file): file is LiveFileUploadState => + (file) => (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status), ); }; diff --git a/src/routes/(fullscreen)/file/downloads/+page.svelte b/src/routes/(fullscreen)/file/downloads/+page.svelte index 5865db8..860901f 100644 --- a/src/routes/(fullscreen)/file/downloads/+page.svelte +++ b/src/routes/(fullscreen)/file/downloads/+page.svelte @@ -1,18 +1,29 @@ @@ -23,14 +34,11 @@ - {#await filesPromise then files} -
- {#each downloadingFiles as state} - {@const info = files.get(state.id)!} - {#if info.exists} - - {/if} - {/each} -
- {/await} +
+ {#each downloadingFiles as { info, state } (info.id)} + {#if info.exists} + + {/if} + {/each} +
diff --git a/src/routes/(fullscreen)/file/downloads/File.svelte b/src/routes/(fullscreen)/file/downloads/File.svelte index f5012bc..d70428e 100644 --- a/src/routes/(fullscreen)/file/downloads/File.svelte +++ b/src/routes/(fullscreen)/file/downloads/File.svelte @@ -1,6 +1,6 @@ {#if entries.length > 0}
- 56 + (index + 1 < entries.length ? 4 : 0)} - > + 56} itemGap={4}> {#snippet item(index)} {@const entry = entries[index]!} -
- {#if entry.type === "parent"} - - - - {:else if entry.type === "directory"} - - {:else if entry.type === "file"} - - {:else} - - {/if} -
+ {#if entry.type === "parent"} + + + + {:else if entry.type === "directory"} + + {:else if entry.type === "file"} + + {:else} + + {/if} {/snippet}
From 280d46b48dbbfabfa20a4fb9bcb1d4e0158f49e4 Mon Sep 17 00:00:00 2001 From: static Date: Fri, 2 Jan 2026 14:55:26 +0900 Subject: [PATCH 12/12] =?UTF-8?q?=EC=82=AC=EC=86=8C=ED=95=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/molecules/Categories.svelte | 44 +++++++++++++++++++ .../molecules/Categories/Categories.svelte | 33 -------------- .../molecules/Categories/Category.svelte | 26 ----------- .../components/molecules/Categories/index.ts | 2 - .../molecules/Categories/service.ts | 7 --- src/lib/components/molecules/index.ts | 4 +- src/lib/components/organisms/Gallery.svelte | 2 +- .../(fullscreen)/settings/cache/+page.svelte | 17 +++---- .../settings/thumbnail/+page.svelte | 8 +--- .../DirectoryEntries/DirectoryEntries.svelte | 42 +++++++++--------- src/routes/(main)/home/+page.svelte | 2 +- 11 files changed, 75 insertions(+), 112 deletions(-) create mode 100644 src/lib/components/molecules/Categories.svelte delete mode 100644 src/lib/components/molecules/Categories/Categories.svelte delete mode 100644 src/lib/components/molecules/Categories/Category.svelte delete mode 100644 src/lib/components/molecules/Categories/index.ts delete mode 100644 src/lib/components/molecules/Categories/service.ts diff --git a/src/lib/components/molecules/Categories.svelte b/src/lib/components/molecules/Categories.svelte new file mode 100644 index 0000000..b8d52bb --- /dev/null +++ b/src/lib/components/molecules/Categories.svelte @@ -0,0 +1,44 @@ + + + + +{#if categoriesWithName.length > 0} +
+ {#each categoriesWithName as category (category.id)} + onCategoryClick(category)} + actionButtonIcon={categoryMenuIcon} + onActionButtonClick={() => onCategoryMenuClick?.(category)} + > + + + {/each} +
+{/if} diff --git a/src/lib/components/molecules/Categories/Categories.svelte b/src/lib/components/molecules/Categories/Categories.svelte deleted file mode 100644 index 12398f6..0000000 --- a/src/lib/components/molecules/Categories/Categories.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -{#if categoriesWithName.length > 0} -
- {#each categoriesWithName as category} - - {/each} -
-{/if} diff --git a/src/lib/components/molecules/Categories/Category.svelte b/src/lib/components/molecules/Categories/Category.svelte deleted file mode 100644 index 1613da5..0000000 --- a/src/lib/components/molecules/Categories/Category.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - onclick(info)} - actionButtonIcon={menuIcon} - onActionButtonClick={() => onMenuClick?.(info)} -> - - diff --git a/src/lib/components/molecules/Categories/index.ts b/src/lib/components/molecules/Categories/index.ts deleted file mode 100644 index d8a70c2..0000000 --- a/src/lib/components/molecules/Categories/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Categories.svelte"; -export * from "./service"; diff --git a/src/lib/components/molecules/Categories/service.ts b/src/lib/components/molecules/Categories/service.ts deleted file mode 100644 index 2a2a3e0..0000000 --- a/src/lib/components/molecules/Categories/service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { DataKey } from "$lib/modules/filesystem"; - -export interface SelectedCategory { - id: number; - dataKey?: DataKey; - name: string; -} diff --git a/src/lib/components/molecules/index.ts b/src/lib/components/molecules/index.ts index 8edc84a..a36afcd 100644 --- a/src/lib/components/molecules/index.ts +++ b/src/lib/components/molecules/index.ts @@ -1,7 +1,7 @@ export * from "./ActionModal.svelte"; export { default as ActionModal } from "./ActionModal.svelte"; -export * from "./Categories"; -export { default as Categories } from "./Categories"; +export * from "./Categories.svelte"; +export { default as Categories } from "./Categories.svelte"; export { default as IconEntryButton } from "./IconEntryButton.svelte"; export * from "./labels"; export { default as SubCategories } from "./SubCategories.svelte"; diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte index 40d4baf..dc9f157 100644 --- a/src/lib/components/organisms/Gallery.svelte +++ b/src/lib/components/organisms/Gallery.svelte @@ -62,7 +62,7 @@

{row.label}

{:else}
- {#each row.files as file} + {#each row.files as file (file.id)} {/each}
diff --git a/src/routes/(fullscreen)/settings/cache/+page.svelte b/src/routes/(fullscreen)/settings/cache/+page.svelte index 7884b20..1d6f0c4 100644 --- a/src/routes/(fullscreen)/settings/cache/+page.svelte +++ b/src/routes/(fullscreen)/settings/cache/+page.svelte @@ -15,7 +15,9 @@ } let fileCache: FileCache[] | undefined = $state(); - let fileCacheTotalSize = $state(0); + let fileCacheTotalSize = $derived( + fileCache?.reduce((acc, { index }) => acc + index.size, 0) ?? 0, + ); const deleteFileCache = async (fileId: number) => { await doDeleteFileCache(fileId); @@ -29,18 +31,9 @@ $masterKeyStore?.get(1)?.key!, ); fileCache = indexes - .map((index, i) => ({ - index, - info: infos.get(index.fileId)!, - })) + .map((index) => ({ index, info: infos.get(index.fileId)! })) .sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); }); - - $effect(() => { - if (fileCache) { - fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0); - } - }); @@ -59,7 +52,7 @@

캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.

- {#each fileCache as { index, info }} + {#each fileCache as { index, info } (info.id)} {/each}
diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index a5b658e..2c06964 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -7,11 +7,7 @@ import { bulkGetFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; import File from "./File.svelte"; - import { - persistentStates, - getGenerationStatus, - requestThumbnailGeneration, - } from "./service.svelte"; + import { persistentStates, requestThumbnailGeneration } from "./service.svelte"; import IconDelete from "~icons/material-symbols/delete"; @@ -55,7 +51,7 @@ {persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.

- {#each persistentStates.files as { info, status }} + {#each persistentStates.files as { info, status } (info.id)} {#if info.exists} {#if entries.length > 0} -
- 56} itemGap={4}> - {#snippet item(index)} - {@const entry = entries[index]!} - {#if entry.type === "parent"} - - - - {:else if entry.type === "directory"} - - {:else if entry.type === "file"} - - {:else} - - {/if} - {/snippet} - -
+ 56} itemGap={4} class="pb-[4.5rem]"> + {#snippet item(index)} + {@const entry = entries[index]!} + {#if entry.type === "parent"} + + + + {:else if entry.type === "directory"} + + {:else if entry.type === "file"} + + {:else} + + {/if} + {/snippet} + {:else}

폴더가 비어 있어요.

diff --git a/src/routes/(main)/home/+page.svelte b/src/routes/(main)/home/+page.svelte index bf94bad..bd3d92d 100644 --- a/src/routes/(main)/home/+page.svelte +++ b/src/routes/(main)/home/+page.svelte @@ -33,7 +33,7 @@ {#if mediaFiles.length > 0}
- {#each mediaFiles as file} + {#each mediaFiles as file (file.id)} {#if file.exists} goto(`/file/${id}`)} /> {/if}