mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-14 22:08:45 +00:00
카테고리 목록에서 파일 목록을 재귀적으로 표시할 수 있는 기능 구현
This commit is contained in:
23
src/lib/components/inputs/CheckBox.svelte
Normal file
23
src/lib/components/inputs/CheckBox.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
import IconCheckCircle from "~icons/material-symbols/check-circle";
|
||||||
|
import IconCheckCircleOutline from "~icons/material-symbols/check-circle-outline";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
checked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, checked = $bindable(false) }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-center gap-x-1">
|
||||||
|
<input bind:checked type="checkbox" class="hidden" />
|
||||||
|
{@render children?.()}
|
||||||
|
{#if checked}
|
||||||
|
<IconCheckCircle class="text-primary-600" />
|
||||||
|
{:else}
|
||||||
|
<IconCheckCircleOutline class="text-gray-300" />
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export { default as CheckBox } from "./CheckBox.svelte";
|
||||||
export { default as TextInput } from "./TextInput.svelte";
|
export { default as TextInput } from "./TextInput.svelte";
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export type CategoryInfo =
|
|||||||
dataKeyVersion?: Date;
|
dataKeyVersion?: Date;
|
||||||
name: string;
|
name: string;
|
||||||
subCategoryIds: number[];
|
subCategoryIds: number[];
|
||||||
files: number[];
|
files: { id: number; isRecursive: boolean }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
||||||
@@ -256,7 +256,7 @@ const fetchCategoryInfoFromServer = async (
|
|||||||
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
||||||
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
|
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
|
||||||
|
|
||||||
res = await callGetApi(`/api/category/${id}/file/list`);
|
res = await callGetApi(`/api/category/${id}/file/list?recursive=true`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to fetch category files");
|
throw new Error("Failed to fetch category files");
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ const fetchCategoryInfoFromServer = async (
|
|||||||
dataKeyVersion: new Date(metadata!.dekVersion),
|
dataKeyVersion: new Date(metadata!.dekVersion),
|
||||||
name,
|
name,
|
||||||
subCategoryIds: subCategories,
|
subCategoryIds: subCategories,
|
||||||
files,
|
files: files.map(({ file, isRecursive }) => ({ id: file, isRecursive })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
|
import { CheckBox } from "$lib/components/inputs";
|
||||||
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
import type { SelectedCategory } from "$lib/molecules/Categories";
|
||||||
import SubCategories from "$lib/molecules/SubCategories.svelte";
|
import SubCategories from "$lib/molecules/SubCategories.svelte";
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
onSubCategoryClick: (subCategory: SelectedCategory) => void;
|
onSubCategoryClick: (subCategory: SelectedCategory) => void;
|
||||||
onSubCategoryCreateClick: () => void;
|
onSubCategoryCreateClick: () => void;
|
||||||
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
|
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
|
||||||
|
isFileRecursive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -25,12 +27,16 @@
|
|||||||
onSubCategoryClick,
|
onSubCategoryClick,
|
||||||
onSubCategoryCreateClick,
|
onSubCategoryCreateClick,
|
||||||
onSubCategoryMenuClick,
|
onSubCategoryMenuClick,
|
||||||
|
isFileRecursive = $bindable(),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let files: Writable<FileInfo | null>[] = $state([]);
|
let files: Writable<FileInfo | null>[] = $state([]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
files = info.files?.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
|
files =
|
||||||
|
info.files
|
||||||
|
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
|
||||||
|
.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
|
||||||
|
|
||||||
// TODO: Sorting
|
// TODO: Sorting
|
||||||
});
|
});
|
||||||
@@ -51,7 +57,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if info.id !== "root"}
|
{#if info.id !== "root"}
|
||||||
<div class="space-y-4 bg-white p-4">
|
<div class="space-y-4 bg-white p-4">
|
||||||
<p class="text-lg font-bold text-gray-800">파일</p>
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-lg font-bold text-gray-800">파일</p>
|
||||||
|
<CheckBox bind:checked={isFileRecursive}>
|
||||||
|
<p class="font-medium">하위 카테고리의 파일</p>
|
||||||
|
</CheckBox>
|
||||||
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#key info}
|
{#key info}
|
||||||
{#each files as file}
|
{#each files as file}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from "kysely";
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
import { IntegrityError } from "./error";
|
import { IntegrityError } from "./error";
|
||||||
import db from "./kysely";
|
import db from "./kysely";
|
||||||
@@ -290,34 +291,37 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllFilesByCategory = async (userId: number, categoryId: number) => {
|
export const getAllFilesByCategory = async (
|
||||||
|
userId: number,
|
||||||
|
categoryId: number,
|
||||||
|
recursive: boolean,
|
||||||
|
) => {
|
||||||
const files = await db
|
const files = await db
|
||||||
.selectFrom("file")
|
.withRecursive("cte", (db) =>
|
||||||
.innerJoin("file_category", "file.id", "file_category.file_id")
|
db
|
||||||
.selectAll("file")
|
.selectFrom("file")
|
||||||
.where("user_id", "=", userId)
|
.innerJoin("file_category", "file.id", "file_category.file_id")
|
||||||
.where("category_id", "=", categoryId)
|
.selectAll("file_category")
|
||||||
|
.select(sql<number>`0`.as("depth"))
|
||||||
|
.where("user_id", "=", userId)
|
||||||
|
.where("category_id", "=", categoryId)
|
||||||
|
.$if(recursive, (qb) =>
|
||||||
|
qb.unionAll((db) =>
|
||||||
|
db
|
||||||
|
.selectFrom("file")
|
||||||
|
.innerJoin("file_category", "file.id", "file_category.file_id")
|
||||||
|
.innerJoin("category", "file_category.category_id", "category.id")
|
||||||
|
.innerJoin("cte", "category.parent_id", "cte.category_id")
|
||||||
|
.selectAll("file_category")
|
||||||
|
.select(sql<number>`cte.depth + 1`.as("depth"))
|
||||||
|
.where("file.user_id", "=", userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom("cte")
|
||||||
|
.select(["file_id", "depth"])
|
||||||
.execute();
|
.execute();
|
||||||
return files.map(
|
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
|
||||||
(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,
|
|
||||||
}) satisfies File,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllFileIdsByContentHmac = async (
|
export const getAllFileIdsByContentHmac = async (
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ export const categoryFileAddRequest = z.object({
|
|||||||
export type CategoryFileAddRequest = z.infer<typeof categoryFileAddRequest>;
|
export type CategoryFileAddRequest = z.infer<typeof categoryFileAddRequest>;
|
||||||
|
|
||||||
export const categoryFileListResponse = z.object({
|
export const categoryFileListResponse = z.object({
|
||||||
files: z.number().int().positive().array(),
|
files: z.array(
|
||||||
|
z.object({
|
||||||
|
file: z.number().int().positive(),
|
||||||
|
isRecursive: z.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
export type CategoryFileListResponse = z.infer<typeof categoryFileListResponse>;
|
export type CategoryFileListResponse = z.infer<typeof categoryFileListResponse>;
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ export const addCategoryFile = async (userId: number, categoryId: number, fileId
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCategoryFiles = async (userId: number, categoryId: number) => {
|
export const getCategoryFiles = async (userId: number, categoryId: number, recursive: boolean) => {
|
||||||
const category = await getCategory(userId, categoryId);
|
const category = await getCategory(userId, categoryId);
|
||||||
if (!category) {
|
if (!category) {
|
||||||
error(404, "Invalid category id");
|
error(404, "Invalid category id");
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await getAllFilesByCategory(userId, categoryId);
|
const files = await getAllFilesByCategory(userId, categoryId, recursive);
|
||||||
return { files: files.map(({ id }) => id) };
|
return { files };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
|
export const removeCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
let info: Writable<CategoryInfo | null> | undefined = $state();
|
let info: Writable<CategoryInfo | null> | undefined = $state();
|
||||||
let selectedSubCategory: SelectedCategory | undefined = $state();
|
let selectedSubCategory: SelectedCategory | undefined = $state();
|
||||||
|
|
||||||
|
let isFileRecursive = $state(false);
|
||||||
|
|
||||||
let isCreateCategoryModalOpen = $state(false);
|
let isCreateCategoryModalOpen = $state(false);
|
||||||
let isSubCategoryMenuBottomSheetOpen = $state(false);
|
let isSubCategoryMenuBottomSheetOpen = $state(false);
|
||||||
let isRenameCategoryModalOpen = $state(false);
|
let isRenameCategoryModalOpen = $state(false);
|
||||||
@@ -49,6 +51,7 @@
|
|||||||
<div class="flex-grow bg-gray-100 pb-[5.5em]">
|
<div class="flex-grow bg-gray-100 pb-[5.5em]">
|
||||||
{#if $info}
|
{#if $info}
|
||||||
<Category
|
<Category
|
||||||
|
bind:isFileRecursive
|
||||||
info={$info}
|
info={$info}
|
||||||
onFileClick={({ id }) => goto(`/file/${id}`)}
|
onFileClick={({ id }) => goto(`/file/${id}`)}
|
||||||
onFileRemoveClick={({ id }) => {
|
onFileRemoveClick={({ id }) => {
|
||||||
|
|||||||
@@ -5,13 +5,23 @@ import { categoryFileListResponse, type CategoryFileListResponse } from "$lib/se
|
|||||||
import { getCategoryFiles } from "$lib/server/services/category";
|
import { getCategoryFiles } from "$lib/server/services/category";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
export const GET: RequestHandler = async ({ locals, url, params }) => {
|
||||||
const { userId } = await authorize(locals, "activeClient");
|
const { userId } = await authorize(locals, "activeClient");
|
||||||
|
|
||||||
const zodRes = z.object({ id: z.coerce.number().int().positive() }).safeParse(params);
|
const paramsZodRes = z.object({ id: z.coerce.number().int().positive() }).safeParse(params);
|
||||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
if (!paramsZodRes.success) error(400, "Invalid path parameters");
|
||||||
const { id } = zodRes.data;
|
const { id } = paramsZodRes.data;
|
||||||
|
|
||||||
const { files } = await getCategoryFiles(userId, id);
|
const queryZodRes = z
|
||||||
return json(categoryFileListResponse.parse({ files }) as CategoryFileListResponse);
|
.object({ recursive: z.coerce.boolean().nullable() })
|
||||||
|
.safeParse({ recursive: url.searchParams.get("recursive") });
|
||||||
|
if (!queryZodRes.success) error(400, "Invalid query parameters");
|
||||||
|
const { recursive } = queryZodRes.data;
|
||||||
|
|
||||||
|
const { files } = await getCategoryFiles(userId, id, recursive ?? false);
|
||||||
|
return json(
|
||||||
|
categoryFileListResponse.parse({
|
||||||
|
files: files.map(({ id, isRecursive }) => ({ file: id, isRecursive })),
|
||||||
|
}) as CategoryFileListResponse,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user