6 Commits

50 changed files with 1076 additions and 1167 deletions

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, placeholder }: Props = $props();
let element: HTMLElement | undefined = $state();
let scrollMargin = $state(0);
let virtualizer = $derived(
createWindowVirtualizer({
count,
estimateSize: itemHeight,
scrollMargin,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => {
if (!element) return;
const observer = new ResizeObserver(() => {
scrollMargin = element!.getBoundingClientRect().top + window.scrollY;
});
observer.observe(element.parentElement!);
return () => observer.disconnect();
});
</script>
<div bind:this={element} class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div
class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start - scrollMargin}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && $virtualizer.getVirtualItems().length === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -3,3 +3,4 @@ export * from "./buttons";
export * from "./divs"; export * from "./divs";
export * from "./inputs"; export * from "./inputs";
export { default as Modal } from "./Modal.svelte"; export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

@@ -1,59 +1,29 @@
<script lang="ts"> <script lang="ts">
import { untrack, type Component } from "svelte"; import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements"; import type { SvelteHTMLElements } from "svelte/elements";
import { get, type Writable } from "svelte/store"; import type { SubCategoryInfo } from "$lib/modules/filesystem2.svelte";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils"; import { SortBy, sortEntries } from "$lib/utils";
import Category from "./Category.svelte"; import Category from "./Category.svelte";
import type { SelectedCategory } from "./service"; import type { SelectedCategory } from "./service";
interface Props { interface Props {
categories: Writable<CategoryInfo | null>[]; categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>; categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void; onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void; onCategoryMenuClick?: (category: SelectedCategory) => void;
sortBy?: SortBy; sortBy?: SortBy;
} }
let { let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]); let categoriesWithName = $derived(sortEntries(structuredClone($state.snapshot(categories))));
$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> </script>
{#if categoriesWithName.length > 0} {#if categoriesWithName.length > 0}
<div class="space-y-1"> <div class="space-y-1">
{#each categoriesWithName as { info }} {#each categoriesWithName as category}
<Category <Category
{info} info={category}
menuIcon={categoryMenuIcon} menuIcon={categoryMenuIcon}
onclick={onCategoryClick} onclick={onCategoryClick}
onMenuClick={onCategoryMenuClick} onMenuClick={onCategoryMenuClick}

View File

@@ -1,43 +1,26 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements"; import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules"; import { CategoryLabel } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem"; import type { SubCategoryInfo } from "$lib/modules/filesystem2.svelte";
import type { SelectedCategory } from "./service"; import type { SelectedCategory } from "./service";
interface Props { interface Props {
info: Writable<CategoryInfo | null>; info: SubCategoryInfo;
menuIcon?: Component<SvelteHTMLElements["svg"]>; menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void; onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void; onMenuClick?: (category: SelectedCategory) => void;
} }
let { info, menuIcon, onclick, onMenuClick }: Props = $props(); 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> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton class="h-12"
class="h-12" onclick={() => onclick(info)}
onclick={openCategory} actionButtonIcon={menuIcon}
actionButtonIcon={menuIcon} onActionButtonClick={() => onMenuClick?.(info)}
onActionButtonClick={openMenu} >
> <CategoryLabel name={info.name} />
<CategoryLabel name={$info.name!} /> </ActionEntryButton>
</ActionEntryButton>
{/if}

View File

@@ -1,6 +1,5 @@
export interface SelectedCategory { export interface SelectedCategory {
id: number; id: number;
dataKey: CryptoKey; dataKey?: { key: CryptoKey; version: Date };
dataKeyVersion: Date;
name: string; 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/filesystem2.svelte";
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={info.subCategories}
categories={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,11 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { get, type Writable } from "svelte/store";
import { CheckBox } 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 type { CategoryInfo } from "$lib/modules/filesystem2.svelte";
import { masterKeyStore } from "$lib/stores"; import { sortEntries } from "$lib/utils";
import { SortBy, 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 +10,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,43 +25,16 @@
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 files = $derived(
[], sortEntries(
);
$effect(() => {
files =
info.files info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive) ?.map((file) => ({ name: file.name, details: file }))
.map(({ id, isRecursive }) => { .filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [],
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!); ),
return { );
name: get(info)?.name,
info,
isRecursive,
};
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
};
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>
<div class="space-y-4"> <div class="space-y-4">
@@ -89,19 +58,24 @@
<p class="font-medium">하위 카테고리의 파일</p> <p class="font-medium">하위 카테고리의 파일</p>
</CheckBox> </CheckBox>
</div> </div>
<div class="space-y-1"> <RowVirtualizer
{#key info} count={files.length}
{#each files as { info, isRecursive }} itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)}
>
{#snippet item(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}
/> />
{:else} </div>
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p> {/snippet}
{/each} {#snippet placeholder()}
{/key} <p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
</div> {/snippet}
</RowVirtualizer>
</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/filesystem2.svelte";
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={() => onclick(info)}
onclick={openFile} actionButtonIcon={onRemoveClick && IconClose}
actionButtonIcon={onRemoveClick && IconClose} onActionButtonClick={() => onRemoveClick?.(info)}
onActionButtonClick={removeFile} >
> {#await thumbnailPromise}
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} /> <DirectoryEntryLabel type="file" name={info.name} />
</ActionEntryButton> {:then thumbnail}
{/if} <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,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store"; import { get, type Writable } from "svelte/store";
import { FileThumbnailButton } from "$lib/components/atoms"; import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem"; import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils"; import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
@@ -17,25 +16,11 @@
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> } | { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> }; | { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row = type Row =
| { type: "header"; key: string; label: string } | { type: "header"; label: string }
| { type: "items"; key: string; items: FileEntry[] }; | { type: "items"; items: FileEntry[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]); let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]); let rows: Row[] = $state([]);
let listElement: HTMLDivElement | undefined = $state();
const virtualizer = createWindowVirtualizer({
count: 0,
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$effect(() => { $effect(() => {
filesWithDate = files.map((file) => { filesWithDate = files.map((file) => {
@@ -76,18 +61,19 @@
newRows.push({ newRows.push({
type: "header", type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!), label: formatDate(entries[0]!.date!),
}); });
newRows.push({
type: "items", for (let i = 0; i < entries.length; i += 4) {
key: `items-${date}`, newRows.push({
items: entries, type: "items",
}); items: entries.slice(i, i + 4),
isLast: i + 4 >= entries.length,
});
}
} }
rows = newRows; rows = newRows;
$virtualizer.setOptions({ count: rows.length });
}; };
return untrack(() => { return untrack(() => {
buildRows(); buildRows();
@@ -110,29 +96,29 @@
}); });
</script> </script>
<div bind:this={listElement} class="relative flex flex-grow flex-col"> <RowVirtualizer
<div style="height: {$virtualizer.getTotalSize()}px;"> count={rows.length}
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)} itemHeight={(index) =>
{@const row = rows[virtualRow.index]!} rows[index]!.type === "header"
<div ? 28
use:measureRow : Math.ceil(rows[index]!.items.length / 4) * 181 +
data-index={virtualRow.index} (Math.ceil(rows[index]!.items.length / 4) - 1) * 4 +
class="absolute left-0 top-0 w-full" 16}
style="transform: translateY({virtualRow.start}px);" class="flex flex-grow flex-col"
> >
{#if row.type === "header"} {#snippet item(index)}
<p class="pb-2 font-medium">{row.label}</p> {@const row = rows[index]!}
{:else} {#if row.type === "header"}
<div class="grid grid-cols-4 gap-1 pb-4"> <p class="pb-2 text-sm font-medium">{row.label}</p>
{#each row.items as { info }} {:else}
<FileThumbnailButton {info} onclick={onFileClick} /> <div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{/each} {#each row.items as { info }}
</div> <FileThumbnailButton {info} onclick={onFileClick} />
{/if} {/each}
</div> </div>
{/each} {/if}
</div> {/snippet}
{#if $virtualizer.getVirtualItems().length === 0} {#snippet placeholder()}
<div class="flex h-full flex-grow items-center justify-center"> <div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500"> <p class="text-gray-500">
{#if files.length === 0} {#if files.length === 0}
@@ -144,5 +130,5 @@
{/if} {/if}
</p> </p>
</div> </div>
{/if} {/snippet}
</div> </RowVirtualizer>

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;

View File

@@ -1,3 +1,3 @@
export * from "./cache"; export * from "./cache";
export * from "./download"; export * from "./download";
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): file is LiveFileUploadState =>
(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,44 +1,11 @@
import { TRPCClientError } from "@trpc/client";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB, getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo, storeFileInfo,
deleteFileInfo, deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB"; } from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto"; import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc } from "$trpc/client"; 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 { export interface FileInfo {
id: number; id: number;
@@ -53,117 +20,7 @@ export interface FileInfo {
categoryIds: number[]; 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 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 (e instanceof TRPCClientError && 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>) => { const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return; if (get(info)) return;
@@ -187,7 +44,7 @@ const fetchFileInfoFromServer = async (
try { try {
metadata = await trpc().file.get.query({ id }); metadata = await trpc().file.get.query({ id });
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") { if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null); info.set(null);
await deleteFileInfo(id); await deleteFileInfo(id);
return; return;
@@ -247,124 +104,3 @@ export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
fetchFileInfo(fileId, info, masterKey); // Intended fetchFileInfo(fileId, info, masterKey); // Intended
return info; 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 (e instanceof TRPCClientError && 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,341 @@
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
type DataKey = { key: CryptoKey; version: Date };
interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv: string | undefined;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: { id: number; name: string }[];
}
export type SummarizedFileInfo = Omit<FileInfo, "parentId" | "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {
id: number;
dataKey?: DataKey | undefined;
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"
>;
const directoryInfoCache = new Map<DirectoryId, DirectoryInfo | Promise<DirectoryInfo>>();
const categoryInfoCache = new Map<CategoryId, CategoryInfo | Promise<CategoryInfo>>();
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
const info = directoryInfoCache.get(id);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<DirectoryInfo>();
if (!info) {
directoryInfoCache.set(id, promise);
}
monotonicResolve(
[!info && fetchDirectoryInfoFromIndexedDB(id), fetchDirectoryInfoFromServer(id, masterKey)],
(directoryInfo) => {
let info = directoryInfoCache.get(id);
if (info instanceof Promise) {
const state = $state(directoryInfo);
directoryInfoCache.set(id, state);
resolve(state);
} else {
Object.assign(info!, directoryInfo);
resolve(info!);
}
},
);
return info ?? promise;
};
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
): Promise<DirectoryInfo | undefined> => {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
if (id === "root") {
return { id, subDirectories, files };
} else if (directory) {
return { id, parentId: directory.parentId, name: directory.name, subDirectories, files };
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
masterKey: CryptoKey,
): Promise<DirectoryInfo | undefined> => {
try {
const {
metadata,
subDirectories: subDirectoriesRaw,
files: filesRaw,
} = await trpc().directory.get.query({ id });
const [subDirectories, files] = await Promise.all([
Promise.all(
subDirectoriesRaw.map(async (directory) => {
const { dataKey } = await unwrapDataKey(directory.dek, masterKey);
const name = await decryptString(directory.name, directory.nameIv, dataKey);
return {
id: directory.id,
dataKey: { key: dataKey, version: directory.dekVersion },
name,
};
}),
),
Promise.all(
filesRaw.map(async (file) => {
const { dataKey } = await unwrapDataKey(file.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(file.name, file.nameIv, dataKey),
file.createdAt ? decryptDate(file.createdAt, file.createdAtIv!, dataKey) : undefined,
decryptDate(file.lastModifiedAt, file.lastModifiedAtIv, dataKey),
]);
return {
id: file.id,
dataKey: { key: dataKey, version: file.dekVersion },
contentType: file.contentType,
name,
createdAt,
lastModifiedAt,
};
}),
),
]);
if (id === "root") {
return { id, subDirectories, files };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
return {
id,
parentId: metadata!.parent,
dataKey: { key: dataKey, version: metadata!.dekVersion },
name,
subDirectories,
files,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
directoryInfoCache.delete(id);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
const info = categoryInfoCache.get(id);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<CategoryInfo>();
if (!info) {
categoryInfoCache.set(id, promise);
const categoryInfo = await fetchCategoryInfoFromIndexedDB(id);
if (categoryInfo) {
const state = $state(categoryInfo);
categoryInfoCache.set(id, state);
resolve(state);
}
}
fetchCategoryInfoFromServer(id, masterKey).then((categoryInfo) => {
if (!categoryInfo) return;
let info = categoryInfoCache.get(id);
if (info instanceof Promise) {
const state = $state(categoryInfo);
categoryInfoCache.set(id, state);
resolve(state);
} else {
Object.assign(info!, categoryInfo);
resolve(info!);
}
});
return info ?? promise;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
): Promise<CategoryInfo | undefined> => {
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const files = category
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await getFileInfoFromIndexedDB(file.id);
return fileInfo
? {
id: file.id,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return { id, subCategories };
} else if (category) {
return {
id,
name: category.name,
subCategories,
files: files!.filter((file) => !!file),
isFileRecursive: category.isFileRecursive,
};
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
masterKey: CryptoKey,
): Promise<CategoryInfo | undefined> => {
try {
const {
metadata,
subCategories: subCategoriesRaw,
files: filesRaw,
} = await trpc().category.get.query({ id, recurse: true });
const [subCategories, files] = await Promise.all([
Promise.all(
subCategoriesRaw.map(async (category) => {
const { dataKey } = await unwrapDataKey(category.dek, masterKey);
const name = await decryptString(category.name, category.nameIv, dataKey);
return {
id: category.id,
dataKey: { key: dataKey, version: category.dekVersion },
name,
};
}),
),
id !== "root"
? Promise.all(
filesRaw!.map(async (file) => {
const { dataKey } = await unwrapDataKey(file.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(file.name, file.nameIv, dataKey),
file.createdAt
? decryptDate(file.createdAt, file.createdAtIv!, dataKey)
: undefined,
decryptDate(file.lastModifiedAt, file.lastModifiedAtIv, dataKey),
]);
return {
id: file.id,
dataKey: { key: dataKey, version: file.dekVersion },
contentType: file.contentType,
name,
createdAt,
lastModifiedAt,
isRecursive: file.isRecursive,
};
}),
)
: undefined,
]);
if (id === "root") {
return { id, subCategories };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
return {
id,
dataKey: { key: dataKey, version: metadata!.dekVersion },
name,
subCategories,
files: files!,
isFileRecursive: false,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
categoryInfoCache.delete(id);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
};

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

@@ -4,8 +4,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 DirectoryId = "root" | number;
interface Directory { interface Directory {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
@@ -306,39 +304,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) => {

View File

@@ -1,6 +1,5 @@
import { TRPCClientError } from "@trpc/client";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import { trpc } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
export const requestSessionUpgrade = async ( export const requestSessionUpgrade = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -16,7 +15,7 @@ export const requestSessionUpgrade = async (
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
})); }));
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") { if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const; return [false, "Unregistered client"] as const;
} }
return [false] as const; return [false] as const;
@@ -31,7 +30,7 @@ export const requestSessionUpgrade = async (
force, force,
}); });
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "CONFLICT") { if (isTRPCClientError(e) && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const; return [false, "Already logged in"] as const;
} }
return [false] as const; return [false] as const;

View File

@@ -1,4 +1,3 @@
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -11,7 +10,7 @@ import {
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { requestSessionUpgrade } from "$lib/services/auth"; import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores"; import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { trpc } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -112,10 +111,7 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
} catch (e) { } catch (e) {
if ( if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true; return true;
} }
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,21 +1,5 @@
import { writable, type Writable } from "svelte/store"; 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 { export interface FileDownloadStatus {
id: number; id: number;
status: status:
@@ -32,16 +16,8 @@ export interface FileDownloadStatus {
result?: ArrayBuffer; result?: ArrayBuffer;
} }
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]); 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 = ( export const isFileDownloading = (
status: FileDownloadStatus["status"], status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => { ): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {

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 | undefined> | false)[],
callback: (value: T) => void,
) => {
let latestResolvedIndex = -1;
promises.forEach((promise, index) => {
if (!promise) return;
promise.then((value) => {
if (value !== undefined && index > latestResolvedIndex) {
latestResolvedIndex = index;
callback(value);
}
});
});
};

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

@@ -6,12 +6,7 @@
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 } 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 { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
@@ -32,7 +27,7 @@
let { data } = $props(); let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state(); let info: Writable<FileInfo | null> | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]); // let categories: Writable<CategoryInfo | null>[] = $state([]);
let isMenuOpen = $state(false); let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false); let isAddToCategoryBottomSheetOpen = $state(false);
@@ -90,10 +85,10 @@
viewerType = undefined; viewerType = undefined;
}); });
$effect(() => { // $effect(() => {
categories = // categories =
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? []; // $info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
}); // });
$effect(() => { $effect(() => {
if ($info && $info.dataKey && $info.contentIv) { if ($info && $info.dataKey && $info.contentIv) {
@@ -145,7 +140,9 @@
</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}
/> />
@@ -188,12 +185,12 @@
<div class="space-y-2"> <div class="space-y-2">
<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}
categoryMenuIcon={IconClose} categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)} onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)} onCategoryMenuClick={({ id }) => removeFromCategory(id)}
/> /> -->
<IconEntryButton <IconEntryButton
icon={IconAddCircle} icon={IconAddCircle}
onclick={() => (isAddToCategoryBottomSheetOpen = true)} onclick={() => (isAddToCategoryBottomSheetOpen = true)}

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 CategoryInfo } from "$lib/modules/filesystem2.svelte";
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<CategoryInfo | null> | 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}
<BottomSheet bind:isOpen class="flex flex-col"> {#if categoryInfo}
<FullscreenDiv> <BottomSheet bind:isOpen class="flex flex-col">
<SubCategories <FullscreenDiv>
class="py-4" <SubCategories
info={$category} class="py-4"
onSubCategoryClick={({ id }) => info={categoryInfo}
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} onSubCategoryClick={({ id }) =>
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
subCategoryCreatePosition="top" onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
/> subCategoryCreatePosition="top"
{#if $category.id !== "root"} />
<BottomDiv> {#if categoryInfo.id !== "root"}
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full"> <BottomDiv>
이 카테고리에 추가하기 <Button onclick={() => onAddToCategoryClick(categoryInfo.id)} class="w-full">
</Button> 이 카테고리에 추가하기
</BottomDiv> </Button>
{/if} </BottomDiv>
</FullscreenDiv> {/if}
</BottomSheet> </FullscreenDiv>
{/if} </BottomSheet>
<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,19 +1,10 @@
<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( $effect(() => clearUploadedFiles);
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
);
$effect(() => () => {
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
isFileUploading(get(status).status),
);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -23,8 +14,8 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-2 pb-4"> <div class="space-y-2 pb-4">
{#each uploadingFiles as status} {#each getUploadingFiles() 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

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const files = await createCaller(event).file.list();
return { files };
};

View File

@@ -22,5 +22,5 @@
<TopBar title="사진 및 동영상" /> <TopBar title="사진 및 동영상" />
<FullscreenDiv> <FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} /> <Gallery {files} onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)} />
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,7 +0,0 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const files = await trpc(fetch).file.list.query();
return { files };
};

View File

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const files = await createCaller(event).file.listWithoutThumbnail();
return { files };
};

View File

@@ -1,7 +0,0 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const files = await trpc(fetch).file.listWithoutThumbnail.query();
return { files };
};

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 CategoryInfo } from "$lib/modules/filesystem2.svelte";
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<CategoryInfo> | 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,68 +34,70 @@
<title>카테고리</title> <title>카테고리</title>
</svelte:head> </svelte:head>
{#if data.id !== "root"} {#await infoPromise then info}
<TopBar title={$info?.name} /> {#if info}
{/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}
<Category <div class="min-h-full bg-gray-100 pb-[5.5em]">
bind:isFileRecursive <Category
info={$info} bind:isFileRecursive={info.isFileRecursive}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)} {info}
onFileRemoveClick={async ({ id }) => { onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
await requestFileRemovalFromCategory(id, data.id as number); onFileRemoveClick={async ({ id }) => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME await requestFileRemovalFromCategory(id, data.id as number);
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
context.selectedCategory = subCategory;
isCategoryMenuBottomSheetOpen = true;
}}
/>
</div>
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}} }}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)} />
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => { <CategoryMenuBottomSheet
context.selectedCategory = subCategory; bind:isOpen={isCategoryMenuBottomSheetOpen}
isCategoryMenuBottomSheetOpen = true; onRenameClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryRenameModalOpen = true;
}}
onDeleteClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryDeleteModalOpen = true;
}}
/>
<CategoryRenameModal
bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<CategoryDeleteModal
bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}} }}
/> />
{/if} {/if}
</div> {/await}
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<CategoryMenuBottomSheet
bind:isOpen={isCategoryMenuBottomSheetOpen}
onRenameClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryRenameModalOpen = true;
}}
onDeleteClick={() => {
isCategoryMenuBottomSheetOpen = false;
isCategoryDeleteModalOpen = true;
}}
/>
<CategoryRenameModal
bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<CategoryDeleteModal
bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>

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 DirectoryInfo } from "$lib/modules/filesystem2.svelte";
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<DirectoryInfo> | 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,106 +88,106 @@
<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 showTopBar} {#if info}
<TopBar title={$info?.name} class="flex-shrink-0" /> <div class="flex h-full flex-col">
{/if} {#if showTopBar}
{#if $info} <TopBar title={info.name} class="flex-shrink-0" />
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}> {/if}
<div class="flex gap-x-2"> <div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<UploadStatusCard onclick={() => goto("/file/uploads")} /> <div class="flex gap-x-2">
<DownloadStatusCard onclick={() => goto("/file/downloads")} /> <UploadStatusCard onclick={() => goto("/file/uploads")} />
</div> <DownloadStatusCard onclick={() => goto("/file/downloads")} />
{#key $info} </div>
<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> </div>
<FloatingButton
icon={IconAdd}
onclick={() => {
isEntryCreateBottomSheetOpen = true;
}}
class="bottom-24 right-4"
/>
<EntryCreateBottomSheet
bind:isOpen={isEntryCreateBottomSheetOpen}
onDirectoryCreateClick={() => {
isEntryCreateBottomSheetOpen = false;
isDirectoryCreateModalOpen = true;
}}
onFileUploadClick={() => {
isEntryCreateBottomSheetOpen = false;
fileInput?.click();
}}
/>
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
file={duplicatedFile}
onbeforeclose={() => {
resolveForDuplicateFileModal?.(false);
isDuplicateFileModalOpen = false;
}}
onUploadClick={() => {
resolveForDuplicateFileModal?.(true);
isDuplicateFileModalOpen = false;
}}
/>
<EntryMenuBottomSheet
bind:isOpen={isEntryMenuBottomSheetOpen}
onRenameClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryRenameModalOpen = true;
}}
onDeleteClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true;
}}
/>
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
{/if} {/if}
</div> {/await}
<FloatingButton
icon={IconAdd}
onclick={() => {
isEntryCreateBottomSheetOpen = true;
}}
class="bottom-24 right-4"
/>
<EntryCreateBottomSheet
bind:isOpen={isEntryCreateBottomSheetOpen}
onDirectoryCreateClick={() => {
isEntryCreateBottomSheetOpen = false;
isDirectoryCreateModalOpen = true;
}}
onFileUploadClick={() => {
isEntryCreateBottomSheetOpen = false;
fileInput?.click();
}}
/>
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
file={duplicatedFile}
onbeforeclose={() => {
resolveForDuplicateFileModal?.(false);
isDuplicateFileModalOpen = false;
}}
onUploadClick={() => {
resolveForDuplicateFileModal?.(true);
isDuplicateFileModalOpen = false;
}}
/>
<EntryMenuBottomSheet
bind:isOpen={isEntryMenuBottomSheetOpen}
onRenameClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryRenameModalOpen = true;
}}
onDeleteClick={() => {
isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true;
}}
/>
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>

