3 Commits

39 changed files with 700 additions and 24 deletions

View File

@@ -2,6 +2,8 @@
import { getFileThumbnail } from "$lib/modules/file"; import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props { interface Props {
info: SummarizedFileInfo; info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void; onclick?: (file: SummarizedFileInfo) => void;
@@ -14,11 +16,19 @@
<button <button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))} onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90" class="relative aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
> >
{#if $thumbnail} {#if $thumbnail}
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" /> <img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else} {:else}
<div class="h-full w-full bg-gray-100"></div> <div class="h-full w-full bg-gray-100"></div>
{/if} {/if}
{#if info.isFavorite}
<div class={["absolute bottom-0 right-0", !thumbnail && "rounded-full bg-white p-0.5"]}>
<IconFavorite
class="text-sm text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white);"
/>
</div>
{/if}
</button> </button>

View File

@@ -5,9 +5,11 @@
import IconFolder from "~icons/material-symbols/folder"; import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload"; import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props { interface Props {
class?: ClassValue; class?: ClassValue;
isFavorite?: boolean;
name: string; name: string;
subtext?: string; subtext?: string;
textClass?: ClassValue; textClass?: ClassValue;
@@ -17,6 +19,7 @@
let { let {
class: className, class: className,
isFavorite = false,
name, name,
subtext, subtext,
textClass: textClassName, textClass: textClassName,
@@ -26,7 +29,7 @@
</script> </script>
{#snippet iconSnippet()} {#snippet iconSnippet()}
<div class="flex h-10 w-10 items-center justify-center text-xl"> <div class="relative flex h-10 w-10 items-center justify-center text-xl">
{#if thumbnail} {#if thumbnail}
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" /> <img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
{:else if type === "directory"} {:else if type === "directory"}
@@ -36,6 +39,14 @@
{:else} {:else}
<IconDraft class="text-blue-400" /> <IconDraft class="text-blue-400" />
{/if} {/if}
{#if isFavorite}
<div class={["absolute bottom-0 right-0", !thumbnail && "rounded-full bg-white p-0.5"]}>
<IconFavorite
class="text-xs text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white);"
/>
</div>
{/if}
</div> </div>
{/snippet} {/snippet}

View File

@@ -4,6 +4,7 @@ interface DirectoryInfo {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
name: string; name: string;
isFavorite?: boolean;
} }
interface FileInfo { interface FileInfo {
@@ -14,6 +15,7 @@ interface FileInfo {
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
categoryIds?: number[]; categoryIds?: number[];
isFavorite?: boolean;
} }
interface CategoryInfo { interface CategoryInfo {

View File

@@ -22,6 +22,7 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
name: fileInfo.name, name: fileInfo.name,
createdAt: fileInfo.createdAt, createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt, lastModifiedAt: fileInfo.lastModifiedAt,
isFavorite: fileInfo.isFavorite,
isRecursive: file.isRecursive, isRecursive: file.isRecursive,
} }
: undefined; : undefined;
@@ -66,6 +67,7 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
parentId: file.parent, parentId: file.parent,
contentType: file.contentType, contentType: file.contentType,
isRecursive: file.isRecursive, isRecursive: file.isRecursive,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file, masterKey)), ...(await decryptFileMetadata(file, masterKey)),
})), })),
), ),

View File

@@ -39,6 +39,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
directory.subDirectories.map(async (directory) => ({ directory.subDirectories.map(async (directory) => ({
id: directory.id, id: directory.id,
parentId: id, parentId: id,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory, masterKey)), ...(await decryptDirectoryMetadata(directory, masterKey)),
})), })),
), ),
@@ -47,6 +48,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
id: file.id, id: file.id,
parentId: id, parentId: id,
contentType: file.contentType, contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file, masterKey)), ...(await decryptFileMetadata(file, masterKey)),
})), })),
), ),

View File

@@ -27,6 +27,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
name: file.name, name: file.name,
createdAt: file.createdAt, createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt, lastModifiedAt: file.lastModifiedAt,
isFavorite: file.isFavorite,
categories: categories?.filter((category) => !!category) ?? [], categories: categories?.filter((category) => !!category) ?? [],
}; };
} }
@@ -55,6 +56,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
name: metadata.name, name: metadata.name,
createdAt: metadata.createdAt, createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt, lastModifiedAt: metadata.lastModifiedAt,
isFavorite: file.isFavorite,
categories, categories,
}); });
} catch (e) { } catch (e) {
@@ -121,6 +123,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
parentId: metadataRaw.parent, parentId: metadataRaw.parent,
contentType: metadataRaw.contentType, contentType: metadataRaw.contentType,
categories, categories,
isFavorite: metadataRaw.isFavorite,
...metadata, ...metadata,
}; };
}), }),

