Merge pull request #16 from kmc7468/optimize-networking

네트워크 호출 최적화
This commit is contained in:
static
2026-01-02 15:04:45 +09:00
committed by GitHub
62 changed files with 1846 additions and 1769 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual"; import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack, type Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements"; import type { ClassValue } from "svelte/elements";
interface Props { interface Props {
@@ -8,15 +8,21 @@
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();
const virtualizer = $derived( let element: HTMLElement | undefined = $state();
let scrollMargin = $state(0);
let virtualizer = $derived(
createWindowVirtualizer({ createWindowVirtualizer({
count: untrack(() => count), count,
estimateSize: itemHeight, estimateSize: itemHeight,
gap: itemGap,
scrollMargin,
}), }),
); );
@@ -24,15 +30,23 @@
$effect(() => $virtualizer.measureElement(node)); $effect(() => $virtualizer.measureElement(node));
}; };
$effect(() => $virtualizer.setOptions({ count })); $effect(() => {
if (!element) return;
const observer = new ResizeObserver(() => {
scrollMargin = element!.getBoundingClientRect().top + window.scrollY;
});
observer.observe(element.parentElement!);
return () => observer.disconnect();
});
</script> </script>
<div class={["relative", className]}> <div bind:this={element} class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px"> <div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)} {#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div <div
class="absolute left-0 top-0 w-full" class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start}px)" style:transform="translateY({virtualItem.start - scrollMargin}px)"
data-index={virtualItem.index} data-index={virtualItem.index}
use:measureItem use:measureItem
> >

View File

