파일 페이지에서의 네트워크 호출 최적화

This commit is contained in:
static
2025-12-30 23:30:50 +09:00
parent b5522a4c6d
commit e4413ddbf6
24 changed files with 521 additions and 618 deletions

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { decryptData } from "$lib/modules/crypto";
export interface FileDownloadState {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export type LiveFileDownloadState = FileDownloadState & {
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
};
let downloadingFiles: FileDownloadState[] = $state([]);
export const isFileDownloading = (
status: FileDownloadState["status"],
): status is LiveFileDownloadState["status"] =>
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
export const getFileDownloadState = (fileId: number) => {
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
};
export const getDownloadingFiles = () => {
return downloadingFiles.filter((file): file is LiveFileDownloadState =>
isFileDownloading(file.status),
);
};
export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
const requestFileDownload = limitFunction(
async (state: FileDownloadState, id: number) => {
state.status = "download-pending";
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
},
});
const fileEncrypted: ArrayBuffer = res.data;
state.status = "decryption-pending";
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
state.status = "decrypting";
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
state.status = "decrypted";
state.result = fileBuffer;
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
downloadingFiles.push({
id,
status: "download-pending",
});
const state = downloadingFiles.at(-1)!;
try {
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
} catch (e) {
state.status = "error";
throw e;
}
};

View File