View File

@@ -6,6 +6,7 @@ export interface LocalDirectoryInfo {
parentId: DirectoryId; parentId: DirectoryId;
dataKey?: DataKey; dataKey?: DataKey;
name: string; name: string;
isFavorite?: boolean;
subDirectories: SubDirectoryInfo[]; subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[]; files: SummarizedFileInfo[];
} }
@@ -36,6 +37,7 @@ export interface FileInfo {
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
categories: FileCategoryInfo[]; categories: FileCategoryInfo[];
isFavorite?: boolean;
} }
export type MaybeFileInfo = export type MaybeFileInfo =

View File

@@ -8,10 +8,14 @@ type IntegrityErrorMessages =
| "User client already exists" | "User client already exists"
// File // File
| "Directory not found" | "Directory not found"
| "Directory already favorited"
| "Directory not favorited"
| "File not found" | "File not found"
| "File is not legacy" | "File is not legacy"
| "File not found in category" | "File not found in category"
| "File already added to category" | "File already added to category"
| "File already favorited"
| "File not favorited"
| "Invalid DEK version" | "Invalid DEK version"
// HSK // HSK
| "HSK already registered" | "HSK already registered"

View File

@@ -13,6 +13,7 @@ interface Directory {
encDek: string; encDek: string;
dekVersion: Date; dekVersion: Date;
encName: Ciphertext; encName: Ciphertext;
isFavorite: boolean;
} }
interface File { interface File {
@@ -31,6 +32,7 @@ interface File {
encName: Ciphertext; encName: Ciphertext;
encCreatedAt: Ciphertext | null; encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext; encLastModifiedAt: Ciphertext;
isFavorite: boolean;
} }
interface FileCategory { interface FileCategory {
@@ -42,7 +44,7 @@ interface FileCategory {
encName: Ciphertext; encName: Ciphertext;
} }
export const registerDirectory = async (params: Omit<Directory, "id">) => { export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx const mek = await trx
.selectFrom("master_encryption_key") .selectFrom("master_encryption_key")
@@ -97,6 +99,7 @@ export const getAllDirectoriesByParent = async (userId: number, parentId: Direct
encDek: directory.encrypted_data_encryption_key, encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version, dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name, encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
}) satisfies Directory, }) satisfies Directory,
); );
}; };
@@ -130,6 +133,7 @@ export const getAllRecursiveDirectoriesByParent = async (userId: number, parentI
encDek: directory.encrypted_data_encryption_key, encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version, dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name, encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
}) satisfies Directory, }) satisfies Directory,
); );
}; };
@@ -151,6 +155,7 @@ export const getDirectory = async (userId: number, directoryId: number) => {
encDek: directory.encrypted_data_encryption_key, encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version, dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name, encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
} satisfies Directory) } satisfies Directory)
: null; : null;
}; };
@@ -243,7 +248,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
}); });
}; };
export const registerFile = async (trx: typeof db, params: Omit<File, "id">) => { export const registerFile = async (trx: typeof db, params: Omit<File, "id" | "isFavorite">) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments"); throw new Error("Invalid arguments");
} }
@@ -305,6 +310,7 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File, }) satisfies File,
); );
}; };
@@ -357,6 +363,7 @@ export const getAllFilesByCategory = async (
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
isRecursive: file.depth > 0, isRecursive: file.depth > 0,
}) satisfies File & { isRecursive: boolean }, }) satisfies File & { isRecursive: boolean },
); );
@@ -393,6 +400,7 @@ export const getLegacyFiles = async (userId: number, limit: number = 100) => {
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File, }) satisfies File,
); );
}; };
@@ -436,6 +444,7 @@ export const getFilesWithoutThumbnail = async (userId: number, limit: number = 1
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File, }) satisfies File,
); );
}; };
@@ -480,6 +489,7 @@ export const getFile = async (userId: number, fileId: number) => {
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
} satisfies File) } satisfies File)
: null; : null;
}; };
@@ -518,6 +528,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
categories: file.categories.map((category) => ({ categories: file.categories.map((category) => ({
id: category.id, id: category.id,
parentId: category.parent_id ?? "root", parentId: category.parent_id ?? "root",
@@ -630,6 +641,7 @@ export const searchFiles = async (
encName: file.encrypted_name, encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at, encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at, encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
})); }));
}; };
@@ -803,3 +815,127 @@ export const removeFileFromCategory = async (fileId: number, categoryId: number)
.execute(); .execute();
}); });
}; };
export const setFileFavorite = async (userId: number, fileId: number, isFavorite: boolean) => {
await db.transaction().execute(async (trx) => {
const file = await trx
.selectFrom("file")
.select("is_favorite")
.where("id", "=", fileId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.is_favorite === isFavorite) {
throw new IntegrityError(isFavorite ? "File already favorited" : "File not favorited");
}
await trx
.updateTable("file")
.set({ is_favorite: isFavorite })
.where("id", "=", fileId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
})
.execute();
});
};
export const setDirectoryFavorite = async (
userId: number,
directoryId: number,
isFavorite: boolean,
) => {
await db.transaction().execute(async (trx) => {
const directory = await trx
.selectFrom("directory")
.select("is_favorite")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
} else if (directory.is_favorite === isFavorite) {
throw new IntegrityError(
isFavorite ? "Directory already favorited" : "Directory not favorited",
);
}
await trx
.updateTable("directory")
.set({ is_favorite: isFavorite })
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
})
.execute();
});
};
export const getAllFavoriteFiles = async (userId: number) => {
const files = await db
.selectFrom("file")
.selectAll()
.where("user_id", "=", userId)
.where("is_favorite", "=", true)
.execute();
return files.map(
(file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isFavorite: file.is_favorite,
}) satisfies File,
);
};
export const getAllFavoriteDirectories = async (userId: number) => {
const directories = await db
.selectFrom("directory")
.selectAll()
.where("user_id", "=", userId)
.where("is_favorite", "=", true)
.execute();
return directories.map(
(directory) =>
({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
isFavorite: directory.is_favorite,
}) satisfies Directory,
);
};

