From 174305ca1b05aad2b96377eb79192888ca2db77f Mon Sep 17 00:00:00 2001 From: static Date: Sat, 27 Dec 2025 23:27:57 +0900 Subject: [PATCH 01/26] =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=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=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=ED=95=A0=20=EB=95=8C=EB=8F=84=20=EA=B0=80=EC=83=81=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=ED=9A=A8=EC=9C=A8=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=9E=9C=EB=8D=94=EB=A7=81=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/RowVirtualizer.svelte | 46 ++++++++++ src/lib/components/atoms/index.ts | 1 + .../organisms/Category/Category.svelte | 35 ++++---- src/lib/components/organisms/Gallery.svelte | 84 ++++++++----------- .../DirectoryEntries/DirectoryEntries.svelte | 26 ++++-- 5 files changed, 121 insertions(+), 71 deletions(-) create mode 100644 src/lib/components/atoms/RowVirtualizer.svelte diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte new file mode 100644 index 0000000..e821c5f --- /dev/null +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -0,0 +1,46 @@ + + +
+
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} +
+ {@render item(virtualItem.index)} +
+ {/each} +
+ {#if placeholder && $virtualizer.getVirtualItems().length === 0} + {@render placeholder()} + {/if} +
diff --git a/src/lib/components/atoms/index.ts b/src/lib/components/atoms/index.ts index 14b0849..61a0238 100644 --- a/src/lib/components/atoms/index.ts +++ b/src/lib/components/atoms/index.ts @@ -3,3 +3,4 @@ export * from "./buttons"; export * from "./divs"; export * from "./inputs"; export { default as Modal } from "./Modal.svelte"; +export { default as RowVirtualizer } from "./RowVirtualizer.svelte"; diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte index ce3abcd..0d62b25 100644 --- a/src/lib/components/organisms/Category/Category.svelte +++ b/src/lib/components/organisms/Category/Category.svelte @@ -1,7 +1,7 @@ -
-
- {#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)} - {@const row = rows[virtualRow.index]!} -
- {#if row.type === "header"} -

{row.label}

- {:else} -
- {#each row.items as { info }} - - {/each} -
- {/if} + + rows[index]!.type === "header" + ? 32 + : Math.ceil(rows[index]!.items.length / 4) * 181 + + (Math.ceil(rows[index]!.items.length / 4) - 1) * 4 + + 16} + class="flex flex-grow flex-col" +> + {#snippet item(index)} + {@const row = rows[index]!} + {#if row.type === "header"} +

{row.label}

+ {:else} +
+ {#each row.items as { info }} + + {/each}
- {/each} -
- {#if $virtualizer.getVirtualItems().length === 0} + {/if} + {/snippet} + {#snippet placeholder()}

{#if files.length === 0} @@ -144,5 +130,5 @@ {/if}

- {/if} -
+ {/snippet} + diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte index a3e975e..f7ced96 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/DirectoryEntries.svelte @@ -1,7 +1,7 @@
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 05/26] =?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 06/26] =?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 07/26] =?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 10/26] =?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 11/26] =?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 13/26] =?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 14/26] =?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 15/26] =?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} From 5729af380db8b1e35b11ce5732554777d274fe25 Mon Sep 17 00:00:00 2001 From: static Date: Fri, 2 Jan 2026 17:04:08 +0900 Subject: [PATCH 16/26] =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20=EA=B0=A4=EB=9F=AC=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?=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9D=B4=20=EB=B6=80=EC=9E=90?= =?UTF-8?q?=EC=97=B0=EC=8A=A4=EB=9F=BD=EA=B2=8C=20=EC=9D=B4=EB=A4=84?= =?UTF-8?q?=EC=A7=80=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 --- .../components/atoms/RowVirtualizer.svelte | 37 ++++++++++--------- src/lib/components/organisms/Gallery.svelte | 6 +-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index 444b9c7..5a6ba8d 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -34,27 +34,30 @@ if (!element) return; const observer = new ResizeObserver(() => { - scrollMargin = element!.getBoundingClientRect().top + window.scrollY; + scrollMargin = Math.round(element!.getBoundingClientRect().top + window.scrollY); }); observer.observe(element.parentElement!); return () => observer.disconnect(); }); -
-
- {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} -
- {@render item(virtualItem.index)} -
- {/each} -
- {#if placeholder && $virtualizer.getVirtualItems().length === 0} - {@render placeholder()} - {/if} +
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} +
+ {@render item(virtualItem.index)} +
+ {/each}
+ +{#if placeholder && $virtualizer.getVirtualItems().length === 0} + {@render placeholder()} +{/if} diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte index dc9f157..f1a7eb3 100644 --- a/src/lib/components/organisms/Gallery.svelte +++ b/src/lib/components/organisms/Gallery.svelte @@ -49,11 +49,7 @@ - rows[index]!.type === "header" - ? 28 - : Math.ceil(rows[index]!.files.length / 4) * 181 + - (Math.ceil(rows[index]!.files.length / 4) - 1) * 4 + - 16} + rows[index]!.type === "header" ? 28 : 181 + (rows[index]!.isLast ? 16 : 4)} class="flex flex-grow flex-col" > {#snippet item(index)} From 008c8ad6ba7407b773a3c7bebc15d833bd06a008 Mon Sep 17 00:00:00 2001 From: static Date: Fri, 2 Jan 2026 18:24:09 +0900 Subject: [PATCH 17/26] =?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=20=ED=95=98=EB=8B=A8=EC=97=90=20?= =?UTF-8?q?=EC=97=AC=EB=B0=B1=EC=9D=B4=20=EC=83=9D=EA=B8=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=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 --- .../components/atoms/RowVirtualizer.svelte | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index 5a6ba8d..88ff7e4 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -41,23 +41,20 @@ }); -
- {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} -
- {@render item(virtualItem.index)} -
- {/each} +
+
+ {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} +
+ {@render item(virtualItem.index)} +
+ {/each} +
+ {#if placeholder && $virtualizer.getVirtualItems().length === 0} + {@render placeholder()} + {/if}
- -{#if placeholder && $virtualizer.getVirtualItems().length === 0} - {@render placeholder()} -{/if} From 83d595636b038ecb54ab9c78a2c320583504d1fa Mon Sep 17 00:00:00 2001 From: static Date: Fri, 2 Jan 2026 23:00:25 +0900 Subject: [PATCH 18/26] =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=97=90=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=EC=9D=84=20=EC=A0=9C=ED=95=9C=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EB=B6=80=EC=A1=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=ED=81=AC=EB=9E=98=EC=8B=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/lib/modules/file/upload.svelte.ts | 146 +++++++++-------- src/lib/modules/scheduler.ts | 41 +++++ .../(fullscreen)/file/uploads/File.svelte | 6 +- .../settings/thumbnail/+page.svelte | 46 ++++-- .../settings/thumbnail/File.svelte | 14 +- .../settings/thumbnail/service.svelte.ts | 152 ++++++------------ .../DirectoryEntries/UploadingFile.svelte | 4 +- src/routes/api/trpc/[trpc]/+server.ts | 1 + src/trpc/client.ts | 1 + 10 files changed, 213 insertions(+), 200 deletions(-) create mode 100644 src/lib/modules/scheduler.ts diff --git a/package.json b/package.json index 3479c2c..60b381c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arkvault", "private": true, - "version": "0.6.0", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src/lib/modules/file/upload.svelte.ts b/src/lib/modules/file/upload.svelte.ts index 679de5b..5a23cc1 100644 --- a/src/lib/modules/file/upload.svelte.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -10,6 +10,7 @@ import { digestMessage, signMessageHmac, } from "$lib/modules/crypto"; +import { Scheduler } from "$lib/modules/scheduler"; import { generateThumbnail } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest, @@ -23,6 +24,7 @@ export interface FileUploadState { name: string; parentId: DirectoryId; status: + | "queued" | "encryption-pending" | "encrypting" | "upload-pending" @@ -36,13 +38,16 @@ export interface FileUploadState { } export type LiveFileUploadState = FileUploadState & { - status: "encryption-pending" | "encrypting" | "upload-pending" | "uploading"; + status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading"; }; +const scheduler = new Scheduler< + { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined +>(); let uploadingFiles: FileUploadState[] = $state([]); const isFileUploading = (status: FileUploadState["status"]) => - ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); + ["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status); export const getUploadingFiles = (parentId?: DirectoryId) => { return uploadingFiles.filter( @@ -183,80 +188,85 @@ export const uploadFile = async ( hmacSecret: HmacSecret, masterKey: MasterKey, onDuplicate: () => Promise, -): Promise< - { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined -> => { +) => { uploadingFiles.push({ name: file.name, parentId, - status: "encryption-pending", + status: "queued", }); const state = uploadingFiles.at(-1)!; - try { - const { fileBuffer, fileSigned } = await requestDuplicateFileScan( - file, - hmacSecret, - onDuplicate, - ); - if (!fileBuffer || !fileSigned) { - state.status = "canceled"; - uploadingFiles = uploadingFiles.filter((file) => file !== state); - return undefined; - } + return await scheduler.schedule( + async () => file.size, + async () => { + state.status = "encryption-pending"; - const { - dataKeyWrapped, - dataKeyVersion, - fileType, - fileEncrypted, - fileEncryptedHash, - nameEncrypted, - createdAtEncrypted, - lastModifiedAtEncrypted, - thumbnail, - } = await encryptFile(state, file, fileBuffer, masterKey); + try { + const { fileBuffer, fileSigned } = await requestDuplicateFileScan( + file, + hmacSecret, + onDuplicate, + ); + if (!fileBuffer || !fileSigned) { + state.status = "canceled"; + uploadingFiles = uploadingFiles.filter((file) => file !== state); + return undefined; + } - const form = new FormData(); - form.set( - "metadata", - JSON.stringify({ - parent: parentId, - mekVersion: masterKey.version, - dek: dataKeyWrapped, - dekVersion: dataKeyVersion.toISOString(), - hskVersion: hmacSecret.version, - contentHmac: fileSigned, - contentType: fileType, - contentIv: fileEncrypted.iv, - name: nameEncrypted.ciphertext, - nameIv: nameEncrypted.iv, - createdAt: createdAtEncrypted?.ciphertext, - createdAtIv: createdAtEncrypted?.iv, - lastModifiedAt: lastModifiedAtEncrypted.ciphertext, - lastModifiedAtIv: lastModifiedAtEncrypted.iv, - } satisfies FileUploadRequest), - ); - form.set("content", new Blob([fileEncrypted.ciphertext])); - form.set("checksum", fileEncryptedHash); + const { + dataKeyWrapped, + dataKeyVersion, + fileType, + fileEncrypted, + fileEncryptedHash, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + thumbnail, + } = await encryptFile(state, file, fileBuffer, masterKey); - let thumbnailForm = null; - if (thumbnail) { - thumbnailForm = new FormData(); - thumbnailForm.set( - "metadata", - JSON.stringify({ - dekVersion: dataKeyVersion.toISOString(), - contentIv: thumbnail.iv, - } satisfies FileThumbnailUploadRequest), - ); - thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); - } + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + parent: parentId, + mekVersion: masterKey.version, + dek: dataKeyWrapped, + dekVersion: dataKeyVersion.toISOString(), + hskVersion: hmacSecret.version, + contentHmac: fileSigned, + contentType: fileType, + contentIv: fileEncrypted.iv, + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + createdAt: createdAtEncrypted?.ciphertext, + createdAtIv: createdAtEncrypted?.iv, + lastModifiedAt: lastModifiedAtEncrypted.ciphertext, + lastModifiedAtIv: lastModifiedAtEncrypted.iv, + } satisfies FileUploadRequest), + ); + form.set("content", new Blob([fileEncrypted.ciphertext])); + form.set("checksum", fileEncryptedHash); - const { fileId } = await requestFileUpload(state, form, thumbnailForm); - return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; - } catch (e) { - state.status = "error"; - throw e; - } + let thumbnailForm = null; + if (thumbnail) { + thumbnailForm = new FormData(); + thumbnailForm.set( + "metadata", + JSON.stringify({ + dekVersion: dataKeyVersion.toISOString(), + contentIv: thumbnail.iv, + } satisfies FileThumbnailUploadRequest), + ); + thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); + } + + const { fileId } = await requestFileUpload(state, form, thumbnailForm); + return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; + } catch (e) { + state.status = "error"; + throw e; + } + }, + ); }; diff --git a/src/lib/modules/scheduler.ts b/src/lib/modules/scheduler.ts new file mode 100644 index 0000000..31ce449 --- /dev/null +++ b/src/lib/modules/scheduler.ts @@ -0,0 +1,41 @@ +export class Scheduler { + private tasks = 0; + private memoryUsage = 0; + private queue: (() => void)[] = []; + + constructor(public memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {} + + private next() { + if (this.memoryUsage < this.memoryLimit) { + this.queue.shift()?.(); + } + } + + async schedule(estimateMemoryUsage: () => Promise, task: () => Promise) { + if (this.tasks++ > 0) { + await new Promise((resolve) => { + this.queue.push(resolve); + }); + } + + while (this.memoryUsage >= this.memoryLimit) { + await new Promise((resolve) => { + this.queue.unshift(resolve); + }); + } + + let taskMemoryUsage = 0; + + try { + taskMemoryUsage = await estimateMemoryUsage(); + this.memoryUsage += taskMemoryUsage; + this.next(); + + return await task(); + } finally { + this.tasks--; + this.memoryUsage -= taskMemoryUsage; + this.next(); + } + } +} diff --git a/src/routes/(fullscreen)/file/uploads/File.svelte b/src/routes/(fullscreen)/file/uploads/File.svelte index 7b40ac5..4c620ee 100644 --- a/src/routes/(fullscreen)/file/uploads/File.svelte +++ b/src/routes/(fullscreen)/file/uploads/File.svelte @@ -18,7 +18,7 @@
- {#if state.status === "encryption-pending"} + {#if state.status === "queued" || state.status === "encryption-pending"} {:else if state.status === "encrypting"} @@ -37,7 +37,9 @@ {state.name}

- {#if state.status === "encryption-pending"} + {#if state.status === "queued"} + 대기 중 + {:else if state.status === "encryption-pending"} 준비 중 {:else if state.status === "encrypting"} 암호화하는 중 diff --git a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte index 2c06964..50a11de 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/+page.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/+page.svelte @@ -4,17 +4,36 @@ import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; - import { bulkGetFileInfo } from "$lib/modules/filesystem"; + import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; + import { sortEntries } from "$lib/utils"; import File from "./File.svelte"; - import { persistentStates, requestThumbnailGeneration } from "./service.svelte"; + import { + getThumbnailGenerationStatus, + clearThumbnailGenerationStatuses, + requestThumbnailGeneration, + type GenerationStatus, + } from "./service.svelte"; import IconDelete from "~icons/material-symbols/delete"; let { data } = $props(); + let fileInfos: MaybeFileInfo[] = $state([]); + let files = $derived( + fileInfos + .map((info) => ({ + info, + status: getThumbnailGenerationStatus(info.id), + })) + .filter( + (file): file is { info: MaybeFileInfo; status: Exclude } => + file.status !== "uploaded", + ), + ); + const generateAllThumbnails = () => { - persistentStates.files.forEach(({ info }) => { + files.forEach(({ info }) => { if (info.exists) { requestThumbnailGeneration(info); } @@ -22,13 +41,12 @@ }; onMount(async () => { - const fileInfos = await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!); - persistentStates.files = persistentStates.files.map(({ id, status }) => ({ - id, - info: fileInfos.get(id)!, - status, - })); + fileInfos = sortEntries( + Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()), + ); }); + + $effect(() => clearThumbnailGenerationStatuses); @@ -43,19 +61,19 @@ 저장된 썸네일 모두 삭제하기

- {#if persistentStates.files.length > 0} + {#if files.length > 0}

썸네일이 누락된 파일

- {persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요. + {files.length}개 파일의 썸네일이 존재하지 않아요.

- {#each persistentStates.files as { info, status } (info.id)} + {#each files as { info, status } (info.id)} {#if info.exists} goto(`/file/${id}`)} onGenerateThumbnailClick={requestThumbnailGeneration} /> @@ -66,7 +84,7 @@
{/if}
- {#if persistentStates.files.length > 0} + {#if files.length > 0} diff --git a/src/routes/(fullscreen)/settings/thumbnail/File.svelte b/src/routes/(fullscreen)/settings/thumbnail/File.svelte index 6b3e0d9..3cc5f4c 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/File.svelte +++ b/src/routes/(fullscreen)/settings/thumbnail/File.svelte @@ -10,7 +10,6 @@ onclick(info)} - actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} + actionButtonIcon={!status || status === "error" ? IconCamera : undefined} onActionButtonClick={() => onGenerateThumbnailClick(info)} actionButtonClass="text-gray-800" > - {@const subtext = - $generationStatus && $generationStatus !== "uploaded" - ? subtexts[$generationStatus] - : formatDateTime(info.createdAt ?? info.lastModifiedAt)} + {@const subtext = status + ? subtexts[status] + : 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 23e863f..85226b0 100644 --- a/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts +++ b/src/routes/(fullscreen)/settings/thumbnail/service.svelte.ts @@ -1,8 +1,9 @@ import { limitFunction } from "p-limit"; -import { get, writable, type Writable } from "svelte/store"; +import { SvelteMap } from "svelte/reactivity"; import { encryptData } from "$lib/modules/crypto"; import { storeFileThumbnailCache } from "$lib/modules/file"; -import type { FileInfo, MaybeFileInfo } from "$lib/modules/filesystem"; +import type { FileInfo } from "$lib/modules/filesystem"; +import { Scheduler } from "$lib/modules/scheduler"; import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; @@ -15,41 +16,31 @@ export type GenerationStatus = | "uploaded" | "error"; -interface File { - id: number; - info: MaybeFileInfo; - status?: Writable; -} +const scheduler = new Scheduler(); +const statuses = new SvelteMap(); -const workingFiles = new Map>(); +export const getThumbnailGenerationStatus = (fileId: number) => { + return statuses.get(fileId); +}; -let queue: (() => void)[] = []; -let memoryUsage = 0; -const memoryLimit = 100 * 1024 * 1024; // 100 MiB - -export const persistentStates = $state({ - files: [] as File[], -}); - -export const getGenerationStatus = (fileId: number) => { - return workingFiles.get(fileId); +export const clearThumbnailGenerationStatuses = () => { + for (const [id, status] of statuses) { + if (status === "uploaded" || status === "error") { + statuses.delete(id); + } + } }; const generateThumbnail = limitFunction( - async ( - status: Writable, - fileBuffer: ArrayBuffer, - fileType: string, - dataKey: CryptoKey, - ) => { - status.set("generating"); + async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => { + statuses.set(fileId, "generating"); const thumbnail = await doGenerateThumbnail(fileBuffer, fileType); if (!thumbnail) return null; const thumbnailBuffer = await thumbnail.arrayBuffer(); const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey); - status.set("upload-pending"); + statuses.set(fileId, "upload-pending"); return { plaintext: thumbnailBuffer, ...thumbnailEncrypted }; }, { concurrency: 4 }, @@ -57,106 +48,55 @@ const generateThumbnail = limitFunction( const requestThumbnailUpload = limitFunction( async ( - status: Writable, fileId: number, dataKeyVersion: Date, thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string }, ) => { - status.set("uploading"); + statuses.set(fileId, "uploading"); const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail); if (!res.ok) return false; - - status.set("uploaded"); - workingFiles.delete(fileId); - persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId); - + statuses.set(fileId, "uploaded"); storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended return true; }, { concurrency: 4 }, ); -const enqueue = async ( - status: Writable | undefined, - fileInfo: FileInfo, - priority = false, -) => { - if (status) { - status.set("queued"); - } else { - status = writable("queued"); - workingFiles.set(fileInfo.id, status); - persistentStates.files = persistentStates.files.map((file) => - file.id === fileInfo.id ? { ...file, status } : file, - ); - } - - let resolver; - const promise = new Promise((resolve) => { - resolver = resolve; - }); - - if (priority) { - queue = [resolver!, ...queue]; - } else { - queue.push(resolver!); - } - - await promise; -}; - export const requestThumbnailGeneration = async (fileInfo: FileInfo) => { - let status = workingFiles.get(fileInfo.id); - if (status && get(status) !== "error") return; - - if (workingFiles.values().some((status) => get(status) !== "error")) { - await enqueue(status, fileInfo); - } - while (memoryUsage >= memoryLimit) { - await enqueue(status, fileInfo, true); - } - + const status = statuses.get(fileInfo.id); if (status) { - status.set("generation-pending"); + if (status !== "error") return; } else { - status = writable("generation-pending"); - workingFiles.set(fileInfo.id, status); - persistentStates.files = persistentStates.files.map((file) => - file.id === fileInfo.id ? { ...file, status } : file, - ); + statuses.set(fileInfo.id, "queued"); } - let fileSize = 0; try { - const file = await requestFileDownload( - fileInfo.id, - fileInfo.contentIv!, - fileInfo.dataKey?.key!, - ); - fileSize = file.byteLength; + let file: ArrayBuffer | undefined; - memoryUsage += fileSize; - if (memoryUsage < memoryLimit) { - queue.shift()?.(); - } - - const thumbnail = await generateThumbnail( - status, - file, - fileInfo.contentType, - fileInfo.dataKey?.key!, + await scheduler.schedule( + async () => { + statuses.set(fileInfo.id, "generation-pending"); + file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!); + return file.byteLength; + }, + async () => { + const thumbnail = await generateThumbnail( + fileInfo.id, + file!, + fileInfo.contentType, + fileInfo.dataKey?.key!, + ); + if ( + !thumbnail || + !(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail)) + ) { + statuses.set(fileInfo.id, "error"); + } + }, ); - if ( - !thumbnail || - !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail)) - ) { - status.set("error"); - } - } catch { - status.set("error"); - } finally { - memoryUsage -= fileSize; - queue.shift()?.(); + } catch (e) { + statuses.set(fileInfo.id, "error"); + throw e; } }; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte index 0ec7263..30e6e20 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/UploadingFile.svelte @@ -20,7 +20,9 @@ {state.name}

- {#if state.status === "encryption-pending"} + {#if state.status === "queued"} + 대기 중 + {:else if state.status === "encryption-pending"} 준비 중 {:else if state.status === "encrypting"} 암호화하는 중 diff --git a/src/routes/api/trpc/[trpc]/+server.ts b/src/routes/api/trpc/[trpc]/+server.ts index ec1320a..9c8d8a9 100644 --- a/src/routes/api/trpc/[trpc]/+server.ts +++ b/src/routes/api/trpc/[trpc]/+server.ts @@ -6,6 +6,7 @@ import type { RequestHandler } from "./$types"; const trpcHandler: RequestHandler = (event) => fetchRequestHandler({ endpoint: "/api/trpc", + allowMethodOverride: true, req: event.request, router: appRouter, createContext: () => createContext(event), diff --git a/src/trpc/client.ts b/src/trpc/client.ts index 9c4c5db..f62916a 100644 --- a/src/trpc/client.ts +++ b/src/trpc/client.ts @@ -9,6 +9,7 @@ const createClient = (fetch: typeof globalThis.fetch) => httpBatchLink({ url: "/api/trpc", maxURLLength: 4096, + methodOverride: "POST", transformer: superjson, fetch, }), From 30c56e09267a956a32fa60ecf05c3b69e0585484 Mon Sep 17 00:00:00 2001 From: static Date: Sat, 3 Jan 2026 00:54:32 +0900 Subject: [PATCH 19/26] =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=90=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC,=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC,=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=84=B0=EB=A6=AC=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B0=80=20IndexedDB=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=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/indexedDB/filesystem.ts | 24 ++++++++++++++++++++++++ src/lib/modules/filesystem/category.ts | 3 +++ src/lib/modules/filesystem/directory.ts | 4 ++++ 3 files changed, 31 insertions(+) diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index 7be44c7..c78ce4d 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -62,6 +62,16 @@ export const deleteDirectoryInfo = async (id: number) => { await filesystem.directory.delete(id); }; +export const deleteDanglingDirectoryInfos = async ( + parentId: DirectoryId, + validIds: Set, +) => { + await filesystem.directory + .where({ parentId }) + .and((directory) => !validIds.has(directory.id)) + .delete(); +}; + export const getAllFileInfos = async () => { return await filesystem.file.toArray(); }; @@ -86,6 +96,13 @@ export const deleteFileInfo = async (id: number) => { await filesystem.file.delete(id); }; +export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set) => { + await filesystem.file + .where({ parentId }) + .and((file) => !validIds.has(file.id)) + .delete(); +}; + export const getCategoryInfos = async (parentId: CategoryId) => { return await filesystem.category.where({ parentId }).toArray(); }; @@ -106,6 +123,13 @@ export const deleteCategoryInfo = async (id: number) => { await filesystem.category.delete(id); }; +export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set) => { + await filesystem.category + .where({ parentId }) + .and((category) => !validIds.has(category.id)) + .delete(); +}; + export const cleanupDanglingInfos = async () => { const validDirectoryIds: number[] = []; const validFileIds: number[] = []; diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index dc25506..2ca3472 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -53,6 +53,9 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { subCategories: subCategoriesRaw, files: filesRaw, } = await trpc().category.get.query({ id, recurse: true }); + + void IndexedDB.deleteDanglingCategoryInfos(id, new Set(subCategoriesRaw.map(({ id }) => id))); + const subCategories = await Promise.all( subCategoriesRaw.map(async (category) => { const decrypted = await decryptCategoryMetadata(category, masterKey); diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index 6449480..8932698 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -39,6 +39,10 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { subDirectories: subDirectoriesRaw, files: filesRaw, } = await trpc().directory.get.query({ id }); + + void IndexedDB.deleteDanglingDirectoryInfos(id, new Set(subDirectoriesRaw.map(({ id }) => id))); + void IndexedDB.deleteDanglingFileInfos(id, new Set(filesRaw.map(({ id }) => id))); + const existingFiles = await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id)); const [subDirectories, files, decryptedMetadata] = await Promise.all([ Promise.all( From cf0f8fe0b90f9feea274d7321cae2072ea7f6892 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 4 Jan 2026 01:50:02 +0900 Subject: [PATCH 20/26] =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20@eslint/js?= =?UTF-8?q?=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 7 +- pnpm-lock.yaml | 201 +++++++++--------- src/lib/components/organisms/Gallery.svelte | 2 +- src/lib/utils/sort.ts | 3 +- .../(fullscreen)/file/[id]/+page.svelte | 1 - .../settings/thumbnail/+page.svelte | 2 +- .../settings/thumbnail/File.svelte | 2 +- .../{service.svelte.ts => service.ts} | 0 8 files changed, 111 insertions(+), 107 deletions(-) rename src/routes/(fullscreen)/settings/thumbnail/{service.svelte.ts => service.ts} (100%) diff --git a/package.json b/package.json index 60b381c..7f0d51f 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,12 @@ }, "devDependencies": { "@eslint/compat": "^2.0.0", + "@eslint/js": "^9.39.2", "@iconify-json/material-symbols": "^1.2.50", "@sveltejs/adapter-node": "^5.4.0", "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tanstack/svelte-virtual": "^3.13.13", + "@tanstack/svelte-virtual": "^3.13.15", "@trpc/client": "^11.8.1", "@types/file-saver": "^2.0.7", "@types/ms": "^0.7.34", @@ -49,7 +50,7 @@ "svelte-check": "^4.3.5", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", - "typescript-eslint": "^8.50.1", + "typescript-eslint": "^8.51.0", "unplugin-icons": "^22.5.0", "vite": "^7.3.0" }, @@ -63,7 +64,7 @@ "pg": "^8.16.3", "superjson": "^2.2.6", "uuid": "^13.0.0", - "zod": "^4.2.1" + "zod": "^4.3.4" }, "engines": { "node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9dcf04f..2299ddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,12 +36,15 @@ importers: specifier: ^13.0.0 version: 13.0.0 zod: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.4 + version: 4.3.4 devDependencies: '@eslint/compat': specifier: ^2.0.0 version: 2.0.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 '@iconify-json/material-symbols': specifier: ^1.2.50 version: 1.2.50 @@ -55,8 +58,8 @@ importers: specifier: ^6.2.1 version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)) '@tanstack/svelte-virtual': - specifier: ^3.13.13 - version: 3.13.13(svelte@5.46.1) + specifier: ^3.13.15 + version: 3.13.15(svelte@5.46.1) '@trpc/client': specifier: ^11.8.1 version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3) @@ -139,8 +142,8 @@ importers: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.50.1 - version: 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.51.0 + version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) unplugin-icons: specifier: ^22.5.0 version: 22.5.0(svelte@5.46.1) @@ -316,8 +319,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -617,13 +620,13 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 - '@tanstack/svelte-virtual@3.13.13': - resolution: {integrity: sha512-VDOvbRw3R+XBQdFodEJ4E7AOmEyo3Bmr4zL4DLVnJ0fxICdbvY5F5t8zSwJ4f7lqjckXi0yKFzY8WBtjaNbsGQ==} + '@tanstack/svelte-virtual@3.13.15': + resolution: {integrity: sha512-3PPLI3hsyT70zSZhBkSIZXIarlN+GjFNKeKr2Wk1UR7EuEVtXgNlB/Zk0sYtaeJ4CvGvldQNakOvbdETnWAgeA==} peerDependencies: svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 - '@tanstack/virtual-core@3.13.13': - resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} + '@tanstack/virtual-core@3.13.15': + resolution: {integrity: sha512-8cG3acM2cSIm3h8WxboHARAhQAJbYUhvmadvnN8uz8aziDwrbYb9KiARni+uY2qrLh49ycn+poGoxvtIAKhjog==} '@trpc/client@11.8.1': resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==} @@ -663,63 +666,63 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@typescript-eslint/eslint-plugin@8.50.1': - resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + '@typescript-eslint/eslint-plugin@8.51.0': + resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.50.1 + '@typescript-eslint/parser': ^8.51.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.50.1': - resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + '@typescript-eslint/parser@8.51.0': + resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.50.1': - resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + '@typescript-eslint/project-service@8.51.0': + resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.50.1': - resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + '@typescript-eslint/scope-manager@8.51.0': + resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.50.1': - resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + '@typescript-eslint/tsconfig-utils@8.51.0': + resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.50.1': - resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + '@typescript-eslint/type-utils@8.51.0': + resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.50.1': - resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + '@typescript-eslint/types@8.51.0': + resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.50.1': - resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + '@typescript-eslint/typescript-estree@8.51.0': + resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.50.1': - resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + '@typescript-eslint/utils@8.51.0': + resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.50.1': - resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + '@typescript-eslint/visitor-keys@8.51.0': + resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.8': @@ -827,8 +830,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001761: - resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -1039,8 +1042,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrap@2.2.1: @@ -1854,8 +1857,8 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -1877,8 +1880,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.50.1: - resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + typescript-eslint@8.51.0: + resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2022,8 +2025,8 @@ packages: zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.4: + resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} snapshots: @@ -2114,7 +2117,7 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 @@ -2386,12 +2389,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/svelte-virtual@3.13.13(svelte@5.46.1)': + '@tanstack/svelte-virtual@3.13.15(svelte@5.46.1)': dependencies: - '@tanstack/virtual-core': 3.13.13 + '@tanstack/virtual-core': 3.13.15 svelte: 5.46.1 - '@tanstack/virtual-core@3.13.13': {} + '@tanstack/virtual-core@3.13.15': {} '@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)': dependencies: @@ -2428,95 +2431,95 @@ snapshots: '@types/resolve@1.20.2': {} - '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.51.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.51.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.50.1': + '@typescript-eslint/scope-manager@8.51.0': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/visitor-keys': 8.51.0 - '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.50.1': {} + '@typescript-eslint/types@8.51.0': {} - '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/visitor-keys': 8.50.1 + '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/visitor-keys': 8.51.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.50.1 - '@typescript-eslint/types': 8.50.1 - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.51.0 + '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.50.1': + '@typescript-eslint/visitor-keys@8.51.0': dependencies: - '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/types': 8.51.0 eslint-visitor-keys: 4.2.1 '@xmldom/xmldom@0.9.8': @@ -2564,7 +2567,7 @@ snapshots: autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -2602,7 +2605,7 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001761 + caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -2631,7 +2634,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001761: {} + caniuse-lite@1.0.30001762: {} chalk@4.1.2: dependencies: @@ -2795,7 +2798,7 @@ snapshots: eslint-plugin-svelte@3.13.1(eslint@9.39.2(jiti@1.21.7))(svelte@5.46.1): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@jridgewell/sourcemap-codec': 1.5.5 eslint: 9.39.2(jiti@1.21.7) esutils: 2.0.3 @@ -2828,7 +2831,7 @@ snapshots: eslint@9.39.2(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -2848,7 +2851,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -2875,7 +2878,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -3601,7 +3604,7 @@ snapshots: totalist@3.0.1: {} - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3615,12 +3618,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: @@ -3704,4 +3707,4 @@ snapshots: zimmerframe@1.1.4: {} - zod@4.2.1: {} + zod@4.3.4: {} diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte index f1a7eb3..d656c7f 100644 --- a/src/lib/components/organisms/Gallery.svelte +++ b/src/lib/components/organisms/Gallery.svelte @@ -23,7 +23,7 @@ ); return Array.from(groups.entries()) .sort(([dateA], [dateB]) => dateB.localeCompare(dateA)) - .flatMap(([_, entries]) => { + .flatMap(([, entries]) => { const sortedEntries = [...entries]; sortEntries(sortedEntries, SortBy.DATE_DESC); diff --git a/src/lib/utils/sort.ts b/src/lib/utils/sort.ts index a92c444..2cbcbf7 100644 --- a/src/lib/utils/sort.ts +++ b/src/lib/utils/sort.ts @@ -48,9 +48,10 @@ export const sortEntries = (entries: T[], sortBy = SortBy.N case SortBy.DATE_DESC: sortFunc = sortByDateDesc; break; - default: + default: { const exhaustive: never = sortBy; sortFunc = exhaustive; + } } entries.sort(sortFunc); diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 7a04a7f..585c0d0 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -164,7 +164,6 @@ {:else if viewerType === "video"} {#if fileBlobUrl}

- Date: Sun, 4 Jan 2026 17:54:42 +0900 Subject: [PATCH 21/26] =?UTF-8?q?Scheduler=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EC=9D=98=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=A7=81=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/file/upload.svelte.ts | 127 +++++++++++++------------- src/lib/modules/scheduler.ts | 35 ++++--- 2 files changed, 83 insertions(+), 79 deletions(-) diff --git a/src/lib/modules/file/upload.svelte.ts b/src/lib/modules/file/upload.svelte.ts index 5a23cc1..a632eb5 100644 --- a/src/lib/modules/file/upload.svelte.ts +++ b/src/lib/modules/file/upload.svelte.ts @@ -196,77 +196,74 @@ export const uploadFile = async ( }); const state = uploadingFiles.at(-1)!; - return await scheduler.schedule( - async () => file.size, - async () => { - state.status = "encryption-pending"; + return await scheduler.schedule(file.size, async () => { + state.status = "encryption-pending"; - try { - const { fileBuffer, fileSigned } = await requestDuplicateFileScan( - file, - hmacSecret, - onDuplicate, - ); - if (!fileBuffer || !fileSigned) { - state.status = "canceled"; - uploadingFiles = uploadingFiles.filter((file) => file !== state); - return undefined; - } + try { + const { fileBuffer, fileSigned } = await requestDuplicateFileScan( + file, + hmacSecret, + onDuplicate, + ); + if (!fileBuffer || !fileSigned) { + state.status = "canceled"; + uploadingFiles = uploadingFiles.filter((file) => file !== state); + return undefined; + } - const { - dataKeyWrapped, - dataKeyVersion, - fileType, - fileEncrypted, - fileEncryptedHash, - nameEncrypted, - createdAtEncrypted, - lastModifiedAtEncrypted, - thumbnail, - } = await encryptFile(state, file, fileBuffer, masterKey); + const { + dataKeyWrapped, + dataKeyVersion, + fileType, + fileEncrypted, + fileEncryptedHash, + nameEncrypted, + createdAtEncrypted, + lastModifiedAtEncrypted, + thumbnail, + } = await encryptFile(state, file, fileBuffer, masterKey); - const form = new FormData(); - form.set( + const form = new FormData(); + form.set( + "metadata", + JSON.stringify({ + parent: parentId, + mekVersion: masterKey.version, + dek: dataKeyWrapped, + dekVersion: dataKeyVersion.toISOString(), + hskVersion: hmacSecret.version, + contentHmac: fileSigned, + contentType: fileType, + contentIv: fileEncrypted.iv, + name: nameEncrypted.ciphertext, + nameIv: nameEncrypted.iv, + createdAt: createdAtEncrypted?.ciphertext, + createdAtIv: createdAtEncrypted?.iv, + lastModifiedAt: lastModifiedAtEncrypted.ciphertext, + lastModifiedAtIv: lastModifiedAtEncrypted.iv, + } satisfies FileUploadRequest), + ); + form.set("content", new Blob([fileEncrypted.ciphertext])); + form.set("checksum", fileEncryptedHash); + + let thumbnailForm = null; + if (thumbnail) { + thumbnailForm = new FormData(); + thumbnailForm.set( "metadata", JSON.stringify({ - parent: parentId, - mekVersion: masterKey.version, - dek: dataKeyWrapped, dekVersion: dataKeyVersion.toISOString(), - hskVersion: hmacSecret.version, - contentHmac: fileSigned, - contentType: fileType, - contentIv: fileEncrypted.iv, - name: nameEncrypted.ciphertext, - nameIv: nameEncrypted.iv, - createdAt: createdAtEncrypted?.ciphertext, - createdAtIv: createdAtEncrypted?.iv, - lastModifiedAt: lastModifiedAtEncrypted.ciphertext, - lastModifiedAtIv: lastModifiedAtEncrypted.iv, - } satisfies FileUploadRequest), + contentIv: thumbnail.iv, + } satisfies FileThumbnailUploadRequest), ); - form.set("content", new Blob([fileEncrypted.ciphertext])); - form.set("checksum", fileEncryptedHash); - - let thumbnailForm = null; - if (thumbnail) { - thumbnailForm = new FormData(); - thumbnailForm.set( - "metadata", - JSON.stringify({ - dekVersion: dataKeyVersion.toISOString(), - contentIv: thumbnail.iv, - } satisfies FileThumbnailUploadRequest), - ); - thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); - } - - const { fileId } = await requestFileUpload(state, form, thumbnailForm); - return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; - } catch (e) { - state.status = "error"; - throw e; + thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); } - }, - ); + + const { fileId } = await requestFileUpload(state, form, thumbnailForm); + return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; + } catch (e) { + state.status = "error"; + throw e; + } + }); }; diff --git a/src/lib/modules/scheduler.ts b/src/lib/modules/scheduler.ts index 31ce449..4216db3 100644 --- a/src/lib/modules/scheduler.ts +++ b/src/lib/modules/scheduler.ts @@ -1,39 +1,46 @@ export class Scheduler { - private tasks = 0; + private isEstimating = false; private memoryUsage = 0; private queue: (() => void)[] = []; - constructor(public memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {} + constructor(public readonly memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {} private next() { - if (this.memoryUsage < this.memoryLimit) { - this.queue.shift()?.(); + if (!this.isEstimating && this.memoryUsage < this.memoryLimit) { + const resolve = this.queue.shift(); + if (resolve) { + this.isEstimating = true; + resolve(); + } } } - async schedule(estimateMemoryUsage: () => Promise, task: () => Promise) { - if (this.tasks++ > 0) { + async schedule( + estimateMemoryUsage: number | (() => number | Promise), + task: () => Promise, + ) { + if (this.isEstimating || this.memoryUsage >= this.memoryLimit) { await new Promise((resolve) => { this.queue.push(resolve); }); - } - - while (this.memoryUsage >= this.memoryLimit) { - await new Promise((resolve) => { - this.queue.unshift(resolve); - }); + } else { + this.isEstimating = true; } let taskMemoryUsage = 0; try { - taskMemoryUsage = await estimateMemoryUsage(); + taskMemoryUsage = + typeof estimateMemoryUsage === "number" ? estimateMemoryUsage : await estimateMemoryUsage(); this.memoryUsage += taskMemoryUsage; + } finally { + this.isEstimating = false; this.next(); + } + try { return await task(); } finally { - this.tasks--; this.memoryUsage -= taskMemoryUsage; this.next(); } From f10a0a2da35be75d931bc26f64a692188abca3c3 Mon Sep 17 00:00:00 2001 From: static Date: Sun, 4 Jan 2026 20:01:30 +0900 Subject: [PATCH 22/26] =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=A1=9C=EC=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../atoms/buttons/FileThumbnailButton.svelte | 22 ++--- .../components/organisms/Category/File.svelte | 16 +--- src/lib/modules/file/cache.ts | 32 +------ src/lib/modules/file/index.ts | 1 + src/lib/modules/file/thumbnail.ts | 90 +++++++++++++++++++ src/lib/services/file.ts | 29 +----- .../[[id]]/DirectoryEntries/File.svelte | 30 ++----- 7 files changed, 110 insertions(+), 110 deletions(-) create mode 100644 src/lib/modules/file/thumbnail.ts diff --git a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte index bb7761e..6c5632c 100644 --- a/src/lib/components/atoms/buttons/FileThumbnailButton.svelte +++ b/src/lib/components/atoms/buttons/FileThumbnailButton.svelte @@ -1,7 +1,6 @@ diff --git a/src/lib/components/organisms/Category/File.svelte b/src/lib/components/organisms/Category/File.svelte index d4f5d4d..166de06 100644 --- a/src/lib/components/organisms/Category/File.svelte +++ b/src/lib/components/organisms/Category/File.svelte @@ -1,9 +1,8 @@ onRemoveClick?.(info)} > - {#await thumbnailPromise} - - {:then thumbnail} - - {/await} + diff --git a/src/lib/modules/file/cache.ts b/src/lib/modules/file/cache.ts index ccb187e..fe3c66c 100644 --- a/src/lib/modules/file/cache.ts +++ b/src/lib/modules/file/cache.ts @@ -1,15 +1,12 @@ -import { LRUCache } from "lru-cache"; import { getFileCacheIndex as getFileCacheIndexFromIndexedDB, storeFileCacheIndex, deleteFileCacheIndex, type FileCacheIndex, } from "$lib/indexedDB"; -import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; -import { getThumbnailUrl } from "$lib/modules/thumbnail"; +import { readFile, writeFile, deleteFile } from "$lib/modules/opfs"; const fileCacheIndex = new Map(); -const loadedThumbnails = new LRUCache({ max: 100 }); export const prepareFileCache = async () => { for (const cache of await getFileCacheIndexFromIndexedDB()) { @@ -51,30 +48,3 @@ export const deleteFileCache = async (fileId: number) => { await deleteFile(`/cache/${fileId}`); await deleteFileCacheIndex(fileId); }; - -export const getFileThumbnailCache = async (fileId: number) => { - const thumbnail = loadedThumbnails.get(fileId); - if (thumbnail) return thumbnail; - - const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`); - if (!thumbnailBuffer) return null; - - const thumbnailUrl = getThumbnailUrl(thumbnailBuffer); - loadedThumbnails.set(fileId, thumbnailUrl); - return thumbnailUrl; -}; - -export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { - await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); - loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer)); -}; - -export const deleteFileThumbnailCache = async (fileId: number) => { - loadedThumbnails.delete(fileId); - await deleteFile(`/thumbnail/file/${fileId}`); -}; - -export const deleteAllFileThumbnailCaches = async () => { - loadedThumbnails.clear(); - await deleteDirectory("/thumbnail/file"); -}; diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index 871d299..9e9ce0c 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,3 +1,4 @@ export * from "./cache"; export * from "./download.svelte"; +export * from "./thumbnail"; export * from "./upload.svelte"; diff --git a/src/lib/modules/file/thumbnail.ts b/src/lib/modules/file/thumbnail.ts new file mode 100644 index 0000000..f923153 --- /dev/null +++ b/src/lib/modules/file/thumbnail.ts @@ -0,0 +1,90 @@ +import { LRUCache } from "lru-cache"; +import { writable, type Writable } from "svelte/store"; +import { browser } from "$app/environment"; +import { decryptData } from "$lib/modules/crypto"; +import type { SummarizedFileInfo } from "$lib/modules/filesystem"; +import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; +import { getThumbnailUrl } from "$lib/modules/thumbnail"; +import { isTRPCClientError, trpc } from "$trpc/client"; + +const loadedThumbnails = new LRUCache>({ max: 100 }); +const loadingThumbnails = new Map>(); + +const fetchFromOpfs = async (fileId: number) => { + const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`); + if (thumbnailBuffer) { + return getThumbnailUrl(thumbnailBuffer); + } +}; + +const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => { + try { + const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([ + fetch(`/api/file/${fileId}/thumbnail/download`), + trpc().file.thumbnail.query({ id: fileId }), + ]); + const thumbnailBuffer = await decryptData( + await thumbnailEncrypted.arrayBuffer(), + thumbnailEncryptedIv, + dataKey, + ); + + void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); + return getThumbnailUrl(thumbnailBuffer); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + return null; + } + throw e; + } +}; + +export const getFileThumbnail = (file: SummarizedFileInfo) => { + if ( + !browser || + !(file.contentType.startsWith("image/") || file.contentType.startsWith("video/")) + ) { + return undefined; + } + + const thumbnail = loadedThumbnails.get(file.id); + if (thumbnail) return thumbnail; + + let loadingThumbnail = loadingThumbnails.get(file.id); + if (loadingThumbnail) return loadingThumbnail; + + loadingThumbnail = writable(undefined); + loadingThumbnails.set(file.id, loadingThumbnail); + + fetchFromOpfs(file.id) + .then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key))) + .then((thumbnail) => { + if (thumbnail) { + loadingThumbnail.set(thumbnail); + loadedThumbnails.set(file.id, loadingThumbnail as Writable); + } + loadingThumbnails.delete(file.id); + }); + return loadingThumbnail; +}; + +export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => { + await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); + + const oldThumbnail = loadedThumbnails.get(fileId); + if (oldThumbnail) { + oldThumbnail.set(getThumbnailUrl(thumbnailBuffer)); + } else { + loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer))); + } +}; + +export const deleteFileThumbnailCache = async (fileId: number) => { + loadedThumbnails.delete(fileId); + await deleteFile(`/thumbnail/file/${fileId}`); +}; + +export const deleteAllFileThumbnailCaches = async () => { + loadedThumbnails.clear(); + await deleteDirectory(`/thumbnail/file`); +}; diff --git a/src/lib/services/file.ts b/src/lib/services/file.ts index da05824..05a92e1 100644 --- a/src/lib/services/file.ts +++ b/src/lib/services/file.ts @@ -1,15 +1,11 @@ import { getAllFileInfos } from "$lib/indexedDB/filesystem"; -import { decryptData } from "$lib/modules/crypto"; import { getFileCache, storeFileCache, deleteFileCache, - getFileThumbnailCache, - storeFileThumbnailCache, - deleteFileThumbnailCache, downloadFile, + deleteFileThumbnailCache, } from "$lib/modules/file"; -import { getThumbnailUrl } from "$lib/modules/thumbnail"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import { trpc } from "$trpc/client"; @@ -44,29 +40,6 @@ export const requestFileThumbnailUpload = async ( return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); }; -export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => { - const cache = await getFileThumbnailCache(fileId); - if (cache || !dataKey) return cache; - - let thumbnailInfo; - try { - thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId }); - } catch { - // TODO: Error Handling - return null; - } - const { contentIv: thumbnailEncryptedIv } = thumbnailInfo; - - const res = await fetch(`/api/file/${fileId}/thumbnail/download`); - if (!res.ok) return null; - - const thumbnailEncrypted = await res.arrayBuffer(); - const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey); - - storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended - return getThumbnailUrl(thumbnailBuffer); -}; - export const requestDeletedFilesCleanup = async () => { let liveFiles; try { diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte index 41f1a84..741bac3 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/File.svelte @@ -1,9 +1,8 @@ {#if categoriesWithName.length > 0} diff --git a/src/lib/indexedDB/filesystem.ts b/src/lib/indexedDB/filesystem.ts index c78ce4d..1709033 100644 --- a/src/lib/indexedDB/filesystem.ts +++ b/src/lib/indexedDB/filesystem.ts @@ -13,15 +13,15 @@ interface FileInfo { contentType: string; createdAt?: Date; lastModifiedAt: Date; - categoryIds: number[]; + categoryIds?: number[]; } interface CategoryInfo { id: number; parentId: CategoryId; name: string; - files: { id: number; isRecursive: boolean }[]; - isFileRecursive: boolean; + files?: { id: number; isRecursive: boolean }[]; + isFileRecursive?: boolean; } const filesystem = new Dexie("filesystem") as Dexie & { @@ -55,7 +55,7 @@ export const getDirectoryInfo = async (id: number) => { }; export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => { - await filesystem.directory.put(directoryInfo); + await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo }); }; export const deleteDirectoryInfo = async (id: number) => { @@ -89,7 +89,7 @@ export const bulkGetFileInfos = async (ids: number[]) => { }; export const storeFileInfo = async (fileInfo: FileInfo) => { - await filesystem.file.put(fileInfo); + await filesystem.file.upsert(fileInfo.id, { ...fileInfo }); }; export const deleteFileInfo = async (id: number) => { @@ -112,7 +112,7 @@ export const getCategoryInfo = async (id: number) => { }; export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => { - await filesystem.category.put(categoryInfo); + await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo }); }; export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => { diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index 2ca3472..47a4565 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -1,167 +1,121 @@ import * as IndexedDB from "$lib/indexedDB"; import { trpc, isTRPCClientError } from "$trpc/client"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; -import type { MaybeCategoryInfo } from "./types"; +import type { CategoryInfo, MaybeCategoryInfo } from "./types"; -const cache = new FilesystemCache>(); - -const fetchFromIndexedDB = async (id: CategoryId) => { - const [category, subCategories] = await Promise.all([ - id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined, - IndexedDB.getCategoryInfos(id), - ]); - const files = category - ? await Promise.all( - category.files.map(async (file) => { - const fileInfo = await IndexedDB.getFileInfo(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, - exists: true as const, - subCategories, - }; - } else if (category) { - return { - id, - exists: true as const, - name: category.name, - subCategories, - files: files!.filter((file) => !!file), - isFileRecursive: category.isFileRecursive, - }; - } -}; - -const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => { - try { - const { - metadata, - subCategories: subCategoriesRaw, - files: filesRaw, - } = await trpc().category.get.query({ id, recurse: true }); - - void IndexedDB.deleteDanglingCategoryInfos(id, new Set(subCategoriesRaw.map(({ id }) => id))); - - 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, - 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 +const cache = new FilesystemCache({ + async fetchFromIndexedDB(id) { + const [category, subCategories] = await Promise.all([ + id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined, + IndexedDB.getCategoryInfos(id), + ]); + const files = category?.files ? 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, - ...decrypted, - }; + category.files.map(async (file) => { + const fileInfo = await IndexedDB.getFileInfo(file.id); + return fileInfo + ? { + id: file.id, + parentId: fileInfo.parentId, + contentType: fileInfo.contentType, + name: fileInfo.name, + createdAt: fileInfo.createdAt, + lastModifiedAt: fileInfo.lastModifiedAt, + isRecursive: file.isRecursive, + } + : undefined; }), ) : 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 { id, - exists: true as const, + exists: true, subCategories, }; - } else { + } else if (category) { return { id, - exists: true as const, + exists: true, + parentId: category.parentId, + name: category.name, subCategories, - files, - ...decryptedMetadata!, + files: files?.filter((file) => !!file) ?? [], + isFileRecursive: category.isFileRecursive ?? false, }; } - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - await IndexedDB.deleteCategoryInfo(id as number); - return { id, exists: false as const }; + }, + + async fetchFromServer(id, cachedInfo, masterKey) { + try { + const category = await trpc().category.get.query({ id, recurse: true }); + const [subCategories, files, metadata] = await Promise.all([ + Promise.all( + category.subCategories.map(async (category) => ({ + id: category.id, + parentId: id, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + category.files && + Promise.all( + category.files.map(async (file) => ({ + id: file.id, + parentId: file.parent, + contentType: file.contentType, + isRecursive: file.isRecursive, + ...(await decryptFileMetadata(file, masterKey)), + })), + ), + category.metadata && decryptCategoryMetadata(category.metadata, masterKey), + ]); + + return storeToIndexedDB( + id !== "root" + ? { + id, + parentId: category.metadata!.parent, + subCategories, + files: files!, + isFileRecursive: cachedInfo?.isFileRecursive ?? false, + ...metadata!, + } + : { id, subCategories }, + ); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteCategoryInfo(id as number); + return { id, exists: false }; + } + throw e; } - throw e; + }, +}); + +const storeToIndexedDB = (info: CategoryInfo) => { + if (info.id !== "root") { + void IndexedDB.storeCategoryInfo(info); + + // TODO: Bulk Upsert + new Map(info.files.map((file) => [file.id, file])).forEach((file) => { + void IndexedDB.storeFileInfo(file); + }); } + + // TODO: Bulk Upsert + info.subCategories.forEach((category) => { + void IndexedDB.storeCategoryInfo(category); + }); + + void IndexedDB.deleteDanglingCategoryInfos( + info.id, + new Set(info.subCategories.map(({ id }) => id)), + ); + + return { ...info, exists: true as const }; }; export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { - return await cache.get(id, async (isInitial, resolve) => { - if (isInitial) { - const info = await fetchFromIndexedDB(id); - if (info) { - resolve(info); - } - } - - const info = await fetchFromServer(id, masterKey); - if (info) { - resolve(info); - } - }); + return await cache.get(id, masterKey); }; diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index 8932698..3f6cab1 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -1,125 +1,102 @@ 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 { MaybeDirectoryInfo } from "./types"; +import type { DirectoryInfo, MaybeDirectoryInfo } from "./types"; -const cache = new FilesystemCache(); - -const fetchFromIndexedDB = async (id: DirectoryId) => { - const [directory, subDirectories, files] = await Promise.all([ - id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined, - IndexedDB.getDirectoryInfos(id), - IndexedDB.getFileInfos(id), - ]); - - if (id === "root") { - return { - id, - exists: true as const, - subDirectories, - files, - }; - } else if (directory) { - return { - id, - exists: true as const, - parentId: directory.parentId, - name: directory.name, - subDirectories, - files, - }; - } -}; - -const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => { - try { - const { - metadata, - subDirectories: subDirectoriesRaw, - files: filesRaw, - } = await trpc().directory.get.query({ id }); - - void IndexedDB.deleteDanglingDirectoryInfos(id, new Set(subDirectoriesRaw.map(({ id }) => id))); - void IndexedDB.deleteDanglingFileInfos(id, new Set(filesRaw.map(({ id }) => id))); - - const existingFiles = await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id)); - const [subDirectories, files, decryptedMetadata] = await Promise.all([ - Promise.all( - 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, 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, +const cache = new FilesystemCache({ + async fetchFromIndexedDB(id) { + const [directory, subDirectories, files] = await Promise.all([ + id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined, + IndexedDB.getDirectoryInfos(id), + IndexedDB.getFileInfos(id), ]); - if (id !== "root" && metadata && decryptedMetadata) { - await IndexedDB.storeDirectoryInfo({ - id, - parentId: metadata.parent, - name: decryptedMetadata.name, - }); - } - if (id === "root") { return { id, - exists: true as const, + exists: true, subDirectories, files, }; - } else { + } else if (directory) { return { id, - exists: true as const, - parentId: metadata!.parent, + exists: true, + parentId: directory.parentId, + name: directory.name, subDirectories, files, - ...decryptedMetadata!, }; } - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - await IndexedDB.deleteDirectoryInfo(id as number); - return { id, exists: false as const }; + }, + + async fetchFromServer(id, _cachedInfo, masterKey) { + try { + const directory = await trpc().directory.get.query({ id }); + const [subDirectories, files, metadata] = await Promise.all([ + Promise.all( + directory.subDirectories.map(async (directory) => ({ + id: directory.id, + parentId: id, + ...(await decryptDirectoryMetadata(directory, masterKey)), + })), + ), + Promise.all( + directory.files.map(async (file) => ({ + id: file.id, + parentId: id, + contentType: file.contentType, + ...(await decryptFileMetadata(file, masterKey)), + })), + ), + directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey), + ]); + + return storeToIndexedDB( + id !== "root" + ? { + id, + parentId: directory.metadata!.parent, + subDirectories, + files, + ...metadata!, + } + : { id, subDirectories, files }, + ); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteDirectoryInfo(id as number); + return { id, exists: false as const }; + } + throw e; } - throw e; + }, +}); + +const storeToIndexedDB = (info: DirectoryInfo) => { + if (info.id !== "root") { + void IndexedDB.storeDirectoryInfo(info); } + + // TODO: Bulk Upsert + info.subDirectories.forEach((subDirectory) => { + void IndexedDB.storeDirectoryInfo(subDirectory); + }); + + // TODO: Bulk Upsert + info.files.forEach((file) => { + void IndexedDB.storeFileInfo(file); + }); + + void IndexedDB.deleteDanglingDirectoryInfos( + info.id, + new Set(info.subDirectories.map(({ id }) => id)), + ); + void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id))); + + return { ...info, exists: true as const }; }; export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { - return await cache.get(id, (isInitial, resolve) => - monotonicResolve( - [isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)], - resolve, - ), - ); + return await cache.get(id, masterKey); }; diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index 45fef78..66ad359 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -1,175 +1,177 @@ 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 { MaybeFileInfo } from "./types"; +import type { FileInfo, MaybeFileInfo } from "./types"; -const cache = new FilesystemCache(); +const cache = new FilesystemCache({ + async fetchFromIndexedDB(id) { + const file = await IndexedDB.getFileInfo(id); + const categories = file?.categoryIds + ? await Promise.all( + file.categoryIds.map(async (categoryId) => { + const category = await IndexedDB.getCategoryInfo(categoryId); + return category + ? { id: category.id, parentId: category.parentId, name: category.name } + : undefined; + }), + ) + : undefined; -const fetchFromIndexedDB = async (id: number) => { - const file = await IndexedDB.getFileInfo(id); - const categories = file - ? await Promise.all( - file.categoryIds.map(async (categoryId) => { - const category = await IndexedDB.getCategoryInfo(categoryId); - return category ? { id: category.id, name: category.name } : undefined; - }), - ) - : undefined; - - if (file) { - return { - id, - exists: true as const, - parentId: file.parentId, - contentType: file.contentType, - name: file.name, - createdAt: file.createdAt, - lastModifiedAt: file.lastModifiedAt, - categories: categories!.filter((category) => !!category), - }; - } -}; - -const bulkFetchFromIndexedDB = async (ids: number[]) => { - const files = await IndexedDB.bulkGetFileInfos(ids); - const categories = await Promise.all( - files.map(async (file) => - file - ? await Promise.all( - file.categoryIds.map(async (categoryId) => { - const category = await IndexedDB.getCategoryInfo(categoryId); - return category ? { id: category.id, name: category.name } : undefined; - }), - ) - : undefined, - ), - ); - return new Map( - files - .map((file, index) => - file - ? ([ - file.id, - { - ...file, - exists: true, - categories: categories[index]!.filter((category) => !!category), - }, - ] as const) - : undefined, - ) - .filter((file) => !!file), - ); -}; - -const fetchFromServer = async (id: number, masterKey: CryptoKey) => { - try { - const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id }); - 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, - parentId: metadata.parent, - contentType: metadata.contentType, - contentIv: metadata.contentIv, - categories, - ...decryptedMetadata, - }; - } catch (e) { - if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { - await IndexedDB.deleteFileInfo(id); - return { id, exists: false as const }; + if (file) { + return { + id, + exists: true, + parentId: file.parentId, + contentType: file.contentType, + name: file.name, + createdAt: file.createdAt, + lastModifiedAt: file.lastModifiedAt, + categories: categories?.filter((category) => !!category) ?? [], + }; } - throw e; - } -}; + }, -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, decryptedMetadata] = await Promise.all([ + async fetchFromServer(id, _cachedInfo, masterKey) { + try { + const file = await trpc().file.get.query({ id }); + const [categories, metadata] = await Promise.all([ Promise.all( file.categories.map(async (category) => ({ id: category.id, + parentId: category.parent, ...(await decryptCategoryMetadata(category, 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, + return storeToIndexedDB({ + id, parentId: file.parent, + dataKey: metadata.dataKey, contentType: file.contentType, contentIv: file.contentIv, + name: metadata.name, + createdAt: metadata.createdAt, + lastModifiedAt: metadata.lastModifiedAt, categories, - ...decryptedMetadata, - }; - }), - ); + }); + } catch (e) { + if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { + await IndexedDB.deleteFileInfo(id); + return { id, exists: false as const }; + } + throw e; + } + }, - const existingIds = new Set(filesRaw.map(({ id }) => id)); - return new Map([ - ...files.map((file) => [file.id, file] as const), - ...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] as const), - ]); + async bulkFetchFromIndexedDB(ids) { + const files = await IndexedDB.bulkGetFileInfos([...ids]); + const categories = await Promise.all( + files.map(async (file) => + file?.categoryIds + ? await Promise.all( + file.categoryIds.map(async (categoryId) => { + const category = await IndexedDB.getCategoryInfo(categoryId); + return category + ? { id: category.id, parentId: category.parentId, name: category.name } + : undefined; + }), + ) + : undefined, + ), + ); + + return new Map( + files + .filter((file) => !!file) + .map((file, index) => [ + file.id, + { + ...file, + exists: true, + categories: categories[index]?.filter((category) => !!category) ?? [], + }, + ]), + ); + }, + + async bulkFetchFromServer(ids, masterKey) { + const idsArray = [...ids.keys()]; + + const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray }); + const files = await Promise.all( + filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => { + const [categories, metadata] = await Promise.all([ + Promise.all( + categoriesRaw.map(async (category) => ({ + id: category.id, + parentId: category.parent, + ...(await decryptCategoryMetadata(category, masterKey)), + })), + ), + decryptFileMetadata(metadataRaw, masterKey), + ]); + + return { + id, + exists: true as const, + parentId: metadataRaw.parent, + contentType: metadataRaw.contentType, + contentIv: metadataRaw.contentIv, + categories, + ...metadata, + }; + }), + ); + + const existingIds = new Set(filesRaw.map(({ id }) => id)); + + return new Map([ + ...bulkStoreToIndexedDB(files), + ...idsArray + .filter((id) => !existingIds.has(id)) + .map((id) => [id, { id, exists: false }] as const), + ]); + }, +}); + +const storeToIndexedDB = (info: FileInfo) => { + void IndexedDB.storeFileInfo({ + ...info, + categoryIds: info.categories.map(({ id }) => id), + }); + + info.categories.forEach((category) => { + void IndexedDB.storeCategoryInfo(category); + }); + + return { ...info, exists: true as const }; +}; + +const bulkStoreToIndexedDB = (infos: FileInfo[]) => { + // TODO: Bulk Upsert + infos.forEach((info) => { + void IndexedDB.storeFileInfo({ + ...info, + categoryIds: info.categories.map(({ id }) => id), + }); + }); + + // TODO: Bulk Upsert + new Map( + infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]), + ).forEach((category) => { + void IndexedDB.storeCategoryInfo(category); + }); + + return infos.map((info) => [info.id, { ...info, exists: true }] as const); }; export const getFileInfo = async (id: number, masterKey: CryptoKey) => { - return await cache.get(id, (isInitial, resolve) => - monotonicResolve( - [isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)], - resolve, - ), - ); + return await cache.get(id, masterKey); }; export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => { - return await cache.bulkGet(new Set(ids), (keys, resolve) => - monotonicResolve( - [ - bulkFetchFromIndexedDB( - Array.from( - keys - .entries() - .filter(([, isInitial]) => isInitial) - .map(([key]) => key), - ), - ), - bulkFetchFromServer(Array.from(keys.keys()), masterKey), - ], - resolve, - ), - ); + return await cache.bulkGet(new Set(ids), masterKey); }; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index 8b2b092..6e8d7f2 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -1,82 +1,120 @@ +import { untrack } from "svelte"; import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; -export class FilesystemCache { - private map = new Map>(); +interface FilesystemCacheOptions { + fetchFromIndexedDB: (key: K) => Promise; + fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise; + bulkFetchFromIndexedDB?: (keys: Set) => Promise>; + bulkFetchFromServer?: ( + keys: Map, + masterKey: CryptoKey, + ) => Promise>; +} - get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) { - const info = this.map.get(key); - if (info instanceof Promise) { - return info; - } +export class FilesystemCache { + private map = new Map }>(); - const { promise, resolve } = Promise.withResolvers(); - if (!info) { - this.map.set(key, promise); - } + constructor(private readonly options: FilesystemCacheOptions) {} - loader(!info, (loadedInfo) => { - if (!loadedInfo) return; + get(key: K, masterKey: CryptoKey) { + return untrack(() => { + let state = this.map.get(key); + if (state?.promise) return state.value ?? state.promise; - const info = this.map.get(key)!; - if (info instanceof Promise) { - const state = $state(loadedInfo); - this.map.set(key, state as V); - resolve(state as V); - } else { - Object.assign(info, loadedInfo); - resolve(info); + const { promise: newPromise, resolve } = Promise.withResolvers(); + + if (!state) { + const newState = $state({}); + state = newState; + this.map.set(key, newState); } - }); - return info ?? promise; + state.promise = newPromise; + + (state.value + ? Promise.resolve(state.value) + : this.options.fetchFromIndexedDB(key).then((loadedInfo) => { + if (loadedInfo) { + state.value = loadedInfo; + resolve(state.value); + } + return loadedInfo; + }) + ) + .then((cachedInfo) => this.options.fetchFromServer(key, cachedInfo, masterKey)) + .then((loadedInfo) => { + if (state.value) { + Object.assign(state.value, loadedInfo); + } else { + state.value = loadedInfo; + } + resolve(state.value); + }) + .finally(() => { + state.promise = undefined; + }); + + return newPromise; + }); } - async bulkGet( - keys: Set, - loader: (keys: Map, resolve: (values: Map) => void) => void, - ) { - const states = new Map(); - const promises = new Map>(); - const resolvers = new Map void>(); + bulkGet(keys: Set, masterKey: CryptoKey) { + return untrack(() => { + const newPromises = new Map( + keys + .keys() + .filter((key) => this.map.get(key)?.promise === undefined) + .map((key) => [key, Promise.withResolvers()]), + ); + newPromises.forEach(({ promise }, key) => { + const state = this.map.get(key); + if (state) { + state.promise = promise; + } else { + const newState = $state({ promise }); + this.map.set(key, newState); + } + }); - keys.forEach((key) => { - const info = this.map.get(key); - if (info instanceof Promise) { - promises.set(key, info); - } else if (info) { - states.set(key, info); - } else { - const { promise, resolve } = Promise.withResolvers(); - this.map.set(key, promise); - promises.set(key, promise); - resolvers.set(key, resolve); - } - }); - - loader( - new Map([ - ...states.keys().map((key) => [key, false] as const), - ...resolvers.keys().map((key) => [key, true] as const), - ]), - (loadedInfos) => + const resolve = (loadedInfos: Map) => { loadedInfos.forEach((loadedInfo, key) => { - const info = this.map.get(key)!; - const resolve = resolvers.get(key); - if (info instanceof Promise) { - const state = $state(loadedInfo); - this.map.set(key, state as V); - resolve?.(state as V); + const state = this.map.get(key)!; + if (state.value) { + Object.assign(state.value, loadedInfo); } else { - Object.assign(info, loadedInfo); - resolve?.(info); + state.value = loadedInfo; } - }), - ); + newPromises.get(key)!.resolve(state.value); + }); + return loadedInfos; + }; - const newStates = await Promise.all( - promises.entries().map(async ([key, promise]) => [key, await promise] as const), - ); - return new Map([...states, ...newStates]); + this.options.bulkFetchFromIndexedDB!( + new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)), + ) + .then(resolve) + .then(() => + this.options.bulkFetchFromServer!( + new Map( + newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]), + ), + masterKey, + ), + ) + .then(resolve) + .finally(() => { + newPromises.forEach((_, key) => { + this.map.get(key)!.promise = undefined; + }); + }); + + return Promise.all( + keys + .keys() + .filter((key) => this.map.get(key)!.value === undefined) + .map((key) => this.map.get(key)!.promise!), + ).then(() => new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const))); + }); } } diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts index 15b0e93..9f33113 100644 --- a/src/lib/modules/filesystem/types.ts +++ b/src/lib/modules/filesystem/types.ts @@ -20,11 +20,12 @@ interface RootDirectoryInfo { } export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; -export type SubDirectoryInfo = Omit; export type MaybeDirectoryInfo = | (DirectoryInfo & { exists: true }) | ({ id: DirectoryId; exists: false } & AllUndefined>); +export type SubDirectoryInfo = Omit; + export interface FileInfo { id: number; parentId: DirectoryId; @@ -34,17 +35,19 @@ export interface FileInfo { name: string; createdAt?: Date; lastModifiedAt: Date; - categories: { id: number; name: string }[]; + categories: FileCategoryInfo[]; } -export type SummarizedFileInfo = Omit; -export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; export type MaybeFileInfo = | (FileInfo & { exists: true }) | ({ id: number; exists: false } & AllUndefined>); +export type SummarizedFileInfo = Omit; +export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; + interface LocalCategoryInfo { id: number; + parentId: DirectoryId; dataKey?: DataKey; name: string; subCategories: SubCategoryInfo[]; @@ -54,6 +57,7 @@ interface LocalCategoryInfo { interface RootCategoryInfo { id: "root"; + parentId?: undefined; dataKey?: undefined; name?: undefined; subCategories: SubCategoryInfo[]; @@ -62,10 +66,12 @@ interface RootCategoryInfo { } export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo; +export type MaybeCategoryInfo = + | (CategoryInfo & { exists: true }) + | ({ id: CategoryId; exists: false } & AllUndefined>); + export type SubCategoryInfo = Omit< LocalCategoryInfo, "subCategories" | "files" | "isFileRecursive" >; -export type MaybeCategoryInfo = - | (CategoryInfo & { exists: true }) - | ({ id: CategoryId; exists: false } & AllUndefined>); +export type FileCategoryInfo = Omit; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 6a0a062..472930a 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -39,6 +39,7 @@ export type NewFile = Omit; interface FileCategory { id: number; + parentId: CategoryId; mekVersion: number; encDek: string; dekVersion: Date; @@ -445,6 +446,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[]) encLastModifiedAt: file.encrypted_last_modified_at, categories: file.categories.map((category) => ({ id: category.id, + parentId: category.parent_id ?? "root", mekVersion: category.master_encryption_key_version, encDek: category.encrypted_data_encryption_key, dekVersion: new Date(category.data_encryption_key_version), @@ -548,6 +550,7 @@ export const getAllFileCategories = async (fileId: number) => { (category) => ({ id: category.id, + parentId: category.parent_id ?? "root", mekVersion: category.master_encryption_key_version, encDek: category.encrypted_data_encryption_key, dekVersion: category.data_encryption_key_version, diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 9dc3631..1db9577 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,4 +1,3 @@ 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 deleted file mode 100644 index 9e841c8..0000000 --- a/src/lib/utils/promise.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const monotonicResolve = ( - promises: (Promise | false)[], - callback: (value: T) => void, -) => { - let latestResolvedIndex = -1; - promises - .filter((promise) => !!promise) - .forEach((promise, index) => { - promise.then((value) => { - if (index > latestResolvedIndex) { - latestResolvedIndex = index; - callback(value); - } - }); - }); -}; diff --git a/src/trpc/routers/category.ts b/src/trpc/routers/category.ts index 9b2567a..a292889 100644 --- a/src/trpc/routers/category.ts +++ b/src/trpc/routers/category.ts @@ -46,6 +46,7 @@ const categoryRouter = router({ })), files: files?.map((file) => ({ id: file.id, + parent: file.parentId, mekVersion: file.mekVersion, dek: file.encDek, dekVersion: file.dekVersion, diff --git a/src/trpc/routers/file.ts b/src/trpc/routers/file.ts index b08bbf2..c3f8159 100644 --- a/src/trpc/routers/file.ts +++ b/src/trpc/routers/file.ts @@ -33,6 +33,7 @@ const fileRouter = router({ lastModifiedAtIv: file.encLastModifiedAt.iv, categories: categories.map((category) => ({ id: category.id, + parent: category.parentId, mekVersion: category.mekVersion, dek: category.encDek, dekVersion: category.dekVersion, @@ -66,6 +67,7 @@ const fileRouter = router({ lastModifiedAtIv: file.encLastModifiedAt.iv, categories: file.categories.map((category) => ({ id: category.id, + parent: category.parentId, mekVersion: category.mekVersion, dek: category.encDek, dekVersion: category.dekVersion, From 1d3704bfad2e26a41f807203df978bec689d3354 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 6 Jan 2026 06:48:35 +0900 Subject: [PATCH 24/26] =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=ED=83=90=EC=83=89?= =?UTF-8?q?=EC=8B=9C=EC=9D=98=20=EA=B9=9C=EB=B9=A1=EC=9E=84=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/filesystem/category.ts | 4 +- src/lib/modules/filesystem/directory.ts | 4 +- src/lib/modules/filesystem/file.ts | 8 +- src/lib/modules/filesystem/internal.svelte.ts | 14 +- src/lib/utils/HybridPromise.ts | 93 +++++++++ src/lib/utils/index.ts | 1 + .../(fullscreen)/file/[id]/+page.svelte | 176 ++++++++--------- .../file/[id]/AddToCategoryBottomSheet.svelte | 73 +++---- .../(main)/category/[[id]]/+page.svelte | 141 ++++++------- .../(main)/directory/[[id]]/+page.svelte | 187 +++++++++--------- 10 files changed, 405 insertions(+), 296 deletions(-) create mode 100644 src/lib/utils/HybridPromise.ts diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index 47a4565..778f75c 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -116,6 +116,6 @@ const storeToIndexedDB = (info: CategoryInfo) => { return { ...info, exists: true as const }; }; -export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { - return await cache.get(id, masterKey); +export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => { + return cache.get(id, masterKey); }; diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index 3f6cab1..4144a68 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -97,6 +97,6 @@ const storeToIndexedDB = (info: DirectoryInfo) => { return { ...info, exists: true as const }; }; -export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { - return await cache.get(id, masterKey); +export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => { + return cache.get(id, masterKey); }; diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index 66ad359..9f8827d 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -168,10 +168,10 @@ const bulkStoreToIndexedDB = (infos: FileInfo[]) => { return infos.map((info) => [info.id, { ...info, exists: true }] as const); }; -export const getFileInfo = async (id: number, masterKey: CryptoKey) => { - return await cache.get(id, masterKey); +export const getFileInfo = (id: number, masterKey: CryptoKey) => { + return cache.get(id, masterKey); }; -export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => { - return await cache.bulkGet(new Set(ids), masterKey); +export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => { + return cache.bulkGet(new Set(ids), masterKey); }; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index 6e8d7f2..7a8c446 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -29,8 +29,6 @@ export class FilesystemCache { this.map.set(key, newState); } - state.promise = newPromise; - (state.value ? Promise.resolve(state.value) : this.options.fetchFromIndexedDB(key).then((loadedInfo) => { @@ -54,7 +52,8 @@ export class FilesystemCache { state.promise = undefined; }); - return newPromise; + state.promise = newPromise; + return state.value ?? newPromise; }); } @@ -108,12 +107,17 @@ export class FilesystemCache { }); }); - return Promise.all( + const bottleneckPromises = Array.from( keys .keys() .filter((key) => this.map.get(key)!.value === undefined) .map((key) => this.map.get(key)!.promise!), - ).then(() => new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const))); + ); + const makeResult = () => + new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const)); + return bottleneckPromises.length > 0 + ? Promise.all(bottleneckPromises).then(makeResult) + : makeResult(); }); } } diff --git a/src/lib/utils/HybridPromise.ts b/src/lib/utils/HybridPromise.ts new file mode 100644 index 0000000..10c6be9 --- /dev/null +++ b/src/lib/utils/HybridPromise.ts @@ -0,0 +1,93 @@ +type MaybePromise = T | Promise | HybridPromise; + +type HybridPromiseState = + | { mode: "sync"; status: "fulfilled"; value: T } + | { mode: "sync"; status: "rejected"; reason: unknown } + | { mode: "async"; promise: Promise }; + +export class HybridPromise implements PromiseLike { + private isConsumed = false; + + private constructor(private readonly state: HybridPromiseState) { + if (state.mode === "sync" && state.status === "rejected") { + queueMicrotask(() => { + if (!this.isConsumed) { + throw state.reason; + } + }); + } + } + + isSync(): boolean { + return this.state.mode === "sync"; + } + + toPromise(): Promise { + this.isConsumed = true; + + if (this.state.mode === "async") return this.state.promise; + return this.state.status === "fulfilled" + ? Promise.resolve(this.state.value) + : Promise.reject(this.state.reason); + } + + static resolve(value: MaybePromise): HybridPromise { + if (value instanceof HybridPromise) return value; + return new HybridPromise( + value instanceof Promise + ? { mode: "async", promise: value } + : { mode: "sync", status: "fulfilled", value }, + ); + } + + static reject(reason?: unknown): HybridPromise { + return new HybridPromise({ mode: "sync", status: "rejected", reason }); + } + + then( + onfulfilled?: ((value: T) => MaybePromise) | null | undefined, + onrejected?: ((reason: unknown) => MaybePromise) | null | undefined, + ): HybridPromise { + this.isConsumed = true; + + if (this.state.mode === "async") { + return new HybridPromise({ + mode: "async", + promise: this.state.promise.then(onfulfilled, onrejected) as any, + }); + } + + try { + if (this.state.status === "fulfilled") { + if (!onfulfilled) return HybridPromise.resolve(this.state.value as any); + return HybridPromise.resolve(onfulfilled(this.state.value)); + } else { + if (!onrejected) return HybridPromise.reject(this.state.reason); + return HybridPromise.resolve(onrejected(this.state.reason)); + } + } catch (e) { + return HybridPromise.reject(e); + } + } + + catch( + onrejected?: ((reason: unknown) => MaybePromise) | null | undefined, + ): HybridPromise { + return this.then(null, onrejected); + } + + finally(onfinally?: (() => void) | null | undefined): HybridPromise { + this.isConsumed = true; + + if (this.state.mode === "async") { + return new HybridPromise({ mode: "async", promise: this.state.promise.finally(onfinally) }); + } + + try { + onfinally?.(); + return new HybridPromise(this.state); + } catch (e) { + return HybridPromise.reject(e); + } + } +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1db9577..5d5b9d4 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 "./HybridPromise"; export * from "./sort"; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 585c0d0..0b344bc 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -9,6 +9,7 @@ import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { getFileDownloadState } from "$lib/modules/file"; import { masterKeyStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import DownloadStatus from "./DownloadStatus.svelte"; import { @@ -26,8 +27,7 @@ let { data } = $props(); - let infoPromise: Promise | undefined = $state(); - let info: FileInfo | null = $state(null); + let info: MaybeFileInfo | undefined = $state(); let downloadState = $derived(getFileDownloadState(data.id)); let isMenuOpen = $state(false); @@ -65,22 +65,20 @@ const addToCategory = async (categoryId: number) => { await requestFileAdditionToCategory(data.id, categoryId); isAddToCategoryBottomSheetOpen = false; - infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME }; const removeFromCategory = async (categoryId: number) => { await requestFileRemovalFromCategory(data.id, categoryId); - infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME }; $effect(() => { - infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => { - if (fileInfo.exists) { - info = fileInfo; + HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => { + if (data.id === result.id) { + info = result; } - return fileInfo; }); - info = null; isDownloadRequested = false; viewerType = undefined; }); @@ -111,8 +109,8 @@ }); $effect(() => { - if (info && downloadState?.status === "decrypted") { - untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType)); + if (info?.exists && downloadState?.status === "decrypted") { + untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!)); } }); @@ -123,87 +121,85 @@ 파일 -{#if info} - - - -
e.stopPropagation()}> - - -
-
- -
- {#if downloadState} - - {/if} - {#if viewerType} -
- {#snippet viewerLoading(message: string)} -

{message}

- {/snippet} + + + +
e.stopPropagation()}> + + +
+
+ +
+ {#if downloadState} + + {/if} + {#if info && viewerType} +
+ {#snippet viewerLoading(message: string)} +

{message}

+ {/snippet} - {#if viewerType === "image"} - {#if fileBlobUrl} - {info.name} - {:else} - {@render viewerLoading("이미지를 불러오고 있어요.")} - {/if} - {:else if viewerType === "video"} - {#if fileBlobUrl} -
- - updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)} - class="w-full" - > - 이 장면을 썸네일로 설정하기 - -
- {:else} - {@render viewerLoading("비디오를 불러오고 있어요.")} - {/if} + {#if viewerType === "image"} + {#if fileBlobUrl} + {info.name} + {:else} + {@render viewerLoading("이미지를 불러오고 있어요.")} {/if} -
- {/if} -
-

카테고리

-
- goto(`/category/${id}`)} - onCategoryMenuClick={({ id }) => removeFromCategory(id)} - /> - (isAddToCategoryBottomSheetOpen = true)} - class="h-12 w-full" - iconClass="text-gray-600" - textClass="text-gray-700" - > - 카테고리에 추가하기 - -
+ {:else if viewerType === "video"} + {#if fileBlobUrl} +
+ + updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)} + class="w-full" + > + 이 장면을 썸네일로 설정하기 + +
+ {:else} + {@render viewerLoading("비디오를 불러오고 있어요.")} + {/if} + {/if} +
+ {/if} +
+

카테고리

+
+ goto(`/category/${id}`)} + onCategoryMenuClick={({ id }) => removeFromCategory(id)} + /> + (isAddToCategoryBottomSheetOpen = true)} + class="h-12 w-full" + iconClass="text-gray-600" + textClass="text-gray-700" + > + 카테고리에 추가하기 +
- +
+
- -{/if} + diff --git a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte index 5d89512..17b6662 100644 --- a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte +++ b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte @@ -4,6 +4,7 @@ import { CategoryCreateModal } from "$lib/components/organisms"; import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import { requestCategoryCreation } from "./service"; interface Props { @@ -13,48 +14,50 @@ let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); - let categoryInfoPromise: Promise | undefined = $state(); + let categoryInfo: MaybeCategoryInfo | undefined = $state(); let isCategoryCreateModalOpen = $state(false); $effect(() => { if (isOpen) { - categoryInfoPromise = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!); + HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then( + (result) => (categoryInfo = result), + ); } }); -{#await categoryInfoPromise then categoryInfo} - {#if categoryInfo?.exists} - - - - (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - subCategoryCreatePosition="top" - /> - {#if categoryInfo.id !== "root"} - - - - {/if} - - +{#if categoryInfo?.exists} + + + + HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then( + (result) => (categoryInfo = result), + )} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + subCategoryCreatePosition="top" + /> + {#if categoryInfo.id !== "root"} + + + + {/if} + + +{/if} - { - 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} + { + if (await requestCategoryCreation(name, categoryInfo!.id, $masterKeyStore?.get(1)!)) { + void getCategoryInfo(categoryInfo!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte index f57b402..3c07474 100644 --- a/src/routes/(main)/category/[[id]]/+page.svelte +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -4,6 +4,7 @@ import { Category, CategoryCreateModal } from "$lib/components/organisms"; import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryRenameModal from "./CategoryRenameModal.svelte"; @@ -18,7 +19,7 @@ let { data } = $props(); let context = createContext(); - let infoPromise: Promise | undefined = $state(); + let info: MaybeCategoryInfo | undefined = $state(); let isCategoryCreateModalOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false); @@ -26,7 +27,13 @@ let isCategoryDeleteModalOpen = $state(false); $effect(() => { - infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + HybridPromise.resolve(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then( + (result) => { + if (data.id === result.id) { + info = result; + } + }, + ); }); @@ -34,70 +41,68 @@ 카테고리 -{#await infoPromise then info} - {#if info?.exists} - {#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; - }} - /> - - { - 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 info?.exists} + {#if info.id !== "root"} + {/if} -{/await} +
+ goto(`/file/${id}?from=category`)} + onFileRemoveClick={async ({ id }) => { + await requestFileRemovalFromCategory(id, data.id as number); + void 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} + + { + if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + void 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)) { + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + { + if (await requestCategoryDeletion(context.selectedCategory!)) { + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index 8edd04b..1919d7b 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -6,6 +6,7 @@ import { TopBar } from "$lib/components/molecules"; import { getDirectoryInfo, type MaybeDirectoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore, hmacSecretStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryEntries from "./DirectoryEntries"; import DownloadStatusCard from "./DownloadStatusCard.svelte"; @@ -29,7 +30,7 @@ let { data } = $props(); let context = createContext(); - let infoPromise: Promise | undefined = $state(); + let info: MaybeDirectoryInfo | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state(); let duplicatedFile: File | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); @@ -60,7 +61,7 @@ .then((res) => { if (!res) return; // TODO: FIXME - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); }) .catch((e: Error) => { // TODO: FIXME @@ -78,7 +79,13 @@ }); $effect(() => { - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + HybridPromise.resolve(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then( + (result) => { + if (data.id === result.id) { + info = result; + } + }, + ); }); @@ -88,17 +95,17 @@ -{#await infoPromise then info} - {#if info?.exists} -
- {#if showTopBar} - - {/if} -
-
- goto("/file/uploads")} /> - goto("/file/downloads")} /> -
+{#if info?.exists} +
+ {#if showTopBar} + + {/if} +
+
+ goto("/file/uploads")} /> + goto("/file/downloads")} /> +
+ {#key info.id} goto(`/${type}/${id}`)} @@ -109,85 +116,85 @@ 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}
+
+{/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)!)) { - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - { - resolveForDuplicateFileModal?.(false); - isDuplicateFileModalOpen = false; - }} - onUploadClick={() => { - resolveForDuplicateFileModal?.(true); - isDuplicateFileModalOpen = false; - }} - /> + { + 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)!)) { + void 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} -{/await} + { + isEntryMenuBottomSheetOpen = false; + isEntryRenameModalOpen = true; + }} + onDeleteClick={() => { + isEntryMenuBottomSheetOpen = false; + isEntryDeleteModalOpen = true; + }} +/> + { + if (await requestEntryRename(context.selectedEntry!, newName)) { + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + { + if (await requestEntryDeletion(context.selectedEntry!)) { + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> From 4997b1f38cb1d82b4f29f05b6f2578ccdcdf85f2 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 6 Jan 2026 07:17:58 +0900 Subject: [PATCH 25/26] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B6=84=EB=A6=AC=EB=90=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/atoms/RowVirtualizer.svelte | 2 +- .../organisms/Category/Category.svelte | 93 --------------- .../components/organisms/Category/index.ts | 2 - .../components/organisms/Category/service.ts | 4 - src/lib/components/organisms/Gallery.svelte | 78 ------------- src/lib/components/organisms/index.ts | 3 - src/routes/(fullscreen)/gallery/+page.svelte | 81 +++++++++++-- .../(main)/category/[[id]]/+page.svelte | 107 ++++++++++++++---- .../(main)/category/[[id]]}/File.svelte | 2 +- .../(main)/category/[[id]]/service.svelte.ts | 5 + .../(main)/directory/[[id]]/+page.svelte | 14 +-- 11 files changed, 171 insertions(+), 220 deletions(-) delete mode 100644 src/lib/components/organisms/Category/Category.svelte delete mode 100644 src/lib/components/organisms/Category/index.ts delete mode 100644 src/lib/components/organisms/Category/service.ts delete mode 100644 src/lib/components/organisms/Gallery.svelte rename src/{lib/components/organisms/Category => routes/(main)/category/[[id]]}/File.svelte (93%) diff --git a/src/lib/components/atoms/RowVirtualizer.svelte b/src/lib/components/atoms/RowVirtualizer.svelte index 88ff7e4..67a684d 100644 --- a/src/lib/components/atoms/RowVirtualizer.svelte +++ b/src/lib/components/atoms/RowVirtualizer.svelte @@ -54,7 +54,7 @@
{/each}
- {#if placeholder && $virtualizer.getVirtualItems().length === 0} + {#if placeholder && count === 0} {@render placeholder()} {/if}
diff --git a/src/lib/components/organisms/Category/Category.svelte b/src/lib/components/organisms/Category/Category.svelte deleted file mode 100644 index 6482a16..0000000 --- a/src/lib/components/organisms/Category/Category.svelte +++ /dev/null @@ -1,93 +0,0 @@ - - -
-
- {#if info.id !== "root"} -

하위 카테고리

- {/if} - -
- {#if info.id !== "root"} -
-
-

파일

- -

하위 카테고리의 파일

-
-
- 48} itemGap={4}> - {#snippet item(index)} - {@const { details } = files[index]!} - - {/snippet} - {#snippet placeholder()} -

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

- {/snippet} -
-
- {/if} -
diff --git a/src/lib/components/organisms/Category/index.ts b/src/lib/components/organisms/Category/index.ts deleted file mode 100644 index 51e0a58..0000000 --- a/src/lib/components/organisms/Category/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Category.svelte"; -export * from "./service"; diff --git a/src/lib/components/organisms/Category/service.ts b/src/lib/components/organisms/Category/service.ts deleted file mode 100644 index 3c78d2f..0000000 --- a/src/lib/components/organisms/Category/service.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface SelectedFile { - id: number; - name: string; -} diff --git a/src/lib/components/organisms/Gallery.svelte b/src/lib/components/organisms/Gallery.svelte deleted file mode 100644 index d656c7f..0000000 --- a/src/lib/components/organisms/Gallery.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - - rows[index]!.type === "header" ? 28 : 181 + (rows[index]!.isLast ? 16 : 4)} - class="flex flex-grow flex-col" -> - {#snippet item(index)} - {@const row = rows[index]!} - {#if row.type === "header"} -

{row.label}

- {:else} -
- {#each row.files as file (file.id)} - - {/each} -
- {/if} - {/snippet} - {#snippet placeholder()} -
-

- {#if files.length === 0} - 업로드된 파일이 없어요. - {:else} - 사진 또는 동영상이 없어요. - {/if} -

-
- {/snippet} -
diff --git a/src/lib/components/organisms/index.ts b/src/lib/components/organisms/index.ts index 9687bfe..fa02317 100644 --- a/src/lib/components/organisms/index.ts +++ b/src/lib/components/organisms/index.ts @@ -1,4 +1 @@ -export * from "./Category"; -export { default as Category } from "./Category"; -export { default as Gallery } from "./Gallery.svelte"; export * from "./modals"; diff --git a/src/routes/(fullscreen)/gallery/+page.svelte b/src/routes/(fullscreen)/gallery/+page.svelte index 01eed54..e458a12 100644 --- a/src/routes/(fullscreen)/gallery/+page.svelte +++ b/src/routes/(fullscreen)/gallery/+page.svelte @@ -1,15 +1,53 @@ @@ -34,9 +25,7 @@ {#if mediaFiles.length > 0}
{#each mediaFiles as file (file.id)} - {#if file.exists} - goto(`/file/${id}`)} /> - {/if} + goto(`/file/${id}`)} /> {/each}
{/if}