View File

@@ -1,21 +1,9 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { get, type Writable } from "svelte/store";
import { ActionEntryButton } 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/filesystem2.svelte";
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,104 +23,59 @@
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"]) => ({
name?: string; type,
info: Writable<FileInfo | null>; name: details.name,
} details,
| {
type: "uploading-file";
name: string;
info: Writable<FileUploadStatus>;
};
let subDirectories: DirectoryEntry[] = $state([]);
let files: FileEntry[] = $state([]);
$effect(() => {
// TODO: Fix duplicated requests
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 = () => { let entries = $derived([
sortEntries(subDirectories, sortBy); ...(showParentEntry ? ([{ type: "parent" }] as const) : []),
sortEntries(files, sortBy); ...sortEntries(info.subDirectories.map(toEntry("directory"))),
}; ...sortEntries([
return untrack(() => { ...info.files.map(toEntry("file")),
sort(); ...getUploadingFiles(info.id).map(toEntry("uploading-file")),
]),
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]"> <div class="pb-[4.5rem]">
{#if showParentEntry} <RowVirtualizer
<ActionEntryButton class="h-14" onclick={onParentClick}> count={entries.length}
<DirectoryEntryLabel type="parent-directory" name=".." /> itemHeight={(index) => 56 + (index + 1 < entries.length ? 4 : 0)}
</ActionEntryButton> >
{/if} {#snippet item(index)}
{#each subDirectories as { info }} {@const entry = entries[index]!}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> <div class={index + 1 < entries.length ? "pb-1" : ""}>
{/each} {#if entry.type === "parent"}
{#each files as file} <ActionEntryButton class="h-14" onclick={onParentClick}>
{#if file.type === "file"} <DirectoryEntryLabel type="parent-directory" name=".." />
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> </ActionEntryButton>
{:else} {:else if entry.type === "directory"}
<UploadingFile status={file.info} /> <SubDirectory
{/if} info={entry.details}
{/each} onclick={onEntryClick}
onOpenMenuClick={onEntryMenuClick}
/>
{:else if entry.type === "file"}
<File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile state={entry.details} />
{/if}
</div>
{/snippet}
</RowVirtualizer>
</div> </div>
{:else} {:else}
<div class="flex flex-grow items-center justify-center"> <div class="flex flex-grow items-center justify-center">

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/filesystem2.svelte";
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={() => action(onclick)}
onclick={openFile} actionButtonIcon={IconMoreVert}
actionButtonIcon={IconMoreVert} onActionButtonClick={() => action(onOpenMenuClick)}
onActionButtonClick={openMenu} >
> {#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/filesystem2.svelte";
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={() => action(onclick)}
onclick={openDirectory} actionButtonIcon={IconMoreVert}
actionButtonIcon={IconMoreVert} onActionButtonClick={() => action(onOpenMenuClick)}
onActionButtonClick={openMenu} >
> <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 class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs">
{#if $status.status === "encryption-pending"}
준비 중
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if $status.status === "uploading"}
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>
</div> </div>
{/if} <div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={state.name} class="truncate font-medium">
{state.name}
</p>
<p class="text-xs">
{#if state.status === "encryption-pending"}
준비 중
{:else if state.status === "encrypting"}
암호화하는 중
{:else if state.status === "upload-pending"}
업로드를 기다리는 중
{:else if state.status === "uploading"}
전송됨 {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if}
</p>
</div>
</div>

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 { 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

@@ -14,8 +14,7 @@ import { trpc } from "$trpc/client";
export interface SelectedEntry { export interface SelectedEntry {
type: "directory" | "file"; type: "directory" | "file";
id: number; id: number;
dataKey: CryptoKey; dataKey: { key: CryptoKey; version: Date } | undefined;
dataKeyVersion: Date;
name: string; name: string;
} }
@@ -97,20 +96,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

@@ -21,14 +21,16 @@
<div class="min-h-full space-y-4 bg-gray-100 px-4 pb-[5.5em] pt-4"> <div class="min-h-full space-y-4 bg-gray-100 px-4 pb-[5.5em] pt-4">
<p class="px-2 text-2xl font-bold text-gray-800">ArkVault</p> <p class="px-2 text-2xl font-bold text-gray-800">ArkVault</p>
<div class="space-y-2 rounded-xl bg-white px-2 pb-4 pt-2"> <div class="rounded-xl bg-white p-2">
<EntryButton onclick={() => goto("/gallery")} class="w-full"> <EntryButton onclick={() => goto("/gallery")} class="w-full">
<p class="text-left font-semibold">사진 및 동영상</p> <p class="text-left font-semibold">사진 및 동영상</p>
</EntryButton> </EntryButton>
<div class="grid grid-cols-4 gap-2 px-2"> {#if mediaFiles.length > 0}
{#each mediaFiles as file} <div class="grid grid-cols-4 gap-2 p-2">
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} /> {#each mediaFiles as file}
{/each} <FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
</div> {/each}
</div>
{/if}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const { nickname } = await createCaller(event).user.get();
return { nickname };
};

View File

@@ -1,7 +0,0 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const { nickname } = await trpc(fetch).user.get.query();
return { nickname };
};

View File

@@ -2,10 +2,9 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { goto as svelteGoto } from "$app/navigation"; import { goto as svelteGoto } from "$app/navigation";
import { getUploadingFiles } from "$lib/modules/file";
import { import {
fileUploadStatusStore,
fileDownloadStatusStore, fileDownloadStatusStore,
isFileUploading,
isFileDownloading, isFileDownloading,
clientKeyStore, clientKeyStore,
masterKeyStore, masterKeyStore,
@@ -16,7 +15,7 @@
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => { const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if ( if (
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) || getUploadingFiles().length > 0 ||
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status)) $fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
) { ) {
e.preventDefault(); e.preventDefault();

View File

@@ -1,4 +1,4 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client"; import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import type { AppRouter } from "./router.server"; import type { AppRouter } from "./router.server";
@@ -24,3 +24,7 @@ export const trpc = (fetch = globalThis.fetch) => {
} }
return client; return client;
}; };
export const isTRPCClientError = (e: unknown): e is TRPCClientError<AppRouter> => {
return e instanceof TRPCClientError;
};

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,
})),
}; };
}), }),