View File

@@ -0,0 +1,29 @@
import { Kysely, sql } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// file.ts
await db.schema
.alterTable("directory")
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
.execute();
await db.schema
.alterTable("file")
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db
.deleteFrom("file_log")
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
.execute();
await db
.deleteFrom("directory_log")
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
.execute();
await db.schema.alterTable("file").dropColumn("is_favorite").execute();
await db.schema.alterTable("directory").dropColumn("is_favorite").execute();
};

View File

@@ -2,10 +2,12 @@ import * as Initial1737357000 from "./1737357000-Initial";
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory"; import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail"; import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload"; import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
import * as AddFavorites1768643000 from "./1768643000-AddFavorites";
export default { export default {
"1737357000-Initial": Initial1737357000, "1737357000-Initial": Initial1737357000,
"1737422340-AddFileCategory": AddFileCategory1737422340, "1737422340-AddFileCategory": AddFileCategory1737422340,
"1738409340-AddThumbnail": AddThumbnail1738409340, "1738409340-AddThumbnail": AddThumbnail1738409340,
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380, "1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
"1768643000-AddFavorites": AddFavorites1768643000,
}; };

View File

@@ -9,13 +9,14 @@ interface DirectoryTable {
encrypted_data_encryption_key: string; // Base64 encrypted_data_encryption_key: string; // Base64
data_encryption_key_version: Date; data_encryption_key_version: Date;
encrypted_name: Ciphertext; encrypted_name: Ciphertext;
is_favorite: Generated<boolean>;
} }
interface DirectoryLogTable { interface DirectoryLogTable {
id: Generated<number>; id: Generated<number>;
directory_id: number; directory_id: number;
timestamp: ColumnType<Date, Date, never>; timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename"; action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites";
new_name: Ciphertext | null; new_name: Ciphertext | null;
} }
@@ -35,13 +36,21 @@ interface FileTable {
encrypted_name: Ciphertext; encrypted_name: Ciphertext;
encrypted_created_at: Ciphertext | null; encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext; encrypted_last_modified_at: Ciphertext;
is_favorite: Generated<boolean>;
} }
interface FileLogTable { interface FileLogTable {
id: Generated<number>; id: Generated<number>;
file_id: number; file_id: number;
timestamp: ColumnType<Date, Date, never>; timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename" | "migrate" | "add-to-category" | "remove-from-category"; action:
| "create"
| "rename"
| "migrate"
| "add-to-category"
| "remove-from-category"
| "add-to-favorites"
| "remove-from-favorites";
new_name: Ciphertext | null; new_name: Ciphertext | null;
category_id: number | null; category_id: number | null;
} }