@@ -1,42 +1,34 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { browser } from "$app/environment";
import type { FileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file"; import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: SummarizedFileInfo;
onclick?: (file: FileInfo) => void; onclick?: (file: SummarizedFileInfo) => void;
} }
let { info, onclick }: Props = $props(); let { info, onclick }: Props = $props();
let thumbnail: string | undefined = $state(); let showThumbnail = $derived(
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
$effect(() => { );
if ($info) { let thumbnailPromise = $derived(
requestFileThumbnailDownload($info.id, $info.dataKey) showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
.then((thumbnailUrl) => { );
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script> </script>
{#if $info} <button
<button onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
onclick={() => onclick?.($info)}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90" class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
> >
{#await thumbnailPromise}
<div class="h-full w-full bg-gray-100"></div>
{:then thumbnail}
{#if thumbnail} {#if thumbnail}
<img src={thumbnail} alt={$info.name} class="h-full w-full object-cover" /> <img src={thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else} {:else}
<div class="h-full w-full bg-gray-100"></div> <div class="h-full w-full bg-gray-100"></div>
{/if} {/if}
</button> {/await}
{/if} </button>

View File

@@ -0,0 +1,44 @@
<script module lang="ts">
import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory {
id: number;
dataKey?: DataKey;
name: string;
}
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
interface Props {
categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
}
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries($state.snapshot(categories)));
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category (category.id)}
<ActionEntryButton
class="h-12"
onclick={() => onCategoryClick(category)}
actionButtonIcon={categoryMenuIcon}
onActionButtonClick={() => onCategoryMenuClick?.(category)}
>
<CategoryLabel name={category.name} />
</ActionEntryButton>
{/each}
</div>
{/if}

View File

@@ -1,63 +0,0 @@
<script lang="ts">
import { untrack, type Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
sortBy?: SortBy;
}
let {
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
$effect(() => {
categoriesWithName = categories.map((category) => ({
name: get(category)?.name,
info: category,
}));
const sort = () => {
sortEntries(categoriesWithName, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = categoriesWithName.map((category) =>
category.info.subscribe((value) => {
if (category.name === value?.name) return;
category.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as { info }}
<Category
{info}
menuIcon={categoryMenuIcon}
onclick={onCategoryClick}
onMenuClick={onCategoryMenuClick}
/>
{/each}
</div>
{/if}

View File

@@ -1,43 +0,0 @@
<script lang="ts">
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 { SelectedCategory } from "./service";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
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 });
};
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,2 +0,0 @@
export { default } from "./Categories.svelte";
export * from "./service";

View File

@@ -1,6 +0,0 @@
export interface SelectedCategory {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules"; import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import type { CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle"; import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -27,14 +25,6 @@
subCategoryCreatePosition = "bottom", subCategoryCreatePosition = "bottom",
subCategoryMenuIcon, subCategoryMenuIcon,
}: Props = $props(); }: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</script> </script>
<div class={["space-y-1", className]}> <div class={["space-y-1", className]}>
@@ -53,14 +43,12 @@
{#if subCategoryCreatePosition === "top"} {#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}
{#key info}
<Categories <Categories
categories={subCategories} categories={info.subCategories}
categoryMenuIcon={subCategoryMenuIcon} categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick} onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick} onCategoryMenuClick={onSubCategoryMenuClick}
/> />
{/key}
{#if subCategoryCreatePosition === "bottom"} {#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}

View File

@@ -1,7 +1,7 @@
export * from "./ActionModal.svelte"; export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte"; export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories"; export * from "./Categories.svelte";
export { default as Categories } from "./Categories"; export { default as Categories } from "./Categories.svelte";
export { default as IconEntryButton } from "./IconEntryButton.svelte"; export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels"; export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte"; export { default as SubCategories } from "./SubCategories.svelte";

View File

@@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
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 { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { updateCategoryInfo } from "$lib/indexedDB";
import { masterKeyStore } from "$lib/stores"; import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils"; import { sortEntries } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
import type { SelectedFile } from "./service"; import type { SelectedFile } from "./service";
@@ -13,13 +11,12 @@
interface Props { interface Props {
info: CategoryInfo; info: CategoryInfo;
isFileRecursive: boolean | undefined;
onFileClick: (file: SelectedFile) => void; onFileClick: (file: SelectedFile) => void;
onFileRemoveClick: (file: SelectedFile) => void; onFileRemoveClick: (file: SelectedFile) => void;
onSubCategoryClick: (subCategory: SelectedCategory) => void; onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void; onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void; onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
} }
let { let {
@@ -29,42 +26,31 @@
onSubCategoryClick, onSubCategoryClick,
onSubCategoryCreateClick, onSubCategoryCreateClick,
onSubCategoryMenuClick, onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(), isFileRecursive = $bindable(),
}: Props = $props(); }: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state( let lastCategoryId = $state<CategoryInfo["id"] | undefined>();
[], let lastIsFileRecursive = $state<boolean | undefined>();
let files = $derived(
sortEntries(
info.files
?.map((file) => ({ name: file.name, details: file }))
.filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [],
),
); );
$effect(() => { $effect(() => {
files = if (info.id === "root" || isFileRecursive === undefined) return;
info.files if (lastCategoryId !== info.id) {
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive) lastCategoryId = info.id;
.map(({ id, isRecursive }) => { lastIsFileRecursive = isFileRecursive;
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!); return;
return { }
name: get(info)?.name, if (lastIsFileRecursive === isFileRecursive) return;
info,
isRecursive,
};
}) ?? [];
const sort = () => { lastIsFileRecursive = isFileRecursive;
sortEntries(files, sortBy); void updateCategoryInfo(info.id, { isFileRecursive });
};
return untrack(() => {
sort();
const unsubscribes = files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
}); });
</script> </script>
@@ -89,26 +75,19 @@
<p class="font-medium">하위 카테고리의 파일</p> <p class="font-medium">하위 카테고리의 파일</p>
</CheckBox> </CheckBox>
</div> </div>
{#key info} <RowVirtualizer count={files.length} itemHeight={() => 48} itemGap={4}>
<RowVirtualizer
count={files.length}
itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(index)} {#snippet item(index)}
{@const { info, isRecursive } = files[index]!} {@const { details } = files[index]!}
<div class={[index + 1 < files.length && "pb-1"]}>
<File <File
{info} info={details}
onclick={onFileClick} onclick={onFileClick}
onRemoveClick={!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>
{/snippet} {/snippet}
</RowVirtualizer> </RowVirtualizer>
{/key}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,59 +1,38 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { browser } from "$app/environment";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem"; import type { CategoryFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service"; import { requestFileThumbnailDownload } from "$lib/services/file";
import type { SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close"; import IconClose from "~icons/material-symbols/close";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: CategoryFileInfo;
onclick: (selectedFile: SelectedFile) => void; onclick: (file: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void; onRemoveClick?: (file: SelectedFile) => void;
} }
let { info, onclick, onRemoveClick }: Props = $props(); let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state(); let showThumbnail = $derived(
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
const openFile = () => { );
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo; let thumbnailPromise = $derived(
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
);
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton
class="h-12" class="h-12"
onclick={openFile} onclick={() => onclick(info)}
actionButtonIcon={onRemoveClick && IconClose} actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile} onActionButtonClick={() => onRemoveClick?.(info)}
> >
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} /> {#await thumbnailPromise}
</ActionEntryButton> <DirectoryEntryLabel type="file" name={info.name} />
{/if} {:then thumbnail}
<DirectoryEntryLabel type="file" thumbnail={thumbnail ?? undefined} name={info.name} />
{/await}
</ActionEntryButton>

View File

@@ -1,8 +1,4 @@
export { requestFileThumbnailDownload } from "$lib/services/file";
export interface SelectedFile { export interface SelectedFile {
id: number; id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string; name: string;
} }

View File

@@ -1,97 +1,47 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms"; import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils"; import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props { interface Props {
files: Writable<FileInfo | null>[]; files: SummarizedFileInfo[];
onFileClick?: (file: FileInfo) => void; onFileClick?: (file: SummarizedFileInfo) => void;
} }
let { files, onFileClick }: Props = $props(); let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row = type Row =
| { type: "header"; label: string } | { type: "header"; label: string }
| { type: "items"; items: FileEntry[]; isLast: boolean }; | { type: "items"; files: SummarizedFileInfo[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]); let rows = $derived.by(() => {
let rows: Row[] = $state([]); const groups = Map.groupBy(
files.filter(
$effect(() => { (file) => file.contentType.startsWith("image/") || file.contentType.startsWith("video/"),
filesWithDate = files.map((file) => { ),
const info = get(file); (file) => formatDateSortable(file.createdAt ?? file.lastModifiedAt),
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
label: formatDate(entries[0]!.date!),
});
for (let i = 0; i < entries.length; i += 4) {
newRows.push({
type: "items",
items: entries.slice(i, i + 4),
isLast: i + 4 >= entries.length,
});
}
}
rows = newRows;
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
); );
return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); return Array.from(groups.entries())
.sort(([dateA], [dateB]) => dateB.localeCompare(dateA))
.flatMap(([_, entries]) => {
const sortedEntries = [...entries];
sortEntries(sortedEntries, SortBy.DATE_DESC);
return [
{
type: "header",
label: formatDate(sortedEntries[0]!.createdAt ?? sortedEntries[0]!.lastModifiedAt),
},
...Array.from({ length: Math.ceil(sortedEntries.length / 4) }, (_, i) => {
const start = i * 4;
const end = start + 4;
return {
type: "items" as const,
files: sortedEntries.slice(start, end),
isLast: end >= sortedEntries.length,
};
}),
] satisfies Row[];
}); });
}); });
</script> </script>
@@ -101,8 +51,8 @@
itemHeight={(index) => itemHeight={(index) =>
rows[index]!.type === "header" rows[index]!.type === "header"
? 28 ? 28
: Math.ceil(rows[index]!.items.length / 4) * 181 + : Math.ceil(rows[index]!.files.length / 4) * 181 +
(Math.ceil(rows[index]!.items.length / 4) - 1) * 4 + (Math.ceil(rows[index]!.files.length / 4) - 1) * 4 +
16} 16}
class="flex flex-grow flex-col" class="flex flex-grow flex-col"
> >
@@ -112,8 +62,8 @@
<p class="pb-2 text-sm font-medium">{row.label}</p> <p class="pb-2 text-sm font-medium">{row.label}</p>
{:else} {:else}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}> <div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.items as { info }} {#each row.files as file (file.id)}
<FileThumbnailButton {info} onclick={onFileClick} /> <FileThumbnailButton info={file} onclick={onFileClick} />
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -123,8 +73,6 @@
<p class="text-gray-500"> <p class="text-gray-500">
{#if files.length === 0} {#if files.length === 0}
업로드된 파일이 없어요. 업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else} {:else}
사진 또는 동영상이 없어요. 사진 또는 동영상이 없어요.
{/if} {/if}

View File

@@ -1,7 +1,5 @@
import { Dexie, type EntityTable } from "dexie"; import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo { interface DirectoryInfo {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
@@ -18,8 +16,6 @@ interface FileInfo {
categoryIds: number[]; categoryIds: number[];
} }
export type CategoryId = "root" | number;
interface CategoryInfo { interface CategoryInfo {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;
@@ -78,6 +74,10 @@ export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id); return await filesystem.file.get(id);
}; };
export const bulkGetFileInfos = async (ids: number[]) => {
return await filesystem.file.bulkGet(ids);
};
export const storeFileInfo = async (fileInfo: FileInfo) => { export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo); await filesystem.file.put(fileInfo);
}; };

View File

@@ -0,0 +1,95 @@
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;
}
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) => isFileDownloading(file.status));
};
export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
const requestFileDownload = limitFunction(
async (state: FileDownloadState, id: number) => {
state.status = "downloading";
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;
}
};

View File

@@ -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<FileDownloadStatus>, 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<FileDownloadStatus>,
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<FileDownloadStatus>({
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;
}
};

View File

@@ -1,3 +1,3 @@
export * from "./cache"; export * from "./cache";
export * from "./download"; export * from "./download.svelte";
export * from "./upload"; export * from "./upload.svelte";

View File

@@ -1,7 +1,6 @@
import axios from "axios"; import axios from "axios";
import ExifReader from "exifreader"; import ExifReader from "exifreader";
import { limitFunction } from "p-limit"; import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { import {
encodeToBase64, encodeToBase64,
generateDataKey, generateDataKey,
@@ -17,14 +16,45 @@ import type {
FileUploadRequest, FileUploadRequest,
FileUploadResponse, FileUploadResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import { import type { MasterKey, HmacSecret } from "$lib/stores";
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { trpc } from "$trpc/client"; 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) =>
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
);
};
export const clearUploadedFiles = () => {
uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
};
const requestDuplicateFileScan = limitFunction( const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => { async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
@@ -76,16 +106,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
}; };
const encryptFile = limitFunction( const encryptFile = limitFunction(
async ( async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => {
status: Writable<FileUploadStatus>, state.status = "encrypting";
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file); const fileType = getFileType(file);
@@ -109,10 +131,7 @@ const encryptFile = limitFunction(
const thumbnailBuffer = await thumbnail?.arrayBuffer(); const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
status.update((value) => { state.status = "upload-pending";
value.status = "upload-pending";
return value;
});
return { return {
dataKeyWrapped, dataKeyWrapped,
@@ -130,20 +149,14 @@ const encryptFile = limitFunction(
); );
const requestFileUpload = limitFunction( const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => { async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => { state.status = "uploading";
value.status = "uploading";
return value;
});
const res = await axios.post("/api/file/upload", form, { const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => { onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => { state.progress = progress;
value.progress = progress; state.rate = rate;
value.rate = rate; state.estimated = estimated;
value.estimated = estimated;
return value;
});
}, },
}); });
const { file }: FileUploadResponse = res.data; const { file }: FileUploadResponse = res.data;
@@ -157,10 +170,7 @@ const requestFileUpload = limitFunction(
} }
} }
status.update((value) => { state.status = "uploaded";
value.status = "uploaded";
return value;
});
return { fileId: file }; return { fileId: file };
}, },
@@ -176,15 +186,12 @@ export const uploadFile = async (
): Promise< ): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined { fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => { > => {
const status = writable<FileUploadStatus>({ uploadingFiles.push({
name: file.name, name: file.name,
parentId, parentId,
status: "encryption-pending", status: "encryption-pending",
}); });
fileUploadStatusStore.update((value) => { const state = uploadingFiles.at(-1)!;
value.push(status);
return value;
});
try { try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan( const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
@@ -193,14 +200,8 @@ export const uploadFile = async (
onDuplicate, onDuplicate,
); );
if (!fileBuffer || !fileSigned) { if (!fileBuffer || !fileSigned) {
status.update((value) => { state.status = "canceled";
value.status = "canceled"; uploadingFiles = uploadingFiles.filter((file) => file !== state);
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined; return undefined;
} }
@@ -214,7 +215,7 @@ export const uploadFile = async (
createdAtEncrypted, createdAtEncrypted,
lastModifiedAtEncrypted, lastModifiedAtEncrypted,
thumbnail, thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey); } = await encryptFile(state, file, fileBuffer, masterKey);
const form = new FormData(); const form = new FormData();
form.set( form.set(
@@ -252,13 +253,10 @@ export const uploadFile = async (
thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); 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 }; return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) { } catch (e) {
status.update((value) => { state.status = "error";
value.status = "error";
return value;
});
throw e; throw e;
} }
}; };

View File

@@ -1,369 +0,0 @@
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,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
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;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
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 directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
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<DirectoryInfo | null>,
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<DirectoryInfo | null>,
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<FileInfo | null>) => {
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<FileInfo | null>,
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<FileInfo | null>, 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;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
) => {
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<CategoryInfo | null>,
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<CategoryInfo | null>,
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;
});
};

