diff --git a/src/lib/molecules/Categories/Categories.svelte b/src/lib/molecules/Categories/Categories.svelte
index ef21e0e..0c07e2b 100644
--- a/src/lib/molecules/Categories/Categories.svelte
+++ b/src/lib/molecules/Categories/Categories.svelte
@@ -1,4 +1,6 @@
{#if categories.length > 0}
{#each categories as category}
-
+
{/each}
{/if}
diff --git a/src/lib/molecules/Categories/Category.svelte b/src/lib/molecules/Categories/Category.svelte
index 18d6a83..ea2c392 100644
--- a/src/lib/molecules/Categories/Category.svelte
+++ b/src/lib/molecules/Categories/Category.svelte
@@ -1,17 +1,20 @@
@@ -40,13 +48,15 @@
{$info.name}
-
+ {#if MenuIcon && onMenuClick}
+
+ {/if}
{/if}
diff --git a/src/lib/molecules/SubCategories.svelte b/src/lib/molecules/SubCategories.svelte
index 945e29c..7b08629 100644
--- a/src/lib/molecules/SubCategories.svelte
+++ b/src/lib/molecules/SubCategories.svelte
@@ -1,5 +1,6 @@
@@ -30,6 +43,7 @@
info={$category}
onSubCategoryClick={({ id }) =>
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
+ onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
subCategoryCreatePosition="top"
/>
{#if $category.id !== "root"}
@@ -40,3 +54,5 @@
{/if}
+
+
diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts
index f48c16e..e45a108 100644
--- a/src/routes/(fullscreen)/file/[id]/service.ts
+++ b/src/routes/(fullscreen)/file/[id]/service.ts
@@ -1,6 +1,8 @@
import { callPostApi } from "$lib/hooks";
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
-import type { CategoryFileAddRequest } from "$lib/server/schemas";
+import type { CategoryFileAddRequest, CategoryFileRemoveRequest } from "$lib/server/schemas";
+
+export { requestCategoryCreation } from "$lib/services/category";
export const requestFileDownload = async (
fileId: number,
@@ -21,3 +23,11 @@ export const requestFileAdditionToCategory = async (fileId: number, categoryId:
});
return res.ok;
};
+
+export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
+ const res = await callPostApi(
+ `/api/category/${categoryId}/file/remove`,
+ { file: fileId },
+ );
+ return res.ok;
+};
diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte
index aa551f1..cbd0e2e 100644
--- a/src/routes/(main)/category/[[id]]/+page.svelte
+++ b/src/routes/(main)/category/[[id]]/+page.svelte
@@ -3,16 +3,28 @@
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
+ import type { SelectedCategory } from "$lib/molecules/Categories";
import Category from "$lib/organisms/Category";
+ import CreateCategoryModal from "$lib/organisms/CreateCategoryModal.svelte";
import { masterKeyStore } from "$lib/stores";
- import CreateCategoryModal from "./CreateCategoryModal.svelte";
- import { requestCategoryCreation } from "./service";
+ import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
+ import DeleteCategoryModal from "./DeleteCategoryModal.svelte";
+ import RenameCategoryModal from "./RenameCategoryModal.svelte";
+ import {
+ requestCategoryCreation,
+ requestCategoryRename,
+ requestCategoryDeletion,
+ } from "./service";
let { data } = $props();
let info: Writable | undefined = $state();
+ let selectedSubCategory: SelectedCategory | undefined = $state();
let isCreateCategoryModalOpen = $state(false);
+ let isSubCategoryMenuBottomSheetOpen = $state(false);
+ let isRenameCategoryModalOpen = $state(false);
+ let isDeleteCategoryModalOpen = $state(false);
const createCategory = async (name: string) => {
await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!);
@@ -39,6 +51,10 @@
info={$info}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
+ onSubCategoryMenuClick={(subCategory) => {
+ selectedSubCategory = subCategory;
+ isSubCategoryMenuBottomSheetOpen = true;
+ }}
onFileClick={({ id }) => goto(`/file/${id}`)}
/>
{/if}
@@ -46,3 +62,40 @@
+
+ {
+ isSubCategoryMenuBottomSheetOpen = false;
+ isRenameCategoryModalOpen = true;
+ }}
+ onDeleteClick={() => {
+ isSubCategoryMenuBottomSheetOpen = false;
+ isDeleteCategoryModalOpen = true;
+ }}
+/>
+ {
+ if (selectedSubCategory) {
+ await requestCategoryRename(selectedSubCategory, newName);
+ info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
+ return true;
+ }
+ return false;
+ }}
+/>
+ {
+ if (selectedSubCategory) {
+ await requestCategoryDeletion(selectedSubCategory);
+ info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
+ return true;
+ }
+ return false;
+ }}
+/>
diff --git a/src/routes/(main)/category/[[id]]/CategoryMenuBottomSheet.svelte b/src/routes/(main)/category/[[id]]/CategoryMenuBottomSheet.svelte
new file mode 100644
index 0000000..200501e
--- /dev/null
+++ b/src/routes/(main)/category/[[id]]/CategoryMenuBottomSheet.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+ {#if selectedCategory}
+ {@const { name } = selectedCategory}
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte b/src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte
new file mode 100644
index 0000000..880cbda
--- /dev/null
+++ b/src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte
@@ -0,0 +1,55 @@
+
+
+
+ {#if selectedCategory}
+ {@const { name } = selectedCategory}
+ {@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
+
+
+
+ '{nameShort}' 카테고리를 삭제할까요?
+
+
+ 모든 하위 카테고리도 함께 삭제돼요.
+ 하지만 카테고리에 추가된 파일들은 삭제되지 않아요.
+
+
+
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte b/src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte
new file mode 100644
index 0000000..dbb13c6
--- /dev/null
+++ b/src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte
@@ -0,0 +1,47 @@
+
+
+
+ 이름 바꾸기
+
+
+
+
+
+
+
+
diff --git a/src/routes/(main)/category/[[id]]/service.ts b/src/routes/(main)/category/[[id]]/service.ts
index c2018ed..8a5d9f8 100644
--- a/src/routes/(main)/category/[[id]]/service.ts
+++ b/src/routes/(main)/category/[[id]]/service.ts
@@ -1,21 +1,22 @@
import { callPostApi } from "$lib/hooks";
-import { generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
-import type { CategoryCreateRequest } from "$lib/server/schemas";
-import type { MasterKey } from "$lib/stores";
+import { encryptString } from "$lib/modules/crypto";
+import type { SelectedCategory } from "$lib/molecules/Categories";
+import type { CategoryRenameRequest } from "$lib/server/schemas";
-export const requestCategoryCreation = async (
- name: string,
- parentId: "root" | number,
- masterKey: MasterKey,
-) => {
- const { dataKey, dataKeyVersion } = await generateDataKey();
- const nameEncrypted = await encryptString(name, dataKey);
- await callPostApi("/api/category/create", {
- parent: parentId,
- mekVersion: masterKey.version,
- dek: await wrapDataKey(dataKey, masterKey.key),
- dekVersion: dataKeyVersion.toISOString(),
- name: nameEncrypted.ciphertext,
- nameIv: nameEncrypted.iv,
+export { requestCategoryCreation } from "$lib/services/category";
+
+export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
+ const newNameEncrypted = await encryptString(newName, category.dataKey);
+
+ const res = await callPostApi(`/api/category/${category.id}/rename`, {
+ dekVersion: category.dataKeyVersion.toISOString(),
+ name: newNameEncrypted.ciphertext,
+ nameIv: newNameEncrypted.iv,
});
+ return res.ok;
+};
+
+export const requestCategoryDeletion = async (category: SelectedCategory) => {
+ const res = await callPostApi(`/api/category/${category.id}/delete`);
+ return res.ok;
};
diff --git a/src/routes/api/category/[id]/delete/+server.ts b/src/routes/api/category/[id]/delete/+server.ts
new file mode 100644
index 0000000..cbbe356
--- /dev/null
+++ b/src/routes/api/category/[id]/delete/+server.ts
@@ -0,0 +1,20 @@
+import { error, text } from "@sveltejs/kit";
+import { z } from "zod";
+import { authorize } from "$lib/server/modules/auth";
+import { deleteCategory } from "$lib/server/services/category";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ locals, params }) => {
+ const { userId } = await authorize(locals, "activeClient");
+
+ const zodRes = z
+ .object({
+ id: z.coerce.number().int().positive(),
+ })
+ .safeParse(params);
+ if (!zodRes.success) error(400, "Invalid path parameters");
+ const { id } = zodRes.data;
+
+ await deleteCategory(userId, id);
+ return text("Category deleted", { headers: { "Content-Type": "text/plain" } });
+};
diff --git a/src/routes/api/category/[id]/file/remove/+server.ts b/src/routes/api/category/[id]/file/remove/+server.ts
new file mode 100644
index 0000000..6fdcccf
--- /dev/null
+++ b/src/routes/api/category/[id]/file/remove/+server.ts
@@ -0,0 +1,25 @@
+import { error, text } from "@sveltejs/kit";
+import { z } from "zod";
+import { authorize } from "$lib/server/modules/auth";
+import { categoryFileRemoveRequest } from "$lib/server/schemas";
+import { removeCategoryFile } from "$lib/server/services/category";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ locals, params, request }) => {
+ const { userId } = await authorize(locals, "activeClient");
+
+ const paramsZodRes = z
+ .object({
+ id: z.coerce.number().int().positive(),
+ })
+ .safeParse(params);
+ if (!paramsZodRes.success) error(400, "Invalid path parameters");
+ const { id } = paramsZodRes.data;
+
+ const bodyZodRes = categoryFileRemoveRequest.safeParse(await request.json());
+ if (!bodyZodRes.success) error(400, "Invalid request body");
+ const { file } = bodyZodRes.data;
+
+ await removeCategoryFile(userId, id, file);
+ return text("File removed", { headers: { "Content-Type": "text/plain" } });
+};
diff --git a/src/routes/api/category/[id]/rename/+server.ts b/src/routes/api/category/[id]/rename/+server.ts
new file mode 100644
index 0000000..5351544
--- /dev/null
+++ b/src/routes/api/category/[id]/rename/+server.ts
@@ -0,0 +1,25 @@
+import { error, text } from "@sveltejs/kit";
+import { z } from "zod";
+import { authorize } from "$lib/server/modules/auth";
+import { categoryRenameRequest } from "$lib/server/schemas";
+import { renameCategory } from "$lib/server/services/category";
+import type { RequestHandler } from "./$types";
+
+export const POST: RequestHandler = async ({ locals, params, request }) => {
+ const { userId } = await authorize(locals, "activeClient");
+
+ const paramsZodRes = z
+ .object({
+ id: z.coerce.number().int().positive(),
+ })
+ .safeParse(params);
+ if (!paramsZodRes.success) error(400, "Invalid path parameters");
+ const { id } = paramsZodRes.data;
+
+ const bodyZodRes = categoryRenameRequest.safeParse(await request.json());
+ if (!bodyZodRes.success) error(400, "Invalid request body");
+ const { dekVersion, name, nameIv } = bodyZodRes.data;
+
+ await renameCategory(userId, id, new Date(dekVersion), { ciphertext: name, iv: nameIv });
+ return text("Category renamed", { headers: { "Content-Type": "text/plain" } });
+};