View File

@@ -18,6 +18,7 @@
requestThumbnailUpload, requestThumbnailUpload,
requestFileAdditionToCategory, requestFileAdditionToCategory,
requestVideoStream, requestVideoStream,
requestFavoriteToggle,
} from "./service"; } from "./service";
import TopBarMenu from "./TopBarMenu.svelte"; import TopBarMenu from "./TopBarMenu.svelte";
@@ -75,6 +76,15 @@
void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
const toggleFavorite = async () => {
if (!info?.exists) return;
const isFavorite = !!info.isFavorite;
const success = await requestFavoriteToggle(data.id, isFavorite);
if (success) {
info.isFavorite = !isFavorite;
}
};
$effect(() => { $effect(() => {
HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => { HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => {
if (data.id === result.id) { if (data.id === result.id) {
@@ -150,7 +160,7 @@
</button> </button>
<TopBarMenu <TopBarMenu
bind:isOpen={isMenuOpen} bind:isOpen={isMenuOpen}
directoryId={["category", "gallery", "search"].includes( directoryId={["category", "gallery", "search", "favorite"].includes(
page.url.searchParams.get("from") ?? "", page.url.searchParams.get("from") ?? "",
) )
? info?.parentId ? info?.parentId
@@ -158,6 +168,8 @@
{fileBlob} {fileBlob}
downloadUrl={videoStreamUrl} downloadUrl={videoStreamUrl}
filename={info?.name} filename={info?.name}
isFavorite={info?.isFavorite}
onToggleFavorite={toggleFavorite}
/> />
</div> </div>
</TopBar> </TopBar>

View File

@@ -5,6 +5,8 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import IconFavorite from "~icons/material-symbols/favorite";
import IconFavoriteOutline from "~icons/material-symbols/favorite-outline";
import IconFolderOpen from "~icons/material-symbols/folder-open"; import IconFolderOpen from "~icons/material-symbols/folder-open";
import IconCloudDownload from "~icons/material-symbols/cloud-download"; import IconCloudDownload from "~icons/material-symbols/cloud-download";
@@ -13,10 +15,20 @@
downloadUrl?: string; downloadUrl?: string;
fileBlob?: Blob; fileBlob?: Blob;
filename?: string; filename?: string;
isFavorite?: boolean;
isOpen: boolean; isOpen: boolean;
onToggleFavorite?: () => void;
} }
let { directoryId, downloadUrl, fileBlob, filename, isOpen = $bindable() }: Props = $props(); let {
directoryId,
downloadUrl,
fileBlob,
filename,
isFavorite,
isOpen = $bindable(),
onToggleFavorite,
}: Props = $props();
const handleDownload = () => { const handleDownload = () => {
if (fileBlob && filename) { if (fileBlob && filename) {
@@ -34,7 +46,7 @@
{#if isOpen && (directoryId || downloadUrl || fileBlob)} {#if isOpen && (directoryId || downloadUrl || fileBlob)}
<div <div
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl" class="absolute right-2 top-full z-20 min-w-44 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
transition:fly={{ y: -8, duration: 200 }} transition:fly={{ y: -8, duration: 200 }}
> >
<p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p> <p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p>
@@ -54,6 +66,13 @@
</button> </button>
{/snippet} {/snippet}
{#if typeof isFavorite === "boolean"}
{@render menuButton(
isFavorite ? IconFavorite : IconFavoriteOutline,
isFavorite ? "즐겨찾기 해제" : "즐겨찾기",
onToggleFavorite ?? (() => {}),
)}
{/if}
{#if directoryId} {#if directoryId}
{@render menuButton(IconFolderOpen, "폴더에서 보기", () => {@render menuButton(IconFolderOpen, "폴더에서 보기", () =>
goto( goto(

View File

@@ -48,3 +48,16 @@ export const requestFileAdditionToCategory = async (fileId: number, categoryId:
return false; 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 {
return false;
}
};

View File

@@ -12,5 +12,5 @@
</script> </script>
<ActionEntryButton class="h-14" {onclick}> <ActionEntryButton class="h-14" {onclick}>
<DirectoryEntryLabel type="directory" name={info.name} /> <DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
</ActionEntryButton> </ActionEntryButton>

View File

@@ -21,5 +21,6 @@
thumbnail={$thumbnail} thumbnail={$thumbnail}
name={info.name} name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)} subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
/> />
</ActionEntryButton> </ActionEntryButton>

View File

@@ -46,6 +46,7 @@ export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey)
exists: true, exists: true,
parentId: directory.parent, parentId: directory.parent,
...metadata, ...metadata,
isFavorite: !!directory.isFavorite,
}; };
}, },
}), }),
@@ -65,6 +66,7 @@ export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey)
exists: true, exists: true,
parentId: file.parent, parentId: file.parent,
contentType: file.contentType, contentType: file.contentType,
isFavorite: !!file.isFavorite,
...metadata, ...metadata,
}; };
}, },

View File

@@ -12,7 +12,7 @@
const pages = [ const pages = [
{ path: "/home", label: "홈", icon: IconHome }, { path: "/home", label: "홈", icon: IconHome },
{ path: "/directory", label: "파일", icon: IconFolder }, { path: "/directory", label: "파일", icon: IconFolder },
{ path: "/favorite", label: "즐겨찾기", icon: IconFavorite }, { path: "/favorites", label: "즐겨찾기", icon: IconFavorite },
{ path: "/category", label: "카테고리", icon: IconCategory }, { path: "/category", label: "카테고리", icon: IconCategory },
{ path: "/menu", label: "전체", icon: IconMenu }, { path: "/menu", label: "전체", icon: IconMenu },
]; ];

View File

@@ -24,5 +24,10 @@
actionButtonIcon={onRemoveClick && IconClose} actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={() => onRemoveClick?.(info)} onActionButtonClick={() => onRemoveClick?.(info)}
> >
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} /> <DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
name={info.name}
isFavorite={info.isFavorite}
/>
</ActionEntryButton> </ActionEntryButton>

View File

@@ -23,6 +23,7 @@
requestFileUpload, requestFileUpload,
requestEntryRename, requestEntryRename,
requestEntryDeletion, requestEntryDeletion,
requestFavoriteToggle,
} from "./service.svelte"; } from "./service.svelte";
import IconSearch from "~icons/material-symbols/search"; import IconSearch from "~icons/material-symbols/search";
@@ -45,7 +46,7 @@
let isEntryDeleteModalOpen = $state(false); let isEntryDeleteModalOpen = $state(false);
let showParentEntry = $derived( let showParentEntry = $derived(
["file", "search"].includes(page.url.searchParams.get("from") ?? ""), ["file", "search", "favorite"].includes(page.url.searchParams.get("from") ?? ""),
); );
let showBackButton = $derived(data.id !== "root" || showParentEntry); let showBackButton = $derived(data.id !== "root" || showParentEntry);
@@ -194,6 +195,12 @@
isEntryMenuBottomSheetOpen = false; isEntryMenuBottomSheetOpen = false;
isEntryDeleteModalOpen = true; isEntryDeleteModalOpen = true;
}} }}
onFavoriteClick={async () => {
if (await requestFavoriteToggle(context.selectedEntry!)) {
isEntryMenuBottomSheetOpen = false;
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}
}}
/> />
<EntryRenameModal <EntryRenameModal
bind:isOpen={isEntryRenameModalOpen} bind:isOpen={isEntryRenameModalOpen}

