mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 00:00:28 +09:00
파일 시스템 관련 상태와 변경 로직을 도메인 모듈로 통합하여 캐시가 자동으로 갱신되도록 개선하고 오류를 토스트로 표시하도록 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ce73aa331
commit
4e82c5e556
67 files changed
+1091
-1082
No files matched your search
+2
-1
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { deleteDirectory, deleteFile, readFile, writeFile } from "#lib/client/storage/opfs.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { decryptChunk, encodeToBase64 } from "#lib/shared/crypto/index.js";
|
||||
|
||||
export interface Thumbnail {
|
||||
|
||||
+10
-10
@@ -63,8 +63,8 @@ export const requestClientRegistration = async () => {
|
||||
answerSig: await answerChallenge(challenge, clientKeys),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -95,9 +95,9 @@ export const registerInitialMasterKeyAndHmacSecret = async (
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
|
||||
return true;
|
||||
return true; // Already registered by another client
|
||||
}
|
||||
// TODO: Error Handling
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -107,8 +107,8 @@ export const registerInitialMasterKeyAndHmacSecret = async (
|
||||
hsk: hmacSecretWrapped,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -117,8 +117,8 @@ export const downloadHmacSecrets = async () => {
|
||||
let hmacSecretsWrapped;
|
||||
try {
|
||||
hmacSecretsWrapped = await trpc.hsk.list.query();
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -143,8 +143,8 @@ export const downloadMasterKeys = async () => {
|
||||
let masterKeysWrapped;
|
||||
try {
|
||||
masterKeysWrapped = await trpc.mek.list.query();
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isTRPCClientError } from "#trpc/client.js";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const TOAST_DURATION = 3000;
|
||||
|
||||
let toasts: Toast[] = $state([]);
|
||||
let nextId = 0;
|
||||
|
||||
export const getToasts = () => toasts;
|
||||
|
||||
export const showToast = (message: string) => {
|
||||
const toast = { id: nextId++, message };
|
||||
toasts.push(toast);
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter(({ id }) => id !== toast.id);
|
||||
}, TOAST_DURATION);
|
||||
};
|
||||
|
||||
const getErrorMessage = (e: unknown, fallbackMessage: string) => {
|
||||
if (isTRPCClientError(e)) {
|
||||
switch (e.data?.code) {
|
||||
case "NOT_FOUND":
|
||||
return "대상을 찾을 수 없어요. 이미 삭제되었을 수 있어요.";
|
||||
case undefined:
|
||||
return "서버에 연결할 수 없어요.";
|
||||
}
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
export const showErrorToast = (e: unknown, fallbackMessage: string) => {
|
||||
console.error(e);
|
||||
showToast(getErrorMessage(e, fallbackMessage));
|
||||
};
|
||||
|
||||
// Runs the action and shows the error to the user if it fails. Returns whether it succeeded.
|
||||
export const tryAction = async (action: () => Promise<unknown>, errorMessage: string) => {
|
||||
try {
|
||||
await action();
|
||||
return true;
|
||||
} catch (e) {
|
||||
showErrorToast(e, errorMessage);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import { getToasts } from "#lib/client/toast.svelte.js";
|
||||
</script>
|
||||
|
||||
<div class="pointer-events-none fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-y-2 px-4">
|
||||
{#each getToasts() as toast (toast.id)}
|
||||
<div
|
||||
role="alert"
|
||||
transition:fly={{ y: -8, duration: 200 }}
|
||||
class="max-w-md rounded-xl bg-gray-800 px-4 py-3 text-sm break-keep text-white shadow-lg"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./divs";
|
||||
export * from "./inputs";
|
||||
export { default as Modal } from "./Modal.svelte";
|
||||
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";
|
||||
export { default as Toasts } from "./Toasts.svelte";
|
||||
@@ -1,5 +1,5 @@
|
||||
<script module lang="ts">
|
||||
import type { DataKey } from "#lib/modules/filesystem/index.js";
|
||||
import type { DataKey } from "#lib/features/filesystem/index.js";
|
||||
|
||||
export interface SelectedCategory {
|
||||
id: number;
|
||||
@@ -13,7 +13,7 @@
|
||||
import type { SvelteHTMLElements } from "svelte/elements";
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { CategoryLabel } from "#lib/components/molecules/index.js";
|
||||
import type { SubCategoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SubCategoryInfo } from "#lib/features/filesystem/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
IconEntryButton,
|
||||
type SelectedCategory,
|
||||
} from "#lib/components/molecules/index.js";
|
||||
import type { CategoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { CategoryInfo } from "#lib/features/filesystem/index.js";
|
||||
|
||||
import IconAddCircle from "~icons/material-symbols/add-circle";
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const login = async (email: string, password: string) => {
|
||||
await trpc.auth.login.mutate({ email, password });
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
await trpc.auth.logout.mutate();
|
||||
};
|
||||
|
||||
export const changePassword = async (oldPassword: string, newPassword: string) => {
|
||||
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
setDirectoryFavorite,
|
||||
setFileFavorite,
|
||||
type LocalDirectoryInfo,
|
||||
type SummarizedFileInfo,
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export type FavoriteEntry =
|
||||
| { type: "directory"; name: string; details: LocalDirectoryInfo }
|
||||
| { type: "file"; name: string; details: SummarizedFileInfo };
|
||||
|
||||
export const getFavoriteEntries = async () => {
|
||||
const favorites = await trpc.favorites.get.query();
|
||||
const [directories, files] = await Promise.all([
|
||||
Promise.all(
|
||||
favorites.directories.map((directory) =>
|
||||
getDirectoryInfo(directory.id, { serverResponse: { ...directory, isFavorite: true } }),
|
||||
),
|
||||
),
|
||||
Promise.all(
|
||||
favorites.files.map((file) =>
|
||||
getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return [
|
||||
...sortEntries(
|
||||
directories.map((directory): FavoriteEntry => ({
|
||||
type: "directory",
|
||||
name: directory.name!,
|
||||
details: directory as LocalDirectoryInfo,
|
||||
})),
|
||||
),
|
||||
...sortEntries(
|
||||
files.map((file): FavoriteEntry => ({
|
||||
type: "file",
|
||||
name: file.name!,
|
||||
details: file as SummarizedFileInfo,
|
||||
})),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const removeFavorite = async ({ type, details }: FavoriteEntry) => {
|
||||
if (type === "directory") {
|
||||
await setDirectoryFavorite(details, false);
|
||||
} else {
|
||||
await setFileFavorite(details, false);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
|
||||
import { untrack } from "svelte";
|
||||
|
||||
interface FilesystemCacheOptions<K, V> {
|
||||
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
|
||||
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
|
||||
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
|
||||
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
|
||||
}
|
||||
|
||||
interface Entry<V> {
|
||||
value?: V;
|
||||
promise?: Promise<V>;
|
||||
}
|
||||
|
||||
// Caches filesystem entries as reactive objects which are updated in place, so that every view
|
||||
// of an entry reflects its latest state. Values are first loaded from IndexedDB (if any) and then
|
||||
// revalidated against the server.
|
||||
export class FilesystemCache<K, V extends object> {
|
||||
private map = new Map<K, Entry<V>>();
|
||||
|
||||
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
|
||||
|
||||
private getOrCreateEntry(key: K) {
|
||||
let entry = this.map.get(key);
|
||||
if (!entry) {
|
||||
const newEntry: Entry<V> = $state({});
|
||||
entry = newEntry;
|
||||
this.map.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
private settle(entry: Entry<V>, value: V) {
|
||||
if (entry.value) {
|
||||
Object.assign(entry.value, value);
|
||||
} else {
|
||||
entry.value = value;
|
||||
}
|
||||
return entry.value!;
|
||||
}
|
||||
|
||||
// Returns the cached value synchronously if available, and revalidates it in the background
|
||||
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
|
||||
return untrack(() => {
|
||||
const entry = this.getOrCreateEntry(key);
|
||||
if (entry.promise) return entry.value ?? entry.promise;
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<V>();
|
||||
promise.catch(() => {}); // Rejections are handled by callers waiting for the promise
|
||||
entry.promise = promise;
|
||||
|
||||
(async () => {
|
||||
let cachedValue = entry.value && ($state.snapshot(entry.value) as V);
|
||||
if (!cachedValue) {
|
||||
cachedValue = await this.options.fetchFromIndexedDB(key);
|
||||
if (cachedValue) {
|
||||
resolve(this.settle(entry, cachedValue));
|
||||
}
|
||||
}
|
||||
|
||||
const loadedValue = await (options?.fetchFromServer?.(cachedValue) ??
|
||||
this.options.fetchFromServer(key, cachedValue));
|
||||
resolve(this.settle(entry, loadedValue));
|
||||
})()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
reject(e);
|
||||
})
|
||||
.finally(() => {
|
||||
entry.promise = undefined;
|
||||
});
|
||||
|
||||
return entry.value ?? promise;
|
||||
});
|
||||
}
|
||||
|
||||
bulkGet(keys: Set<K>) {
|
||||
return untrack(() => {
|
||||
const pending = new Map<K, PromiseWithResolvers<V>>();
|
||||
for (const key of keys) {
|
||||
const entry = this.getOrCreateEntry(key);
|
||||
if (entry.promise) continue;
|
||||
|
||||
const resolvers = Promise.withResolvers<V>();
|
||||
resolvers.promise.catch(() => {});
|
||||
entry.promise = resolvers.promise;
|
||||
pending.set(key, resolvers);
|
||||
}
|
||||
|
||||
const settleAll = (values: Map<K, V>) => {
|
||||
values.forEach((value, key) => {
|
||||
pending.get(key)?.resolve(this.settle(this.map.get(key)!, value));
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const pendingKeys = [...pending.keys()];
|
||||
settleAll(
|
||||
await this.options.bulkFetchFromIndexedDB!(
|
||||
new Set(pendingKeys.filter((key) => !this.map.get(key)!.value)),
|
||||
),
|
||||
);
|
||||
settleAll(
|
||||
await this.options.bulkFetchFromServer!(
|
||||
new Map(pendingKeys.map((key) => [key, { cachedValue: this.map.get(key)!.value }])),
|
||||
),
|
||||
);
|
||||
})()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
pending.forEach(({ reject }) => reject(e));
|
||||
})
|
||||
.finally(() => {
|
||||
pending.forEach((_, key) => {
|
||||
this.map.get(key)!.promise = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
const makeResult = () => new Map([...keys].map((key) => [key, this.map.get(key)!.value!]));
|
||||
const waiting = [...keys]
|
||||
.map((key) => this.map.get(key)!)
|
||||
.filter((entry) => !entry.value)
|
||||
.map((entry) => entry.promise!);
|
||||
return waiting.length > 0 ? Promise.all(waiting).then(makeResult) : makeResult();
|
||||
});
|
||||
}
|
||||
|
||||
// Revalidates the entry if it has been loaded
|
||||
invalidate(key: K) {
|
||||
if (this.map.has(key)) {
|
||||
void Promise.resolve(this.get(key)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
evict(key: K) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
|
||||
import type { CategoryInfo, MaybeCategoryInfo } from "./types";
|
||||
import { isTRPCClientError, trpc } from "#trpc/client.js";
|
||||
import { FilesystemCache } from "./cache.svelte";
|
||||
import { invalidateFile } from "./file";
|
||||
import {
|
||||
decryptCategoryMetadata,
|
||||
decryptFileMetadata,
|
||||
encryptName,
|
||||
generateEntryKeys,
|
||||
} from "./metadata";
|
||||
import { useResource } from "./resource.svelte";
|
||||
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types";
|
||||
|
||||
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
||||
async fetchFromIndexedDB(id) {
|
||||
@@ -121,3 +129,57 @@ const storeToIndexedDB = (info: CategoryInfo) => {
|
||||
export const getCategoryInfo = (id: CategoryId) => {
|
||||
return cache.get(id);
|
||||
};
|
||||
|
||||
export const useCategory = (getId: () => CategoryId) => {
|
||||
return useResource(getId, (id) => getCategoryInfo(id));
|
||||
};
|
||||
|
||||
export const invalidateCategory = (id: CategoryId) => {
|
||||
cache.invalidate(id);
|
||||
};
|
||||
|
||||
export const createCategory = async (parentId: CategoryId, name: string) => {
|
||||
await trpc.category.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
|
||||
invalidateCategory(parentId);
|
||||
};
|
||||
|
||||
export const renameCategory = async (
|
||||
category: { id: number; parentId: CategoryId; dataKey?: DataKey },
|
||||
newName: string,
|
||||
) => {
|
||||
if (!category.dataKey) {
|
||||
throw new Error("Data key not loaded");
|
||||
}
|
||||
|
||||
await trpc.category.rename.mutate({
|
||||
id: category.id,
|
||||
dekVersion: category.dataKey.version,
|
||||
...(await encryptName(newName, category.dataKey.key)),
|
||||
});
|
||||
invalidateCategory(category.parentId);
|
||||
invalidateCategory(category.id);
|
||||
};
|
||||
|
||||
export const deleteCategory = async (category: { id: number; parentId: CategoryId }) => {
|
||||
await trpc.category.delete.mutate({ id: category.id });
|
||||
cache.evict(category.id);
|
||||
invalidateCategory(category.parentId);
|
||||
};
|
||||
|
||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
||||
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
|
||||
invalidateFile(fileId);
|
||||
invalidateCategory(categoryId);
|
||||
};
|
||||
|
||||
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
|
||||
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
|
||||
invalidateFile(fileId);
|
||||
invalidateCategory(categoryId);
|
||||
};
|
||||
|
||||
// Whether to show the files of subcategories is a local preference
|
||||
export const setCategoryFileRecursive = async (category: LocalCategoryInfo, value: boolean) => {
|
||||
category.isFileRecursive = value;
|
||||
await IndexedDB.updateCategoryInfo(category.id, { isFileRecursive: value });
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
deleteFileCache,
|
||||
deleteFileThumbnailCache,
|
||||
getFileCache,
|
||||
storeFileCache,
|
||||
storeFileThumbnailCache,
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { getDecryptedFileUrl, prepareFileDecryption } from "#lib/client/serviceWorker.js";
|
||||
import { getAllFileInfos } from "#lib/client/storage/indexedDB/index.js";
|
||||
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
|
||||
import { downloadFile } from "#lib/client/transfer/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
import type { DataKey } from "./types";
|
||||
|
||||
// Returns the decrypted content of the file, from the cache if available
|
||||
export const getFileContent = async (fileId: number, dataKey: CryptoKey, isLegacy: boolean) => {
|
||||
const cache = await getFileCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
const fileBuffer = await downloadFile(fileId, dataKey, isLegacy);
|
||||
void storeFileCache(fileId, fileBuffer);
|
||||
return fileBuffer;
|
||||
};
|
||||
|
||||
// Returns a URL from which the service worker streams the decrypted file
|
||||
export const getFileStreamUrl = async (fileId: number, dataKey: CryptoKey, contentType: string) => {
|
||||
const res = await fetch(`/api/file/${fileId}/download`, { method: "HEAD" });
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to get the file size");
|
||||
}
|
||||
|
||||
const encContentSize = parseInt(res.headers.get("Content-Length") ?? "0", 10);
|
||||
if (encContentSize <= 0) {
|
||||
throw new Error("Invalid file size");
|
||||
}
|
||||
|
||||
await prepareFileDecryption(fileId, { isLegacy: false, dataKey, encContentSize, contentType });
|
||||
return getDecryptedFileUrl(fileId);
|
||||
};
|
||||
|
||||
export const uploadFileThumbnail = async (fileId: number, thumbnail: Blob, dataKey: DataKey) => {
|
||||
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
|
||||
file: fileId,
|
||||
dekVersion: dataKey.version,
|
||||
});
|
||||
await uploadBlob(uploadId, thumbnail, dataKey.key);
|
||||
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
|
||||
|
||||
await storeFileThumbnailCache(fileId, await thumbnail.arrayBuffer());
|
||||
};
|
||||
|
||||
// Deletes local caches of files deleted on other devices
|
||||
export const cleanupDeletedFileCaches = async () => {
|
||||
const liveFiles = new Set(await trpc.file.list.query());
|
||||
const maybeCachedFiles = await getAllFileInfos();
|
||||
|
||||
await Promise.all(
|
||||
maybeCachedFiles
|
||||
.filter(({ id }) => !liveFiles.has(id))
|
||||
.flatMap(({ id }) => [deleteFileCache(id), deleteFileThumbnailCache(id)]),
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,15 @@
|
||||
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
|
||||
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
|
||||
import { isTRPCClientError, trpc } from "#trpc/client.js";
|
||||
import { FilesystemCache } from "./cache.svelte";
|
||||
import {
|
||||
decryptDirectoryMetadata,
|
||||
decryptFileMetadata,
|
||||
encryptName,
|
||||
generateEntryKeys,
|
||||
} from "./metadata";
|
||||
import { useResource } from "./resource.svelte";
|
||||
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types";
|
||||
|
||||
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
async fetchFromIndexedDB(id) {
|
||||
@@ -31,7 +39,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFromServer(id, _cachedInfo) {
|
||||
async fetchFromServer(id) {
|
||||
try {
|
||||
const directory = await trpc.directory.get.query({ id });
|
||||
const [subDirectories, files, metadata] = await Promise.all([
|
||||
@@ -132,3 +140,56 @@ export const getDirectoryInfo = (
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDirectory = (getId: () => DirectoryId) => {
|
||||
return useResource(getId, (id) => getDirectoryInfo(id));
|
||||
};
|
||||
|
||||
export const invalidateDirectory = (id: DirectoryId) => {
|
||||
cache.invalidate(id);
|
||||
};
|
||||
|
||||
export const createDirectory = async (parentId: DirectoryId, name: string) => {
|
||||
await trpc.directory.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
|
||||
invalidateDirectory(parentId);
|
||||
};
|
||||
|
||||
export const renameDirectory = async (
|
||||
directory: { id: number; parentId: DirectoryId; dataKey?: DataKey },
|
||||
newName: string,
|
||||
) => {
|
||||
if (!directory.dataKey) {
|
||||
throw new Error("Data key not loaded");
|
||||
}
|
||||
|
||||
await trpc.directory.rename.mutate({
|
||||
id: directory.id,
|
||||
dekVersion: directory.dataKey.version,
|
||||
...(await encryptName(newName, directory.dataKey.key)),
|
||||
});
|
||||
invalidateDirectory(directory.parentId);
|
||||
invalidateDirectory(directory.id);
|
||||
};
|
||||
|
||||
export const deleteDirectory = async (directory: { id: number; parentId: DirectoryId }) => {
|
||||
const { deletedFiles } = await trpc.directory.delete.mutate({ id: directory.id });
|
||||
cache.evict(directory.id);
|
||||
invalidateDirectory(directory.parentId);
|
||||
|
||||
await Promise.all(
|
||||
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
|
||||
);
|
||||
};
|
||||
|
||||
export const setDirectoryFavorite = async (
|
||||
directory: { id: number; parentId: DirectoryId },
|
||||
isFavorite: boolean,
|
||||
) => {
|
||||
if (isFavorite) {
|
||||
await trpc.favorites.addDirectory.mutate({ id: directory.id });
|
||||
} else {
|
||||
await trpc.favorites.removeDirectory.mutate({ id: directory.id });
|
||||
}
|
||||
invalidateDirectory(directory.parentId);
|
||||
invalidateDirectory(directory.id);
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
|
||||
import type { FileInfo, MaybeFileInfo } from "./types";
|
||||
import { isTRPCClientError, trpc } from "#trpc/client.js";
|
||||
import { FilesystemCache } from "./cache.svelte";
|
||||
import { invalidateDirectory } from "./directory";
|
||||
import { decryptCategoryMetadata, decryptFileMetadata, encryptName } from "./metadata";
|
||||
import { useResource } from "./resource.svelte";
|
||||
import type { DataKey, FileInfo, MaybeFileInfo } from "./types";
|
||||
|
||||
const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
async fetchFromIndexedDB(id) {
|
||||
@@ -32,7 +36,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
}
|
||||
},
|
||||
|
||||
async fetchFromServer(id, _cachedInfo) {
|
||||
async fetchFromServer(id) {
|
||||
try {
|
||||
const file = await trpc.file.get.query({ id });
|
||||
const [categories, metadata] = await Promise.all([
|
||||
@@ -211,3 +215,49 @@ export const getFileInfo = (
|
||||
export const bulkGetFileInfo = (ids: number[]) => {
|
||||
return cache.bulkGet(new Set(ids));
|
||||
};
|
||||
|
||||
export const useFile = (getId: () => number) => {
|
||||
return useResource(getId, (id) => getFileInfo(id));
|
||||
};
|
||||
|
||||
export const invalidateFile = (id: number) => {
|
||||
cache.invalidate(id);
|
||||
};
|
||||
|
||||
export const renameFile = async (
|
||||
file: { id: number; parentId: DirectoryId; dataKey?: DataKey },
|
||||
newName: string,
|
||||
) => {
|
||||
if (!file.dataKey) {
|
||||
throw new Error("Data key not loaded");
|
||||
}
|
||||
|
||||
await trpc.file.rename.mutate({
|
||||
id: file.id,
|
||||
dekVersion: file.dataKey.version,
|
||||
...(await encryptName(newName, file.dataKey.key)),
|
||||
});
|
||||
invalidateDirectory(file.parentId);
|
||||
invalidateFile(file.id);
|
||||
};
|
||||
|
||||
export const deleteFile = async (file: { id: number; parentId: DirectoryId }) => {
|
||||
await trpc.file.delete.mutate({ id: file.id });
|
||||
cache.evict(file.id);
|
||||
invalidateDirectory(file.parentId);
|
||||
|
||||
await Promise.all([deleteFileCache(file.id), deleteFileThumbnailCache(file.id)]);
|
||||
};
|
||||
|
||||
export const setFileFavorite = async (
|
||||
file: { id: number; parentId: DirectoryId },
|
||||
isFavorite: boolean,
|
||||
) => {
|
||||
if (isFavorite) {
|
||||
await trpc.favorites.addFile.mutate({ id: file.id });
|
||||
} else {
|
||||
await trpc.favorites.removeFile.mutate({ id: file.id });
|
||||
}
|
||||
invalidateDirectory(file.parentId);
|
||||
invalidateFile(file.id);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./category";
|
||||
export * from "./content";
|
||||
export * from "./directory";
|
||||
export * from "./file";
|
||||
export * from "./types";
|
||||
export * from "./upload";
|
||||
@@ -0,0 +1,83 @@
|
||||
import { keyring } from "#lib/client/keys/index.js";
|
||||
import {
|
||||
decryptString,
|
||||
encryptString,
|
||||
generateDataKey,
|
||||
unwrapDataKey,
|
||||
wrapDataKey,
|
||||
} from "#lib/shared/crypto/index.js";
|
||||
|
||||
const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => {
|
||||
const { dataKey } = await unwrapDataKey(
|
||||
metadata.dek,
|
||||
keyring.getMasterKey(metadata.mekVersion).key,
|
||||
);
|
||||
return dataKey;
|
||||
};
|
||||
|
||||
export const decryptDirectoryMetadata = async (metadata: {
|
||||
mekVersion: number;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
}) => {
|
||||
const dataKey = await unwrapEntryDataKey(metadata);
|
||||
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
};
|
||||
};
|
||||
|
||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
export const decryptFileMetadata = async (metadata: {
|
||||
mekVersion: number;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
createdAt?: string;
|
||||
createdAtIv?: string;
|
||||
lastModifiedAt: string;
|
||||
lastModifiedAtIv: string;
|
||||
}) => {
|
||||
const dataKey = await unwrapEntryDataKey(metadata);
|
||||
const [name, createdAt, lastModifiedAt] = await Promise.all([
|
||||
decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||
metadata.createdAt
|
||||
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
|
||||
: undefined,
|
||||
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptCategoryMetadata = decryptDirectoryMetadata;
|
||||
|
||||
export const encryptName = async (name: string, dataKey: CryptoKey) => {
|
||||
const { ciphertext, iv } = await encryptString(name, dataKey);
|
||||
return { name: ciphertext, nameIv: iv };
|
||||
};
|
||||
|
||||
// Generates the keys of a new directory or category encrypted with the active master key
|
||||
export const generateEntryKeys = async (name: string) => {
|
||||
const masterKey = keyring.activeMasterKey;
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
return {
|
||||
mekVersion: masterKey.version,
|
||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||
dekVersion: dataKeyVersion,
|
||||
...(await encryptName(name, dataKey)),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { untrack } from "svelte";
|
||||
|
||||
// Exposes the value of a cache entry identified by a reactive key. Cached values are available
|
||||
// synchronously, so that views don't flicker while revalidating.
|
||||
export const useResource = <K, V>(getKey: () => K, load: (key: K) => V | Promise<V>) => {
|
||||
let current: V | undefined = $state();
|
||||
|
||||
$effect.pre(() => {
|
||||
const key = getKey();
|
||||
let isStale = false;
|
||||
|
||||
const result = untrack(() => load(key));
|
||||
if (result instanceof Promise) {
|
||||
result
|
||||
.then((value) => {
|
||||
if (!isStale) current = value;
|
||||
})
|
||||
.catch(() => {}); // Logged by the cache
|
||||
} else {
|
||||
current = result;
|
||||
}
|
||||
|
||||
return () => {
|
||||
isStale = true;
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
get current() {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -49,7 +49,7 @@ export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
|
||||
|
||||
export interface LocalCategoryInfo {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
parentId: CategoryId;
|
||||
dataKey?: DataKey;
|
||||
name: string;
|
||||
subCategories: SubCategoryInfo[];
|
||||
@@ -0,0 +1,22 @@
|
||||
import { storeFileCache, storeFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import { uploadFile as doUploadFile } from "#lib/client/transfer/index.js";
|
||||
import { invalidateDirectory } from "./directory";
|
||||
|
||||
// Uploads the file and caches it locally. Returns `false` if the upload is canceled.
|
||||
export const uploadFile = async (
|
||||
file: File,
|
||||
parentId: DirectoryId,
|
||||
onDuplicate: () => Promise<boolean>,
|
||||
) => {
|
||||
const res = await doUploadFile(file, parentId, onDuplicate);
|
||||
if (!res) return false;
|
||||
|
||||
invalidateDirectory(parentId);
|
||||
if (res.fileBuffer) {
|
||||
void storeFileCache(res.fileId, res.fileBuffer);
|
||||
}
|
||||
if (res.thumbnailBuffer) {
|
||||
void storeFileThumbnailCache(res.fileId, res.thumbnailBuffer);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
type LocalDirectoryInfo,
|
||||
type FileInfo,
|
||||
type LocalCategoryInfo,
|
||||
} from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise } from "#lib/utils/index.js";
|
||||
type LocalDirectoryInfo,
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export interface SearchFilter {
|
||||
@@ -19,24 +18,24 @@ export interface SearchResult {
|
||||
files: FileInfo[];
|
||||
}
|
||||
|
||||
export const requestSearch = async (filter: SearchFilter) => {
|
||||
const getCategoryIds = (filter: SearchFilter, type: "include" | "exclude") => {
|
||||
return filter.categories.filter((category) => category.type === type).map(({ info }) => info.id);
|
||||
};
|
||||
|
||||
export const search = async (filter: SearchFilter) => {
|
||||
const { directories: directoriesRaw, files: filesRaw } = await trpc.search.search.query({
|
||||
ancestor: filter.ancestorId,
|
||||
inFavorites: filter.inFavorites,
|
||||
includeCategories: filter.categories
|
||||
.filter(({ type }) => type === "include")
|
||||
.map(({ info }) => info.id),
|
||||
excludeCategories: filter.categories
|
||||
.filter(({ type }) => type === "exclude")
|
||||
.map(({ info }) => info.id),
|
||||
includeCategories: getCategoryIds(filter, "include"),
|
||||
excludeCategories: getCategoryIds(filter, "exclude"),
|
||||
});
|
||||
const [directories, files] = await HybridPromise.all([
|
||||
HybridPromise.all(
|
||||
const [directories, files] = await Promise.all([
|
||||
Promise.all(
|
||||
directoriesRaw.map((directory) =>
|
||||
getDirectoryInfo(directory.id, { serverResponse: directory }),
|
||||
),
|
||||
),
|
||||
HybridPromise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
|
||||
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
|
||||
]);
|
||||
return { directories, files } as SearchResult;
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
import { untrack } from "svelte";
|
||||
import { keyring } from "#lib/client/keys/index.js";
|
||||
import { unwrapDataKey, decryptString } from "#lib/shared/crypto/index.js";
|
||||
|
||||
interface FilesystemCacheOptions<K, V> {
|
||||
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
|
||||
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
|
||||
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
|
||||
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
|
||||
}
|
||||
|
||||
export class FilesystemCache<K, V extends object> {
|
||||
private map = new Map<K, { value?: V; promise?: Promise<V> }>();
|
||||
|
||||
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
|
||||
|
||||
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
|
||||
return untrack(() => {
|
||||
let state = this.map.get(key);
|
||||
if (state?.promise) return state.value ?? state.promise;
|
||||
|
||||
const { promise: newPromise, resolve } = Promise.withResolvers<V>();
|
||||
|
||||
if (!state) {
|
||||
const newState = $state({});
|
||||
state = newState;
|
||||
this.map.set(key, newState);
|
||||
}
|
||||
|
||||
(state.value
|
||||
? Promise.resolve($state.snapshot(state.value) as V)
|
||||
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
|
||||
if (loadedInfo) {
|
||||
state.value = loadedInfo;
|
||||
resolve(state.value);
|
||||
}
|
||||
return loadedInfo;
|
||||
})
|
||||
)
|
||||
.then(
|
||||
(cachedInfo) =>
|
||||
options?.fetchFromServer?.(cachedInfo) ?? this.options.fetchFromServer(key, cachedInfo),
|
||||
)
|
||||
.then((loadedInfo) => {
|
||||
if (state.value) {
|
||||
Object.assign(state.value, loadedInfo);
|
||||
} else {
|
||||
state.value = loadedInfo;
|
||||
}
|
||||
resolve(state.value);
|
||||
})
|
||||
.finally(() => {
|
||||
state.promise = undefined;
|
||||
});
|
||||
|
||||
state.promise = newPromise;
|
||||
return state.value ?? newPromise;
|
||||
});
|
||||
}
|
||||
|
||||
bulkGet(keys: Set<K>) {
|
||||
return untrack(() => {
|
||||
const newPromises = new Map(
|
||||
keys
|
||||
.keys()
|
||||
.filter((key) => this.map.get(key)?.promise === undefined)
|
||||
.map((key) => [key, Promise.withResolvers<V>()]),
|
||||
);
|
||||
newPromises.forEach(({ promise }, key) => {
|
||||
const state = this.map.get(key);
|
||||
if (state) {
|
||||
state.promise = promise;
|
||||
} else {
|
||||
const newState = $state({ promise });
|
||||
this.map.set(key, newState);
|
||||
}
|
||||
});
|
||||
|
||||
const resolve = (loadedInfos: Map<K, V>) => {
|
||||
loadedInfos.forEach((loadedInfo, key) => {
|
||||
const state = this.map.get(key)!;
|
||||
if (state.value) {
|
||||
Object.assign(state.value, loadedInfo);
|
||||
} else {
|
||||
state.value = loadedInfo;
|
||||
}
|
||||
newPromises.get(key)!.resolve(state.value);
|
||||
});
|
||||
return loadedInfos;
|
||||
};
|
||||
|
||||
this.options.bulkFetchFromIndexedDB!(
|
||||
new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)),
|
||||
)
|
||||
.then(resolve)
|
||||
.then(() =>
|
||||
this.options.bulkFetchFromServer!(
|
||||
new Map(
|
||||
newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then(resolve)
|
||||
.finally(() => {
|
||||
newPromises.forEach((_, key) => {
|
||||
this.map.get(key)!.promise = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
const bottleneckPromises = Array.from(
|
||||
keys
|
||||
.keys()
|
||||
.filter((key) => this.map.get(key)!.value === undefined)
|
||||
.map((key) => this.map.get(key)!.promise!),
|
||||
);
|
||||
const makeResult = () =>
|
||||
new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const));
|
||||
return bottleneckPromises.length > 0
|
||||
? Promise.all(bottleneckPromises).then(makeResult)
|
||||
: makeResult();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => {
|
||||
const { dataKey } = await unwrapDataKey(
|
||||
metadata.dek,
|
||||
keyring.getMasterKey(metadata.mekVersion).key,
|
||||
);
|
||||
return dataKey;
|
||||
};
|
||||
|
||||
export const decryptDirectoryMetadata = async (metadata: {
|
||||
mekVersion: number;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
}) => {
|
||||
const dataKey = await unwrapEntryDataKey(metadata);
|
||||
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
};
|
||||
};
|
||||
|
||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
export const decryptFileMetadata = async (metadata: {
|
||||
mekVersion: number;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
createdAt?: string;
|
||||
createdAtIv?: string;
|
||||
lastModifiedAt: string;
|
||||
lastModifiedAtIv: string;
|
||||
}) => {
|
||||
const dataKey = await unwrapEntryDataKey(metadata);
|
||||
const [name, createdAt, lastModifiedAt] = await Promise.all([
|
||||
decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||
metadata.createdAt
|
||||
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
|
||||
: undefined,
|
||||
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptCategoryMetadata = decryptDirectoryMetadata;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const requestLogout = async () => {
|
||||
try {
|
||||
await trpc.auth.logout.mutate();
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { keyring } from "#lib/client/keys/index.js";
|
||||
import { generateDataKey, wrapDataKey, encryptString } from "#lib/shared/crypto/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const requestCategoryCreation = async (name: string, parentId: "root" | number) => {
|
||||
const masterKey = keyring.activeMasterKey;
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
const nameEncrypted = await encryptString(name, dataKey);
|
||||
|
||||
try {
|
||||
await trpc.category.create.mutate({
|
||||
parent: parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||
dekVersion: dataKeyVersion,
|
||||
name: nameEncrypted.ciphertext,
|
||||
nameIv: nameEncrypted.iv,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
|
||||
try {
|
||||
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import {
|
||||
getFileCache,
|
||||
storeFileCache,
|
||||
deleteFileCache,
|
||||
deleteFileThumbnailCache,
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { getAllFileInfos } from "#lib/client/storage/indexedDB/filesystem.js";
|
||||
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
|
||||
import { downloadFile } from "#lib/client/transfer/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const requestFileDownload = async (
|
||||
fileId: number,
|
||||
dataKey: CryptoKey,
|
||||
isLegacy: boolean,
|
||||
) => {
|
||||
const cache = await getFileCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
const fileBuffer = await downloadFile(fileId, dataKey, isLegacy);
|
||||
storeFileCache(fileId, fileBuffer); // Intended
|
||||
return fileBuffer;
|
||||
};
|
||||
|
||||
export const requestFileThumbnailUpload = async (
|
||||
fileId: number,
|
||||
thumbnail: Blob,
|
||||
dataKey: CryptoKey,
|
||||
dataKeyVersion: Date,
|
||||
) => {
|
||||
try {
|
||||
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
|
||||
file: fileId,
|
||||
dekVersion: dataKeyVersion,
|
||||
});
|
||||
|
||||
await uploadBlob(uploadId, thumbnail, dataKey);
|
||||
|
||||
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestDeletedFilesCleanup = async () => {
|
||||
let liveFiles;
|
||||
try {
|
||||
liveFiles = await trpc.file.list.query();
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return;
|
||||
}
|
||||
|
||||
const liveFilesSet = new Set(liveFiles);
|
||||
const maybeCachedFiles = await getAllFileInfos();
|
||||
|
||||
await Promise.all(
|
||||
maybeCachedFiles
|
||||
.filter(({ id }) => !liveFilesSet.has(id))
|
||||
.flatMap(({ id }) => [deleteFileCache(id), deleteFileThumbnailCache(id)]),
|
||||
);
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
type MaybePromise<T> = T | Promise<T> | HybridPromise<T>;
|
||||
|
||||
type HybridPromiseState<T> =
|
||||
| { mode: "sync"; status: "fulfilled"; value: T }
|
||||
| { mode: "sync"; status: "rejected"; reason: unknown }
|
||||
| { mode: "async"; promise: Promise<T> };
|
||||
|
||||
export class HybridPromise<T> implements PromiseLike<T> {
|
||||
private isConsumed = false;
|
||||
|
||||
private constructor(private readonly state: HybridPromiseState<T>) {
|
||||
if (state.mode === "sync" && state.status === "rejected") {
|
||||
queueMicrotask(() => {
|
||||
if (!this.isConsumed) {
|
||||
throw state.reason;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isSync(): boolean {
|
||||
return this.state.mode === "sync";
|
||||
}
|
||||
|
||||
toPromise(): Promise<T> {
|
||||
this.isConsumed = true;
|
||||
|
||||
if (this.state.mode === "async") return this.state.promise;
|
||||
return this.state.status === "fulfilled"
|
||||
? Promise.resolve(this.state.value)
|
||||
: Promise.reject(this.state.reason);
|
||||
}
|
||||
|
||||
static resolve<T>(value: MaybePromise<T>): HybridPromise<T> {
|
||||
if (value instanceof HybridPromise) return value;
|
||||
return new HybridPromise(
|
||||
value instanceof Promise
|
||||
? { mode: "async", promise: value }
|
||||
: { mode: "sync", status: "fulfilled", value },
|
||||
);
|
||||
}
|
||||
|
||||
static reject<T = never>(reason?: unknown): HybridPromise<T> {
|
||||
return new HybridPromise({ mode: "sync", status: "rejected", reason });
|
||||
}
|
||||
|
||||
then<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => MaybePromise<TResult1>) | null | undefined,
|
||||
onrejected?: ((reason: unknown) => MaybePromise<TResult2>) | null | undefined,
|
||||
): HybridPromise<TResult1 | TResult2> {
|
||||
this.isConsumed = true;
|
||||
|
||||
if (this.state.mode === "async") {
|
||||
return new HybridPromise({
|
||||
mode: "async",
|
||||
promise: this.state.promise.then(onfulfilled, onrejected) as any,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.state.status === "fulfilled") {
|
||||
if (!onfulfilled) return HybridPromise.resolve(this.state.value as any);
|
||||
return HybridPromise.resolve(onfulfilled(this.state.value));
|
||||
} else {
|
||||
if (!onrejected) return HybridPromise.reject(this.state.reason);
|
||||
return HybridPromise.resolve(onrejected(this.state.reason));
|
||||
}
|
||||
} catch (e) {
|
||||
return HybridPromise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
catch<TResult = never>(
|
||||
onrejected?: ((reason: unknown) => MaybePromise<TResult>) | null | undefined,
|
||||
): HybridPromise<T | TResult> {
|
||||
return this.then<T, TResult>(null, onrejected);
|
||||
}
|
||||
|
||||
finally(onfinally?: (() => void) | null | undefined): HybridPromise<T> {
|
||||
this.isConsumed = true;
|
||||
|
||||
if (this.state.mode === "async") {
|
||||
return new HybridPromise({ mode: "async", promise: this.state.promise.finally(onfinally) });
|
||||
}
|
||||
|
||||
try {
|
||||
onfinally?.();
|
||||
return new HybridPromise(this.state);
|
||||
} catch (e) {
|
||||
return HybridPromise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
static all<T extends readonly unknown[] | []>(
|
||||
maybePromises: T,
|
||||
): HybridPromise<{ -readonly [P in keyof T]: HybridAwaited<T[P]> }> {
|
||||
const length = maybePromises.length;
|
||||
if (length === 0) {
|
||||
return HybridPromise.resolve([] as any);
|
||||
}
|
||||
|
||||
const hps = Array.from(maybePromises).map((p) => HybridPromise.resolve(p));
|
||||
if (hps.some((hp) => !hp.isSync())) {
|
||||
return new HybridPromise({
|
||||
mode: "async",
|
||||
promise: Promise.all(hps.map((hp) => hp.toPromise())) as any,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return HybridPromise.resolve(
|
||||
Array.from(
|
||||
hps.map((hp) => {
|
||||
if (hp.state.mode === "sync") {
|
||||
if (hp.state.status === "fulfilled") {
|
||||
return hp.state.value;
|
||||
} else {
|
||||
throw hp.state.reason;
|
||||
}
|
||||
}
|
||||
}),
|
||||
) as any,
|
||||
);
|
||||
} catch (e) {
|
||||
return HybridPromise.reject(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type HybridAwaited<T> =
|
||||
T extends HybridPromise<infer U> ? U : T extends Promise<infer U> ? U : T;
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./BoundedQueue";
|
||||
export * from "./HybridPromise";
|
||||
export * from "./Scheduler";
|
||||
@@ -2,7 +2,8 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { BottomDiv, Button, FullscreenDiv, TextInput } from "#lib/components/atoms/index.js";
|
||||
import { TitledDiv, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { requestPasswordChange } from "./service";
|
||||
import { showToast, tryAction } from "#lib/client/toast.svelte.js";
|
||||
import { changePassword as requestPasswordChange } from "#lib/features/auth/index.js";
|
||||
|
||||
let oldPassword = $state("");
|
||||
let newPassword = $state("");
|
||||
@@ -10,11 +11,16 @@
|
||||
|
||||
const changePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
// TODO: Alert
|
||||
showToast("새 비밀번호가 일치하지 않아요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await requestPasswordChange(oldPassword, newPassword)) {
|
||||
if (
|
||||
await tryAction(
|
||||
() => requestPasswordChange(oldPassword, newPassword),
|
||||
"비밀번호를 바꾸지 못했어요. 기존 비밀번호를 확인해 주세요.",
|
||||
)
|
||||
) {
|
||||
await goto("/menu");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const requestPasswordChange = async (oldPassword: string, newPassword: string) => {
|
||||
try {
|
||||
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -14,9 +14,10 @@
|
||||
keyring,
|
||||
requestClientRegistrationAndSessionUpgrade,
|
||||
} from "#lib/client/keys/index.js";
|
||||
import { requestLogout } from "#lib/services/auth.js";
|
||||
import { requestDeletedFilesCleanup } from "#lib/services/file.js";
|
||||
import { requestLogin } from "./service";
|
||||
import { showErrorToast, showToast } from "#lib/client/toast.svelte.js";
|
||||
import { login as requestLogin, logout as requestLogout } from "#lib/features/auth/index.js";
|
||||
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.js";
|
||||
import { isTRPCClientError } from "#trpc/client.js";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -30,42 +31,45 @@
|
||||
};
|
||||
|
||||
const upgradeSession = async (force: boolean) => {
|
||||
try {
|
||||
const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force);
|
||||
if (!force && upgradeError === "Already logged in") {
|
||||
isForceLoginModalOpen = true;
|
||||
return;
|
||||
} else if (!upgradeRes) {
|
||||
throw new Error("Failed to upgrade session");
|
||||
}
|
||||
const [upgradeRes, upgradeError] = await requestClientRegistrationAndSessionUpgrade(force);
|
||||
if (!force && upgradeError === "Already logged in") {
|
||||
isForceLoginModalOpen = true;
|
||||
return;
|
||||
} else if (!upgradeRes) {
|
||||
showToast("로그인하지 못했어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Multi-user support
|
||||
// TODO: Multi-user support
|
||||
|
||||
if (keyring.hasMasterKeys || (await downloadMasterKeys())) {
|
||||
await requestDeletedFilesCleanup();
|
||||
await goto(data.redirectPath);
|
||||
} else {
|
||||
await redirect("/client/pending");
|
||||
}
|
||||
} catch (e) {
|
||||
// TODO
|
||||
throw e;
|
||||
if (keyring.hasMasterKeys || (await downloadMasterKeys())) {
|
||||
await cleanupDeletedFileCaches().catch(console.error);
|
||||
await goto(data.redirectPath);
|
||||
} else {
|
||||
await redirect("/client/pending");
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
// TODO: Validation
|
||||
if (!email || !password) {
|
||||
showToast("이메일과 비밀번호를 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await requestLogin(email, password))) throw new Error("Failed to login");
|
||||
|
||||
if (!keyring.clientKeys) return await redirect("/key/generate");
|
||||
|
||||
await upgradeSession(false);
|
||||
await requestLogin(email, password);
|
||||
} catch (e) {
|
||||
// TODO: Alert
|
||||
throw e;
|
||||
if (isTRPCClientError(e) && e.data?.code === "UNAUTHORIZED") {
|
||||
showToast("이메일 또는 비밀번호가 올바르지 않아요.");
|
||||
} else {
|
||||
showErrorToast(e, "로그인하지 못했어요.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keyring.clientKeys) return await redirect("/key/generate");
|
||||
|
||||
await upgradeSession(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -93,6 +97,6 @@
|
||||
|
||||
<ForceLoginModal
|
||||
bind:isOpen={isForceLoginModalOpen}
|
||||
oncancel={requestLogout}
|
||||
oncancel={() => requestLogout().catch(console.error)}
|
||||
onLoginClick={() => upgradeSession(true)}
|
||||
/>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const requestLogin = async (email: string, password: string) => {
|
||||
try {
|
||||
await trpc.auth.login.mutate({ email, password });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -5,20 +5,22 @@
|
||||
import { page } from "$app/state";
|
||||
import { FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { Categories, IconEntryButton, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { getFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { captureVideoThumbnail } from "#lib/client/media/thumbnail.js";
|
||||
import { getFileDownloadState } from "#lib/client/transfer/index.js";
|
||||
import { HybridPromise } from "#lib/utils/index.js";
|
||||
import { showErrorToast, tryAction } from "#lib/client/toast.svelte.js";
|
||||
import {
|
||||
addFileToCategory,
|
||||
getFileContent,
|
||||
getFileStreamUrl,
|
||||
removeFileFromCategory,
|
||||
setFileFavorite,
|
||||
uploadFileThumbnail,
|
||||
useFile,
|
||||
type DataKey,
|
||||
type FileInfo,
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
|
||||
import DownloadStatus from "./DownloadStatus.svelte";
|
||||
import {
|
||||
requestFileRemovalFromCategory,
|
||||
requestFileDownload,
|
||||
requestThumbnailUpload,
|
||||
requestFileAdditionToCategory,
|
||||
requestVideoStream,
|
||||
requestFavoriteToggle,
|
||||
} from "./service";
|
||||
import TopBarMenu from "./TopBarMenu.svelte";
|
||||
|
||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||
@@ -28,7 +30,8 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let info: MaybeFileInfo | undefined = $state();
|
||||
const file = useFile(() => data.id);
|
||||
let info = $derived(file.current);
|
||||
let downloadState = $derived(getFileDownloadState(data.id));
|
||||
|
||||
let isMenuOpen = $state(false);
|
||||
@@ -59,33 +62,43 @@
|
||||
);
|
||||
};
|
||||
|
||||
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
|
||||
const thumbnail = await captureVideoThumbnail(videoElement!);
|
||||
await requestThumbnailUpload(data.id, thumbnail, dataKey, dataKeyVersion);
|
||||
};
|
||||
const updateThumbnail = (dataKey: DataKey) =>
|
||||
tryAction(async () => {
|
||||
const thumbnail = await captureVideoThumbnail(videoElement!);
|
||||
await uploadFileThumbnail(data.id, thumbnail, dataKey);
|
||||
}, "썸네일을 설정하지 못했어요.");
|
||||
|
||||
const addToCategory = async (categoryId: number) => {
|
||||
await requestFileAdditionToCategory(data.id, categoryId);
|
||||
isAddToCategoryBottomSheetOpen = false;
|
||||
void getFileInfo(data.id); // TODO: FIXME
|
||||
if (
|
||||
await tryAction(
|
||||
() => addFileToCategory(data.id, categoryId),
|
||||
"카테고리에 파일을 추가하지 못했어요.",
|
||||
)
|
||||
) {
|
||||
isAddToCategoryBottomSheetOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromCategory = async (categoryId: number) => {
|
||||
await requestFileRemovalFromCategory(data.id, categoryId);
|
||||
void getFileInfo(data.id); // TODO: FIXME
|
||||
};
|
||||
const removeFromCategory = (categoryId: number) =>
|
||||
tryAction(
|
||||
() => removeFileFromCategory(data.id, categoryId),
|
||||
"카테고리에서 파일을 제거하지 못했어요.",
|
||||
);
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
await requestFavoriteToggle(data.id, !!info?.isFavorite);
|
||||
void getFileInfo(data.id); // TODO: FIXME
|
||||
};
|
||||
const toggleFavorite = () =>
|
||||
tryAction(
|
||||
() => setFileFavorite(info as FileInfo, !info?.isFavorite),
|
||||
"즐겨찾기를 변경하지 못했어요.",
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
HybridPromise.resolve(getFileInfo(data.id)).then((result) => {
|
||||
if (data.id === result.id) {
|
||||
info = result;
|
||||
}
|
||||
const loadContent = (dataKey: CryptoKey, isLegacy: boolean) =>
|
||||
getFileContent(data.id, dataKey, isLegacy).catch((e) => {
|
||||
showErrorToast(e, "파일을 불러오지 못했어요.");
|
||||
throw e;
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
void data.id; // Reset the viewer when navigating to another file
|
||||
isDownloadRequested = false;
|
||||
viewerType = undefined;
|
||||
});
|
||||
@@ -103,25 +116,24 @@
|
||||
if (!downloadState && !isDownloadRequested) {
|
||||
isDownloadRequested = true;
|
||||
|
||||
if (viewerType === "video" && !info!.isLegacy) {
|
||||
requestVideoStream(data.id, info!.dataKey!.key, contentType).then((streamUrl) => {
|
||||
if (streamUrl) {
|
||||
const { key } = info!.dataKey!;
|
||||
const isLegacy = !!info!.isLegacy;
|
||||
if (viewerType === "video" && !isLegacy) {
|
||||
getFileStreamUrl(data.id, key, contentType)
|
||||
.then((streamUrl) => {
|
||||
videoStreamUrl = streamUrl;
|
||||
} else {
|
||||
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then((buffer) =>
|
||||
updateViewer(buffer, contentType),
|
||||
);
|
||||
})
|
||||
.catch(async () => {
|
||||
// Fall back to downloading the whole file
|
||||
await updateViewer(await loadContent(key, isLegacy), contentType);
|
||||
});
|
||||
} else {
|
||||
void loadContent(key, isLegacy).then(async (buffer) => {
|
||||
const blob = await updateViewer(buffer, contentType);
|
||||
if (!viewerType) {
|
||||
FileSaver.saveAs(blob, info!.name);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then(
|
||||
async (buffer) => {
|
||||
const blob = await updateViewer(buffer, contentType);
|
||||
if (!viewerType) {
|
||||
FileSaver.saveAs(blob, info!.name);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -192,7 +204,7 @@
|
||||
></video>
|
||||
<IconEntryButton
|
||||
icon={IconCamera}
|
||||
onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
|
||||
onclick={() => updateThumbnail(info!.dataKey!)}
|
||||
class="w-full"
|
||||
>
|
||||
이 장면을 썸네일로 설정하기
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { SubCategories } from "#lib/components/molecules/index.js";
|
||||
import { CategoryCreateModal } from "#lib/components/organisms/index.js";
|
||||
import { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise } from "#lib/utils/index.js";
|
||||
import { requestCategoryCreation } from "./service";
|
||||
import { tryAction } from "#lib/client/toast.svelte.js";
|
||||
import { createCategory, useCategory } from "#lib/features/filesystem/index.js";
|
||||
|
||||
interface Props {
|
||||
onAddToCategoryClick: (categoryId: number) => void;
|
||||
@@ -13,13 +12,15 @@
|
||||
|
||||
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
|
||||
|
||||
let categoryInfo: MaybeCategoryInfo | undefined = $state();
|
||||
let categoryId: CategoryId = $state("root");
|
||||
const category = useCategory(() => categoryId);
|
||||
let categoryInfo = $derived(category.current);
|
||||
|
||||
let isCategoryCreateModalOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
$effect.pre(() => {
|
||||
if (isOpen) {
|
||||
HybridPromise.resolve(getCategoryInfo("root")).then((result) => (categoryInfo = result));
|
||||
categoryId = "root";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -30,8 +31,7 @@
|
||||
<SubCategories
|
||||
class="py-4"
|
||||
info={categoryInfo}
|
||||
onSubCategoryClick={({ id }) =>
|
||||
HybridPromise.resolve(getCategoryInfo(id)).then((result) => (categoryInfo = result))}
|
||||
onSubCategoryClick={({ id }) => (categoryId = id)}
|
||||
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
|
||||
subCategoryCreatePosition="top"
|
||||
/>
|
||||
@@ -48,11 +48,6 @@
|
||||
|
||||
<CategoryCreateModal
|
||||
bind:isOpen={isCategoryCreateModalOpen}
|
||||
onCreateClick={async (name: string) => {
|
||||
if (await requestCategoryCreation(name, categoryInfo!.id)) {
|
||||
void getCategoryInfo(categoryInfo!.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onCreateClick={(name: string) =>
|
||||
tryAction(() => createCategory(categoryId, name), "카테고리를 만들지 못했어요.")}
|
||||
/>
|
||||
@@ -1,64 +0,0 @@
|
||||
import { storeFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import { prepareFileDecryption, getDecryptedFileUrl } from "#lib/client/serviceWorker.js";
|
||||
import { requestFileThumbnailUpload } from "#lib/services/file.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "#lib/services/category.js";
|
||||
export { requestFileDownload } from "#lib/services/file.js";
|
||||
|
||||
export const requestVideoStream = async (
|
||||
fileId: number,
|
||||
dataKey: CryptoKey,
|
||||
contentType: string,
|
||||
) => {
|
||||
const res = await fetch(`/api/file/${fileId}/download`, { method: "HEAD" });
|
||||
if (!res.ok) return null;
|
||||
|
||||
const encContentSize = parseInt(res.headers.get("Content-Length") ?? "0", 10);
|
||||
if (encContentSize <= 0) return null;
|
||||
|
||||
try {
|
||||
await prepareFileDecryption(fileId, { isLegacy: false, dataKey, encContentSize, contentType });
|
||||
return getDecryptedFileUrl(fileId);
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestThumbnailUpload = async (
|
||||
fileId: number,
|
||||
thumbnail: Blob,
|
||||
dataKey: CryptoKey,
|
||||
dataKeyVersion: Date,
|
||||
) => {
|
||||
const res = await requestFileThumbnailUpload(fileId, thumbnail, dataKey, dataKeyVersion);
|
||||
if (!res) return false;
|
||||
|
||||
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileId, buffer));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
|
||||
try {
|
||||
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFavoriteToggle = async (fileId: number, isFavorite: boolean) => {
|
||||
try {
|
||||
if (isFavorite) {
|
||||
await trpc.favorites.removeFile.mutate({ id: fileId });
|
||||
} else {
|
||||
await trpc.favorites.addFile.mutate({ id: fileId });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -7,7 +7,7 @@
|
||||
clearDownloadedFiles,
|
||||
type FileDownloadState,
|
||||
} from "#lib/client/transfer/index.js";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import File from "./File.svelte";
|
||||
|
||||
let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { FileDownloadState } from "#lib/client/transfer/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.js";
|
||||
|
||||
import IconCloud from "~icons/material-symbols/cloud";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
bulkGetFileInfo,
|
||||
type MaybeFileInfo,
|
||||
type SummarizedFileInfo,
|
||||
} from "#lib/modules/filesystem/index.js";
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import { formatDate, formatDateSortable, SortBy, sortEntries } from "#lib/utils/index.js";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
requestSessionUpgrade,
|
||||
serializeClientKeys,
|
||||
} from "#lib/client/keys/index.js";
|
||||
import { showToast } from "#lib/client/toast.svelte.js";
|
||||
import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte";
|
||||
import BeforeContinueModal from "./BeforeContinueModal.svelte";
|
||||
|
||||
@@ -33,26 +34,26 @@
|
||||
};
|
||||
|
||||
const registerPubKeys = async () => {
|
||||
try {
|
||||
if (!(await requestClientRegistration())) throw new Error("Failed to register client");
|
||||
|
||||
await keyring.persistClientKeys();
|
||||
|
||||
if (!(await requestSessionUpgrade())[0]) throw new Error("Failed to upgrade session");
|
||||
|
||||
if (
|
||||
!(await registerInitialMasterKeyAndHmacSecret(
|
||||
data.masterKeyWrapped,
|
||||
data.hmacSecretWrapped,
|
||||
))
|
||||
)
|
||||
throw new Error("Failed to register initial MEK and HSK");
|
||||
|
||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
||||
} catch (e) {
|
||||
// TODO: Error handling
|
||||
throw e;
|
||||
if (!(await requestClientRegistration())) {
|
||||
showToast("이 디바이스를 등록하지 못했어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
await keyring.persistClientKeys();
|
||||
|
||||
if (!(await requestSessionUpgrade())[0]) {
|
||||
showToast("로그인하지 못했어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await registerInitialMasterKeyAndHmacSecret(data.masterKeyWrapped, data.hmacSecretWrapped))
|
||||
) {
|
||||
showToast("암호 키를 등록하지 못했어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
registerInitialMasterKeyAndHmacSecret,
|
||||
requestClientRegistrationAndSessionUpgrade,
|
||||
} from "#lib/client/keys/index.js";
|
||||
import { requestDeletedFilesCleanup } from "#lib/services/file.js";
|
||||
import { showToast } from "#lib/client/toast.svelte.js";
|
||||
import { cleanupDeletedFileCaches } from "#lib/features/filesystem/index.js";
|
||||
import { gotoStateful } from "#lib/utils/index.js";
|
||||
import Order from "./Order.svelte";
|
||||
|
||||
@@ -46,8 +47,6 @@
|
||||
];
|
||||
|
||||
const generateKeys = async () => {
|
||||
// TODO: Loading indicator
|
||||
|
||||
const { encryptKey, ...clientKeys } = await generateClientKeys();
|
||||
const { masterKey, masterKeyWrapped } = await generateInitialMasterKey(encryptKey);
|
||||
const { hmacSecretWrapped } = await generateInitialHmacSecret(masterKey);
|
||||
@@ -66,7 +65,7 @@
|
||||
isForceLoginModalOpen = true;
|
||||
return;
|
||||
} else if (!upgradeRes) {
|
||||
// TODO: Error Handling
|
||||
showToast("이 디바이스를 등록하지 못했어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,11 +77,11 @@
|
||||
await keyring.persistClientKeys();
|
||||
|
||||
if (!(await registerInitialMasterKeyAndHmacSecret(masterKeyWrapped, hmacSecretWrapped))) {
|
||||
// TODO: Error Handling
|
||||
showToast("암호 키를 등록하지 못했어요.");
|
||||
return;
|
||||
}
|
||||
|
||||
await requestDeletedFilesCleanup();
|
||||
await cleanupDeletedFileCaches().catch(console.error);
|
||||
await goto("/client/pending?redirect=" + encodeURIComponent(data.redirectPath));
|
||||
};
|
||||
|
||||
@@ -90,10 +89,10 @@
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (await importClientKeys(await file.text())) {
|
||||
if (await importClientKeys(await file.text()).catch(() => false)) {
|
||||
await upgradeSession(false);
|
||||
} else {
|
||||
// TODO: Error Handling
|
||||
showToast("올바른 암호 키 파일이 아니에요.");
|
||||
}
|
||||
|
||||
fileInput!.value = "";
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
import { slide } from "svelte/transition";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Chip, FullscreenDiv, RowVirtualizer } from "#lib/components/atoms/index.js";
|
||||
import { showErrorToast } from "#lib/client/toast.svelte.js";
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
type LocalCategoryInfo,
|
||||
type MaybeDirectoryInfo,
|
||||
} from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise, searchString, sortEntries } from "#lib/utils/index.js";
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import { search, type SearchFilter, type SearchResult } from "#lib/features/search/index.js";
|
||||
import { searchString, sortEntries } from "#lib/utils/index.js";
|
||||
import Directory from "./Directory.svelte";
|
||||
import File from "./File.svelte";
|
||||
import SearchBar from "./SearchBar.svelte";
|
||||
import SelectCategoryBottomSheet from "./SelectCategoryBottomSheet.svelte";
|
||||
import { requestSearch, type SearchFilter, type SearchResult } from "./service";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -121,10 +122,18 @@
|
||||
|
||||
$effect.pre(() => {
|
||||
if (data.directoryId) {
|
||||
HybridPromise.resolve(getDirectoryInfo(data.directoryId)).then((res) => {
|
||||
const applyDirectoryInfo = (res: MaybeDirectoryInfo) => {
|
||||
directoryInfo = res;
|
||||
filters.searchInDirectory = res.exists;
|
||||
});
|
||||
};
|
||||
|
||||
// Apply synchronously if cached so that a restored snapshot isn't overwritten later
|
||||
const res = getDirectoryInfo(data.directoryId);
|
||||
if (res instanceof Promise) {
|
||||
res.then(applyDirectoryInfo).catch(() => {});
|
||||
} else {
|
||||
applyDirectoryInfo(res);
|
||||
}
|
||||
} else {
|
||||
directoryInfo = undefined;
|
||||
filters.searchInDirectory = false;
|
||||
@@ -148,13 +157,15 @@
|
||||
}
|
||||
|
||||
if (hasAnyFilter) {
|
||||
requestSearch({
|
||||
search({
|
||||
ancestorId: filters.searchInDirectory ? data.directoryId! : "root",
|
||||
inFavorites: filters.searchInFavorites,
|
||||
categories: filters.categories,
|
||||
}).then((res) => {
|
||||
serverResult = res;
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
serverResult = res;
|
||||
})
|
||||
.catch((e) => showErrorToast(e, "검색하지 못했어요."));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import type { SubDirectoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SubDirectoryInfo } from "#lib/features/filesystem/index.js";
|
||||
|
||||
interface Props {
|
||||
info: SubDirectoryInfo;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { SubCategories } from "#lib/components/molecules/index.js";
|
||||
import {
|
||||
getCategoryInfo,
|
||||
type LocalCategoryInfo,
|
||||
type MaybeCategoryInfo,
|
||||
} from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise } from "#lib/utils/index.js";
|
||||
import { useCategory, type LocalCategoryInfo } from "#lib/features/filesystem/index.js";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -16,11 +11,13 @@
|
||||
|
||||
let { isOpen = $bindable(), mode, onSelectCategoryClick }: Props = $props();
|
||||
|
||||
let categoryInfo: MaybeCategoryInfo | undefined = $state();
|
||||
let categoryId: CategoryId = $state("root");
|
||||
const category = useCategory(() => categoryId);
|
||||
let categoryInfo = $derived(category.current);
|
||||
|
||||
$effect(() => {
|
||||
$effect.pre(() => {
|
||||
if (isOpen) {
|
||||
HybridPromise.resolve(getCategoryInfo("root")).then((result) => (categoryInfo = result));
|
||||
categoryId = "root";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -31,8 +28,7 @@
|
||||
<SubCategories
|
||||
class="py-4"
|
||||
info={categoryInfo}
|
||||
onSubCategoryClick={({ id }) =>
|
||||
HybridPromise.resolve(getCategoryInfo(id)).then((result) => (categoryInfo = result))}
|
||||
onSubCategoryClick={({ id }) => (categoryId = id)}
|
||||
/>
|
||||
{#if categoryInfo.id !== "root"}
|
||||
<BottomDiv>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
getFileCacheIndex,
|
||||
deleteFileCache as doDeleteFileCache,
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatFileSize } from "#lib/utils/index.js";
|
||||
import File from "./File.svelte";
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { FileCacheIndex } from "#lib/client/storage/indexedDB/index.js";
|
||||
import type { MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { MaybeFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatDate, formatFileSize } from "#lib/utils/index.js";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { BottomDiv, Button, FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { IconEntryButton, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { deleteAllFileThumbnailCaches } from "#lib/client/cache/index.js";
|
||||
import type { MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { MaybeFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
import File from "./File.svelte";
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import type { FileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { FileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
import type { GenerationStatus } from "./service";
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { storeFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import { generateThumbnail } from "#lib/client/media/thumbnail.js";
|
||||
import { getFileInfo, type FileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { requestFileDownload, requestFileThumbnailUpload } from "#lib/services/file.js";
|
||||
import { HybridPromise, Scheduler } from "#lib/utils/index.js";
|
||||
import {
|
||||
getFileContent,
|
||||
getFileInfo,
|
||||
uploadFileThumbnail,
|
||||
type FileInfo,
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import { Scheduler } from "#lib/utils/index.js";
|
||||
import type { RouterOutputs } from "#trpc/router.server.js";
|
||||
|
||||
export type GenerationStatus =
|
||||
@@ -28,34 +31,26 @@ export const clearThumbnailGenerationStatuses = () => {
|
||||
export const requestMissingThumbnailFiles = async (
|
||||
filesRaw: RouterOutputs["file"]["listWithoutThumbnail"],
|
||||
) => {
|
||||
const files = await HybridPromise.all(
|
||||
const files = await Promise.all(
|
||||
filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file })),
|
||||
);
|
||||
return files;
|
||||
};
|
||||
|
||||
const requestThumbnailUpload = limitFunction(
|
||||
const generateAndUploadThumbnail = limitFunction(
|
||||
async (fileInfo: FileInfo, fileBuffer: ArrayBuffer) => {
|
||||
statuses.set(fileInfo.id, "generating");
|
||||
|
||||
const thumbnail = await generateThumbnail(
|
||||
new Blob([fileBuffer], { type: fileInfo.contentType }),
|
||||
);
|
||||
if (!thumbnail) return false;
|
||||
if (!thumbnail) {
|
||||
throw new Error("Failed to generate thumbnail");
|
||||
}
|
||||
|
||||
statuses.set(fileInfo.id, "uploading");
|
||||
|
||||
const res = await requestFileThumbnailUpload(
|
||||
fileInfo.id,
|
||||
thumbnail,
|
||||
fileInfo.dataKey?.key!,
|
||||
fileInfo.dataKey?.version!,
|
||||
);
|
||||
if (!res) return false;
|
||||
|
||||
await uploadFileThumbnail(fileInfo.id, thumbnail, fileInfo.dataKey!);
|
||||
statuses.set(fileInfo.id, "uploaded");
|
||||
void thumbnail.arrayBuffer().then((buffer) => storeFileThumbnailCache(fileInfo.id, buffer));
|
||||
return true;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
@@ -74,17 +69,13 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
||||
await scheduler.schedule(
|
||||
async () => {
|
||||
statuses.set(fileInfo.id, "generation-pending");
|
||||
file = await requestFileDownload(fileInfo.id, fileInfo.dataKey?.key!, fileInfo.isLegacy!);
|
||||
file = await getFileContent(fileInfo.id, fileInfo.dataKey!.key, !!fileInfo.isLegacy);
|
||||
return file.byteLength;
|
||||
},
|
||||
async () => {
|
||||
if (!(await requestThumbnailUpload(fileInfo, file!))) {
|
||||
statuses.set(fileInfo.id, "error");
|
||||
}
|
||||
},
|
||||
() => generateAndUploadThumbnail(fileInfo, file!),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statuses.set(fileInfo.id, "error");
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -3,36 +3,36 @@
|
||||
import { CheckBox, RowVirtualizer } from "#lib/components/atoms/index.js";
|
||||
import { SubCategories, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { CategoryCreateModal } from "#lib/components/organisms/index.js";
|
||||
import { updateCategoryInfo } from "#lib/client/storage/indexedDB/index.js";
|
||||
import { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise, sortEntries } from "#lib/utils/index.js";
|
||||
import { tryAction } from "#lib/client/toast.svelte.js";
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
removeFileFromCategory,
|
||||
renameCategory,
|
||||
setCategoryFileRecursive,
|
||||
useCategory,
|
||||
type LocalCategoryInfo,
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
|
||||
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
|
||||
import CategoryRenameModal from "./CategoryRenameModal.svelte";
|
||||
import File from "./File.svelte";
|
||||
import {
|
||||
createContext,
|
||||
requestCategoryCreation,
|
||||
requestFileRemovalFromCategory,
|
||||
requestCategoryRename,
|
||||
requestCategoryDeletion,
|
||||
} from "./service.svelte";
|
||||
import { createContext } from "./service.svelte";
|
||||
|
||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||
|
||||
let { data } = $props();
|
||||
let context = createContext();
|
||||
|
||||
let info: MaybeCategoryInfo | undefined = $state();
|
||||
const category = useCategory(() => data.id);
|
||||
let info = $derived(category.current);
|
||||
|
||||
let isCategoryCreateModalOpen = $state(false);
|
||||
let isCategoryMenuBottomSheetOpen = $state(false);
|
||||
let isCategoryRenameModalOpen = $state(false);
|
||||
let isCategoryDeleteModalOpen = $state(false);
|
||||
|
||||
let lastCategoryId: CategoryId | undefined = $state();
|
||||
let lastIsFileRecursive: boolean | undefined = $state();
|
||||
|
||||
let files = $derived(
|
||||
sortEntries(
|
||||
info?.files
|
||||
@@ -40,27 +40,6 @@
|
||||
.filter(({ details }) => info?.isFileRecursive || !details.isRecursive) ?? [],
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!info || info.id === "root" || info.isFileRecursive === undefined) return;
|
||||
if (lastCategoryId !== info.id) {
|
||||
lastCategoryId = info.id;
|
||||
lastIsFileRecursive = info.isFileRecursive;
|
||||
return;
|
||||
}
|
||||
if (lastIsFileRecursive === info.isFileRecursive) return;
|
||||
|
||||
lastIsFileRecursive = info.isFileRecursive;
|
||||
void updateCategoryInfo(info.id, { isFileRecursive: info.isFileRecursive });
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
HybridPromise.resolve(getCategoryInfo(data.id)).then((result) => {
|
||||
if (data.id === result.id) {
|
||||
info = result;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -93,7 +72,12 @@
|
||||
<div class="space-y-2 bg-white p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-lg font-bold text-gray-800">파일</p>
|
||||
<CheckBox bind:checked={info.isFileRecursive}>
|
||||
<CheckBox
|
||||
bind:checked={
|
||||
() => info.isFileRecursive,
|
||||
(value) => setCategoryFileRecursive(info as LocalCategoryInfo, value)
|
||||
}
|
||||
>
|
||||
<p class="font-medium">하위 카테고리의 파일</p>
|
||||
</CheckBox>
|
||||
</div>
|
||||
@@ -109,10 +93,11 @@
|
||||
info={details}
|
||||
onclick={({ id }) => goto(`/file/${id}?from=category`)}
|
||||
onRemoveClick={!details.isRecursive
|
||||
? async ({ id }) => {
|
||||
await requestFileRemovalFromCategory(id, data.id as number);
|
||||
void getCategoryInfo(data.id); // TODO: FIXME
|
||||
}
|
||||
? ({ id }) =>
|
||||
tryAction(
|
||||
() => removeFileFromCategory(id, data.id as number),
|
||||
"카테고리에서 파일을 제거하지 못했어요.",
|
||||
)
|
||||
: undefined}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -128,13 +113,8 @@
|
||||
|
||||
<CategoryCreateModal
|
||||
bind:isOpen={isCategoryCreateModalOpen}
|
||||
onCreateClick={async (name: string) => {
|
||||
if (await requestCategoryCreation(name, data.id)) {
|
||||
void getCategoryInfo(data.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onCreateClick={(name: string) =>
|
||||
tryAction(() => createCategory(data.id, name), "카테고리를 만들지 못했어요.")}
|
||||
/>
|
||||
|
||||
<CategoryMenuBottomSheet
|
||||
@@ -150,21 +130,17 @@
|
||||
/>
|
||||
<CategoryRenameModal
|
||||
bind:isOpen={isCategoryRenameModalOpen}
|
||||
onRenameClick={async (newName: string) => {
|
||||
if (await requestCategoryRename(context.selectedCategory!, newName)) {
|
||||
void getCategoryInfo(data.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onRenameClick={(newName: string) =>
|
||||
tryAction(
|
||||
() => renameCategory({ ...context.selectedCategory!, parentId: data.id }, newName),
|
||||
"이름을 바꾸지 못했어요.",
|
||||
)}
|
||||
/>
|
||||
<CategoryDeleteModal
|
||||
bind:isOpen={isCategoryDeleteModalOpen}
|
||||
onDeleteClick={async () => {
|
||||
if (await requestCategoryDeletion(context.selectedCategory!)) {
|
||||
void getCategoryInfo(data.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onDeleteClick={() =>
|
||||
tryAction(
|
||||
() => deleteCategory({ ...context.selectedCategory!, parentId: data.id }),
|
||||
"카테고리를 삭제하지 못했어요.",
|
||||
)}
|
||||
/>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { CategoryFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { CategoryFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
import type { SelectedFile } from "./service.svelte";
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import type { SelectedCategory } from "#lib/components/molecules/index.js";
|
||||
import { encryptString } from "#lib/shared/crypto/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "#lib/services/category.js";
|
||||
|
||||
export interface SelectedFile {
|
||||
id: number;
|
||||
@@ -20,35 +16,3 @@ export const createContext = () => {
|
||||
export const useContext = () => {
|
||||
return getContext<ReturnType<typeof createContext>>("context");
|
||||
};
|
||||
|
||||
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
|
||||
if (!category.dataKey) {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
|
||||
const newNameEncrypted = await encryptString(newName, category.dataKey.key);
|
||||
|
||||
try {
|
||||
await trpc.category.rename.mutate({
|
||||
id: category.id,
|
||||
dekVersion: category.dataKey.version,
|
||||
name: newNameEncrypted.ciphertext,
|
||||
nameIv: newNameEncrypted.iv,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestCategoryDeletion = async (category: SelectedCategory) => {
|
||||
try {
|
||||
await trpc.category.delete.mutate({ id: category.id });
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -3,8 +3,8 @@
|
||||
import { page } from "$app/state";
|
||||
import { FloatingButton } from "#lib/components/atoms/index.js";
|
||||
import { TopBar } from "#lib/components/molecules/index.js";
|
||||
import { getDirectoryInfo, type MaybeDirectoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise } from "#lib/utils/index.js";
|
||||
import { tryAction } from "#lib/client/toast.svelte.js";
|
||||
import { createDirectory, uploadFile, useDirectory } from "#lib/features/filesystem/index.js";
|
||||
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
|
||||
import DirectoryEntries from "./DirectoryEntries";
|
||||
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
||||
@@ -14,14 +14,7 @@
|
||||
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
|
||||
import EntryRenameModal from "./EntryRenameModal.svelte";
|
||||
import UploadStatusCard from "./UploadStatusCard.svelte";
|
||||
import {
|
||||
createContext,
|
||||
requestDirectoryCreation,
|
||||
requestFileUpload,
|
||||
requestEntryRename,
|
||||
requestEntryDeletion,
|
||||
requestFavoriteToggle,
|
||||
} from "./service.svelte";
|
||||
import { createContext, deleteEntry, renameEntry, toggleEntryFavorite } from "./service.svelte";
|
||||
|
||||
import IconSearch from "~icons/material-symbols/search";
|
||||
import IconAdd from "~icons/material-symbols/add";
|
||||
@@ -29,7 +22,8 @@
|
||||
let { data } = $props();
|
||||
let context = createContext();
|
||||
|
||||
let info: MaybeDirectoryInfo | undefined = $state();
|
||||
const directory = useDirectory(() => data.id);
|
||||
let info = $derived(directory.current);
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let duplicatedFile: File | undefined = $state();
|
||||
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
|
||||
@@ -56,46 +50,33 @@
|
||||
await goto(`/search${query ? `?${query}` : ""}`);
|
||||
};
|
||||
|
||||
const uploadFile = () => {
|
||||
const uploadFiles = () => {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
for (const file of files) {
|
||||
requestFileUpload(file, data.id, () => {
|
||||
return new Promise((resolve) => {
|
||||
duplicatedFile = file;
|
||||
resolveForDuplicateFileModal = resolve;
|
||||
isDuplicateFileModalOpen = true;
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res) return;
|
||||
// TODO: FIXME
|
||||
void getDirectoryInfo(data.id);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
// TODO: FIXME
|
||||
console.error(e);
|
||||
});
|
||||
void tryAction(
|
||||
() =>
|
||||
uploadFile(file, data.id, () => {
|
||||
return new Promise((resolve) => {
|
||||
duplicatedFile = file;
|
||||
resolveForDuplicateFileModal = resolve;
|
||||
isDuplicateFileModalOpen = true;
|
||||
});
|
||||
}),
|
||||
`'${file.name}' 파일을 업로드하지 못했어요.`,
|
||||
);
|
||||
}
|
||||
|
||||
fileInput!.value = "";
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
HybridPromise.resolve(getDirectoryInfo(data.id)).then((result) => {
|
||||
if (data.id === result.id) {
|
||||
info = result;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>내 파일</title>
|
||||
</svelte:head>
|
||||
|
||||
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
|
||||
<input bind:this={fileInput} onchange={uploadFiles} type="file" multiple class="hidden" />
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<TopBar title={info?.name ?? "내 파일"} {showBackButton} class="shrink-0">
|
||||
@@ -150,13 +131,8 @@
|
||||
/>
|
||||
<DirectoryCreateModal
|
||||
bind:isOpen={isDirectoryCreateModalOpen}
|
||||
onCreateClick={async (name) => {
|
||||
if (await requestDirectoryCreation(name, data.id)) {
|
||||
void getDirectoryInfo(data.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onCreateClick={(name) =>
|
||||
tryAction(() => createDirectory(data.id, name), "폴더를 만들지 못했어요.")}
|
||||
/>
|
||||
<DuplicateFileModal
|
||||
bind:isOpen={isDuplicateFileModalOpen}
|
||||
@@ -182,29 +158,22 @@
|
||||
isEntryDeleteModalOpen = true;
|
||||
}}
|
||||
onFavoriteClick={async () => {
|
||||
if (await requestFavoriteToggle(context.selectedEntry!)) {
|
||||
if (
|
||||
await tryAction(
|
||||
() => toggleEntryFavorite(context.selectedEntry!),
|
||||
"즐겨찾기를 변경하지 못했어요.",
|
||||
)
|
||||
) {
|
||||
isEntryMenuBottomSheetOpen = false;
|
||||
void getDirectoryInfo(data.id); // TODO: FIXME
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EntryRenameModal
|
||||
bind:isOpen={isEntryRenameModalOpen}
|
||||
onRenameClick={async (newName: string) => {
|
||||
if (await requestEntryRename(context.selectedEntry!, newName)) {
|
||||
void getDirectoryInfo(data.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onRenameClick={(newName: string) =>
|
||||
tryAction(() => renameEntry(context.selectedEntry!, newName), "이름을 바꾸지 못했어요.")}
|
||||
/>
|
||||
<EntryDeleteModal
|
||||
bind:isOpen={isEntryDeleteModalOpen}
|
||||
onDeleteClick={async () => {
|
||||
if (await requestEntryDeletion(context.selectedEntry!)) {
|
||||
void getDirectoryInfo(data.id); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
onDeleteClick={() => tryAction(() => deleteEntry(context.selectedEntry!), "삭제하지 못했어요.")}
|
||||
/>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ActionEntryButton, RowVirtualizer } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getUploadingFiles, type LiveFileUploadState } from "#lib/client/transfer/index.js";
|
||||
import type { DirectoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { DirectoryInfo } from "#lib/features/filesystem/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
import File from "./File.svelte";
|
||||
import SubDirectory from "./SubDirectory.svelte";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
import type { SelectedEntry } from "../service.svelte";
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
callback({
|
||||
type: "file",
|
||||
id: info.id,
|
||||
parentId: info.parentId,
|
||||
dataKey: info.dataKey,
|
||||
name: info.name,
|
||||
isFavorite: info.isFavorite,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import type { SubDirectoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SubDirectoryInfo } from "#lib/features/filesystem/index.js";
|
||||
import type { SelectedEntry } from "../service.svelte";
|
||||
|
||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||
@@ -18,6 +18,7 @@
|
||||
callback({
|
||||
type: "directory",
|
||||
id: info.id,
|
||||
parentId: info.parentId,
|
||||
dataKey: info.dataKey,
|
||||
name: info.name,
|
||||
isFavorite: info.isFavorite,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import {
|
||||
storeFileCache,
|
||||
deleteFileCache,
|
||||
storeFileThumbnailCache,
|
||||
deleteFileThumbnailCache,
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { keyring } from "#lib/client/keys/index.js";
|
||||
import { uploadFile } from "#lib/client/transfer/index.js";
|
||||
import type { DataKey } from "#lib/modules/filesystem/index.js";
|
||||
import { generateDataKey, wrapDataKey, encryptString } from "#lib/shared/crypto/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
deleteDirectory,
|
||||
deleteFile,
|
||||
renameDirectory,
|
||||
renameFile,
|
||||
setDirectoryFavorite,
|
||||
setFileFavorite,
|
||||
type DataKey,
|
||||
} from "#lib/features/filesystem/index.js";
|
||||
|
||||
export interface SelectedEntry {
|
||||
type: "directory" | "file";
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
dataKey: DataKey | undefined;
|
||||
name: string;
|
||||
isFavorite: boolean;
|
||||
@@ -30,115 +29,26 @@ export const useContext = () => {
|
||||
return getContext<ReturnType<typeof createContext>>("context");
|
||||
};
|
||||
|
||||
export const requestDirectoryCreation = async (name: string, parentId: "root" | number) => {
|
||||
const masterKey = keyring.activeMasterKey;
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
const nameEncrypted = await encryptString(name, dataKey);
|
||||
|
||||
try {
|
||||
await trpc.directory.create.mutate({
|
||||
parent: parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||
dekVersion: dataKeyVersion,
|
||||
name: nameEncrypted.ciphertext,
|
||||
nameIv: nameEncrypted.iv,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
export const renameEntry = async (entry: SelectedEntry, newName: string) => {
|
||||
if (entry.type === "directory") {
|
||||
await renameDirectory(entry, newName);
|
||||
} else {
|
||||
await renameFile(entry, newName);
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFileUpload = async (
|
||||
file: File,
|
||||
parentId: "root" | number,
|
||||
onDuplicate: () => Promise<boolean>,
|
||||
) => {
|
||||
const res = await uploadFile(file, parentId, onDuplicate);
|
||||
if (!res) return false;
|
||||
|
||||
if (res.fileBuffer) {
|
||||
storeFileCache(res.fileId, res.fileBuffer); // Intended
|
||||
}
|
||||
if (res.thumbnailBuffer) {
|
||||
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
|
||||
if (!entry.dataKey) {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
|
||||
const newNameEncrypted = await encryptString(newName, entry.dataKey.key);
|
||||
|
||||
try {
|
||||
if (entry.type === "directory") {
|
||||
await trpc.directory.rename.mutate({
|
||||
id: entry.id,
|
||||
dekVersion: entry.dataKey.version,
|
||||
name: newNameEncrypted.ciphertext,
|
||||
nameIv: newNameEncrypted.iv,
|
||||
});
|
||||
} else {
|
||||
await trpc.file.rename.mutate({
|
||||
id: entry.id,
|
||||
dekVersion: entry.dataKey.version,
|
||||
name: newNameEncrypted.ciphertext,
|
||||
nameIv: newNameEncrypted.iv,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
export const deleteEntry = async (entry: SelectedEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
await deleteDirectory(entry);
|
||||
} else {
|
||||
await deleteFile(entry);
|
||||
}
|
||||
};
|
||||
|
||||
export const requestEntryDeletion = async (entry: SelectedEntry) => {
|
||||
try {
|
||||
if (entry.type === "directory") {
|
||||
const { deletedFiles } = await trpc.directory.delete.mutate({ id: entry.id });
|
||||
await Promise.all(
|
||||
deletedFiles.flatMap((fileId) => [
|
||||
deleteFileCache(fileId),
|
||||
deleteFileThumbnailCache(fileId),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
await trpc.file.delete.mutate({ id: entry.id });
|
||||
await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFavoriteToggle = async (entry: SelectedEntry) => {
|
||||
try {
|
||||
if (entry.type === "directory") {
|
||||
if (entry.isFavorite) {
|
||||
await trpc.favorites.removeDirectory.mutate({ id: entry.id });
|
||||
} else {
|
||||
await trpc.favorites.addDirectory.mutate({ id: entry.id });
|
||||
}
|
||||
} else {
|
||||
if (entry.isFavorite) {
|
||||
await trpc.favorites.removeFile.mutate({ id: entry.id });
|
||||
} else {
|
||||
await trpc.favorites.addFile.mutate({ id: entry.id });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
export const toggleEntryFavorite = async (entry: SelectedEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
await setDirectoryFavorite(entry, !entry.isFavorite);
|
||||
} else {
|
||||
await setFileFavorite(entry, !entry.isFavorite);
|
||||
}
|
||||
};
|
||||
@@ -5,22 +5,30 @@
|
||||
import { TopBar } from "#lib/components/molecules/index.js";
|
||||
import Directory from "./Directory.svelte";
|
||||
import File from "./File.svelte";
|
||||
import { requestFavoriteEntries, requestRemoveFavorite, type FavoriteEntry } from "./service";
|
||||
import { showErrorToast, tryAction } from "#lib/client/toast.svelte.js";
|
||||
import {
|
||||
getFavoriteEntries,
|
||||
removeFavorite,
|
||||
type FavoriteEntry,
|
||||
} from "#lib/features/favorites/index.js";
|
||||
|
||||
import IconSearch from "~icons/material-symbols/search";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let entries: FavoriteEntry[] = $state([]);
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
entries = await requestFavoriteEntries(data.favorites);
|
||||
isLoading = false;
|
||||
try {
|
||||
entries = await getFavoriteEntries();
|
||||
} catch (e) {
|
||||
showErrorToast(e, "즐겨찾기를 불러오지 못했어요.");
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemove = async (entry: FavoriteEntry) => {
|
||||
if (await requestRemoveFavorite(entry.type, entry.details.id)) {
|
||||
if (await tryAction(() => removeFavorite(entry), "즐겨찾기를 해제하지 못했어요.")) {
|
||||
entries = entries.filter(
|
||||
(e) => !(e.type === entry.type && e.details.id === entry.details.id),
|
||||
);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { trpc } from "#trpc/client.js";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ parent }) => {
|
||||
await parent(); // Wait for the key guard in the root layout
|
||||
const favorites = await trpc.favorites.get.query();
|
||||
return { favorites };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import type { SubDirectoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SubDirectoryInfo } from "#lib/features/filesystem/index.js";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
type SummarizedFileInfo,
|
||||
type SubDirectoryInfo,
|
||||
type LocalDirectoryInfo,
|
||||
} from "#lib/modules/filesystem/index.js";
|
||||
import { HybridPromise, sortEntries } from "#lib/utils/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
import type { RouterOutputs } from "#trpc/router.server.js";
|
||||
|
||||
export type FavoriteEntry =
|
||||
| { type: "directory"; name: string; details: SubDirectoryInfo }
|
||||
| { type: "file"; name: string; details: SummarizedFileInfo };
|
||||
|
||||
export const requestFavoriteEntries = async (favorites: RouterOutputs["favorites"]["get"]) => {
|
||||
const [directories, files] = await HybridPromise.all([
|
||||
HybridPromise.all(
|
||||
favorites.directories.map((directory) =>
|
||||
getDirectoryInfo(directory.id, {
|
||||
serverResponse: { ...directory, isFavorite: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
HybridPromise.all(
|
||||
favorites.files.map((file) =>
|
||||
getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return [
|
||||
...sortEntries(
|
||||
directories.map((directory): FavoriteEntry => ({
|
||||
type: "directory",
|
||||
name: directory.name!,
|
||||
details: directory as LocalDirectoryInfo,
|
||||
})),
|
||||
),
|
||||
...sortEntries(
|
||||
files.map((file): FavoriteEntry => ({
|
||||
type: "file",
|
||||
name: file.name!,
|
||||
details: file as SummarizedFileInfo,
|
||||
})),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const requestRemoveFavorite = async (type: "file" | "directory", id: number) => {
|
||||
try {
|
||||
if (type === "directory") {
|
||||
await trpc.favorites.removeDirectory.mutate({ id });
|
||||
} else {
|
||||
await trpc.favorites.removeFile.mutate({ id });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { EntryButton, FileThumbnailButton } from "#lib/components/atoms/index.js";
|
||||
import { type SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { type SummarizedFileInfo } from "#lib/features/filesystem/index.js";
|
||||
import { requestFreshMediaFilesRetrieval } from "./service";
|
||||
|
||||
let mediaFiles: SummarizedFileInfo[] = $state([]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import MenuEntryButton from "./MenuEntryButton.svelte";
|
||||
import { requestLogout } from "./service";
|
||||
import { tryAction } from "#lib/client/toast.svelte.js";
|
||||
import { logout as requestLogout } from "#lib/features/auth/index.js";
|
||||
|
||||
import IconStorage from "~icons/material-symbols/storage";
|
||||
import IconImage from "~icons/material-symbols/image";
|
||||
@@ -11,7 +12,7 @@
|
||||
let { data } = $props();
|
||||
|
||||
const logout = async () => {
|
||||
if (await requestLogout()) {
|
||||
if (await tryAction(requestLogout, "로그아웃하지 못했어요.")) {
|
||||
await goto("/auth/login");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { requestLogout } from "#lib/services/auth.js";
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getDownloadingFiles, getUploadingFiles } from "#lib/client/transfer/index.js";
|
||||
import { Toasts } from "#lib/components/atoms/index.js";
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
@@ -14,3 +15,5 @@
|
||||
<svelte:window onbeforeunload={protectFileUploadAndDownload} />
|
||||
|
||||
{@render children()}
|
||||
|
||||
<Toasts />
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FilesystemCache } from "#lib/features/filesystem/cache.svelte.js";
|
||||
|
||||
interface Entry {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const createCache = (options?: {
|
||||
local?: Record<number, string>;
|
||||
server?: Record<number, string>;
|
||||
}) => {
|
||||
const local = options?.local ?? {};
|
||||
const server = options?.server ?? {};
|
||||
const fetchFromServer = vi.fn(async (id: number): Promise<Entry> => {
|
||||
if (!(id in server)) throw new Error("Not found");
|
||||
return { id, name: server[id]! };
|
||||
});
|
||||
const cache = new FilesystemCache<number, Entry>({
|
||||
fetchFromIndexedDB: async (id) => (id in local ? { id, name: local[id]! } : undefined),
|
||||
fetchFromServer,
|
||||
bulkFetchFromIndexedDB: async (ids) =>
|
||||
new Map([...ids].filter((id) => id in local).map((id) => [id, { id, name: local[id]! }])),
|
||||
bulkFetchFromServer: async (ids) =>
|
||||
new Map([...ids.keys()].map((id) => [id, { id, name: server[id]! }])),
|
||||
});
|
||||
return { cache, server, fetchFromServer };
|
||||
};
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("FilesystemCache", () => {
|
||||
it("resolves with the local value first and updates it in place", async () => {
|
||||
const { cache } = createCache({ local: { 1: "local" }, server: { 1: "server" } });
|
||||
|
||||
const entry = await cache.get(1);
|
||||
expect(entry.name).toBe("local");
|
||||
|
||||
await flush();
|
||||
expect(entry.name).toBe("server");
|
||||
});
|
||||
|
||||
it("returns the cached value synchronously", async () => {
|
||||
const { cache } = createCache({ server: { 1: "server" } });
|
||||
|
||||
const entry = await cache.get(1);
|
||||
await flush();
|
||||
|
||||
expect(cache.get(1)).toBe(entry);
|
||||
});
|
||||
|
||||
it("revalidates entries when invalidated", async () => {
|
||||
const { cache, server, fetchFromServer } = createCache({ server: { 1: "old" } });
|
||||
const entry = await cache.get(1);
|
||||
await flush();
|
||||
|
||||
server[1] = "new";
|
||||
cache.invalidate(1);
|
||||
await flush();
|
||||
|
||||
expect(fetchFromServer).toHaveBeenCalledTimes(2);
|
||||
expect(entry.name).toBe("new");
|
||||
});
|
||||
|
||||
it("ignores invalidation of entries that haven't been loaded", () => {
|
||||
const { cache, fetchFromServer } = createCache();
|
||||
|
||||
cache.invalidate(1);
|
||||
|
||||
expect(fetchFromServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects if loading fails without a cached value", async () => {
|
||||
const { cache } = createCache();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await expect(cache.get(1)).rejects.toThrow("Not found");
|
||||
// Can be retried
|
||||
await expect(cache.get(1)).rejects.toThrow("Not found");
|
||||
});
|
||||
|
||||
it("loads multiple entries at once", async () => {
|
||||
const { cache } = createCache({ local: { 1: "local" }, server: { 1: "a", 2: "b" } });
|
||||
|
||||
const entries = await cache.bulkGet(new Set([1, 2]));
|
||||
await flush();
|
||||
|
||||
expect(entries.get(1)?.name).toBe("a");
|
||||
expect(entries.get(2)?.name).toBe("b");
|
||||
expect(cache.get(2)).toBe(entries.get(2));
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
// Compiles runes in `.svelte.ts` files
|
||||
plugins: [svelte()],
|
||||
plugins: [svelte({ configFile: false })],
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user