7 Commits

Author SHA1 Message Date
static
90ac5ba4c3 Merge pull request #15 from kmc7468/dev
v0.6.0
2025-12-27 14:22:26 +09:00
static
dfffa004ac Merge pull request #13 from kmc7468/dev
v0.5.1
2025-07-12 19:56:12 +09:00
static
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
50 changed files with 1167 additions and 1076 deletions

View File

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

View File

@@ -1,26 +1,43 @@
<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 { SubCategoryInfo } from "$lib/modules/filesystem2.svelte"; import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service"; import type { SelectedCategory } from "./service";
interface Props { interface Props {
info: SubCategoryInfo; info: Writable<CategoryInfo | null>;
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>
<ActionEntryButton {#if $info}
class="h-12" <ActionEntryButton
onclick={() => onclick(info)} class="h-12"
actionButtonIcon={menuIcon} onclick={openCategory}
onActionButtonClick={() => onMenuClick?.(info)} actionButtonIcon={menuIcon}
> onActionButtonClick={openMenu}
<CategoryLabel name={info.name} /> >
</ActionEntryButton> <CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

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

View File

@@ -1,8 +1,10 @@
<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 type { CategoryInfo } from "$lib/modules/filesystem2.svelte"; import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle"; import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -25,6 +27,14 @@
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]}>
@@ -43,12 +53,14 @@
{#if subCategoryCreatePosition === "top"} {#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}
<Categories {#key info}
categories={info.subCategories} <Categories
categoryMenuIcon={subCategoryMenuIcon} categories={subCategories}
onCategoryClick={onSubCategoryClick} categoryMenuIcon={subCategoryMenuIcon}
onCategoryMenuClick={onSubCategoryMenuClick} onCategoryClick={onSubCategoryClick}
/> onCategoryMenuClick={onSubCategoryMenuClick}
/>
{/key}
{#if subCategoryCreatePosition === "bottom"} {#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { CheckBox, RowVirtualizer } from "$lib/components/atoms"; import { untrack } from "svelte";
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 type { CategoryInfo } from "$lib/modules/filesystem2.svelte"; import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils"; import { masterKeyStore } from "$lib/stores";
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";
@@ -10,12 +13,13 @@
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 {
@@ -25,16 +29,43 @@
onSubCategoryClick, onSubCategoryClick,
onSubCategoryCreateClick, onSubCategoryCreateClick,
onSubCategoryMenuClick, onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(), isFileRecursive = $bindable(),
}: Props = $props(); }: Props = $props();
let files = $derived( let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
sortEntries( [],
info.files
?.map((file) => ({ name: file.name, details: file }))
.filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [],
),
); );
$effect(() => {
files =
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, 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">
@@ -58,24 +89,19 @@
<p class="font-medium">하위 카테고리의 파일</p> <p class="font-medium">하위 카테고리의 파일</p>
</CheckBox> </CheckBox>
</div> </div>
<RowVirtualizer <div class="space-y-1">
count={files.length} {#key info}
itemHeight={(index) => 48 + (index + 1 < files.length ? 4 : 0)} {#each files as { info, isRecursive }}
>
{#snippet item(index)}
{@const { details } = files[index]!}
<div class={[index + 1 < files.length && "pb-1"]}>
<File <File
info={details} {info}
onclick={onFileClick} onclick={onFileClick}
onRemoveClick={!details.isRecursive ? onFileRemoveClick : undefined} onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/> />
</div> {:else}
{/snippet} <p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{#snippet placeholder()} {/each}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p> {/key}
{/snippet} </div>
</RowVirtualizer>
</div> </div>
{/if} {/if}
</div> </div>

View File

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

View File

@@ -1,4 +1,8 @@
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,7 +1,8 @@
<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, RowVirtualizer } from "$lib/components/atoms"; import { FileThumbnailButton } 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";
@@ -16,11 +17,25 @@
| { 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"; label: string } | { type: "header"; key: string; label: string }
| { type: "items"; items: FileEntry[]; isLast: boolean }; | { type: "items"; key: string; items: FileEntry[] };
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) => {
@@ -61,19 +76,18 @@
newRows.push({ newRows.push({
type: "header", type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!), label: formatDate(entries[0]!.date!),
}); });
newRows.push({
for (let i = 0; i < entries.length; i += 4) { type: "items",
newRows.push({ key: `items-${date}`,
type: "items", items: entries,
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();
@@ -96,29 +110,29 @@
}); });
</script> </script>
<RowVirtualizer <div bind:this={listElement} class="relative flex flex-grow flex-col">
count={rows.length} <div style="height: {$virtualizer.getTotalSize()}px;">
itemHeight={(index) => {#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
rows[index]!.type === "header" {@const row = rows[virtualRow.index]!}
? 28 <div
: Math.ceil(rows[index]!.items.length / 4) * 181 + use:measureRow
(Math.ceil(rows[index]!.items.length / 4) - 1) * 4 + data-index={virtualRow.index}
16} class="absolute left-0 top-0 w-full"
class="flex flex-grow flex-col" style="transform: translateY({virtualRow.start}px);"
> >
{#snippet item(index)} {#if row.type === "header"}
{@const row = rows[index]!} <p class="pb-2 font-medium">{row.label}</p>
{#if row.type === "header"} {:else}
<p class="pb-2 text-sm font-medium">{row.label}</p> <div class="grid grid-cols-4 gap-1 pb-4">
{:else} {#each row.items as { info }}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}> <FileThumbnailButton {info} onclick={onFileClick} />
{#each row.items as { info }} {/each}
<FileThumbnailButton {info} onclick={onFileClick} /> </div>
{/each} {/if}
</div> </div>
{/if} {/each}
{/snippet} </div>
{#snippet placeholder()} {#if $virtualizer.getVirtualItems().length === 0}
<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}
@@ -130,5 +144,5 @@
{/if} {/if}
</p> </p>
</div> </div>
{/snippet} {/if}
</RowVirtualizer> </div>

View File

@@ -1,5 +1,7 @@
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;
@@ -16,6 +18,8 @@ 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.svelte"; export * from "./upload";

View File

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

View File

@@ -1,11 +1,44 @@
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, isTRPCClientError } from "$trpc/client"; import { trpc } 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;
@@ -20,7 +53,117 @@ 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;
@@ -44,7 +187,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 (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") { if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null); info.set(null);
await deleteFileInfo(id); await deleteFileInfo(id);
return; return;
@@ -104,3 +247,124 @@ 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

@@ -1,341 +0,0 @@
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,6 +2,8 @@ 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,6 +4,8 @@ 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;
@@ -304,51 +306,39 @@ export const getAllFilesByCategory = async (
recurse: boolean, recurse: boolean,
) => { ) => {
const files = await db const files = await db
.withRecursive("category_tree", (db) => .withRecursive("cte", (db) =>
db db
.selectFrom("category") .selectFrom("category")
.select(["id", sql<number>`0`.as("depth")]) .leftJoin("file_category", "category.id", "file_category.category_id")
.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")
.innerJoin("category_tree", "category.parent_id", "category_tree.id") .leftJoin("file_category", "category.id", "file_category.category_id")
.select(["category.id", sql<number>`depth + 1`.as("depth")]), .innerJoin("cte", "category.parent_id", "cte.id")
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
), ),
), ),
) )
.selectFrom("category_tree") .selectFrom("cte")
.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( return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
(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,5 +1,6 @@
import { TRPCClientError } from "@trpc/client";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestSessionUpgrade = async ( export const requestSessionUpgrade = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -15,7 +16,7 @@ export const requestSessionUpgrade = async (
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
})); }));
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") { if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const; return [false, "Unregistered client"] as const;
} }
return [false] as const; return [false] as const;
@@ -30,7 +31,7 @@ export const requestSessionUpgrade = async (
force, force,
}); });
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "CONFLICT") { if (e instanceof TRPCClientError && 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,3 +1,4 @@
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -10,7 +11,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, isTRPCClientError } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -111,7 +112,10 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) { if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true; return true;
} }
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,5 +1,21 @@
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:
@@ -16,8 +32,16 @@ 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" => {

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
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.NAME_ASC) => { export const sortEntries = <T extends SortEntry>(entries: T[], sortBy: SortBy) => {
let sortFunc: SortFunc; let sortFunc: SortFunc;
switch (sortBy) { switch (sortBy) {
@@ -54,5 +54,4 @@ export const sortEntries = <T extends SortEntry>(entries: T[], sortBy = SortBy.N
} }
entries.sort(sortFunc); entries.sort(sortFunc);
return entries;
}; };

View File

@@ -6,7 +6,12 @@
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 { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import {
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";
@@ -27,7 +32,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);
@@ -85,10 +90,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) {
@@ -140,9 +145,7 @@
</button> </button>
<TopBarMenu <TopBarMenu
bind:isOpen={isMenuOpen} bind:isOpen={isMenuOpen}
directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "") directoryId={page.url.searchParams.get("from") === "category" ? $info?.parentId : undefined}
? $info?.parentId
: undefined}
{fileBlob} {fileBlob}
filename={$info?.name} filename={$info?.name}
/> />
@@ -185,12 +188,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,8 +1,9 @@
<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/filesystem2.svelte"; import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { requestCategoryCreation } from "./service"; import { requestCategoryCreation } from "./service";
@@ -13,48 +14,46 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let categoryInfoPromise: Promise<CategoryInfo | null> | undefined = $state(); let category: Writable<CategoryInfo | null> | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
categoryInfoPromise = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!); category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!);
} }
}); });
</script> </script>
{#await categoryInfoPromise then categoryInfo} {#if $category}
{#if categoryInfo} <BottomSheet bind:isOpen class="flex flex-col">
<BottomSheet bind:isOpen class="flex flex-col"> <FullscreenDiv>
<FullscreenDiv> <SubCategories
<SubCategories class="py-4"
class="py-4" info={$category}
info={categoryInfo} onSubCategoryClick={({ id }) =>
onSubCategoryClick={({ id }) => (category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
(categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} subCategoryCreatePosition="top"
subCategoryCreatePosition="top" />
/> {#if $category.id !== "root"}
{#if categoryInfo.id !== "root"} <BottomDiv>
<BottomDiv> <Button onclick={() => onAddToCategoryClick($category.id)} class="w-full">
<Button onclick={() => onAddToCategoryClick(categoryInfo.id)} class="w-full"> 이 카테고리에 추가하기
이 카테고리에 추가하기 </Button>
</Button> </BottomDiv>
</BottomDiv> {/if}
{/if} </FullscreenDiv>
</FullscreenDiv> </BottomSheet>
</BottomSheet> {/if}
<CategoryCreateModal <CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen} bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => { onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, categoryInfo.id, $masterKeyStore?.get(1)!)) { if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) {
categoryInfoPromise = getCategoryInfo(categoryInfo.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { FileUploadState } from "$lib/modules/file"; import type { Writable } from "svelte/store";
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";
@@ -10,45 +11,45 @@
import IconError from "~icons/material-symbols/error"; import IconError from "~icons/material-symbols/error";
interface Props { interface Props {
state: FileUploadState; status: Writable<FileUploadStatus>;
} }
let { state }: Props = $props(); let { status }: 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 state.status === "encryption-pending"} {#if $status.status === "encryption-pending"}
<IconPending /> <IconPending />
{:else if state.status === "encrypting"} {:else if $status.status === "encrypting"}
<IconLockClock /> <IconLockClock />
{:else if state.status === "upload-pending"} {:else if $status.status === "upload-pending"}
<IconCloud /> <IconCloud />
{:else if state.status === "uploading"} {:else if $status.status === "uploading"}
<IconCloudUpload /> <IconCloudUpload />
{:else if state.status === "uploaded"} {:else if $status.status === "uploaded"}
<IconCloudDone class="text-blue-500" /> <IconCloudDone class="text-blue-500" />
{:else if state.status === "error"} {:else if $status.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={state.name} class="truncate font-medium"> <p title={$status.name} class="truncate font-medium">
{state.name} {$status.name}
</p> </p>
<p class="text-xs text-gray-800"> <p class="text-xs text-gray-800">
{#if state.status === "encryption-pending"} {#if $status.status === "encryption-pending"}
준비 중 준비 중
{:else if state.status === "encrypting"} {:else if $status.status === "encrypting"}
암호화하는 중 암호화하는 중
{:else if state.status === "upload-pending"} {:else if $status.status === "upload-pending"}
업로드를 기다리는 중 업로드를 기다리는 중
{:else if state.status === "uploading"} {:else if $status.status === "uploading"}
전송됨 전송됨
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)} {Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if state.status === "uploaded"} {:else if $status.status === "uploaded"}
업로드 완료 업로드 완료
{:else if state.status === "error"} {:else if $status.status === "error"}
업로드 실패 업로드 실패
{/if} {/if}
</p> </p>

View File

@@ -1,7 +0,0 @@
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}?from=gallery`)} /> <Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} />
</FullscreenDiv> </FullscreenDiv>

View File

@@ -0,0 +1,7 @@
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

@@ -1,7 +0,0 @@
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

@@ -0,0 +1,7 @@
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,8 +1,9 @@
<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, type CategoryInfo } from "$lib/modules/filesystem2.svelte"; import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -18,7 +19,9 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let infoPromise: Promise<CategoryInfo> | undefined = $state(); let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
@@ -26,7 +29,20 @@
let isCategoryDeleteModalOpen = $state(false); let isCategoryDeleteModalOpen = $state(false);
$effect(() => { $effect(() => {
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = 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>
@@ -34,70 +50,68 @@
<title>카테고리</title> <title>카테고리</title>
</svelte:head> </svelte:head>
{#await infoPromise then info} {#if data.id !== "root"}
{#if info} <TopBar title={$info?.name} />
{#if info.id !== "root"} {/if}
<TopBar title={info.name} /> <div class="min-h-full bg-gray-100 pb-[5.5em]">
{/if} {#if $info && isFileRecursive !== undefined}
<div class="min-h-full bg-gray-100 pb-[5.5em]"> <Category
<Category bind:isFileRecursive
bind:isFileRecursive={info.isFileRecursive} info={$info}
{info} onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)} onFileRemoveClick={async ({ id }) => {
onFileRemoveClick={async ({ id }) => { await requestFileRemovalFromCategory(id, data.id as number);
await requestFileRemovalFromCategory(id, data.id as number); info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
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)}
<CategoryMenuBottomSheet onSubCategoryMenuClick={(subCategory) => {
bind:isOpen={isCategoryMenuBottomSheetOpen} context.selectedCategory = subCategory;
onRenameClick={() => { isCategoryMenuBottomSheetOpen = true;
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}
{/await} </div>
<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,17 +17,12 @@ export const useContext = () => {
}; };
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
if (!category.dataKey) { const newNameEncrypted = await encryptString(newName, 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.dataKey.version, dekVersion: category.dataKeyVersion,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });

View File

@@ -1,10 +1,11 @@
<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/filesystem2.svelte"; import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores"; import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
@@ -29,7 +30,7 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let infoPromise: Promise<DirectoryInfo> | undefined = $state(); let info: Writable<DirectoryInfo | null> | 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();
@@ -60,7 +61,7 @@
.then((res) => { .then((res) => {
if (!res) return; if (!res) return;
// TODO: FIXME // TODO: FIXME
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}) })
.catch((e: Error) => { .catch((e: Error) => {
// TODO: FIXME // TODO: FIXME
@@ -78,7 +79,7 @@
}); });
$effect(() => { $effect(() => {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}); });
</script> </script>
@@ -88,106 +89,106 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" /> <input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
{#await infoPromise then info} <div class="flex h-full flex-col">
{#if info} {#if showTopBar}
<div class="flex h-full flex-col"> <TopBar title={$info?.name} class="flex-shrink-0" />
{#if showTopBar} {/if}
<TopBar title={info.name} class="flex-shrink-0" /> {#if $info}
{/if} <div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}> <div class="flex gap-x-2">
<div class="flex gap-x-2"> <UploadStatusCard onclick={() => goto("/file/uploads")} />
<UploadStatusCard onclick={() => goto("/file/uploads")} /> <DownloadStatusCard onclick={() => goto("/file/downloads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} /> </div>
</div> {#key $info}
<DirectoryEntries <DirectoryEntries
{info} info={$info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)} onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => { onEntryMenuClick={(entry) => {
context.selectedEntry = entry; context.selectedEntry = entry;
isEntryMenuBottomSheetOpen = true; isEntryMenuBottomSheetOpen = true;
}} }}
showParentEntry={isFromFilePage && info.parentId !== undefined} showParentEntry={isFromFilePage && $info.parentId !== undefined}
onParentClick={() => onParentClick={() =>
goto( goto(
info.parentId === "root" $info.parentId === "root"
? "/directory?from=file" ? "/directory?from=file"
: `/directory/${info.parentId}?from=file`, : `/directory/${$info.parentId}?from=file`,
)} )}
/> />
</div> {/key}
</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}
{/await} </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)!)) {
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,9 +1,21 @@
<script lang="ts"> <script lang="ts">
import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms"; import { untrack } from "svelte";
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 { getUploadingFiles, type LiveFileUploadState } from "$lib/modules/file"; import {
import type { DirectoryInfo } from "$lib/modules/filesystem2.svelte"; getDirectoryInfo,
import { sortEntries } from "$lib/utils"; getFileInfo,
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";
@@ -15,6 +27,7 @@
onEntryMenuClick: (entry: SelectedEntry) => void; onEntryMenuClick: (entry: SelectedEntry) => void;
onParentClick?: () => void; onParentClick?: () => void;
showParentEntry?: boolean; showParentEntry?: boolean;
sortBy?: SortBy;
} }
let { let {
@@ -23,59 +36,104 @@
onEntryMenuClick, onEntryMenuClick,
onParentClick, onParentClick,
showParentEntry = false, showParentEntry = false,
sortBy = SortBy.NAME_ASC,
}: Props = $props(); }: Props = $props();
type Entry = interface DirectoryEntry {
| { type: "parent" } name?: string;
| { type: "directory"; name: string; details: (typeof info.subDirectories)[number] } info: Writable<DirectoryInfo | null>;
| { type: "file"; name: string; details: (typeof info.files)[number] } }
| { type: "uploading-file"; name: string; details: LiveFileUploadState };
const toEntry = type FileEntry =
<T extends Exclude<Entry["type"], "parent">>(type: T) => | {
(details: Extract<Entry, { type: T }>["details"]) => ({ type: "file";
type, name?: string;
name: details.name, info: Writable<FileInfo | null>;
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,
})),
);
let entries = $derived([ const sort = () => {
...(showParentEntry ? ([{ type: "parent" }] as const) : []), sortEntries(subDirectories, sortBy);
...sortEntries(info.subDirectories.map(toEntry("directory"))), sortEntries(files, sortBy);
...sortEntries([ };
...info.files.map(toEntry("file")), return untrack(() => {
...getUploadingFiles(info.id).map(toEntry("uploading-file")), sort();
]),
]); const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if entries.length > 0} {#if subDirectories.length + files.length > 0 || showParentEntry}
<div class="pb-[4.5rem]"> <div class="space-y-1 pb-[4.5rem]">
<RowVirtualizer {#if showParentEntry}
count={entries.length} <ActionEntryButton class="h-14" onclick={onParentClick}>
itemHeight={(index) => 56 + (index + 1 < entries.length ? 4 : 0)} <DirectoryEntryLabel type="parent-directory" name=".." />
> </ActionEntryButton>
{#snippet item(index)} {/if}
{@const entry = entries[index]!} {#each subDirectories as { info }}
<div class={index + 1 < entries.length ? "pb-1" : ""}> <SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{#if entry.type === "parent"} {/each}
<ActionEntryButton class="h-14" onclick={onParentClick}> {#each files as file}
<DirectoryEntryLabel type="parent-directory" name=".." /> {#if file.type === "file"}
</ActionEntryButton> <File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else if entry.type === "directory"} {:else}
<SubDirectory <UploadingFile status={file.info} />
info={entry.details} {/if}
onclick={onEntryClick} {/each}
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,52 +1,66 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; 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 { SummarizedFileInfo } from "$lib/modules/filesystem2.svelte"; import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
import { formatDateTime } from "$lib/utils"; import { formatDateTime } from "$lib/utils";
import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert"; import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props { interface Props {
info: SummarizedFileInfo; info: Writable<FileInfo | null>;
onclick: (entry: SelectedEntry) => void; onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (entry: SelectedEntry) => void; onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
} }
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
let showThumbnail = $derived( let thumbnail: string | undefined = $state();
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
);
let thumbnailPromise = $derived(
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
);
const action = (callback: typeof onclick) => { const openFile = () => {
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name }); const { id, dataKey, dataKeyVersion, name } = $info!;
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>
<ActionEntryButton {#if $info}
class="h-14" <ActionEntryButton
onclick={() => action(onclick)} class="h-14"
actionButtonIcon={IconMoreVert} onclick={openFile}
onActionButtonClick={() => action(onOpenMenuClick)} actionButtonIcon={IconMoreVert}
> onActionButtonClick={openMenu}
{#await thumbnailPromise} >
<DirectoryEntryLabel <DirectoryEntryLabel
type="file" type="file"
name={info.name} {thumbnail}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)} name={$info.name}
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
/> />
{:then thumbnail} </ActionEntryButton>
<DirectoryEntryLabel {/if}
type="file"
thumbnail={thumbnail ?? undefined}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
/>
{/await}
</ActionEntryButton>

View File

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

View File

@@ -1,35 +1,38 @@
<script lang="ts"> <script lang="ts">
import type { LiveFileUploadState } from "$lib/modules/file"; import type { Writable } from "svelte/store";
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 {
state: LiveFileUploadState; status: Writable<FileUploadStatus>;
} }
let { state }: Props = $props(); let { status }: Props = $props();
</script> </script>
<div class="flex h-14 gap-x-4 p-2"> {#if isFileUploading($status.status)}
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl"> <div class="flex h-14 gap-x-4 p-2">
<IconDraft class="text-gray-600" /> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<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>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800"> {/if}
<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

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

View File

@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getUploadingFiles } from "$lib/modules/file"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { fileUploadStatusStore, isFileUploading, type FileUploadStatus } from "$lib/stores";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -7,7 +9,21 @@
let { onclick }: Props = $props(); let { onclick }: Props = $props();
let uploadingFiles = $derived(getUploadingFiles()); let uploadingFiles: Writable<FileUploadStatus>[] = $state([]);
$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,7 +14,8 @@ import { trpc } from "$trpc/client";
export interface SelectedEntry { export interface SelectedEntry {
type: "directory" | "file"; type: "directory" | "file";
id: number; id: number;
dataKey: { key: CryptoKey; version: Date } | undefined; dataKey: CryptoKey;
dataKeyVersion: Date;
name: string; name: string;
} }
@@ -96,25 +97,20 @@ export const requestFileUpload = async (
}; };
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
if (!entry.dataKey) { const newNameEncrypted = await encryptString(newName, 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.dataKey.version, dekVersion: entry.dataKeyVersion,
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.dataKey.version, dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });

View File

@@ -21,16 +21,14 @@
<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="rounded-xl bg-white p-2"> <div class="space-y-2 rounded-xl bg-white px-2 pb-4 pt-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>
{#if mediaFiles.length > 0} <div class="grid grid-cols-4 gap-2 px-2">
<div class="grid grid-cols-4 gap-2 p-2"> {#each mediaFiles as file}
{#each mediaFiles as file} <FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} /> {/each}
{/each} </div>
</div>
{/if}
</div> </div>
</div> </div>

View File

@@ -1,7 +0,0 @@
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

@@ -0,0 +1,7 @@
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,9 +2,10 @@
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,
@@ -15,7 +16,7 @@
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => { const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if ( if (
getUploadingFiles().length > 0 || $fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
$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, TRPCClientError } from "@trpc/client"; import { createTRPCClient, httpBatchLink } 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,7 +24,3 @@ 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,7 +9,6 @@ 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 }) => {
@@ -21,12 +20,7 @@ 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, files] = await Promise.all([ const categories = await CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id);
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,
@@ -36,28 +30,7 @@ const categoryRouter = router({
name: category.encName.ciphertext, name: category.encName.ciphertext,
nameIv: category.encName.iv, nameIv: category.encName.iv,
}, },
subCategories: categories.map((category) => ({ subCategories: categories.map(({ id }) => id),
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,
})),
}; };
}), }),
@@ -140,6 +113,27 @@ 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,27 +32,8 @@ const directoryRouter = router({
name: directory.encName.ciphertext, name: directory.encName.ciphertext,
nameIv: directory.encName.iv, nameIv: directory.encName.iv,
}, },
subDirectories: directories.map((directory) => ({ subDirectories: directories.map(({ id }) => id),
id: directory.id, files: files.map(({ id }) => 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,
})),
}; };
}), }),