View File

@@ -19,7 +19,13 @@
let thumbnail = $derived(getFileThumbnail(info)); let thumbnail = $derived(getFileThumbnail(info));
const action = (callback: typeof onclick) => { const action = (callback: typeof onclick) => {
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name }); callback({
type: "file",
id: info.id,
dataKey: info.dataKey,
name: info.name,
isFavorite: info.isFavorite ?? false,
});
}; };
</script> </script>
@@ -34,5 +40,6 @@
thumbnail={$thumbnail} thumbnail={$thumbnail}
name={info.name} name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)} subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite}
/> />
</ActionEntryButton> </ActionEntryButton>

View File

@@ -15,7 +15,13 @@
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
const action = (callback: typeof onclick) => { const action = (callback: typeof onclick) => {
callback({ type: "directory", id: info.id, dataKey: info.dataKey, name: info.name }); callback({
type: "directory",
id: info.id,
dataKey: info.dataKey,
name: info.name,
isFavorite: info.isFavorite ?? false,
});
}; };
</script> </script>
@@ -25,5 +31,5 @@
actionButtonIcon={IconMoreVert} actionButtonIcon={IconMoreVert}
onActionButtonClick={() => action(onOpenMenuClick)} onActionButtonClick={() => action(onOpenMenuClick)}
> >
<DirectoryEntryLabel type="directory" name={info.name} /> <DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
</ActionEntryButton> </ActionEntryButton>

View File