@@ -1,84 +0,0 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { decryptData } from "$lib/modules/crypto";
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
const requestFileDownload = limitFunction(
async (status: Writable<FileDownloadStatus>, id: number) => {
status.update((value) => {
value.status = "downloading";
return value;
});
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const fileEncrypted: ArrayBuffer = res.data;
status.update((value) => {
value.status = "decryption-pending";
return value;
});
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
status: Writable<FileDownloadStatus>,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
status.update((value) => {
value.status = "decrypting";
return value;
});
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
status.update((value) => {
value.status = "decrypted";
value.result = fileBuffer;
return value;
});
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
const status = writable<FileDownloadStatus>({
id,
status: "download-pending",
});
fileDownloadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
return await decryptFile(
status,
await requestFileDownload(status, id),
fileEncryptedIv,
dataKey,
);
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

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

View File

@@ -1,106 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import {
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client";
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
let metadata;
try {
metadata = await trpc().file.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey); // Intended
return info;
};

View File

@@ -41,12 +41,12 @@ interface RootDirectoryInfo {
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo; export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">; export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
interface FileInfo { export interface FileInfo {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
dataKey?: DataKey; dataKey?: DataKey;
contentType: string; contentType: string;
contentIv: string | undefined; contentIv?: string;
name: string; name: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
@@ -81,6 +81,7 @@ export type SubCategoryInfo = Omit<
>; >;
const directoryInfoCache = new Map<DirectoryId, DirectoryInfo | Promise<DirectoryInfo>>(); const directoryInfoCache = new Map<DirectoryId, DirectoryInfo | Promise<DirectoryInfo>>();
const fileInfoCache = new Map<number, FileInfo | Promise<FileInfo>>();
const categoryInfoCache = new Map<CategoryId, CategoryInfo | Promise<CategoryInfo>>(); const categoryInfoCache = new Map<CategoryId, CategoryInfo | Promise<CategoryInfo>>();
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
@@ -197,6 +198,100 @@ const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) =
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10)); return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
}; };
export const getFileInfo = async (id: number, masterKey: CryptoKey) => {
const info = fileInfoCache.get(id);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<FileInfo>();
if (!info) {
fileInfoCache.set(id, promise);
}
monotonicResolve(
[!info && fetchFileInfoFromIndexedDB(id), fetchFileInfoFromServer(id, masterKey)],
(fileInfo) => {
let info = fileInfoCache.get(id);
if (info instanceof Promise) {
const state = $state(fileInfo);
fileInfoCache.set(id, state);
resolve(state);
} else {
Object.assign(info!, fileInfo);
resolve(info!);
}
},
);
return info ?? promise;
};
const fetchFileInfoFromIndexedDB = async (id: number): Promise<FileInfo | undefined> => {
const file = await getFileInfoFromIndexedDB(id);
const categories = await Promise.all(
file?.categoryIds.map(async (categoryId) => {
const categoryInfo = await getCategoryInfoFromIndexedDB(categoryId);
return categoryInfo ? { id: categoryId, name: categoryInfo.name } : undefined;
}) ?? [],
);
if (file) {
return {
id,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories.filter((category) => !!category),
};
}
};
const fetchFileInfoFromServer = async (
id: number,
masterKey: CryptoKey,
): Promise<FileInfo | undefined> => {
try {
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
const categories = await Promise.all(
categoriesRaw.map(async (category) => {
const { dataKey } = await unwrapDataKey(category.dek, masterKey);
const name = await decryptString(category.name, category.nameIv, dataKey);
return { id: category.id, name };
}),
);
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(metadata.name, metadata.nameIv, dataKey),
metadata.createdAt
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
: undefined,
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
]);
return {
id,
parentId: metadata.parent,
dataKey: { key: dataKey, version: new Date(metadata.dekVersion) },
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categories,
};
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
fileInfoCache.delete(id);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
};
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
const info = categoryInfoCache.get(id); const info = categoryInfoCache.get(id);
if (info instanceof Promise) { if (info instanceof Promise) {

View File

@@ -1,4 +1,4 @@
import { sql, type NotNull } from "kysely"; import { sql } from "kysely";
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "./error";
import db from "./kysely"; import db from "./kysely";
@@ -486,10 +486,17 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => { export const getAllFileCategories = async (fileId: number) => {
const categories = await db const categories = await db
.selectFrom("file_category") .selectFrom("file_category")
.select("category_id") .innerJoin("category", "file_category.category_id", "category.id")
.selectAll("category")
.where("file_id", "=", fileId) .where("file_id", "=", fileId)
.execute(); .execute();
return categories.map(({ category_id }) => ({ id: category_id })); return categories.map((category) => ({
id: category.id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}));
}; };
export const removeFileFromCategory = async (fileId: number, categoryId: number) => { export const removeFileFromCategory = async (fileId: number, categoryId: number) => {

View File

@@ -1,25 +0,0 @@
import { writable, type Writable } from "svelte/store";
export interface FileDownloadStatus {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
export const isFileDownloading = (
status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
};

View File

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

View File

@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import FileSaver from "file-saver"; import FileSaver from "file-saver";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { getFileInfo, type FileInfo } from "$lib/modules/filesystem2.svelte";
import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores"; import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte"; import DownloadStatus from "./DownloadStatus.svelte";
import { import {
@@ -26,19 +26,13 @@
let { data } = $props(); let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state(); let infoPromise: Promise<FileInfo | null> | undefined = $state();
// let categories: Writable<CategoryInfo | null>[] = $state([]); let info: FileInfo | null = $state(null);
let downloadState = $derived(getFileDownloadState(data.id));
let isMenuOpen = $state(false); let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false); let isAddToCategoryBottomSheetOpen = $state(false);
let downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
const { id, status } = get(statusStore);
return id === data.id && isFileDownloading(status);
}),
);
let isDownloadRequested = $state(false); let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state(); let viewerType: "image" | "video" | undefined = $state();
let fileBlob: Blob | undefined = $state(); let fileBlob: Blob | undefined = $state();
@@ -71,28 +65,27 @@
const addToCategory = async (categoryId: number) => { const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId); await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false; isAddToCategoryBottomSheetOpen = false;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
const removeFromCategory = async (categoryId: number) => { const removeFromCategory = async (categoryId: number) => {
await requestFileRemovalFromCategory(data.id, categoryId); await requestFileRemovalFromCategory(data.id, categoryId);
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
$effect(() => { $effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => {
info = fileInfo;
return fileInfo;
});
info = null;
isDownloadRequested = false; isDownloadRequested = false;
viewerType = undefined; viewerType = undefined;
}); });
// $effect(() => {
// categories =
// $info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
// });
$effect(() => { $effect(() => {
if ($info && $info.dataKey && $info.contentIv) { if (info?.dataKey) {
const contentType = $info.contentType; const contentType = info.contentType;
if (contentType.startsWith("image")) { if (contentType.startsWith("image")) {
viewerType = "image"; viewerType = "image";
} else if (contentType.startsWith("video")) { } else if (contentType.startsWith("video")) {
@@ -100,24 +93,24 @@
} }
untrack(() => { untrack(() => {
if (!downloadStatus && !isDownloadRequested) { if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true; isDownloadRequested = true;
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => { requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType); const blob = await updateViewer(buffer, contentType);
if (!viewerType) { if (!viewerType) {
FileSaver.saveAs(blob, $info.name); FileSaver.saveAs(blob, info!.name);
} }
}); },
);
} }
}); });
} }
}); });
$effect(() => { $effect(() => {
if ($info && $downloadStatus?.status === "decrypted") { if (info && downloadState?.status === "decrypted") {
untrack( untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType));
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
} }
}); });
@@ -128,7 +121,8 @@
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<TopBar title={$info?.name}> {#if info}
<TopBar title={info.name}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={(e) => e.stopPropagation()}> <div onclick={(e) => e.stopPropagation()}>
@@ -141,17 +135,19 @@
<TopBarMenu <TopBarMenu
bind:isOpen={isMenuOpen} bind:isOpen={isMenuOpen}
directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "") directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? $info?.parentId ? info.parentId
: undefined} : undefined}
{fileBlob} {fileBlob}
filename={$info?.name} filename={info.name}
/> />
</div> </div>
</TopBar> </TopBar>
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-4 pb-4"> <div class="space-y-4 pb-4">
<DownloadStatus status={downloadStatus} /> {#if downloadState}
{#if $info && viewerType} <DownloadStatus state={downloadState} />
{/if}
{#if viewerType}
<div class="flex w-full justify-center"> <div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)} {#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p> <p class="text-gray-500">{message}</p>
@@ -159,7 +155,7 @@
{#if viewerType === "image"} {#if viewerType === "image"}
{#if fileBlobUrl} {#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} /> <img src={fileBlobUrl} alt={info.name} onerror={convertHeicToJpeg} />
{:else} {:else}
{@render viewerLoading("이미지를 불러오고 있어요.")} {@render viewerLoading("이미지를 불러오고 있어요.")}
{/if} {/if}
@@ -170,7 +166,7 @@
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video> <video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton <IconEntryButton
icon={IconCamera} icon={IconCamera}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)} onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
class="w-full" class="w-full"
> >
이 장면을 썸네일로 설정하기 이 장면을 썸네일로 설정하기
@@ -185,12 +181,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={info.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)}
@@ -209,3 +205,4 @@
bind:isOpen={isAddToCategoryBottomSheetOpen} bind:isOpen={isAddToCategoryBottomSheetOpen}
onAddToCategoryClick={addToCategory} onAddToCategoryClick={addToCategory}
/> />
{/if}

View File

@@ -1,32 +1,31 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { isFileDownloading, type FileDownloadState } from "$lib/modules/file";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
interface Props { interface Props {
status?: Writable<FileDownloadStatus>; state: FileDownloadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
{#if $status && isFileDownloading($status.status)} {#if isFileDownloading(state.status)}
<div class="w-full rounded-xl bg-gray-100 p-3"> <div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium"> <p class="font-medium">
{#if $status.status === "download-pending"} {#if state.status === "download-pending"}
다운로드를 기다리는 중 다운로드를 기다리는 중
{:else if $status.status === "downloading"} {:else if state.status === "downloading"}
다운로드하는 중 다운로드하는 중
{:else if $status.status === "decryption-pending"} {:else if state.status === "decryption-pending"}
복호화를 기다리는 중 복호화를 기다리는 중
{:else if $status.status === "decrypting"} {:else if state.status === "decrypting"}
복호화하는 중 복호화하는 중
{/if} {/if}
</p> </p>
<p class="text-xs"> <p class="text-xs">
{#if $status.status === "downloading"} {#if state.status === "downloading"}
전송됨 전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if} {/if}
</p> </p>
</div> </div>

View File

@@ -1,19 +1,21 @@
<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 { fileDownloadStatusStore, isFileDownloading } from "$lib/stores"; import { getFileInfo } from "$lib/modules/filesystem2.svelte";
import { getDownloadingFiles, clearDownloadedFiles } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
let downloadingFiles = $derived( let downloadingFilesPromise = $derived(
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)), Promise.all(
getDownloadingFiles().map(async (file) => ({
state: file,
fileInfo: await getFileInfo(file.id, $masterKeyStore?.get(1)?.key!),
})),
),
); );
$effect(() => () => { $effect(() => clearDownloadedFiles);
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -22,9 +24,9 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-2 pb-4"> {#await downloadingFilesPromise then downloadingFiles}
{#each downloadingFiles as status} {#each downloadingFiles as { state, fileInfo }}
<File {status} /> <File {state} info={fileInfo} />
{/each} {/each}
</div> {/await}
</FullscreenDiv> </FullscreenDiv>

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import type { FileCacheIndex } from "$lib/indexedDB"; import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { getFileInfo, type FileInfo } from "$lib/modules/filesystem2.svelte";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils"; import { formatFileSize } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
interface FileCache { interface FileCache {
index: FileCacheIndex; index: FileCacheIndex;
fileInfo: Writable<FileInfo | null>; fileInfo: FileInfo | null;
} }
let fileCache: FileCache[] | undefined = $state(); let fileCache: FileCache[] | undefined = $state();
@@ -23,13 +22,14 @@
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId); fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
}; };
onMount(() => { onMount(async () => {
fileCache = getFileCacheIndex() fileCache = await Promise.all(
.map((index) => ({ getFileCacheIndex().map(async (index) => ({
index, index,
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), fileInfo: await getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
})) })),
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); );
fileCache.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
}); });
$effect(() => { $effect(() => {

View File

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

View File

@@ -5,7 +5,7 @@
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem"; import { getFileInfo } from "$lib/modules/filesystem2.svelte";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
import { import {
@@ -20,19 +20,20 @@
const generateAllThumbnails = () => { const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => { persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info); if (info) {
if (fileInfo) { requestThumbnailGeneration(info);
requestThumbnailGeneration(fileInfo);
} }
}); });
}; };
onMount(() => { onMount(async () => {
persistentStates.files = data.files.map((fileId) => ({ persistentStates.files = await Promise.all(
data.files.map(async (fileId) => ({
id: fileId, id: fileId,
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), info: await getFileInfo(fileId, $masterKeyStore?.get(1)?.key!),
status: getGenerationStatus(fileId), status: getGenerationStatus(fileId),
})); })),
);
}); });
</script> </script>

View File

@@ -13,34 +13,32 @@
import type { Writable } from "svelte/store"; 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 { FileInfo } from "$lib/modules/filesystem"; import type { FileInfo } from "$lib/modules/filesystem2.svelte";
import { formatDateTime } from "$lib/utils"; import { formatDateTime } from "$lib/utils";
import type { GenerationStatus } from "./service.svelte"; import type { GenerationStatus } from "./service.svelte";
import IconCamera from "~icons/material-symbols/camera"; import IconCamera from "~icons/material-symbols/camera";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: FileInfo;
onclick: (selectedFile: FileInfo) => void; onclick: (file: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void; onGenerateThumbnailClick: (file: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>; generationStatus?: Writable<GenerationStatus>;
} }
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props(); let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
</script> </script>
{#if $info}
<ActionEntryButton <ActionEntryButton
class="h-14" class="h-14"
onclick={() => onclick($info)} onclick={() => onclick(info)}
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick($info)} onActionButtonClick={() => onGenerateThumbnailClick(info)}
actionButtonClass="text-gray-800" actionButtonClass="text-gray-800"
> >
{@const subtext = {@const subtext =
$generationStatus && $generationStatus !== "uploaded" $generationStatus && $generationStatus !== "uploaded"
? subtexts[$generationStatus] ? subtexts[$generationStatus]
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)} : formatDateTime(info.createdAt ?? info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.name} {subtext} /> <DirectoryEntryLabel type="file" name={info.name} {subtext} />
</ActionEntryButton> </ActionEntryButton>
{/if}

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,14 @@ const fileRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: categories.map(({ id }) => id), categories: categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
}; };
}), }),