View File

@@ -0,0 +1,164 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo, Partial<MaybeCategoryInfo>>();
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 });
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
? 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,
};
}),
)
: 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,
subCategories,
};
} else {
return {
id,
exists: true as const,
subCategories,
files,
...decryptedMetadata!,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
};
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);
}
});
};

View File

@@ -0,0 +1,121 @@
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";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>();
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 });
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,
]);
if (id !== "root" && metadata && decryptedMetadata) {
await IndexedDB.storeDirectoryInfo({
id,
parentId: metadata.parent,
name: decryptedMetadata.name,
});
}
if (id === "root") {
return {
id,
exists: true as const,
subDirectories,
files,
};
} else {
return {
id,
exists: true as const,
parentId: metadata!.parent,
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 };
}
throw e;
}
};
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};

View File

@@ -0,0 +1,175 @@
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";
const cache = new FilesystemCache<number, MaybeFileInfo>();
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 };
}
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([
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,
parentId: file.parent,
contentType: file.contentType,
contentIv: file.contentIv,
categories,
...decryptedMetadata,
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
return new Map<number, MaybeFileInfo>([
...files.map((file) => [file.id, file] as const),
...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] 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,
),
);
};
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,
),
);
};

View File

@@ -0,0 +1,4 @@
export * from "./category";
export * from "./directory";
export * from "./file";
export * from "./types";

View File

@@ -0,0 +1,130 @@
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
export class FilesystemCache<K, V extends RV, RV = V> {
private map = new Map<K, V | Promise<V>>();
get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) {
const info = this.map.get(key);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<V>();
if (!info) {
this.map.set(key, promise);
}
loader(!info, (loadedInfo) => {
if (!loadedInfo) return;
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);
}
});
return info ?? promise;
}
async bulkGet(
keys: Set<K>,
loader: (keys: Map<K, boolean>, resolve: (values: Map<K, RV>) => void) => void,
) {
const states = new Map<K, V>();
const promises = new Map<K, Promise<V>>();
const resolvers = new Map<K, (value: V) => void>();
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<V>();
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) =>
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);
} else {
Object.assign(info, loadedInfo);
resolve?.(info);
}
}),
);
const newStates = await Promise.all(
promises.entries().map(async ([key, promise]) => [key, await promise] as const),
);
return new Map([...states, ...newStates]);
}
}
export const decryptDirectoryMetadata = async (
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
};
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const decryptFileMetadata = async (
metadata: {
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
},
masterKey: CryptoKey,
) => {
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 {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
createdAt,
lastModifiedAt,
};
};
export const decryptCategoryMetadata = decryptDirectoryMetadata;

View File

