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 "./inputs";
export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

@@ -1,29 +1,59 @@
<script lang="ts">
import type { Component } from "svelte";
import { untrack, type Component } from "svelte";
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 Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categories: SubCategoryInfo[];
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
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>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category}
{#each categoriesWithName as { info }}
<Category
info={category}
{info}
menuIcon={categoryMenuIcon}
onclick={onCategoryClick}
onMenuClick={onCategoryMenuClick}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte";
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 { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
@@ -16,11 +17,25 @@
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; label: string }
| { type: "items"; items: FileEntry[]; isLast: boolean };
| { type: "header"; key: string; label: string }
| { type: "items"; key: string; items: FileEntry[] };
let filesWithDate: FileEntry[] = $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(() => {
filesWithDate = files.map((file) => {
@@ -61,19 +76,18 @@
newRows.push({
type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!),
});
for (let i = 0; i < entries.length; i += 4) {
newRows.push({
type: "items",
items: entries.slice(i, i + 4),
isLast: i + 4 >= entries.length,
});
}
newRows.push({
type: "items",
key: `items-${date}`,
items: entries,
});
}
rows = newRows;
$virtualizer.setOptions({ count: rows.length });
};
return untrack(() => {
buildRows();
@@ -96,29 +110,29 @@
});
</script>
<RowVirtualizer
count={rows.length}
itemHeight={(index) =>
rows[index]!.type === "header"
? 28
: Math.ceil(rows[index]!.items.length / 4) * 181 +
(Math.ceil(rows[index]!.items.length / 4) - 1) * 4 +
16}
class="flex flex-grow flex-col"
>
{#snippet item(index)}
{@const row = rows[index]!}
{#if row.type === "header"}
<p class="pb-2 text-sm font-medium">{row.label}</p>
{:else}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/each}
<div bind:this={listElement} class="relative flex flex-grow flex-col">
<div style="height: {$virtualizer.getTotalSize()}px;">
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
{@const row = rows[virtualRow.index]!}
<div
use:measureRow
data-index={virtualRow.index}
class="absolute left-0 top-0 w-full"
style="transform: translateY({virtualRow.start}px);"
>
{#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p>
{:else}
<div class="grid grid-cols-4 gap-1 pb-4">
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet placeholder()}
{/each}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
@@ -130,5 +144,5 @@
{/if}
</p>
</div>
{/snippet}
</RowVirtualizer>
{/if}
</div>

View File

@@ -1,5 +1,7 @@
import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo {
id: number;
parentId: DirectoryId;
@@ -16,6 +18,8 @@ interface FileInfo {
categoryIds: number[];
}
export type CategoryId = "root" | number;
interface CategoryInfo {
id: number;
parentId: CategoryId;

View File

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

View File

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

View File

@@ -1,11 +1,44 @@
import { TRPCClientError } from "@trpc/client";
import { get, writable, type Writable } from "svelte/store";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client";
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 {
id: number;
@@ -20,7 +53,117 @@ export interface FileInfo {
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({
id,
parentId: directory.parentId,
name: directory.name,
subDirectoryIds,
fileIds,
});
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().directory.get.query({ id });
} catch (e) {
if (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>) => {
if (get(info)) return;
@@ -44,7 +187,7 @@ const fetchFileInfoFromServer = async (
try {
metadata = await trpc().file.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
@@ -104,3 +247,124 @@ export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
fetchFileInfo(fileId, info, masterKey); // Intended
return info;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
) => {
if (get(info)) return;
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
info.set({ id, subCategoryIds });
} else {
if (!category) return;
info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
let data;
try {
data = await trpc().category.get.query({ id });
} catch (e) {
if (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 type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category {
id: number;
parentId: CategoryId;

View File

@@ -4,6 +4,8 @@ import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
export type DirectoryId = "root" | number;
interface Directory {
id: number;
parentId: DirectoryId;
@@ -304,51 +306,39 @@ export const getAllFilesByCategory = async (
recurse: boolean,
) => {
const files = await db
.withRecursive("category_tree", (db) =>
.withRecursive("cte", (db) =>
db
.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("user_id", "=", userId)
.$if(recurse, (qb) =>
qb.unionAll((db) =>
db
.selectFrom("category")
.innerJoin("category_tree", "category.parent_id", "category_tree.id")
.select(["category.id", sql<number>`depth + 1`.as("depth")]),
.leftJoin("file_category", "category.id", "file_category.category_id")
.innerJoin("cte", "category.parent_id", "cte.id")
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
),
),
)
.selectFrom("category_tree")
.innerJoin("file_category", "category_tree.id", "file_category.category_id")
.innerJoin("file", "file_category.file_id", "file.id")
.selectFrom("cte")
.select(["file_id", "depth"])
.selectAll("file")
.distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id")
.orderBy("depth")
.execute();
return files.map(
(file) =>
({
id: 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 },
);
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
};
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 { trpc, isTRPCClientError } from "$trpc/client";
import { trpc } from "$trpc/client";
export const requestSessionUpgrade = async (
encryptKeyBase64: string,
@@ -15,7 +16,7 @@ export const requestSessionUpgrade = async (
sigPubKey: verifyKeyBase64,
}));
} 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] as const;
@@ -30,7 +31,7 @@ export const requestSessionUpgrade = async (
force,
});
} 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] as const;

View File

@@ -1,3 +1,4 @@
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
@@ -10,7 +11,7 @@ import {
} from "$lib/modules/crypto";
import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { trpc, isTRPCClientError } from "$trpc/client";
import { trpc } from "$trpc/client";
export const requestClientRegistration = async (
encryptKeyBase64: string,
@@ -111,7 +112,10 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
} 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;
}
// TODO: Error Handling

View File

@@ -1,5 +1,21 @@
import { writable, type Writable } from "svelte/store";
export interface FileUploadStatus {
name: string;
parentId: "root" | number;
status:
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export interface FileDownloadStatus {
id: number;
status:
@@ -16,8 +32,16 @@ export interface FileDownloadStatus {
result?: ArrayBuffer;
}
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
export const isFileUploading = (
status: FileUploadStatus["status"],
): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
};
export const isFileDownloading = (
status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {

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 "./gotoStateful";
export * from "./promise";
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);
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;
switch (sortBy) {
@@ -54,5 +54,4 @@ export const sortEntries = <T extends SortEntry>(entries: T[], sortBy = SortBy.N
}
entries.sort(sortFunc);
return entries;
};

View File

@@ -6,7 +6,12 @@
import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import {
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
@@ -27,7 +32,7 @@
let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state();
// let categories: Writable<CategoryInfo | null>[] = $state([]);
let categories: Writable<CategoryInfo | null>[] = $state([]);
let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false);
@@ -85,10 +90,10 @@
viewerType = undefined;
});
// $effect(() => {
// categories =
// $info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
// });
$effect(() => {
categories =
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
});
$effect(() => {
if ($info && $info.dataKey && $info.contentIv) {
@@ -140,9 +145,7 @@
</button>
<TopBarMenu
bind:isOpen={isMenuOpen}
directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? $info?.parentId
: undefined}
directoryId={page.url.searchParams.get("from") === "category" ? $info?.parentId : undefined}
{fileBlob}
filename={$info?.name}
/>
@@ -185,12 +188,12 @@
<div class="space-y-2">
<p class="text-lg font-bold">카테고리</p>
<div class="space-y-1">
<!-- <Categories
<Categories
{categories}
categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)}
/> -->
/>
<IconEntryButton
icon={IconAddCircle}
onclick={() => (isAddToCategoryBottomSheetOpen = true)}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<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 IconPending from "~icons/material-symbols/pending";
@@ -10,45 +11,45 @@
import IconError from "~icons/material-symbols/error";
interface Props {
state: FileUploadState;
status: Writable<FileUploadStatus>;
}
let { state }: Props = $props();
let { status }: Props = $props();
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if state.status === "encryption-pending"}
{#if $status.status === "encryption-pending"}
<IconPending />
{:else if state.status === "encrypting"}
{:else if $status.status === "encrypting"}
<IconLockClock />
{:else if state.status === "upload-pending"}
{:else if $status.status === "upload-pending"}
<IconCloud />
{:else if state.status === "uploading"}
{:else if $status.status === "uploading"}
<IconCloudUpload />
{:else if state.status === "uploaded"}
{:else if $status.status === "uploaded"}
<IconCloudDone class="text-blue-500" />
{:else if state.status === "error"}
{:else if $status.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={state.name} class="truncate font-medium">
{state.name}
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<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)}
{:else if state.status === "uploaded"}
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "uploaded"}
업로드 완료
{:else if state.status === "error"}
{:else if $status.status === "error"}
업로드 실패
{/if}
</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="사진 및 동영상" />
<FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)} />
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} />
</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">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules";
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 CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -18,7 +19,9 @@
let { data } = $props();
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 isCategoryMenuBottomSheetOpen = $state(false);
@@ -26,7 +29,20 @@
let isCategoryDeleteModalOpen = $state(false);
$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>
@@ -34,70 +50,68 @@
<title>카테고리</title>
</svelte:head>
{#await infoPromise then info}
{#if info}
{#if info.id !== "root"}
<TopBar title={info.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
<Category
bind:isFileRecursive={info.isFileRecursive}
{info}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
context.selectedCategory = subCategory;
isCategoryMenuBottomSheetOpen = true;
}}
/>
</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;
{#if data.id !== "root"}
<TopBar title={$info?.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info && isFileRecursive !== undefined}
<Category
bind:isFileRecursive
info={$info}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}}
/>
<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)) {
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;
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryMenuClick={(subCategory) => {
context.selectedCategory = subCategory;
isCategoryMenuBottomSheetOpen = true;
}}
/>
{/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) => {
if (!category.dataKey) {
// TODO: Error Handling
return false;
}
const newNameEncrypted = await encryptString(newName, category.dataKey.key);
const newNameEncrypted = await encryptString(newName, category.dataKey);
try {
await trpc().category.rename.mutate({
id: category.id,
dekVersion: category.dataKey.version,
dekVersion: category.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FloatingButton } from "$lib/components/atoms";
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 DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
@@ -29,7 +30,7 @@
let { data } = $props();
let context = createContext();
let infoPromise: Promise<DirectoryInfo> | undefined = $state();
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -60,7 +61,7 @@
.then((res) => {
if (!res) return;
// TODO: FIXME
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
})
.catch((e: Error) => {
// TODO: FIXME
@@ -78,7 +79,7 @@
});
$effect(() => {
infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
@@ -88,106 +89,106 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
{#await infoPromise then info}
{#if info}
<div class="flex h-full flex-col">
{#if showTopBar}
<TopBar title={info.name} class="flex-shrink-0" />
{/if}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
<div class="flex h-full flex-col">
{#if showTopBar}
<TopBar title={$info?.name} class="flex-shrink-0" />
{/if}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
{#key $info}
<DirectoryEntries
{info}
info={$info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => {
context.selectedEntry = entry;
isEntryMenuBottomSheetOpen = true;
}}
showParentEntry={isFromFilePage && info.parentId !== undefined}
showParentEntry={isFromFilePage && $info.parentId !== undefined}
onParentClick={() =>
goto(
info.parentId === "root"
$info.parentId === "root"
? "/directory?from=file"
: `/directory/${info.parentId}?from=file`,
: `/directory/${$info.parentId}?from=file`,
)}
/>
</div>
{/key}
</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}
{/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">
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 { getUploadingFiles, type LiveFileUploadState } from "$lib/modules/file";
import type { DirectoryInfo } from "$lib/modules/filesystem2.svelte";
import { sortEntries } from "$lib/utils";
import {
getDirectoryInfo,
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 SubDirectory from "./SubDirectory.svelte";
import UploadingFile from "./UploadingFile.svelte";
@@ -15,6 +27,7 @@
onEntryMenuClick: (entry: SelectedEntry) => void;
onParentClick?: () => void;
showParentEntry?: boolean;
sortBy?: SortBy;
}
let {
@@ -23,59 +36,104 @@
onEntryMenuClick,
onParentClick,
showParentEntry = false,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
type Entry =
| { type: "parent" }
| { type: "directory"; name: string; details: (typeof info.subDirectories)[number] }
| { type: "file"; name: string; details: (typeof info.files)[number] }
| { type: "uploading-file"; name: string; details: LiveFileUploadState };
interface DirectoryEntry {
name?: string;
info: Writable<DirectoryInfo | null>;
}
const toEntry =
<T extends Exclude<Entry["type"], "parent">>(type: T) =>
(details: Extract<Entry, { type: T }>["details"]) => ({
type,
name: details.name,
details,
type FileEntry =
| {
type: "file";
name?: string;
info: Writable<FileInfo | null>;
}
| {
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([
...(showParentEntry ? ([{ type: "parent" }] as const) : []),
...sortEntries(info.subDirectories.map(toEntry("directory"))),
...sortEntries([
...info.files.map(toEntry("file")),
...getUploadingFiles(info.id).map(toEntry("uploading-file")),
]),
]);
const sort = () => {
sortEntries(subDirectories, sortBy);
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if entries.length > 0}
<div class="pb-[4.5rem]">
<RowVirtualizer
count={entries.length}
itemHeight={(index) => 56 + (index + 1 < entries.length ? 4 : 0)}
>
{#snippet item(index)}
{@const entry = entries[index]!}
<div class={index + 1 < entries.length ? "pb-1" : ""}>
{#if entry.type === "parent"}
<ActionEntryButton class="h-14" onclick={onParentClick}>
<DirectoryEntryLabel type="parent-directory" name=".." />
</ActionEntryButton>
{:else if entry.type === "directory"}
<SubDirectory
info={entry.details}
onclick={onEntryClick}
onOpenMenuClick={onEntryMenuClick}
/>
{:else if entry.type === "file"}
<File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile state={entry.details} />
{/if}
</div>
{/snippet}
</RowVirtualizer>
{#if subDirectories.length + files.length > 0 || showParentEntry}
<div class="space-y-1 pb-[4.5rem]">
{#if showParentEntry}
<ActionEntryButton class="h-14" onclick={onParentClick}>
<DirectoryEntryLabel type="parent-directory" name=".." />
</ActionEntryButton>
{/if}
{#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}
{#each files as file}
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}
<UploadingFile status={file.info} />
{/if}
{/each}
</div>
{:else}
<div class="flex flex-grow items-center justify-center">

View File

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

View File

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

View File

@@ -1,35 +1,38 @@
<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 IconDraft from "~icons/material-symbols/draft";
interface Props {
state: LiveFileUploadState;
status: Writable<FileUploadStatus>;
}
let { state }: Props = $props();
let { status }: Props = $props();
</script>
<div class="flex h-14 gap-x-4 p-2">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<IconDraft class="text-gray-600" />
{#if isFileUploading($status.status)}
<div class="flex h-14 gap-x-4 p-2">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<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 class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={state.name} class="truncate font-medium">
{state.name}
</p>
<p class="text-xs">
{#if state.status === "encryption-pending"}
준비 중
{:else if state.status === "encrypting"}
암호화하는 중
{:else if state.status === "upload-pending"}
업로드를 기다리는 중
{:else if state.status === "uploading"}
전송됨 {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if}
</p>
</div>
</div>
{/if}

View File

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

View File

@@ -1,5 +1,7 @@
<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 {
onclick: () => void;
@@ -7,7 +9,21 @@
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>
{#if uploadingFiles.length > 0}

View File

@@ -14,7 +14,8 @@ import { trpc } from "$trpc/client";
export interface SelectedEntry {
type: "directory" | "file";
id: number;
dataKey: { key: CryptoKey; version: Date } | undefined;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}
@@ -96,25 +97,20 @@ export const requestFileUpload = async (
};
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
if (!entry.dataKey) {
// TODO: Error Handling
return false;
}
const newNameEncrypted = await encryptString(newName, entry.dataKey.key);
const newNameEncrypted = await encryptString(newName, entry.dataKey);
try {
if (entry.type === "directory") {
await trpc().directory.rename.mutate({
id: entry.id,
dekVersion: entry.dataKey.version,
dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
} else {
await trpc().file.rename.mutate({
id: entry.id,
dekVersion: entry.dataKey.version,
dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext,
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">
<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">
<p class="text-left font-semibold">사진 및 동영상</p>
</EntryButton>
{#if mediaFiles.length > 0}
<div class="grid grid-cols-4 gap-2 p-2">
{#each mediaFiles as file}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/each}
</div>
{/if}
<div class="grid grid-cols-4 gap-2 px-2">
{#each mediaFiles as file}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/each}
</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 { get } from "svelte/store";
import { goto as svelteGoto } from "$app/navigation";
import { getUploadingFiles } from "$lib/modules/file";
import {
fileUploadStatusStore,
fileDownloadStatusStore,
isFileUploading,
isFileDownloading,
clientKeyStore,
masterKeyStore,
@@ -15,7 +16,7 @@
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if (
getUploadingFiles().length > 0 ||
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
) {
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 { browser } from "$app/environment";
import type { AppRouter } from "./router.server";
@@ -24,7 +24,3 @@ export const trpc = (fetch = globalThis.fetch) => {
}
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(
z.object({
id: categoryIdSchema,
recurse: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
@@ -21,12 +20,7 @@ const categoryRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
}
const [categories, files] = await Promise.all([
CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id),
input.id !== "root"
? FileRepo.getAllFilesByCategory(ctx.session.userId, input.id, input.recurse)
: undefined,
]);
const categories = await CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id);
return {
metadata: category && {
parent: category.parentId,
@@ -36,28 +30,7 @@ const categoryRouter = router({
name: category.encName.ciphertext,
nameIv: category.encName.iv,
},
subCategories: categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
files: files?.map((file) => ({
id: file.id,
mekVersion: file.mekVersion,
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
isRecursive: file.isRecursive,
})),
subCategories: categories.map(({ id }) => id),
};
}),
@@ -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"]
.input(
z.object({

View File

@@ -32,27 +32,8 @@ const directoryRouter = router({
name: directory.encName.ciphertext,
nameIv: directory.encName.iv,
},
subDirectories: directories.map((directory) => ({
id: directory.id,
mekVersion: directory.mekVersion,
dek: directory.encDek,
dekVersion: directory.dekVersion,
name: directory.encName.ciphertext,
nameIv: directory.encName.iv,
})),
files: files.map((file) => ({
id: file.id,
mekVersion: file.mekVersion,
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
})),
subDirectories: directories.map(({ id }) => id),
files: files.map(({ id }) => id),
};
}),