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} +
+
+ +
+

+ {name} +

+
+
+ {/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" } }); +};