@@ -0,0 +1,71 @@
export type DataKey = { key: CryptoKey; version: Date };
type AllUndefined<T> = { [K in keyof T]?: undefined };
interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: { id: number; name: string }[];
}
export type SummarizedFileInfo = Omit<FileInfo, "parentId" | "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
interface LocalCategoryInfo {
id: number;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
interface RootCategoryInfo {
id: "root";
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);

View File

@@ -2,8 +2,6 @@ import { IntegrityError } from "./error";
import db from "./kysely"; import db from "./kysely";
import type { Ciphertext } from "./schema"; import type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category { interface Category {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;

View File

@@ -1,11 +1,10 @@
import { sql, type NotNull } from "kysely"; import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "./error";
import db from "./kysely"; import db from "./kysely";
import type { Ciphertext } from "./schema"; import type { Ciphertext } from "./schema";
export type DirectoryId = "root" | number;
interface Directory { interface Directory {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
@@ -38,6 +37,14 @@ interface File {
export type NewFile = Omit<File, "id">; export type NewFile = Omit<File, "id">;
interface FileCategory {
id: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export const registerDirectory = async (params: NewDirectory) => { export const registerDirectory = async (params: NewDirectory) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx const mek = await trx
@@ -306,39 +313,51 @@ export const getAllFilesByCategory = async (
recurse: boolean, recurse: boolean,
) => { ) => {
const files = await db const files = await db
.withRecursive("cte", (db) => .withRecursive("category_tree", (db) =>
db db
.selectFrom("category") .selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id") .select(["id", sql<number>`0`.as("depth")])
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.where("id", "=", categoryId) .where("id", "=", categoryId)
.where("user_id", "=", userId)
.$if(recurse, (qb) => .$if(recurse, (qb) =>
qb.unionAll((db) => qb.unionAll((db) =>
db db
.selectFrom("category") .selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id") .innerJoin("category_tree", "category.parent_id", "category_tree.id")
.innerJoin("cte", "category.parent_id", "cte.id") .select(["category.id", sql<number>`depth + 1`.as("depth")]),
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.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"]) .select(["file_id", "depth"])
.selectAll("file")
.distinctOn("file_id") .distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id") .orderBy("file_id")
.orderBy("depth") .orderBy("depth")
.execute(); .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) => { export const getAllFileIds = async (userId: number) => {
@@ -390,6 +409,51 @@ export const getFile = async (userId: number, fileId: number) => {
: null; : null;
}; };
export const getFilesWithCategories = async (userId: number, fileIds: number[]) => {
const files = await db
.selectFrom("file")
.selectAll()
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("file_category")
.innerJoin("category", "file_category.category_id", "category.id")
.where("file_category.file_id", "=", eb.ref("file.id"))
.selectAll("category"),
).as("categories"),
)
.where("id", "=", (eb) => eb.fn.any(eb.val(fileIds)))
.where("user_id", "=", userId)
.execute();
return files.map(
(file) =>
({
id: 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,
categories: file.categories.map((category) => ({
id: category.id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: new Date(category.data_encryption_key_version),
encName: category.encrypted_name,
})),
}) satisfies File & { categories: FileCategory[] },
);
};
export const setFileEncName = async ( export const setFileEncName = async (
userId: number, userId: number,
fileId: number, fileId: number,
@@ -476,10 +540,20 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => { export const getAllFileCategories = async (fileId: number) => {
const categories = await db const categories = await db
.selectFrom("file_category") .selectFrom("file_category")
.select("category_id") .innerJoin("category", "file_category.category_id", "category.id")
.selectAll("category")
.where("file_id", "=", fileId) .where("file_id", "=", fileId)
.execute(); .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,
}) satisfies FileCategory,
);
}; };
export const removeFileFromCategory = async (fileId: number, categoryId: number) => { export const removeFileFromCategory = async (fileId: number, categoryId: number) => {

View File

@@ -1,49 +0,0 @@
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:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
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" => {
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
};

View File

@@ -1,2 +1 @@
export * from "./file";
export * from "./key"; export * from "./key";

2
src/lib/types/filesystem.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
type DirectoryId = "root" | number;
type CategoryId = "root" | number;

View File

@@ -1,3 +1,4 @@
export * from "./format"; export * from "./format";
export * from "./gotoStateful"; export * from "./gotoStateful";
export * from "./promise";
export * from "./sort"; export * from "./sort";

16
src/lib/utils/promise.ts Normal file
View File

@@ -0,0 +1,16 @@
export const monotonicResolve = <T>(
promises: (Promise<T> | 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);
}
});
});
};

View File

@@ -32,7 +32,7 @@ const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => {
const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b); const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b);
export const sortEntries = <T extends SortEntry>(entries: T[], sortBy: SortBy) => { export const sortEntries = <T extends SortEntry>(entries: T[], sortBy = SortBy.NAME_ASC) => {
let sortFunc: SortFunc; let sortFunc: SortFunc;
switch (sortBy) { switch (sortBy) {
@@ -54,4 +54,5 @@ export const sortEntries = <T extends SortEntry>(entries: T[], sortBy: SortBy) =
} }
entries.sort(sortFunc); entries.sort(sortFunc);
return entries;
}; };

View File

@@ -1,19 +1,14 @@
<script lang="ts"> <script lang="ts">
import FileSaver from "file-saver"; import FileSaver from "file-saver";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores"; import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte"; import DownloadStatus from "./DownloadStatus.svelte";
import { import {
@@ -31,19 +26,13 @@
let { data } = $props(); let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state(); let infoPromise: Promise<MaybeFileInfo> | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]); let info: FileInfo | null = $state(null);
let downloadState = $derived(getFileDownloadState(data.id));
let isMenuOpen = $state(false); let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false); let isAddToCategoryBottomSheetOpen = $state(false);
let downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
const { id, status } = get(statusStore);
return id === data.id && isFileDownloading(status);
}),
);
let isDownloadRequested = $state(false); let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state(); let viewerType: "image" | "video" | undefined = $state();
let fileBlob: Blob | undefined = $state(); let fileBlob: Blob | undefined = $state();
@@ -76,28 +65,29 @@
const addToCategory = async (categoryId: number) => { const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId); await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false; isAddToCategoryBottomSheetOpen = false;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
const removeFromCategory = async (categoryId: number) => { const removeFromCategory = async (categoryId: number) => {
await requestFileRemovalFromCategory(data.id, categoryId); await requestFileRemovalFromCategory(data.id, categoryId);
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
$effect(() => { $effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => {
if (fileInfo.exists) {
info = fileInfo;
}
return fileInfo;
});
info = null;
isDownloadRequested = false; isDownloadRequested = false;
viewerType = undefined; viewerType = undefined;
}); });
$effect(() => { $effect(() => {
categories = if (info?.dataKey) {
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? []; const contentType = info.contentType;
});
$effect(() => {
if ($info && $info.dataKey && $info.contentIv) {
const contentType = $info.contentType;
if (contentType.startsWith("image")) { if (contentType.startsWith("image")) {
viewerType = "image"; viewerType = "image";
} else if (contentType.startsWith("video")) { } else if (contentType.startsWith("video")) {
@@ -105,24 +95,24 @@
} }
untrack(() => { untrack(() => {
if (!downloadStatus && !isDownloadRequested) { if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true; isDownloadRequested = true;
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => { requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType); const blob = await updateViewer(buffer, contentType);
if (!viewerType) { if (!viewerType) {
FileSaver.saveAs(blob, $info.name); FileSaver.saveAs(blob, info!.name);
} }
}); },
);
} }
}); });
} }
}); });
$effect(() => { $effect(() => {
if ($info && $downloadStatus?.status === "decrypted") { if (info && downloadState?.status === "decrypted") {
untrack( untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType));
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
} }
}); });
@@ -133,7 +123,8 @@
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<TopBar title={$info?.name}> {#if info}
<TopBar title={info.name}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={(e) => e.stopPropagation()}> <div onclick={(e) => e.stopPropagation()}>
@@ -145,16 +136,20 @@
</button> </button>
<TopBarMenu <TopBarMenu
bind:isOpen={isMenuOpen} bind:isOpen={isMenuOpen}
directoryId={page.url.searchParams.get("from") === "category" ? $info?.parentId : undefined} directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? info.parentId
: undefined}
{fileBlob} {fileBlob}
filename={$info?.name} filename={info.name}
/> />
</div> </div>
</TopBar> </TopBar>
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-4 pb-4"> <div class="space-y-4 pb-4">
<DownloadStatus status={downloadStatus} /> {#if downloadState}
{#if $info && viewerType} <DownloadStatus state={downloadState} />
{/if}
{#if viewerType}
<div class="flex w-full justify-center"> <div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)} {#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p> <p class="text-gray-500">{message}</p>
@@ -162,7 +157,7 @@
{#if viewerType === "image"} {#if viewerType === "image"}
{#if fileBlobUrl} {#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} /> <img src={fileBlobUrl} alt={info.name} onerror={convertHeicToJpeg} />
{:else} {:else}
{@render viewerLoading("이미지를 불러오고 있어요.")} {@render viewerLoading("이미지를 불러오고 있어요.")}
{/if} {/if}
@@ -173,7 +168,7 @@
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video> <video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton <IconEntryButton
icon={IconCamera} icon={IconCamera}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)} onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
class="w-full" class="w-full"
> >
이 장면을 썸네일로 설정하기 이 장면을 썸네일로 설정하기
@@ -189,7 +184,7 @@
<p class="text-lg font-bold">카테고리</p> <p class="text-lg font-bold">카테고리</p>
<div class="space-y-1"> <div class="space-y-1">
<Categories <Categories
{categories} categories={info.categories}
categoryMenuIcon={IconClose} categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)} onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)} onCategoryMenuClick={({ id }) => removeFromCategory(id)}
@@ -206,9 +201,10 @@
</div> </div>
</div> </div>
</div> </div>
</FullscreenDiv> </FullscreenDiv>
<AddToCategoryBottomSheet <AddToCategoryBottomSheet
bind:isOpen={isAddToCategoryBottomSheetOpen} bind:isOpen={isAddToCategoryBottomSheetOpen}
onAddToCategoryClick={addToCategory} onAddToCategoryClick={addToCategory}
/> />
{/if}

View File