@@ -3,24 +3,35 @@
import { DirectoryEntryLabel, IconEntryButton } from "$lib/components/molecules"; import { DirectoryEntryLabel, IconEntryButton } from "$lib/components/molecules";
import { useContext } from "./service.svelte"; import { useContext } from "./service.svelte";
import IconFavorite from "~icons/material-symbols/favorite";
import IconFavoriteBorder from "~icons/material-symbols/favorite-outline";
import IconEdit from "~icons/material-symbols/edit"; import IconEdit from "~icons/material-symbols/edit";
import IconDelete from "~icons/material-symbols/delete"; import IconDelete from "~icons/material-symbols/delete";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
onDeleteClick: () => void; onDeleteClick: () => void;
onFavoriteClick: () => void;
onRenameClick: () => void; onRenameClick: () => void;
} }
let { isOpen = $bindable(), onDeleteClick, onRenameClick }: Props = $props(); let { isOpen = $bindable(), onDeleteClick, onFavoriteClick, onRenameClick }: Props = $props();
let context = useContext(); let context = useContext();
</script> </script>
{#if context.selectedEntry} {#if context.selectedEntry}
{@const { name, type } = context.selectedEntry} {@const { name, type, isFavorite } = context.selectedEntry}
<BottomSheet bind:isOpen class="p-4"> <BottomSheet bind:isOpen class="p-4">
<DirectoryEntryLabel {type} {name} class="h-12 p-2" textClass="!font-semibold" /> <DirectoryEntryLabel {type} {name} class="h-12 p-2" textClass="!font-semibold" />
<div class="my-2 h-px w-full bg-gray-200"></div> <div class="my-2 h-px w-full bg-gray-200"></div>
<IconEntryButton
icon={isFavorite ? IconFavorite : IconFavoriteBorder}
onclick={onFavoriteClick}
class="h-12 w-full"
iconClass={isFavorite ? "text-red-500" : ""}
>
{isFavorite ? "즐겨찾기에서 해제하기" : "즐겨찾기에 추가하기"}
</IconEntryButton>
<IconEntryButton icon={IconEdit} onclick={onRenameClick} class="h-12 w-full"> <IconEntryButton icon={IconEdit} onclick={onRenameClick} class="h-12 w-full">
이름 바꾸기 이름 바꾸기
</IconEntryButton> </IconEntryButton>

View File

@@ -17,6 +17,7 @@ export interface SelectedEntry {
id: number; id: number;
dataKey: DataKey | undefined; dataKey: DataKey | undefined;
name: string; name: string;
isFavorite: boolean;
} }
export const createContext = () => { export const createContext = () => {
@@ -149,3 +150,25 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => {
return false; 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;
}
};

View File

@@ -1,3 +0,0 @@
<div class="flex h-full items-center justify-center p-4">
<p class="text-gray-500">아직 개발 중이에요.</p>
</div>

View File

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

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { RowVirtualizer } from "$lib/components/atoms";
import { masterKeyStore } from "$lib/stores";
import Directory from "./Directory.svelte";
import File from "./File.svelte";
import { requestFavoriteEntries, requestRemoveFavorite, type FavoriteEntry } from "./service";
let { data } = $props();
let entries: FavoriteEntry[] = $state([]);
let isLoading = $state(true);
onMount(async () => {
const masterKey = $masterKeyStore?.get(1)?.key;
if (masterKey) {
entries = await requestFavoriteEntries(data.favorites, masterKey);
}
isLoading = false;
});
const handleRemove = async (entry: FavoriteEntry) => {
if (await requestRemoveFavorite(entry.type, entry.details.id)) {
entries = entries.filter(
(e) => !(e.type === entry.type && e.details.id === entry.details.id),
);
}
};
const handleClick = (entry: FavoriteEntry) => {
goto(
entry.type === "file"
? `/file/${entry.details.id}?from=favorite`
: `/directory/${entry.details.id}?from=favorite`,
);
};
</script>
<svelte:head>
<title>즐겨찾기</title>
</svelte:head>
<div class="flex h-full flex-col p-4">
{#if isLoading}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">
{#if data.favorites.files.length === 0 && data.favorites.directories.length === 0}
즐겨찾기한 항목이 없어요.
{:else}
로딩 중...
{/if}
</p>
</div>
{:else if entries.length === 0}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">즐겨찾기한 항목이 없어요.</p>
</div>
{:else}
<RowVirtualizer
count={entries.length}
getItemKey={(index) => `${entries[index]!.type}-${entries[index]!.details.id}`}
estimateItemHeight={() => 56}
itemGap={4}
>
{#snippet item(index)}
{@const entry = entries[index]!}
{#if entry.type === "directory"}
<Directory
info={entry.details}
onclick={() => handleClick(entry)}
onRemoveClick={() => handleRemove(entry)}
/>
{:else}
<File
info={entry.details}
onclick={() => handleClick(entry)}
onRemoveClick={() => handleRemove(entry)}
/>
{/if}
{/snippet}
</RowVirtualizer>
{/if}
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { SubDirectoryInfo } from "$lib/modules/filesystem";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: SubDirectoryInfo;
onclick: () => void;
onRemoveClick: () => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={IconClose}
onActionButtonClick={onRemoveClick}
>
<DirectoryEntryLabel type="directory" name={info.name} />
</ActionEntryButton>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: SummarizedFileInfo;
onclick: () => void;
onRemoveClick: () => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<ActionEntryButton
class="h-14"
{onclick}
actionButtonIcon={IconClose}
onActionButtonClick={onRemoveClick}
>
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
</ActionEntryButton>

View File

@@ -0,0 +1,86 @@
import {
decryptDirectoryMetadata,
decryptFileMetadata,
getFileInfo,
type SummarizedFileInfo,
type SubDirectoryInfo,
} from "$lib/modules/filesystem";
import { HybridPromise, sortEntries } from "$lib/utils";
import { trpc } from "$trpc/client";
import type { RouterOutputs } from "$trpc/router.server";
export type FavoriteEntry =
| { type: "directory"; name: string; details: SubDirectoryInfo }
| { type: "file"; name: string; details: SummarizedFileInfo };
export const requestFavoriteEntries = async (
favorites: RouterOutputs["favorites"]["get"],
masterKey: CryptoKey,
): Promise<FavoriteEntry[]> => {
const directories: FavoriteEntry[] = await Promise.all(
favorites.directories.map(async (dir) => {
const metadata = await decryptDirectoryMetadata(dir, masterKey);
return {
type: "directory" as const,
name: metadata.name,
details: {
id: dir.id,
parentId: dir.parent,
isFavorite: true,
dataKey: metadata.dataKey,
name: metadata.name,
} as SubDirectoryInfo,
};
}),
);
const fileResults = await Promise.all(
favorites.files.map(async (file) => {
const result = await HybridPromise.resolve(
getFileInfo(file.id, masterKey, {
async fetchFromServer(id, cachedInfo) {
const metadata = await decryptFileMetadata(file, masterKey);
return {
categories: [],
...cachedInfo,
id: id as number,
exists: true,
parentId: file.parent,
contentType: file.contentType,
isFavorite: true,
...metadata,
};
},
}),
);
if (result?.exists) {
return {
type: "file" as const,
name: result.name,
details: result as SummarizedFileInfo,
};
}
return null;
}),
);
const files = fileResults.filter(
(f): f is { type: "file"; name: string; details: SummarizedFileInfo } => f !== null,
);
return [...sortEntries(directories), ...sortEntries(files)];
};
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;
}
};

View File

@@ -62,9 +62,7 @@ const streamFromServer = async (
) => { ) => {
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy); const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
const start = range?.start ?? 0; const start = range?.start ?? 0;
const end = const end = range?.end ?? totalSize - 1;
range?.end ??
(range && !metadata.isLegacy ? Math.min(start + CHUNK_SIZE, totalSize) : totalSize) - 1;
if (start > end || start < 0 || end >= totalSize) { if (start > end || start < 0 || end >= totalSize) {
return new Response("Invalid range", { status: 416 }); return new Response("Invalid range", { status: 416 });
} }

View File

@@ -6,6 +6,7 @@ import {
categoryRouter, categoryRouter,
clientRouter, clientRouter,
directoryRouter, directoryRouter,
favoritesRouter,
fileRouter, fileRouter,
hskRouter, hskRouter,
mekRouter, mekRouter,
@@ -19,6 +20,7 @@ export const appRouter = router({
category: categoryRouter, category: categoryRouter,
client: clientRouter, client: clientRouter,
directory: directoryRouter, directory: directoryRouter,
favorites: favoritesRouter,
file: fileRouter, file: fileRouter,
hsk: hskRouter, hsk: hskRouter,
mek: mekRouter, mek: mekRouter,

View File

@@ -57,6 +57,7 @@ const categoryRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
isFavorite: file.isFavorite,
isRecursive: file.isRecursive, isRecursive: file.isRecursive,
})), })),
}; };

