2 Commits

Author SHA1 Message Date
static
d1f9018213 사소한 리팩토링 2026-01-02 00:31:58 +09:00
static
2e3cd4f8a2 네트워크 호출 결과가 IndexedDB에 캐시되지 않던 버그 수정 2026-01-01 23:52:47 +09:00
9 changed files with 214 additions and 96 deletions

View File

@@ -8,10 +8,11 @@
count: number; count: number;
item: Snippet<[index: number]>; item: Snippet<[index: number]>;
itemHeight: (index: number) => number; itemHeight: (index: number) => number;
itemGap?: number;
placeholder?: Snippet; 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 element: HTMLElement | undefined = $state();
let scrollMargin = $state(0); let scrollMargin = $state(0);
@@ -20,6 +21,7 @@
createWindowVirtualizer({ createWindowVirtualizer({
count, count,
estimateSize: itemHeight, estimateSize: itemHeight,
gap: itemGap,
scrollMargin, scrollMargin,
}), }),
); );

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CheckBox, RowVirtualizer } from "$lib/components/atoms"; import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules"; import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { updateCategoryInfo } from "$lib/indexedDB";
import type { CategoryInfo } from "$lib/modules/filesystem"; import type { CategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils"; import { sortEntries } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
@@ -28,6 +29,9 @@
isFileRecursive = $bindable(), isFileRecursive = $bindable(),
}: Props = $props(); }: Props = $props();
let lastCategoryId = $state<CategoryInfo["id"] | undefined>();
let lastIsFileRecursive = $state<boolean | undefined>();
let files = $derived( let files = $derived(
sortEntries( sortEntries(
info.files info.files
@@ -35,6 +39,19 @@
.filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [], .filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [],
), ),
); );
$effect(() => {
if (info.id === "root" || isFileRecursive === undefined) return;
if (lastCategoryId !== info.id) {
lastCategoryId = info.id;
lastIsFileRecursive = isFileRecursive;
return;
}
if (lastIsFileRecursive === isFileRecursive) return;
lastIsFileRecursive = isFileRecursive;
void updateCategoryInfo(info.id, { isFileRecursive });
});
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
@@ -58,19 +75,14 @@
<p class="font-medium">하위 카테고리의 파일</p> <p class="font-medium">하위 카테고리의 파일</p>
</CheckBox> </CheckBox>
</div> </div>
<RowVirtualizer <RowVirtualizer count={files.length} itemHeight={() => 48} itemGap={4}>
count={files.length}
itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)} {#snippet item(index)}
{@const { details } = files[index]!} {@const { details } = files[index]!}
<div class={[index + 1 < files.length && "pb-1"]}> <File
<File info={details}
info={details} onclick={onFileClick}
onclick={onFileClick} onRemoveClick={!details.isRecursive ? onFileRemoveClick : undefined}
onRemoveClick={!details.isRecursive ? onFileRemoveClick : undefined} />
/>
</div>
{/snippet} {/snippet}
{#snippet placeholder()} {#snippet placeholder()}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p> <p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>

View File

@@ -46,7 +46,7 @@ const isFileUploading = (status: FileUploadState["status"]) =>
export const getUploadingFiles = (parentId?: DirectoryId) => { export const getUploadingFiles = (parentId?: DirectoryId) => {
return uploadingFiles.filter( return uploadingFiles.filter(
(file): file is LiveFileUploadState => (file) =>
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status), (parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
); );
}; };

View File

@@ -52,25 +52,76 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => {
metadata, metadata,
subCategories: subCategoriesRaw, subCategories: subCategoriesRaw,
files: filesRaw, files: filesRaw,
} = await trpc().category.get.query({ id }); } = await trpc().category.get.query({ id, recurse: true });
const [subCategories, files] = await Promise.all([ const subCategories = await Promise.all(
Promise.all( subCategoriesRaw.map(async (category) => {
subCategoriesRaw.map(async (category) => ({ const decrypted = await decryptCategoryMetadata(category, masterKey);
const existing = await IndexedDB.getCategoryInfo(category.id);
await IndexedDB.storeCategoryInfo({
id: category.id, id: category.id,
...(await decryptCategoryMetadata(category, masterKey)), parentId: id,
})), name: decrypted.name,
), files: existing?.files ?? [],
filesRaw isFileRecursive: existing?.isFileRecursive ?? false,
? Promise.all( });
filesRaw.map(async (file) => ({ 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, id: file.id,
contentType: file.contentType, contentType: file.contentType,
isRecursive: file.isRecursive, isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)), ...decrypted,
})), };
) }),
: 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") { if (id === "root") {
return { return {
@@ -84,7 +135,7 @@ const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => {
exists: true as const, exists: true as const,
subCategories, subCategories,
files, files,
...(await decryptCategoryMetadata(metadata!, masterKey)), ...decryptedMetadata!,
}; };
} }
} catch (e) { } catch (e) {

View File

@@ -39,22 +39,52 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
subDirectories: subDirectoriesRaw, subDirectories: subDirectoriesRaw,
files: filesRaw, files: filesRaw,
} = await trpc().directory.get.query({ id }); } = 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( Promise.all(
subDirectoriesRaw.map(async (directory) => ({ subDirectoriesRaw.map(async (directory) => {
id: directory.id, const decrypted = await decryptDirectoryMetadata(directory, masterKey);
...(await decryptDirectoryMetadata(directory, masterKey)), await IndexedDB.storeDirectoryInfo({
})), id: directory.id,
parentId: id,
name: decrypted.name,
});
return {
id: directory.id,
...decrypted,
};
}),
), ),
Promise.all( Promise.all(
filesRaw.map(async (file) => ({ filesRaw.map(async (file, index) => {
id: file.id, const decrypted = await decryptFileMetadata(file, masterKey);
contentType: file.contentType, await IndexedDB.storeFileInfo({
...(await decryptFileMetadata(file, masterKey)), 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") { if (id === "root") {
return { return {
id, id,
@@ -69,7 +99,7 @@ const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
parentId: metadata!.parent, parentId: metadata!.parent,
subDirectories, subDirectories,
files, files,
...(await decryptDirectoryMetadata(metadata!, masterKey)), ...decryptedMetadata!,
}; };
} }
} catch (e) { } catch (e) {

View File

@@ -66,15 +66,26 @@ const bulkFetchFromIndexedDB = async (ids: number[]) => {
const fetchFromServer = async (id: number, masterKey: CryptoKey) => { const fetchFromServer = async (id: number, masterKey: CryptoKey) => {
try { try {
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id }); const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
const [categories] = await Promise.all([ const [categories, decryptedMetadata] = await Promise.all([
Promise.all( Promise.all(
categoriesRaw.map(async (category) => ({ categoriesRaw.map(async (category) => ({
id: category.id, id: category.id,
...(await decryptCategoryMetadata(category, masterKey)), ...(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 { return {
id, id,
exists: true as const, exists: true as const,
@@ -82,7 +93,7 @@ const fetchFromServer = async (id: number, masterKey: CryptoKey) => {
contentType: metadata.contentType, contentType: metadata.contentType,
contentIv: metadata.contentIv, contentIv: metadata.contentIv,
categories, categories,
...(await decryptFileMetadata(metadata, masterKey)), ...decryptedMetadata,
}; };
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { 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 filesRaw = await trpc().file.bulkGet.query({ ids });
const files = await Promise.all( const files = await Promise.all(
filesRaw.map(async (file) => { filesRaw.map(async (file) => {
const categories = await Promise.all( const [categories, decryptedMetadata] = await Promise.all([
file.categories.map(async (category) => ({ Promise.all(
id: category.id, file.categories.map(async (category) => ({
...(await decryptCategoryMetadata(category, masterKey)), 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 { return {
id: file.id, id: file.id,
exists: true as const, exists: true as const,
@@ -110,7 +134,7 @@ const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => {
contentType: file.contentType, contentType: file.contentType,
contentIv: file.contentIv, contentIv: file.contentIv,
categories, categories,
...(await decryptFileMetadata(file, masterKey)), ...decryptedMetadata,
}; };
}), }),
); );

View File

@@ -1,18 +1,29 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { getDownloadingFiles, clearDownloadedFiles } from "$lib/modules/file"; import {
import { bulkGetFileInfo } from "$lib/modules/filesystem"; getDownloadingFiles,
clearDownloadedFiles,
type FileDownloadState,
} from "$lib/modules/file";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
const downloadingFiles = getDownloadingFiles(); let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]);
const filesPromise = $derived(
bulkGetFileInfo( onMount(async () => {
downloadingFiles.map(({ id }) => id), const states = getDownloadingFiles();
const infos = await bulkGetFileInfo(
states.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!, $masterKeyStore?.get(1)?.key!,
), );
); downloadingFiles = states.map((state) => ({
info: infos.get(state.id)!,
state,
}));
});
$effect(() => clearDownloadedFiles); $effect(() => clearDownloadedFiles);
</script> </script>
@@ -23,14 +34,11 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
{#await filesPromise then files} <div class="space-y-2 pb-4">
<div class="space-y-2 pb-4"> {#each downloadingFiles as { info, state } (info.id)}
{#each downloadingFiles as state} {#if info.exists}
{@const info = files.get(state.id)!} <File {info} {state} />
{#if info.exists} {/if}
<File {state} {info} /> {/each}
{/if} </div>
{/each}
</div>
{/await}
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FileDownloadState } from "$lib/modules/file"; import type { FileDownloadState } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud"; import IconCloud from "~icons/material-symbols/cloud";
@@ -11,7 +11,7 @@
import IconError from "~icons/material-symbols/error"; import IconError from "~icons/material-symbols/error";
interface Props { interface Props {
info: FileInfo; info: SummarizedFileInfo;
state: FileDownloadState; state: FileDownloadState;
} }

View File

@@ -33,47 +33,38 @@
const toEntry = const toEntry =
<T extends Exclude<Entry["type"], "parent">>(type: T) => <T extends Exclude<Entry["type"], "parent">>(type: T) =>
(details: Extract<Entry, { type: T }>["details"]) => ({ (details: Extract<Entry, { type: T }>["details"]) => ({ type, name: details.name, details });
type,
name: details.name,
details,
});
let entries = $derived([ let entries = $derived([
...(showParentEntry ? ([{ type: "parent" }] as const) : []), ...(showParentEntry ? ([{ type: "parent" }] as const) : []),
...sortEntries(info.subDirectories.map(toEntry("directory"))), ...sortEntries(info.subDirectories.map(toEntry("directory"))),
...sortEntries([ ...sortEntries([
...info.files.map(toEntry("file")), ...info.files.map(toEntry("file")),
...getUploadingFiles(info.id).map(toEntry("uploading-file")), ...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")),
]), ]),
]); ]);
</script> </script>
{#if entries.length > 0} {#if entries.length > 0}
<div class="pb-[4.5rem]"> <div class="pb-[4.5rem]">
<RowVirtualizer <RowVirtualizer count={entries.length} itemHeight={() => 56} itemGap={4}>
count={entries.length}
itemHeight={(index) => 56 + (index + 1 < entries.length ? 4 : 0)}
>
{#snippet item(index)} {#snippet item(index)}
{@const entry = entries[index]!} {@const entry = entries[index]!}
<div class={index + 1 < entries.length ? "pb-1" : ""}> {#if entry.type === "parent"}
{#if entry.type === "parent"} <ActionEntryButton class="h-14" onclick={onParentClick}>
<ActionEntryButton class="h-14" onclick={onParentClick}> <DirectoryEntryLabel type="parent-directory" name=".." />
<DirectoryEntryLabel type="parent-directory" name=".." /> </ActionEntryButton>
</ActionEntryButton> {:else if entry.type === "directory"}
{:else if entry.type === "directory"} <SubDirectory
<SubDirectory info={entry.details}
info={entry.details} onclick={onEntryClick}
onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick}
onOpenMenuClick={onEntryMenuClick} />
/> {:else if entry.type === "file"}
{:else if entry.type === "file"} <File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
<File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> {:else}
{:else} <UploadingFile state={entry.details} />
<UploadingFile state={entry.details} /> {/if}
{/if}
</div>
{/snippet} {/snippet}
</RowVirtualizer> </RowVirtualizer>
</div> </div>