@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms"; import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
import { SubCategories } from "$lib/components/molecules"; import { SubCategories } from "$lib/components/molecules";
import { CategoryCreateModal } from "$lib/components/organisms"; 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 { masterKeyStore } from "$lib/stores";
import { requestCategoryCreation } from "./service"; import { requestCategoryCreation } from "./service";
@@ -14,46 +13,48 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let category: Writable<CategoryInfo | null> | undefined = $state(); let categoryInfoPromise: Promise<MaybeCategoryInfo> | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!); categoryInfoPromise = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!);
} }
}); });
</script> </script>
{#if $category} {#await categoryInfoPromise then categoryInfo}
{#if categoryInfo?.exists}
<BottomSheet bind:isOpen class="flex flex-col"> <BottomSheet bind:isOpen class="flex flex-col">
<FullscreenDiv> <FullscreenDiv>
<SubCategories <SubCategories
class="py-4" class="py-4"
info={$category} info={categoryInfo}
onSubCategoryClick={({ id }) => onSubCategoryClick={({ id }) =>
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
subCategoryCreatePosition="top" subCategoryCreatePosition="top"
/> />
{#if $category.id !== "root"} {#if categoryInfo.id !== "root"}
<BottomDiv> <BottomDiv>
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full"> <Button onclick={() => onAddToCategoryClick(categoryInfo.id)} class="w-full">
이 카테고리에 추가하기 이 카테고리에 추가하기
</Button> </Button>
</BottomDiv> </BottomDiv>
{/if} {/if}
</FullscreenDiv> </FullscreenDiv>
</BottomSheet> </BottomSheet>
{/if}
<CategoryCreateModal <CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen} bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => { onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) { if (await requestCategoryCreation(name, categoryInfo.id, $masterKeyStore?.get(1)!)) {
category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME categoryInfoPromise = getCategoryInfo(categoryInfo.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

View File

@@ -1,32 +1,31 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { isFileDownloading, type FileDownloadState } from "$lib/modules/file";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
interface Props { interface Props {
status?: Writable<FileDownloadStatus>; state: FileDownloadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
{#if $status && isFileDownloading($status.status)} {#if isFileDownloading(state.status)}
<div class="w-full rounded-xl bg-gray-100 p-3"> <div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium"> <p class="font-medium">
{#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}
</p> </p>
<p class="text-xs"> <p class="text-xs">
{#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} {/if}
</p> </p>
</div> </div>

View File

@@ -1,19 +1,31 @@
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store"; 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 { fileDownloadStatusStore, isFileDownloading } from "$lib/stores"; import {
getDownloadingFiles,
clearDownloadedFiles,
type FileDownloadState,
} from "$lib/modules/file";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
let downloadingFiles = $derived( let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]);
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
);
$effect(() => () => { onMount(async () => {
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) => const states = getDownloadingFiles();
isFileDownloading(get(status).status), const infos = await bulkGetFileInfo(
states.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!,
); );
downloadingFiles = states.map((state) => ({
info: infos.get(state.id)!,
state,
}));
}); });
$effect(() => clearDownloadedFiles);
</script> </script>
<svelte:head> <svelte:head>
@@ -23,8 +35,10 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-2 pb-4"> <div class="space-y-2 pb-4">
{#each downloadingFiles as status} {#each downloadingFiles as { info, state } (info.id)}
<File {status} /> {#if info.exists}
<File {info} {state} />
{/if}
{/each} {/each}
</div> </div>
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { get, type Writable } from "svelte/store"; import type { FileDownloadState } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud"; import IconCloud from "~icons/material-symbols/cloud";
@@ -12,56 +11,49 @@
import IconError from "~icons/material-symbols/error"; import IconError from "~icons/material-symbols/error";
interface Props { interface Props {
status: Writable<FileDownloadStatus>; info: SummarizedFileInfo;
state: FileDownloadState;
} }
let { status }: Props = $props(); let { info, state }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
</script> </script>
{#if $fileInfo} <div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600"> <div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "download-pending"} {#if state.status === "download-pending"}
<IconCloud /> <IconCloud />
{:else if $status.status === "downloading"} {:else if state.status === "downloading"}
<IconCloudDownload /> <IconCloudDownload />
{:else if $status.status === "decryption-pending"} {:else if state.status === "decryption-pending"}
<IconLock /> <IconLock />
{:else if $status.status === "decrypting"} {:else if state.status === "decrypting"}
<IconLockClock /> <IconLockClock />
{:else if $status.status === "decrypted"} {:else if state.status === "decrypted"}
<IconCheckCircle class="text-green-500" /> <IconCheckCircle class="text-green-500" />
{:else if $status.status === "error"} {:else if state.status === "error"}
<IconError class="text-red-500" /> <IconError class="text-red-500" />
{/if} {/if}
</div> </div>
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium"> <p title={info.name} class="truncate font-medium">
{$fileInfo.name} {info.name}
</p> </p>
<p class="text-xs text-gray-800"> <p class="text-xs text-gray-800">
{#if $status.status === "download-pending"} {#if state.status === "download-pending"}
다운로드를 기다리는 중 다운로드를 기다리는 중
{:else if $status.status === "downloading"} {:else if state.status === "downloading"}
전송됨 전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)} {formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if $status.status === "decryption-pending"} {:else if state.status === "decryption-pending"}
복호화를 기다리는 중 복호화를 기다리는 중
{:else if $status.status === "decrypting"} {:else if state.status === "decrypting"}
복호화하는 중 복호화하는 중
{:else if $status.status === "decrypted"} {:else if state.status === "decrypted"}
다운로드 완료 다운로드 완료
{:else if $status.status === "error"} {:else if state.status === "error"}
다운로드 실패 다운로드 실패
{/if} {/if}
</p> </p>
</div> </div>
</div> </div>
{/if}

View File

@@ -1,19 +1,12 @@
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store";
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 { fileUploadStatusStore, isFileUploading } from "$lib/stores"; import { getUploadingFiles, clearUploadedFiles } from "$lib/modules/file";
import File from "./File.svelte"; import File from "./File.svelte";
let uploadingFiles = $derived( const uploadingFiles = getUploadingFiles();
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
);
$effect(() => () => { $effect(() => clearUploadedFiles);
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
isFileUploading(get(status).status),
);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -23,8 +16,8 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-2 pb-4"> <div class="space-y-2 pb-4">
{#each uploadingFiles as status} {#each uploadingFiles as file}
<File {status} /> <File state={file} />
{/each} {/each}
</div> </div>
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { FileUploadState } from "$lib/modules/file";
import type { FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
import IconPending from "~icons/material-symbols/pending"; import IconPending from "~icons/material-symbols/pending";
@@ -11,45 +10,45 @@
import IconError from "~icons/material-symbols/error"; import IconError from "~icons/material-symbols/error";
interface Props { interface Props {
status: Writable<FileUploadStatus>; state: FileUploadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
<div class="flex h-14 items-center gap-x-4 p-2"> <div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600"> <div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "encryption-pending"} {#if state.status === "encryption-pending"}
<IconPending /> <IconPending />
{:else if $status.status === "encrypting"} {:else if state.status === "encrypting"}
<IconLockClock /> <IconLockClock />
{:else if $status.status === "upload-pending"} {:else if state.status === "upload-pending"}
<IconCloud /> <IconCloud />
{:else if $status.status === "uploading"} {:else if state.status === "uploading"}
<IconCloudUpload /> <IconCloudUpload />
{:else if $status.status === "uploaded"} {:else if state.status === "uploaded"}
<IconCloudDone class="text-blue-500" /> <IconCloudDone class="text-blue-500" />
{:else if $status.status === "error"} {:else if state.status === "error"}
<IconError class="text-red-500" /> <IconError class="text-red-500" />
{/if} {/if}
</div> </div>
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<p title={$status.name} class="truncate font-medium"> <p title={state.name} class="truncate font-medium">
{$status.name} {state.name}
</p> </p>
<p class="text-xs text-gray-800"> <p class="text-xs text-gray-800">
{#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)} {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if $status.status === "uploaded"} {:else if state.status === "uploaded"}
업로드 완료 업로드 완료
{:else if $status.status === "error"} {:else if state.status === "error"}
업로드 실패 업로드 실패
{/if} {/if}
</p> </p>

View File

@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
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 { Gallery } from "$lib/components/organisms"; import { Gallery } from "$lib/components/organisms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
let { data } = $props(); let { data } = $props();
let files: Writable<FileInfo | null>[] = $state([]); let files: MaybeFileInfo[] = $state([]);
$effect(() => { onMount(async () => {
files = data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!)); files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values());
}); });
</script> </script>
@@ -22,5 +22,8 @@
<TopBar title="사진 및 동영상" /> <TopBar title="사진 및 동영상" />
<FullscreenDiv> <FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} /> <Gallery
files={files.filter((file) => file?.exists)}
onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)}
/>
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,42 +1,39 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Writable } from "svelte/store";
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 type { FileCacheIndex } from "$lib/indexedDB"; import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils"; import { formatFileSize } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
interface FileCache { interface FileCache {
index: FileCacheIndex; index: FileCacheIndex;
fileInfo: Writable<FileInfo | null>; info: MaybeFileInfo;
} }
let fileCache: FileCache[] | undefined = $state(); 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) => { const deleteFileCache = async (fileId: number) => {
await doDeleteFileCache(fileId); await doDeleteFileCache(fileId);
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId); fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
}; };
onMount(() => { onMount(async () => {
fileCache = getFileCacheIndex() const indexes = getFileCacheIndex();
.map((index) => ({ const infos = await bulkGetFileInfo(
index, indexes.map(({ fileId }) => fileId),
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), $masterKeyStore?.get(1)?.key!,
})) );
fileCache = indexes
.map((index) => ({ index, info: infos.get(index.fileId)! }))
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); .sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
}); });
$effect(() => {
if (fileCache) {
fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0);
}
});
</script> </script>
<svelte:head> <svelte:head>
@@ -55,8 +52,8 @@
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p> <p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
{#each fileCache as { index, fileInfo }} {#each fileCache as { index, info } (info.id)}
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} /> <File {index} {info} onDeleteClick={deleteFileCache} />
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB"; import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfo } from "$lib/modules/filesystem"; import type { MaybeFileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/utils"; import { formatDate, formatFileSize } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
@@ -10,7 +9,7 @@
interface Props { interface Props {
index: FileCacheIndex; index: FileCacheIndex;
info: Writable<FileInfo | null>; info: MaybeFileInfo;
onDeleteClick: (fileId: number) => void; onDeleteClick: (fileId: number) => void;
} }
@@ -18,7 +17,7 @@
</script> </script>
<div class="flex h-14 items-center gap-x-4 p-2"> <div class="flex h-14 items-center gap-x-4 p-2">
{#if $info} {#if info.exists}
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl"> <div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
<IconDraft class="text-blue-400" /> <IconDraft class="text-blue-400" />
</div> </div>
@@ -28,8 +27,8 @@
</div> </div>
{/if} {/if}
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
{#if $info} {#if info.exists}
<p title={$info.name} class="truncate font-medium">{$info.name}</p> <p title={info.name} class="truncate font-medium">{info.name}</p>
{:else} {:else}
<p class="font-medium">삭제된 파일</p> <p class="font-medium">삭제된 파일</p>
{/if} {/if}

View File

@@ -1,18 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
import { import { persistentStates, requestThumbnailGeneration } from "./service.svelte";
persistentStates,
getGenerationStatus,
requestThumbnailGeneration,
} from "./service.svelte";
import IconDelete from "~icons/material-symbols/delete"; import IconDelete from "~icons/material-symbols/delete";
@@ -20,18 +15,18 @@
const generateAllThumbnails = () => { const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => { persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info); if (info.exists) {
if (fileInfo) { requestThumbnailGeneration(info);
requestThumbnailGeneration(fileInfo);
} }
}); });
}; };
onMount(() => { onMount(async () => {
persistentStates.files = data.files.map((fileId) => ({ const fileInfos = await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!);
id: fileId, persistentStates.files = persistentStates.files.map(({ id, status }) => ({
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), id,
status: getGenerationStatus(fileId), info: fileInfos.get(id)!,
status,
})); }));
}); });
</script> </script>
@@ -56,13 +51,15 @@
{persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요. {persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
{#each persistentStates.files as { info, status }} {#each persistentStates.files as { info, status } (info.id)}
{#if info.exists}
<File <File
{info} {info}
generationStatus={status} generationStatus={status}
onclick={({ id }) => goto(`/file/${id}`)} onclick={({ id }) => goto(`/file/${id}`)}
onGenerateThumbnailClick={requestThumbnailGeneration} onGenerateThumbnailClick={requestThumbnailGeneration}
/> />
{/if}
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -20,27 +20,25 @@
import IconCamera from "~icons/material-symbols/camera"; import IconCamera from "~icons/material-symbols/camera";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: FileInfo;
onclick: (selectedFile: FileInfo) => void; onclick: (file: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void; onGenerateThumbnailClick: (file: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>; generationStatus?: Writable<GenerationStatus>;
} }
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props(); let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton
class="h-14" class="h-14"
onclick={() => onclick($info)} onclick={() => onclick(info)}
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick($info)} onActionButtonClick={() => onGenerateThumbnailClick(info)}
actionButtonClass="text-gray-800" actionButtonClass="text-gray-800"
> >
{@const subtext = {@const subtext =
$generationStatus && $generationStatus !== "uploaded" $generationStatus && $generationStatus !== "uploaded"
? subtexts[$generationStatus] ? subtexts[$generationStatus]
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)} : formatDateTime(info.createdAt ?? info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.name} {subtext} /> <DirectoryEntryLabel type="file" name={info.name} {subtext} />
</ActionEntryButton> </ActionEntryButton>
{/if}

View File

@@ -2,7 +2,7 @@ import { limitFunction } from "p-limit";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { encryptData } from "$lib/modules/crypto"; import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file"; import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem"; import type { FileInfo, MaybeFileInfo } from "$lib/modules/filesystem";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail"; import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file"; import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
@@ -17,7 +17,7 @@ export type GenerationStatus =
interface File { interface File {
id: number; id: number;
info: Writable<FileInfo | null>; info: MaybeFileInfo;
status?: Writable<GenerationStatus>; status?: Writable<GenerationStatus>;
} }
@@ -129,7 +129,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
let fileSize = 0; let fileSize = 0;
try { 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; fileSize = file.byteLength;
memoryUsage += fileSize; memoryUsage += fileSize;
@@ -141,11 +145,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
status, status,
file, file,
fileInfo.contentType, fileInfo.contentType,
fileInfo.dataKey!, fileInfo.dataKey?.key!,
); );
if ( if (
!thumbnail || !thumbnail ||
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail)) !(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
) { ) {
status.set("error"); status.set("error");
} }

View File

@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms"; import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -19,9 +18,7 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<CategoryInfo | null> | undefined = $state(); let infoPromise: Promise<MaybeCategoryInfo> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
@@ -29,20 +26,7 @@
let isCategoryDeleteModalOpen = $state(false); let isCategoryDeleteModalOpen = $state(false);
$effect(() => { $effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
}); });
</script> </script>
@@ -50,18 +34,19 @@
<title>카테고리</title> <title>카테고리</title>
</svelte:head> </svelte:head>
{#if data.id !== "root"} {#await infoPromise then info}
<TopBar title={$info?.name} /> {#if info?.exists}
{/if} {#if info.id !== "root"}
<div class="min-h-full bg-gray-100 pb-[5.5em]"> <TopBar title={info.name} />
{#if $info && isFileRecursive !== undefined} {/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
<Category <Category
bind:isFileRecursive bind:isFileRecursive={info.isFileRecursive}
info={$info} {info}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)} onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
onFileRemoveClick={async ({ id }) => { onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number); await requestFileRemovalFromCategory(id, data.id as number);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}} }}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)} onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
@@ -70,21 +55,20 @@
isCategoryMenuBottomSheetOpen = true; isCategoryMenuBottomSheetOpen = true;
}} }}
/> />
{/if} </div>
</div>
<CategoryCreateModal <CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen} bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => { onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
<CategoryMenuBottomSheet <CategoryMenuBottomSheet
bind:isOpen={isCategoryMenuBottomSheetOpen} bind:isOpen={isCategoryMenuBottomSheetOpen}
onRenameClick={() => { onRenameClick={() => {
isCategoryMenuBottomSheetOpen = false; isCategoryMenuBottomSheetOpen = false;
@@ -94,24 +78,26 @@
isCategoryMenuBottomSheetOpen = false; isCategoryMenuBottomSheetOpen = false;
isCategoryDeleteModalOpen = true; isCategoryDeleteModalOpen = true;
}} }}
/> />
<CategoryRenameModal <CategoryRenameModal
bind:isOpen={isCategoryRenameModalOpen} bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => { onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) { if (await requestCategoryRename(context.selectedCategory!, newName)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
<CategoryDeleteModal <CategoryDeleteModal
bind:isOpen={isCategoryDeleteModalOpen} bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => { onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) { if (await requestCategoryDeletion(context.selectedCategory!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

View File

@@ -17,12 +17,17 @@ export const useContext = () => {
}; };
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { 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 { try {
await trpc().category.rename.mutate({ await trpc().category.rename.mutate({
id: category.id, id: category.id,
dekVersion: category.dataKeyVersion, dekVersion: category.dataKey.version,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });

View File

@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { FloatingButton } from "$lib/components/atoms"; import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem"; import { getDirectoryInfo, type MaybeDirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores"; import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
@@ -30,7 +29,7 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<DirectoryInfo | null> | undefined = $state(); let infoPromise: Promise<MaybeDirectoryInfo> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state(); let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -61,7 +60,7 @@
.then((res) => { .then((res) => {
if (!res) return; if (!res) return;
// TODO: FIXME // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}) })
.catch((e: Error) => { .catch((e: Error) => {
// TODO: FIXME // TODO: FIXME
@@ -79,7 +78,7 @@
}); });
$effect(() => { $effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}); });
</script> </script>
@@ -89,45 +88,43 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" /> <input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
<div class="flex h-full flex-col"> {#await infoPromise then info}
{#if info?.exists}
<div class="flex h-full flex-col">
{#if showTopBar} {#if showTopBar}
<TopBar title={$info?.name} class="flex-shrink-0" /> <TopBar title={info.name} class="flex-shrink-0" />
{/if} {/if}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}> <div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} /> <UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} /> <DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div> </div>
{#key $info}
<DirectoryEntries <DirectoryEntries
info={$info} {info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)} onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => { onEntryMenuClick={(entry) => {
context.selectedEntry = entry; context.selectedEntry = entry;
isEntryMenuBottomSheetOpen = true; isEntryMenuBottomSheetOpen = true;
}} }}
showParentEntry={isFromFilePage && $info.parentId !== undefined} showParentEntry={isFromFilePage && info.parentId !== undefined}
onParentClick={() => onParentClick={() =>
goto( goto(
$info.parentId === "root" info.parentId === "root"
? "/directory?from=file" ? "/directory?from=file"
: `/directory/${$info.parentId}?from=file`, : `/directory/${info.parentId}?from=file`,
)} )}
/> />
{/key}
</div> </div>
{/if} </div>
</div>
<FloatingButton <FloatingButton
icon={IconAdd} icon={IconAdd}
onclick={() => { onclick={() => {
isEntryCreateBottomSheetOpen = true; isEntryCreateBottomSheetOpen = true;
}} }}
class="bottom-24 right-4" class="bottom-24 right-4"
/> />
<EntryCreateBottomSheet <EntryCreateBottomSheet
bind:isOpen={isEntryCreateBottomSheetOpen} bind:isOpen={isEntryCreateBottomSheetOpen}
onDirectoryCreateClick={() => { onDirectoryCreateClick={() => {
isEntryCreateBottomSheetOpen = false; isEntryCreateBottomSheetOpen = false;
@@ -137,18 +134,18 @@
isEntryCreateBottomSheetOpen = false; isEntryCreateBottomSheetOpen = false;
fileInput?.click(); fileInput?.click();
}} }}
/> />
<DirectoryCreateModal <DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen} bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => { onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
<DuplicateFileModal <DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen} bind:isOpen={isDuplicateFileModalOpen}
file={duplicatedFile} file={duplicatedFile}
onbeforeclose={() => { onbeforeclose={() => {
@@ -159,9 +156,9 @@
resolveForDuplicateFileModal?.(true); resolveForDuplicateFileModal?.(true);
isDuplicateFileModalOpen = false; isDuplicateFileModalOpen = false;
}} }}
/> />
<EntryMenuBottomSheet <EntryMenuBottomSheet
bind:isOpen={isEntryMenuBottomSheetOpen} bind:isOpen={isEntryMenuBottomSheetOpen}
onRenameClick={() => { onRenameClick={() => {
isEntryMenuBottomSheetOpen = false; isEntryMenuBottomSheetOpen = false;
@@ -171,24 +168,26 @@
isEntryMenuBottomSheetOpen = false; isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true; isEntryDeleteModalOpen = true;
}} }}
/> />
<EntryRenameModal <EntryRenameModal
bind:isOpen={isEntryRenameModalOpen} bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => { onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) { if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
<EntryDeleteModal <EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen} bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => { onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) { if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

View File

@@ -1,21 +1,9 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms"; import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import { import { getUploadingFiles, type LiveFileUploadState } from "$lib/modules/file";
getDirectoryInfo, import type { DirectoryInfo } from "$lib/modules/filesystem";
getFileInfo, import { sortEntries } from "$lib/utils";
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte"; import SubDirectory from "./SubDirectory.svelte";
import UploadingFile from "./UploadingFile.svelte"; import UploadingFile from "./UploadingFile.svelte";
@@ -27,7 +15,6 @@
onEntryMenuClick: (entry: SelectedEntry) => void; onEntryMenuClick: (entry: SelectedEntry) => void;
onParentClick?: () => void; onParentClick?: () => void;
showParentEntry?: boolean; showParentEntry?: boolean;
sortBy?: SortBy;
} }
let { let {
@@ -36,115 +23,49 @@
onEntryMenuClick, onEntryMenuClick,
onParentClick, onParentClick,
showParentEntry = false, showParentEntry = false,
sortBy = SortBy.NAME_ASC,
}: Props = $props(); }: Props = $props();
interface DirectoryEntry { type Entry =
name?: string; | { type: "parent" }
info: Writable<DirectoryInfo | null>; | { 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 };
type FileEntry = const toEntry =
| { <T extends Exclude<Entry["type"], "parent">>(type: T) =>
type: "file"; (details: Extract<Entry, { type: T }>["details"]) => ({ type, name: details.name, details });
name?: string;
info: Writable<FileInfo | null>;
}
| {
type: "uploading-file";
name: string;
info: Writable<FileUploadStatus>;
};
let subDirectories: DirectoryEntry[] = $state([]); let entries = $derived([
let files: FileEntry[] = $state([]); ...(showParentEntry ? ([{ type: "parent" }] as const) : []),
...sortEntries(info.subDirectories.map(toEntry("directory"))),
$effect(() => { ...sortEntries([
// TODO: Fix duplicated requests ...info.files.map(toEntry("file")),
...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")),
subDirectories = info.subDirectoryIds.map((id) => { ]),
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!); ]);
return { name: get(info)?.name, info };
});
files = info.fileIds
.map((id): FileEntry => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
type: "file",
name: get(info)?.name,
info,
};
})
.concat(
$fileUploadStatusStore
.filter((statusStore) => {
const { parentId, status } = get(statusStore);
return parentId === info.id && isFileUploading(status);
})
.map((status) => ({
type: "uploading-file",
name: get(status).name,
info: status,
})),
);
const sort = () => {
sortEntries(subDirectories, sortBy);
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if subDirectories.length + files.length > 0 || showParentEntry} {#if entries.length > 0}
<div class="space-y-1 pb-[4.5rem]"> <RowVirtualizer count={entries.length} itemHeight={() => 56} itemGap={4} class="pb-[4.5rem]">
{#if showParentEntry} {#snippet item(index)}
{@const entry = entries[index]!}
{#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>
{/if} {:else if entry.type === "directory"}
{#each subDirectories as { info }} <SubDirectory
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> info={entry.details}
{/each} onclick={onEntryClick}
{#if files.length > 0} onOpenMenuClick={onEntryMenuClick}
<RowVirtualizer />
count={files.length} {:else if entry.type === "file"}
itemHeight={(index) => 56 + (index + 1 < files.length ? 4 : 0)} <File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
>
{#snippet item(index)}
{@const file = files[index]!}
<div class={index + 1 < files.length ? "pb-1" : ""}>
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else} {:else}
<UploadingFile status={file.info} /> <UploadingFile state={entry.details} />
{/if} {/if}
</div>
{/snippet} {/snippet}
</RowVirtualizer> </RowVirtualizer>
{/if}
</div>
{:else} {:else}
<div class="flex flex-grow items-center justify-center"> <div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">폴더가 비어 있어요.</p> <p class="text-gray-500">폴더가 비어 있어요.</p>

View File

@@ -1,66 +1,52 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { browser } from "$app/environment";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
import { formatDateTime } from "$lib/utils"; import { formatDateTime } from "$lib/utils";
import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert"; import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: SummarizedFileInfo;
onclick: (selectedEntry: SelectedEntry) => void; onclick: (entry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void; onOpenMenuClick: (entry: SelectedEntry) => void;
} }
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
let thumbnail: string | undefined = $state(); let showThumbnail = $derived(
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
);
let thumbnailPromise = $derived(
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
);
const openFile = () => { const action = (callback: typeof onclick) => {
const { id, dataKey, dataKeyVersion, name } = $info!; callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
}; };
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton
class="h-14" class="h-14"
onclick={openFile} onclick={() => action(onclick)}
actionButtonIcon={IconMoreVert} actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu} onActionButtonClick={() => action(onOpenMenuClick)}
> >
{#await thumbnailPromise}
<DirectoryEntryLabel <DirectoryEntryLabel
type="file" type="file"
{thumbnail} name={info.name}
name={$info.name} subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
/> />
</ActionEntryButton> {:then thumbnail}
{/if} <DirectoryEntryLabel
type="file"
thumbnail={thumbnail ?? undefined}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
/>
{/await}
</ActionEntryButton>

View File

@@ -1,44 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { DirectoryInfo } from "$lib/modules/filesystem"; import type { SubDirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert"; import IconMoreVert from "~icons/material-symbols/more-vert";
type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props { interface Props {
info: Writable<DirectoryInfo | null>; info: SubDirectoryInfo;
onclick: (selectedEntry: SelectedEntry) => void; onclick: (entry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void; onOpenMenuClick: (entry: SelectedEntry) => void;
} }
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
const openDirectory = () => { const action = (callback: typeof onclick) => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo; callback({ type: "directory", id: info.id, dataKey: info.dataKey, name: info.name });
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
}; };
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton
class="h-14" class="h-14"
onclick={openDirectory} onclick={() => action(onclick)}
actionButtonIcon={IconMoreVert} actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu} onActionButtonClick={() => action(onOpenMenuClick)}
> >
<DirectoryEntryLabel type="directory" name={$info.name!} /> <DirectoryEntryLabel type="directory" name={info.name} />
</ActionEntryButton> </ActionEntryButton>
{/if}

View File

@@ -1,38 +1,35 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { LiveFileUploadState } from "$lib/modules/file";
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
interface Props { interface Props {
status: Writable<FileUploadStatus>; state: LiveFileUploadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
{#if isFileUploading($status.status)} <div class="flex h-14 gap-x-4 p-2">
<div class="flex h-14 gap-x-4 p-2">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<IconDraft class="text-gray-600" /> <IconDraft class="text-gray-600" />
</div> </div>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800"> <div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={$status.name} class="truncate font-medium"> <p title={state.name} class="truncate font-medium">
{$status.name} {state.name}
</p> </p>
<p class="text-xs"> <p class="text-xs">
{#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)}% · 전송됨 {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)} {formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if} {/if}
</p> </p>
</div> </div>
</div> </div>
{/if}

View File

@@ -1 +0,0 @@
export { requestFileThumbnailDownload } from "$lib/services/file";

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { getDownloadingFiles } from "$lib/modules/file";
import { get, type Writable } from "svelte/store";
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -9,23 +7,7 @@
let { onclick }: Props = $props(); let { onclick }: Props = $props();
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]); let downloadingFiles = $derived(getDownloadingFiles());
$effect(() => {
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
return untrack(() => {
const unsubscribes = downloadingFiles.map((downloadingFile) =>
downloadingFile.subscribe(({ status }) => {
if (!isFileDownloading(status)) {
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if downloadingFiles.length > 0} {#if downloadingFiles.length > 0}

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { getUploadingFiles } from "$lib/modules/file";
import { get, type Writable } from "svelte/store";
import { fileUploadStatusStore, isFileUploading, type FileUploadStatus } from "$lib/stores";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -9,21 +7,7 @@
let { onclick }: Props = $props(); let { onclick }: Props = $props();
let uploadingFiles: Writable<FileUploadStatus>[] = $state([]); let uploadingFiles = $derived(getUploadingFiles());
$effect(() => {
uploadingFiles = $fileUploadStatusStore.filter((status) => isFileUploading(get(status).status));
return untrack(() => {
const unsubscribes = uploadingFiles.map((uploadingFile) =>
uploadingFile.subscribe(({ status }) => {
if (!isFileUploading(status)) {
uploadingFiles = uploadingFiles.filter((file) => file !== uploadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if uploadingFiles.length > 0} {#if uploadingFiles.length > 0}

View File

@@ -8,14 +8,14 @@ import {
deleteFileThumbnailCache, deleteFileThumbnailCache,
uploadFile, uploadFile,
} from "$lib/modules/file"; } from "$lib/modules/file";
import type { DataKey } from "$lib/modules/filesystem";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
export interface SelectedEntry { export interface SelectedEntry {
type: "directory" | "file"; type: "directory" | "file";
id: number; id: number;
dataKey: CryptoKey; dataKey: DataKey | undefined;
dataKeyVersion: Date;
name: string; name: string;
} }
@@ -97,20 +97,25 @@ export const requestFileUpload = async (
}; };
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
const newNameEncrypted = await encryptString(newName, entry.dataKey); if (!entry.dataKey) {
// TODO: Error Handling
return false;
}
const newNameEncrypted = await encryptString(newName, entry.dataKey.key);
try { try {
if (entry.type === "directory") { if (entry.type === "directory") {
await trpc().directory.rename.mutate({ await trpc().directory.rename.mutate({
id: entry.id, id: entry.id,
dekVersion: entry.dataKeyVersion, dekVersion: entry.dataKey.version,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });
} else { } else {
await trpc().file.rename.mutate({ await trpc().file.rename.mutate({
id: entry.id, id: entry.id,
dekVersion: entry.dataKeyVersion, dekVersion: entry.dataKey.version,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });

View File

@@ -1,17 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { EntryButton, FileThumbnailButton } from "$lib/components/atoms"; import { EntryButton, FileThumbnailButton } from "$lib/components/atoms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { requestFreshMediaFilesRetrieval } from "./service"; import { requestFreshMediaFilesRetrieval } from "./service";
let mediaFiles: Writable<FileInfo | null>[] = $state([]); let mediaFiles: MaybeFileInfo[] = $state([]);
$effect(() => { onMount(async () => {
requestFreshMediaFilesRetrieval().then((files) => { const files = await requestFreshMediaFilesRetrieval();
mediaFiles = files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)); mediaFiles = Array.from(
}); (
await bulkGetFileInfo(
files.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!,
)
).values(),
);
}); });
</script> </script>
@@ -27,8 +33,10 @@
</EntryButton> </EntryButton>
{#if mediaFiles.length > 0} {#if mediaFiles.length > 0}
<div class="grid grid-cols-4 gap-2 p-2"> <div class="grid grid-cols-4 gap-2 p-2">
{#each mediaFiles as file} {#each mediaFiles as file (file.id)}
{#if file.exists}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} /> <FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/if}
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -1,24 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto as svelteGoto } from "$app/navigation"; import { goto as svelteGoto } from "$app/navigation";
import { import { getDownloadingFiles, getUploadingFiles } from "$lib/modules/file";
fileUploadStatusStore, import { clientKeyStore, masterKeyStore } from "$lib/stores";
fileDownloadStatusStore,
isFileUploading,
isFileDownloading,
clientKeyStore,
masterKeyStore,
} from "$lib/stores";
import "../app.css"; import "../app.css";
let { children } = $props(); let { children } = $props();
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => { const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if ( if (getDownloadingFiles().length > 0 || getUploadingFiles().length > 0) {
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
) {
e.preventDefault(); e.preventDefault();
} }
}; };

View File

@@ -9,6 +9,7 @@ const categoryRouter = router({
.input( .input(
z.object({ z.object({
id: categoryIdSchema, id: categoryIdSchema,
recurse: z.boolean().default(false),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -20,7 +21,12 @@ const categoryRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); 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 { return {
metadata: category && { metadata: category && {
parent: category.parentId, parent: category.parentId,
@@ -30,7 +36,28 @@ const categoryRouter = router({
name: category.encName.ciphertext, name: category.encName.ciphertext,
nameIv: category.encName.iv, 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"] addFile: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({

View File

@@ -32,8 +32,27 @@ const directoryRouter = router({
name: directory.encName.ciphertext, name: directory.encName.ciphertext,
nameIv: directory.encName.iv, nameIv: directory.encName.iv,
}, },
subDirectories: directories.map(({ id }) => id), subDirectories: directories.map((directory) => ({
files: files.map(({ id }) => id), 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,
})),
}; };
}), }),

View File

@@ -31,10 +31,50 @@ const fileRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: categories.map(({ id }) => id), categories: categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
}; };
}), }),
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 }) => { list: roleProcedure["activeClient"].query(async ({ ctx }) => {
return await FileRepo.getAllFileIds(ctx.session.userId); return await FileRepo.getAllFileIds(ctx.session.userId);
}), }),