View File

@@ -31,6 +31,7 @@ const directoryRouter = router({
dekVersion: directory.dekVersion, dekVersion: directory.dekVersion,
name: directory.encName.ciphertext, name: directory.encName.ciphertext,
nameIv: directory.encName.iv, nameIv: directory.encName.iv,
isFavorite: directory.isFavorite,
}, },
subDirectories: directories.map((directory) => ({ subDirectories: directories.map((directory) => ({
id: directory.id, id: directory.id,
@@ -39,6 +40,7 @@ const directoryRouter = router({
dekVersion: directory.dekVersion, dekVersion: directory.dekVersion,
name: directory.encName.ciphertext, name: directory.encName.ciphertext,
nameIv: directory.encName.iv, nameIv: directory.encName.iv,
isFavorite: directory.isFavorite,
})), })),
files: files.map((file) => ({ files: files.map((file) => ({
id: file.id, id: file.id,
@@ -52,6 +54,7 @@ const directoryRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
isFavorite: file.isFavorite,
})), })),
}; };
}), }),

View File

@@ -0,0 +1,124 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { FileRepo, IntegrityError } from "$lib/server/db";
import { router, roleProcedure } from "../init.server";
const favoritesRouter = router({
get: roleProcedure["activeClient"].query(async ({ ctx }) => {
const [files, directories] = await Promise.all([
FileRepo.getAllFavoriteFiles(ctx.session.userId),
FileRepo.getAllFavoriteDirectories(ctx.session.userId),
]);
return {
files: files.map((file) => ({
id: file.id,
parent: file.parentId,
mekVersion: file.mekVersion,
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
})),
directories: directories.map((directory) => ({
id: directory.id,
parent: directory.parentId,
mekVersion: directory.mekVersion,
dek: directory.encDek,
dekVersion: directory.dekVersion,
name: directory.encName.ciphertext,
nameIv: directory.encName.iv,
})),
};
}),
addFile: roleProcedure["activeClient"]
.input(
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await FileRepo.setFileFavorite(ctx.session.userId, input.id, true);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
} else if (e.message === "File already favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}),
removeFile: roleProcedure["activeClient"]
.input(
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await FileRepo.setFileFavorite(ctx.session.userId, input.id, false);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
} else if (e.message === "File not favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}),
addDirectory: roleProcedure["activeClient"]
.input(
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await FileRepo.setDirectoryFavorite(ctx.session.userId, input.id, true);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
} else if (e.message === "Directory already favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}),
removeDirectory: roleProcedure["activeClient"]
.input(
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await FileRepo.setDirectoryFavorite(ctx.session.userId, input.id, false);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
} else if (e.message === "Directory not favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}),
});
export default favoritesRouter;

View File

@@ -31,6 +31,7 @@ const fileRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
isFavorite: file.isFavorite,
categories: categories.map((category) => ({ categories: categories.map((category) => ({
id: category.id, id: category.id,
parent: category.parentId, parent: category.parentId,
@@ -65,6 +66,7 @@ const fileRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
isFavorite: file.isFavorite,
categories: file.categories.map((category) => ({ categories: file.categories.map((category) => ({
id: category.id, id: category.id,
parent: category.parentId, parent: category.parentId,

View File

@@ -2,6 +2,7 @@ export { default as authRouter } from "./auth";
export { default as categoryRouter } from "./category"; export { default as categoryRouter } from "./category";
export { default as clientRouter } from "./client"; export { default as clientRouter } from "./client";
export { default as directoryRouter } from "./directory"; export { default as directoryRouter } from "./directory";
export { default as favoritesRouter } from "./favorites";
export { default as fileRouter } from "./file"; export { default as fileRouter } from "./file";
export { default as hskRouter } from "./hsk"; export { default as hskRouter } from "./hsk";
export { default as mekRouter } from "./mek"; export { default as mekRouter } from "./mek";

View File

@@ -32,6 +32,7 @@ const searchRouter = router({
dekVersion: directory.dekVersion, dekVersion: directory.dekVersion,
name: directory.encName.ciphertext, name: directory.encName.ciphertext,
nameIv: directory.encName.iv, nameIv: directory.encName.iv,
isFavorite: directory.isFavorite,
})), })),
files: files.map((file) => ({ files: files.map((file) => ({
id: file.id, id: file.id,
@@ -46,6 +47,7 @@ const searchRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
isFavorite: file.isFavorite,
})), })),
}; };
}), }),