mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-12 21:08:46 +00:00
Merge pull request #8 from kmc7468/add-file-category
카테고리를 활용한 파일 분류 시스템 도입
This commit is contained in:
@@ -3,8 +3,5 @@ package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Output
|
||||
/drizzle
|
||||
|
||||
# Documents
|
||||
*.md
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:db": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"db:up": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
|
||||
"db:down": "docker compose -f docker-compose.dev.yaml -p arkvault-dev down",
|
||||
"db:migrate": "kysely migrate"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -20,7 +21,6 @@
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.15.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
@@ -42,7 +42,7 @@
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"svelte": "^5.17.1",
|
||||
"svelte": "^5.19.1",
|
||||
"svelte-check": "^4.1.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
92
pnpm-lock.yaml
generated
92
pnpm-lock.yaml
generated
@@ -41,16 +41,13 @@ importers:
|
||||
version: 1.2.12
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.2.11
|
||||
version: 5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))
|
||||
version: 5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.15.2
|
||||
version: 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
version: 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.12
|
||||
version: 7.6.12
|
||||
version: 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@types/file-saver':
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7
|
||||
@@ -80,7 +77,7 @@ importers:
|
||||
version: 9.1.0(eslint@9.17.0(jiti@2.4.2))
|
||||
eslint-plugin-svelte:
|
||||
specifier: ^2.46.1
|
||||
version: 2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.17.1)
|
||||
version: 2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.19.1)
|
||||
eslint-plugin-tailwindcss:
|
||||
specifier: ^3.17.5
|
||||
version: 3.17.5(tailwindcss@3.4.17)
|
||||
@@ -110,16 +107,16 @@ importers:
|
||||
version: 3.4.2
|
||||
prettier-plugin-svelte:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2(prettier@3.4.2)(svelte@5.17.1)
|
||||
version: 3.3.2(prettier@3.4.2)(svelte@5.19.1)
|
||||
prettier-plugin-tailwindcss:
|
||||
specifier: ^0.6.9
|
||||
version: 0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.17.1))(prettier@3.4.2)
|
||||
version: 0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.19.1))(prettier@3.4.2)
|
||||
svelte:
|
||||
specifier: ^5.17.1
|
||||
version: 5.17.1
|
||||
specifier: ^5.19.1
|
||||
version: 5.19.1
|
||||
svelte-check:
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(picomatch@4.0.2)(svelte@5.17.1)(typescript@5.7.3)
|
||||
version: 4.1.3(picomatch@4.0.2)(svelte@5.19.1)(typescript@5.7.3)
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.17
|
||||
@@ -131,7 +128,7 @@ importers:
|
||||
version: 8.19.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.7.3)
|
||||
unplugin-icons:
|
||||
specifier: ^0.22.0
|
||||
version: 0.22.0(svelte@5.17.1)
|
||||
version: 0.22.0(svelte@5.19.1)
|
||||
vite:
|
||||
specifier: ^5.4.11
|
||||
version: 5.4.11(@types/node@22.10.5)
|
||||
@@ -717,9 +714,6 @@ packages:
|
||||
svelte: ^5.0.0-next.96 || ^5.0.0
|
||||
vite: ^5.0.0
|
||||
|
||||
'@types/better-sqlite3@7.6.12':
|
||||
resolution: {integrity: sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==}
|
||||
|
||||
'@types/cookie@0.6.0':
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
@@ -1120,8 +1114,8 @@ packages:
|
||||
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
esrap@1.3.2:
|
||||
resolution: {integrity: sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==}
|
||||
esrap@1.4.3:
|
||||
resolution: {integrity: sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==}
|
||||
|
||||
esrecurse@4.3.0:
|
||||
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
|
||||
@@ -1998,8 +1992,8 @@ packages:
|
||||
svelte:
|
||||
optional: true
|
||||
|
||||
svelte@5.17.1:
|
||||
resolution: {integrity: sha512-HitqD0XhU9OEytPuux/XYzxle4+7D8+fIb1tHbwMzOtBzDZZO+ESEuwMbahJ/3JoklfmRPB/Gzp74L87Qrxfpw==}
|
||||
svelte@5.19.1:
|
||||
resolution: {integrity: sha512-H/Vs2O51bwILZbaNUSdr4P1NbLpOGsxl4jJAjd88ELjzRgeRi1BHqexcVGannDr7D1pmTYWWajzHOM7bMbtB9Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tailwindcss@3.4.17:
|
||||
@@ -2579,17 +2573,17 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.30.1':
|
||||
optional: true
|
||||
|
||||
'@sveltejs/adapter-node@5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))':
|
||||
'@sveltejs/adapter-node@5.2.11(@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))':
|
||||
dependencies:
|
||||
'@rollup/plugin-commonjs': 28.0.2(rollup@4.30.1)
|
||||
'@rollup/plugin-json': 6.1.0(rollup@4.30.1)
|
||||
'@rollup/plugin-node-resolve': 16.0.0(rollup@4.30.1)
|
||||
'@sveltejs/kit': 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@sveltejs/kit': 2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
rollup: 4.30.1
|
||||
|
||||
'@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))':
|
||||
'@sveltejs/kit@2.15.2(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@types/cookie': 0.6.0
|
||||
cookie: 0.6.0
|
||||
devalue: 5.1.1
|
||||
@@ -2601,36 +2595,32 @@ snapshots:
|
||||
sade: 1.8.1
|
||||
set-cookie-parser: 2.7.1
|
||||
sirv: 3.0.0
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
tiny-glob: 0.2.9
|
||||
vite: 5.4.11(@types/node@22.10.5)
|
||||
|
||||
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))':
|
||||
'@sveltejs/vite-plugin-svelte-inspector@3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@sveltejs/vite-plugin-svelte': 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
debug: 4.4.0
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
vite: 5.4.11(@types/node@22.10.5)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))':
|
||||
'@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
'@sveltejs/vite-plugin-svelte-inspector': 3.0.1(@sveltejs/vite-plugin-svelte@4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5)))(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||
debug: 4.4.0
|
||||
deepmerge: 4.3.1
|
||||
kleur: 4.1.5
|
||||
magic-string: 0.30.17
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
vite: 5.4.11(@types/node@22.10.5)
|
||||
vitefu: 1.0.5(vite@5.4.11(@types/node@22.10.5))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@types/better-sqlite3@7.6.12':
|
||||
dependencies:
|
||||
'@types/node': 22.10.5
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
|
||||
'@types/estree@1.0.6': {}
|
||||
@@ -3011,7 +3001,7 @@ snapshots:
|
||||
dependencies:
|
||||
eslint: 9.17.0(jiti@2.4.2)
|
||||
|
||||
eslint-plugin-svelte@2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.17.1):
|
||||
eslint-plugin-svelte@2.46.1(eslint@9.17.0(jiti@2.4.2))(svelte@5.19.1):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
@@ -3024,9 +3014,9 @@ snapshots:
|
||||
postcss-safe-parser: 6.0.0(postcss@8.4.49)
|
||||
postcss-selector-parser: 6.1.2
|
||||
semver: 7.6.3
|
||||
svelte-eslint-parser: 0.43.0(svelte@5.17.1)
|
||||
svelte-eslint-parser: 0.43.0(svelte@5.19.1)
|
||||
optionalDependencies:
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
@@ -3109,7 +3099,7 @@ snapshots:
|
||||
dependencies:
|
||||
estraverse: 5.3.0
|
||||
|
||||
esrap@1.3.2:
|
||||
esrap@1.4.3:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
@@ -3693,16 +3683,16 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.17.1):
|
||||
prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.19.1):
|
||||
dependencies:
|
||||
prettier: 3.4.2
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
|
||||
prettier-plugin-tailwindcss@0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.17.1))(prettier@3.4.2):
|
||||
prettier-plugin-tailwindcss@0.6.9(prettier-plugin-svelte@3.3.2(prettier@3.4.2)(svelte@5.19.1))(prettier@3.4.2):
|
||||
dependencies:
|
||||
prettier: 3.4.2
|
||||
optionalDependencies:
|
||||
prettier-plugin-svelte: 3.3.2(prettier@3.4.2)(svelte@5.17.1)
|
||||
prettier-plugin-svelte: 3.3.2(prettier@3.4.2)(svelte@5.19.1)
|
||||
|
||||
prettier@3.4.2: {}
|
||||
|
||||
@@ -3838,19 +3828,19 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svelte-check@4.1.3(picomatch@4.0.2)(svelte@5.17.1)(typescript@5.7.3):
|
||||
svelte-check@4.1.3(picomatch@4.0.2)(svelte@5.19.1)(typescript@5.7.3):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
chokidar: 4.0.3
|
||||
fdir: 6.4.2(picomatch@4.0.2)
|
||||
picocolors: 1.1.1
|
||||
sade: 1.8.1
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
typescript: 5.7.3
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-eslint-parser@0.43.0(svelte@5.17.1):
|
||||
svelte-eslint-parser@0.43.0(svelte@5.19.1):
|
||||
dependencies:
|
||||
eslint-scope: 7.2.2
|
||||
eslint-visitor-keys: 3.4.3
|
||||
@@ -3858,9 +3848,9 @@ snapshots:
|
||||
postcss: 8.4.49
|
||||
postcss-scss: 4.0.9(postcss@8.4.49)
|
||||
optionalDependencies:
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
|
||||
svelte@5.17.1:
|
||||
svelte@5.19.1:
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
@@ -3871,7 +3861,7 @@ snapshots:
|
||||
axobject-query: 4.1.0
|
||||
clsx: 2.1.1
|
||||
esm-env: 1.2.2
|
||||
esrap: 1.3.2
|
||||
esrap: 1.4.3
|
||||
is-reference: 3.0.3
|
||||
locate-character: 3.0.0
|
||||
magic-string: 0.30.17
|
||||
@@ -3967,7 +3957,7 @@ snapshots:
|
||||
|
||||
undici-types@6.20.0: {}
|
||||
|
||||
unplugin-icons@0.22.0(svelte@5.17.1):
|
||||
unplugin-icons@0.22.0(svelte@5.19.1):
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 0.5.0
|
||||
'@antfu/utils': 0.7.10
|
||||
@@ -3977,7 +3967,7 @@ snapshots:
|
||||
local-pkg: 0.5.1
|
||||
unplugin: 2.1.2
|
||||
optionalDependencies:
|
||||
svelte: 5.17.1
|
||||
svelte: 5.19.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<AdaptiveDiv>
|
||||
<div
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="flex max-h-[70vh] min-h-[30vh] rounded-t-2xl bg-white px-4"
|
||||
class="flex max-h-[70vh] min-h-[30vh] overflow-y-auto rounded-t-2xl bg-white px-4"
|
||||
transition:fly={{ y: 100, duration: 200 }}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -7,16 +7,20 @@
|
||||
children?: Snippet;
|
||||
onback?: () => void;
|
||||
title?: string;
|
||||
xPadding?: boolean;
|
||||
}
|
||||
|
||||
let { children, onback, title }: Props = $props();
|
||||
let { children, onback, title, xPadding = false }: Props = $props();
|
||||
|
||||
const back = $derived(() => {
|
||||
setTimeout(onback || (() => history.back()), 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4">
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4
|
||||
{xPadding ? 'px-4' : ''}"
|
||||
>
|
||||
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
|
||||
<IconArrowBack class="text-2xl" />
|
||||
</button>
|
||||
|
||||
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 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";
|
||||
|
||||
@@ -15,16 +15,28 @@ interface FileInfo {
|
||||
contentType: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
categoryIds: number[];
|
||||
}
|
||||
|
||||
export type CategoryId = "root" | number;
|
||||
|
||||
interface CategoryInfo {
|
||||
id: number;
|
||||
parentId: CategoryId;
|
||||
name: string;
|
||||
files: { id: number; isRecursive: boolean }[];
|
||||
}
|
||||
|
||||
const filesystem = new Dexie("filesystem") as Dexie & {
|
||||
directory: EntityTable<DirectoryInfo, "id">;
|
||||
file: EntityTable<FileInfo, "id">;
|
||||
category: EntityTable<CategoryInfo, "id">;
|
||||
};
|
||||
|
||||
filesystem.version(1).stores({
|
||||
filesystem.version(2).stores({
|
||||
directory: "id, parentId",
|
||||
file: "id, parentId",
|
||||
category: "id, parentId",
|
||||
});
|
||||
|
||||
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
||||
@@ -59,13 +71,29 @@ export const deleteFileInfo = async (id: number) => {
|
||||
await filesystem.file.delete(id);
|
||||
};
|
||||
|
||||
export const getCategoryInfos = async (parentId: CategoryId) => {
|
||||
return await filesystem.category.where({ parentId }).toArray();
|
||||
};
|
||||
|
||||
export const getCategoryInfo = async (id: number) => {
|
||||
return await filesystem.category.get(id);
|
||||
};
|
||||
|
||||
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
|
||||
await filesystem.category.put(categoryInfo);
|
||||
};
|
||||
|
||||
export const deleteCategoryInfo = async (id: number) => {
|
||||
await filesystem.category.delete(id);
|
||||
};
|
||||
|
||||
export const cleanupDanglingInfos = async () => {
|
||||
const validDirectoryIds: number[] = [];
|
||||
const validFileIds: number[] = [];
|
||||
const queue: DirectoryId[] = ["root"];
|
||||
const directoryQueue: DirectoryId[] = ["root"];
|
||||
|
||||
while (true) {
|
||||
const directoryId = queue.shift();
|
||||
const directoryId = directoryQueue.shift();
|
||||
if (!directoryId) break;
|
||||
|
||||
const [subDirectories, files] = await Promise.all([
|
||||
@@ -74,13 +102,28 @@ export const cleanupDanglingInfos = async () => {
|
||||
]);
|
||||
subDirectories.forEach(({ id }) => {
|
||||
validDirectoryIds.push(id);
|
||||
queue.push(id);
|
||||
directoryQueue.push(id);
|
||||
});
|
||||
files.forEach(({ id }) => validFileIds.push(id));
|
||||
}
|
||||
|
||||
const validCategoryIds: number[] = [];
|
||||
const categoryQueue: CategoryId[] = ["root"];
|
||||
|
||||
while (true) {
|
||||
const categoryId = categoryQueue.shift();
|
||||
if (!categoryId) break;
|
||||
|
||||
const subCategories = await filesystem.category.where({ parentId: categoryId }).toArray();
|
||||
subCategories.forEach(({ id }) => {
|
||||
validCategoryIds.push(id);
|
||||
categoryQueue.push(id);
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
|
||||
filesystem.file.where("id").noneOf(validFileIds).delete(),
|
||||
filesystem.category.where("id").noneOf(validCategoryIds).delete(),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -9,10 +9,20 @@ import {
|
||||
getFileInfo as getFileInfoFromIndexedDB,
|
||||
storeFileInfo,
|
||||
deleteFileInfo,
|
||||
getCategoryInfos as getCategoryInfosFromIndexedDB,
|
||||
getCategoryInfo as getCategoryInfoFromIndexedDB,
|
||||
storeCategoryInfo,
|
||||
deleteCategoryInfo,
|
||||
type DirectoryId,
|
||||
type CategoryId,
|
||||
} from "$lib/indexedDB";
|
||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
|
||||
import type {
|
||||
CategoryInfoResponse,
|
||||
CategoryFileListResponse,
|
||||
DirectoryInfoResponse,
|
||||
FileInfoResponse,
|
||||
} from "$lib/server/schemas";
|
||||
|
||||
export type DirectoryInfo =
|
||||
| {
|
||||
@@ -41,10 +51,30 @@ export interface FileInfo {
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
categoryIds: number[];
|
||||
}
|
||||
|
||||
export type CategoryInfo =
|
||||
| {
|
||||
id: "root";
|
||||
dataKey?: undefined;
|
||||
dataKeyVersion?: undefined;
|
||||
name?: undefined;
|
||||
subCategoryIds: number[];
|
||||
files?: undefined;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
dataKey?: CryptoKey;
|
||||
dataKeyVersion?: Date;
|
||||
name: string;
|
||||
subCategoryIds: number[];
|
||||
files: { id: number; isRecursive: boolean }[];
|
||||
};
|
||||
|
||||
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
||||
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
||||
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
|
||||
|
||||
const fetchDirectoryInfoFromIndexedDB = async (
|
||||
id: DirectoryId,
|
||||
@@ -124,7 +154,7 @@ export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
|
||||
directoryInfoStore.set(id, info);
|
||||
}
|
||||
|
||||
fetchDirectoryInfo(id, info, masterKey);
|
||||
fetchDirectoryInfo(id, info, masterKey); // Intended
|
||||
return info;
|
||||
};
|
||||
|
||||
@@ -178,6 +208,7 @@ const fetchFileInfoFromServer = async (
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
categoryIds: metadata.categories,
|
||||
});
|
||||
await storeFileInfo({
|
||||
id,
|
||||
@@ -186,6 +217,7 @@ const fetchFileInfoFromServer = async (
|
||||
contentType: metadata.contentType,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
categoryIds: metadata.categories,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -203,6 +235,95 @@ export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
||||
fileInfoStore.set(fileId, info);
|
||||
}
|
||||
|
||||
fetchFileInfo(fileId, info, masterKey);
|
||||
fetchFileInfo(fileId, info, masterKey); // Intended
|
||||
return info;
|
||||
};
|
||||
|
||||
const fetchCategoryInfoFromIndexedDB = async (
|
||||
id: CategoryId,
|
||||
info: Writable<CategoryInfo | null>,
|
||||
) => {
|
||||
if (get(info)) return;
|
||||
|
||||
const [category, subCategories] = await Promise.all([
|
||||
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
|
||||
getCategoryInfosFromIndexedDB(id),
|
||||
]);
|
||||
const subCategoryIds = subCategories.map(({ id }) => id);
|
||||
|
||||
if (id === "root") {
|
||||
info.set({ id, subCategoryIds });
|
||||
} else {
|
||||
if (!category) return;
|
||||
info.set({ id, name: category.name, subCategoryIds, files: category.files });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategoryInfoFromServer = async (
|
||||
id: CategoryId,
|
||||
info: Writable<CategoryInfo | null>,
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
let res = await callGetApi(`/api/category/${id}`);
|
||||
if (res.status === 404) {
|
||||
info.set(null);
|
||||
await deleteCategoryInfo(id as number);
|
||||
return;
|
||||
} else if (!res.ok) {
|
||||
throw new Error("Failed to fetch category information");
|
||||
}
|
||||
|
||||
const { metadata, subCategories }: CategoryInfoResponse = await res.json();
|
||||
|
||||
if (id === "root") {
|
||||
info.set({ id, subCategoryIds: subCategories });
|
||||
} else {
|
||||
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
||||
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
|
||||
|
||||
res = await callGetApi(`/api/category/${id}/file/list?recurse=true`);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch category files");
|
||||
}
|
||||
|
||||
const { files }: CategoryFileListResponse = await res.json();
|
||||
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
|
||||
|
||||
info.set({
|
||||
id,
|
||||
dataKey,
|
||||
dataKeyVersion: new Date(metadata!.dekVersion),
|
||||
name,
|
||||
subCategoryIds: subCategories,
|
||||
files: filesMapped,
|
||||
});
|
||||
await storeCategoryInfo({
|
||||
id,
|
||||
parentId: metadata!.parent,
|
||||
name,
|
||||
files: filesMapped,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategoryInfo = async (
|
||||
id: CategoryId,
|
||||
info: Writable<CategoryInfo | null>,
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
await fetchCategoryInfoFromIndexedDB(id, info);
|
||||
await fetchCategoryInfoFromServer(id, info, masterKey);
|
||||
};
|
||||
|
||||
export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => {
|
||||
// TODO: MEK rotation
|
||||
|
||||
let info = categoryInfoStore.get(categoryId);
|
||||
if (!info) {
|
||||
info = writable(null);
|
||||
categoryInfoStore.set(categoryId, info);
|
||||
}
|
||||
|
||||
fetchCategoryInfo(categoryId, info, masterKey); // Intended
|
||||
return info;
|
||||
};
|
||||
|
||||
@@ -27,3 +27,32 @@ export const formatNetworkSpeed = (speed: number) => {
|
||||
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
|
||||
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
|
||||
};
|
||||
|
||||
export enum SortBy {
|
||||
NAME_ASC,
|
||||
NAME_DESC,
|
||||
}
|
||||
|
||||
type SortFunc = (a?: string, b?: string) => number;
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
const sortByNameAsc: SortFunc = (a, b) => {
|
||||
if (a && b) return collator.compare(a, b);
|
||||
if (a) return -1;
|
||||
if (b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
||||
|
||||
export const sortEntries = <T extends { name?: string }>(entries: T[], sortBy: SortBy) => {
|
||||
let sortFunc: SortFunc;
|
||||
if (sortBy === SortBy.NAME_ASC) {
|
||||
sortFunc = sortByNameAsc;
|
||||
} else {
|
||||
sortFunc = sortByNameDesc;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => sortFunc(a.name, b.name));
|
||||
};
|
||||
|
||||
63
src/lib/molecules/Categories/Categories.svelte
Normal file
63
src/lib/molecules/Categories/Categories.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { untrack, type Component } from "svelte";
|
||||
import type { SvelteHTMLElements } from "svelte/elements";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import type { CategoryInfo } from "$lib/modules/filesystem";
|
||||
import { SortBy, sortEntries } from "$lib/modules/util";
|
||||
import Category from "./Category.svelte";
|
||||
import type { SelectedCategory } from "./service";
|
||||
|
||||
interface Props {
|
||||
categories: Writable<CategoryInfo | null>[];
|
||||
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
|
||||
onCategoryClick: (category: SelectedCategory) => void;
|
||||
onCategoryMenuClick?: (category: SelectedCategory) => void;
|
||||
sortBy?: SortBy;
|
||||
}
|
||||
|
||||
let {
|
||||
categories,
|
||||
categoryMenuIcon,
|
||||
onCategoryClick,
|
||||
onCategoryMenuClick,
|
||||
sortBy = SortBy.NAME_ASC,
|
||||
}: Props = $props();
|
||||
|
||||
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
categoriesWithName = categories.map((category) => ({
|
||||
name: get(category)?.name,
|
||||
info: category,
|
||||
}));
|
||||
|
||||
const sort = () => {
|
||||
sortEntries(categoriesWithName, sortBy);
|
||||
};
|
||||
return untrack(() => {
|
||||
sort();
|
||||
|
||||
const unsubscribes = categoriesWithName.map((category) =>
|
||||
category.info.subscribe((value) => {
|
||||
if (category.name === value?.name) return;
|
||||
category.name = value?.name;
|
||||
sort();
|
||||
}),
|
||||
);
|
||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if categoriesWithName.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each categoriesWithName as { info }}
|
||||
<Category
|
||||
{info}
|
||||
menuIcon={categoryMenuIcon}
|
||||
onclick={onCategoryClick}
|
||||
onMenuClick={onCategoryMenuClick}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
71
src/lib/molecules/Categories/Category.svelte
Normal file
71
src/lib/molecules/Categories/Category.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { SvelteHTMLElements } from "svelte/elements";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { CategoryInfo } from "$lib/modules/filesystem";
|
||||
import type { SelectedCategory } from "./service";
|
||||
|
||||
import IconCategory from "~icons/material-symbols/category";
|
||||
|
||||
interface Props {
|
||||
info: Writable<CategoryInfo | null>;
|
||||
menuIcon?: Component<SvelteHTMLElements["svg"]>;
|
||||
onclick: (category: SelectedCategory) => void;
|
||||
onMenuClick?: (category: SelectedCategory) => void;
|
||||
}
|
||||
|
||||
let { info, menuIcon: MenuIcon, onclick, onMenuClick }: Props = $props();
|
||||
|
||||
const openCategory = () => {
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onclick({ id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const openMenu = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onMenuClick!({ id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $info}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div id="button" onclick={openCategory} class="h-12 rounded-xl">
|
||||
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
||||
<div class="flex-shrink-0 text-lg">
|
||||
<IconCategory />
|
||||
</div>
|
||||
<p title={$info.name} class="flex-grow truncate font-medium">
|
||||
{$info.name}
|
||||
</p>
|
||||
{#if MenuIcon && onMenuClick}
|
||||
<button
|
||||
id="open-menu"
|
||||
onclick={openMenu}
|
||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
||||
>
|
||||
<MenuIcon class="text-lg" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#button:active:not(:has(#open-menu:active)) {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
#button-content:active:not(:has(#open-menu:active)) {
|
||||
@apply scale-95;
|
||||
}
|
||||
</style>
|
||||
2
src/lib/molecules/Categories/index.ts
Normal file
2
src/lib/molecules/Categories/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Categories.svelte";
|
||||
export * from "./service";
|
||||
6
src/lib/molecules/Categories/service.ts
Normal file
6
src/lib/molecules/Categories/service.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SelectedCategory {
|
||||
id: number;
|
||||
dataKey: CryptoKey;
|
||||
dataKeyVersion: Date;
|
||||
name: string;
|
||||
}
|
||||
65
src/lib/molecules/SubCategories.svelte
Normal file
65
src/lib/molecules/SubCategories.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { EntryButton } from "$lib/components/buttons";
|
||||
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||
import Categories, { type SelectedCategory } from "$lib/molecules/Categories";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
|
||||
import IconAddCircle from "~icons/material-symbols/add-circle";
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
info: CategoryInfo;
|
||||
onSubCategoryClick: (subCategory: SelectedCategory) => void;
|
||||
onSubCategoryCreateClick: () => void;
|
||||
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
|
||||
subCategoryCreatePosition?: "top" | "bottom";
|
||||
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
|
||||
}
|
||||
|
||||
let {
|
||||
info,
|
||||
onSubCategoryClick,
|
||||
onSubCategoryCreateClick,
|
||||
onSubCategoryMenuClick,
|
||||
subCategoryCreatePosition = "bottom",
|
||||
subCategoryMenuIcon,
|
||||
...props
|
||||
}: Props = $props();
|
||||
|
||||
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
subCategories = info.subCategoryIds.map((id) =>
|
||||
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={["space-y-1", props.class]}>
|
||||
{#snippet subCategoryCreate()}
|
||||
<EntryButton onclick={onSubCategoryCreateClick}>
|
||||
<div class="flex h-8 items-center gap-x-4">
|
||||
<IconAddCircle class="text-lg text-gray-600" />
|
||||
<p class="font-medium text-gray-700">카테고리 추가하기</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
{/snippet}
|
||||
|
||||
{#if subCategoryCreatePosition === "top"}
|
||||
{@render subCategoryCreate()}
|
||||
{/if}
|
||||
{#key info}
|
||||
<Categories
|
||||
categories={subCategories}
|
||||
categoryMenuIcon={subCategoryMenuIcon}
|
||||
onCategoryClick={onSubCategoryClick}
|
||||
onCategoryMenuClick={onSubCategoryMenuClick}
|
||||
/>
|
||||
{/key}
|
||||
{#if subCategoryCreatePosition === "bottom"}
|
||||
{@render subCategoryCreate()}
|
||||
{/if}
|
||||
</div>
|
||||
108
src/lib/organisms/Category/Category.svelte
Normal file
108
src/lib/organisms/Category/Category.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import { CheckBox } from "$lib/components/inputs";
|
||||
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||
import { SortBy, sortEntries } from "$lib/modules/util";
|
||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
||||
import SubCategories from "$lib/molecules/SubCategories.svelte";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
import type { SelectedFile } from "./service";
|
||||
|
||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||
|
||||
interface Props {
|
||||
info: CategoryInfo;
|
||||
onFileClick: (file: SelectedFile) => void;
|
||||
onFileRemoveClick: (file: SelectedFile) => void;
|
||||
onSubCategoryClick: (subCategory: SelectedCategory) => void;
|
||||
onSubCategoryCreateClick: () => void;
|
||||
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
|
||||
sortBy?: SortBy;
|
||||
isFileRecursive: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
info,
|
||||
onFileClick,
|
||||
onFileRemoveClick,
|
||||
onSubCategoryClick,
|
||||
onSubCategoryCreateClick,
|
||||
onSubCategoryMenuClick,
|
||||
sortBy = SortBy.NAME_ASC,
|
||||
isFileRecursive = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
|
||||
[],
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
files =
|
||||
info.files
|
||||
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
|
||||
.map(({ id, isRecursive }) => {
|
||||
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
|
||||
return {
|
||||
name: get(info)?.name,
|
||||
info,
|
||||
isRecursive,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
const sort = () => {
|
||||
sortEntries(files, sortBy);
|
||||
};
|
||||
return untrack(() => {
|
||||
sort();
|
||||
|
||||
const unsubscribes = files.map((file) =>
|
||||
file.info.subscribe((value) => {
|
||||
if (file.name === value?.name) return;
|
||||
file.name = value?.name;
|
||||
sort();
|
||||
}),
|
||||
);
|
||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 bg-white p-4">
|
||||
{#if info.id !== "root"}
|
||||
<p class="text-lg font-bold text-gray-800">하위 카테고리</p>
|
||||
{/if}
|
||||
<SubCategories
|
||||
{info}
|
||||
{onSubCategoryClick}
|
||||
{onSubCategoryCreateClick}
|
||||
{onSubCategoryMenuClick}
|
||||
subCategoryMenuIcon={IconMoreVert}
|
||||
/>
|
||||
</div>
|
||||
{#if info.id !== "root"}
|
||||
<div class="space-y-4 bg-white p-4">
|
||||
<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">
|
||||
{#key info}
|
||||
{#each files as { info, isRecursive }}
|
||||
<File
|
||||
{info}
|
||||
onclick={onFileClick}
|
||||
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
69
src/lib/organisms/Category/File.svelte
Normal file
69
src/lib/organisms/Category/File.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import type { SelectedFile } from "./service";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
interface Props {
|
||||
info: Writable<FileInfo | null>;
|
||||
onclick: (selectedFile: SelectedFile) => void;
|
||||
onRemoveClick?: (selectedFile: SelectedFile) => void;
|
||||
}
|
||||
|
||||
let { info, onclick, onRemoveClick }: Props = $props();
|
||||
|
||||
const openFile = () => {
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onclick({ id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const removeFile = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if $info}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div id="button" onclick={openFile} class="h-12 rounded-xl">
|
||||
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
||||
<div class="flex-shrink-0 text-lg text-blue-400">
|
||||
<IconDraft />
|
||||
</div>
|
||||
<p title={$info.name} class="flex-grow truncate font-medium">
|
||||
{$info.name}
|
||||
</p>
|
||||
{#if onRemoveClick}
|
||||
<button
|
||||
id="remove-file"
|
||||
onclick={removeFile}
|
||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
||||
>
|
||||
<IconClose class="text-lg" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#button:active:not(:has(#remove-file:active)) {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
#button-content:active:not(:has(#remove-file:active)) {
|
||||
@apply scale-95;
|
||||
}
|
||||
</style>
|
||||
2
src/lib/organisms/Category/index.ts
Normal file
2
src/lib/organisms/Category/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from "./Category.svelte";
|
||||
export * from "./service";
|
||||
6
src/lib/organisms/Category/service.ts
Normal file
6
src/lib/organisms/Category/service.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface SelectedFile {
|
||||
id: number;
|
||||
dataKey: CryptoKey;
|
||||
dataKeyVersion: Date;
|
||||
name: string;
|
||||
}
|
||||
30
src/lib/organisms/CreateCategoryModal.svelte
Normal file
30
src/lib/organisms/CreateCategoryModal.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Modal } from "$lib/components";
|
||||
import { Button } from "$lib/components/buttons";
|
||||
import { TextInput } from "$lib/components/inputs";
|
||||
|
||||
interface Props {
|
||||
onCreateClick: (name: string) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
let { onCreateClick, isOpen = $bindable() }: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
|
||||
const closeModal = () => {
|
||||
name = "";
|
||||
isOpen = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:isOpen onclose={closeModal}>
|
||||
<p class="text-xl font-bold">새 카테고리</p>
|
||||
<div class="mt-2 flex w-full">
|
||||
<TextInput bind:value={name} placeholder="카테고리 이름" />
|
||||
</div>
|
||||
<div class="mt-7 flex gap-2">
|
||||
<Button color="gray" onclick={closeModal}>닫기</Button>
|
||||
<Button onclick={() => onCreateClick(name)}>만들기</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
147
src/lib/server/db/category.ts
Normal file
147
src/lib/server/db/category.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext } from "./schema";
|
||||
|
||||
export type CategoryId = "root" | number;
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
parentId: CategoryId;
|
||||
userId: number;
|
||||
mekVersion: number;
|
||||
encDek: string;
|
||||
dekVersion: Date;
|
||||
encName: Ciphertext;
|
||||
}
|
||||
|
||||
export type NewCategory = Omit<Category, "id">;
|
||||
|
||||
export const registerCategory = async (params: NewCategory) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== params.mekVersion) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
|
||||
const { categoryId } = await trx
|
||||
.insertInto("category")
|
||||
.values({
|
||||
parent_id: params.parentId !== "root" ? params.parentId : null,
|
||||
user_id: params.userId,
|
||||
master_encryption_key_version: params.mekVersion,
|
||||
encrypted_data_encryption_key: params.encDek,
|
||||
data_encryption_key_version: params.dekVersion,
|
||||
encrypted_name: params.encName,
|
||||
})
|
||||
.returning("id as categoryId")
|
||||
.executeTakeFirstOrThrow();
|
||||
await trx
|
||||
.insertInto("category_log")
|
||||
.values({
|
||||
category_id: categoryId,
|
||||
timestamp: new Date(),
|
||||
action: "create",
|
||||
new_name: params.encName,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllCategoriesByParent = async (userId: number, parentId: CategoryId) => {
|
||||
let query = db.selectFrom("category").selectAll().where("user_id", "=", userId);
|
||||
query =
|
||||
parentId === "root"
|
||||
? query.where("parent_id", "is", null)
|
||||
: query.where("parent_id", "=", parentId);
|
||||
const categories = await query.execute();
|
||||
return categories.map(
|
||||
(category) =>
|
||||
({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
userId: category.user_id,
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: category.data_encryption_key_version,
|
||||
encName: category.encrypted_name,
|
||||
}) satisfies Category,
|
||||
);
|
||||
};
|
||||
|
||||
export const getCategory = async (userId: number, categoryId: number) => {
|
||||
const category = await db
|
||||
.selectFrom("category")
|
||||
.selectAll()
|
||||
.where("id", "=", categoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return category
|
||||
? ({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
userId: category.user_id,
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: category.data_encryption_key_version,
|
||||
encName: category.encrypted_name,
|
||||
} satisfies Category)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const setCategoryEncName = async (
|
||||
userId: number,
|
||||
categoryId: number,
|
||||
dekVersion: Date,
|
||||
encName: Ciphertext,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const category = await trx
|
||||
.selectFrom("category")
|
||||
.select("data_encryption_key_version")
|
||||
.where("id", "=", categoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!category) {
|
||||
throw new IntegrityError("Category not found");
|
||||
} else if (category.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
|
||||
throw new IntegrityError("Invalid DEK version");
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("category")
|
||||
.set({ encrypted_name: encName })
|
||||
.where("id", "=", categoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("category_log")
|
||||
.values({
|
||||
category_id: categoryId,
|
||||
timestamp: new Date(),
|
||||
action: "rename",
|
||||
new_name: encName,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const unregisterCategory = async (userId: number, categoryId: number) => {
|
||||
const res = await db
|
||||
.deleteFrom("category")
|
||||
.where("id", "=", categoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (res.numDeletedRows === 0n) {
|
||||
throw new IntegrityError("Category not found");
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
type IntegrityErrorMessages =
|
||||
// Category
|
||||
| "Category not found"
|
||||
// Challenge
|
||||
| "Challenge already registered"
|
||||
// Client
|
||||
@@ -7,6 +9,8 @@ type IntegrityErrorMessages =
|
||||
// File
|
||||
| "Directory not found"
|
||||
| "File not found"
|
||||
| "File not found in category"
|
||||
| "File already added to category"
|
||||
| "Invalid DEK version"
|
||||
// HSK
|
||||
| "HSK already registered"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { sql, type NotNull } from "kysely";
|
||||
import pg from "pg";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext } from "./schema";
|
||||
|
||||
type DirectoryId = "root" | number;
|
||||
export type DirectoryId = "root" | number;
|
||||
|
||||
interface Directory {
|
||||
id: number;
|
||||
@@ -289,6 +291,46 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
|
||||
);
|
||||
};
|
||||
|
||||
export const getAllFilesByCategory = async (
|
||||
userId: number,
|
||||
categoryId: number,
|
||||
recurse: boolean,
|
||||
) => {
|
||||
const files = await db
|
||||
.withRecursive("cte", (db) =>
|
||||
db
|
||||
.selectFrom("category")
|
||||
.leftJoin("file_category", "category.id", "file_category.category_id")
|
||||
.select(["id", "parent_id", "user_id", "file_category.file_id"])
|
||||
.select(sql<number>`0`.as("depth"))
|
||||
.where("id", "=", categoryId)
|
||||
.$if(recurse, (qb) =>
|
||||
qb.unionAll((db) =>
|
||||
db
|
||||
.selectFrom("category")
|
||||
.leftJoin("file_category", "category.id", "file_category.category_id")
|
||||
.innerJoin("cte", "category.parent_id", "cte.id")
|
||||
.select([
|
||||
"category.id",
|
||||
"category.parent_id",
|
||||
"category.user_id",
|
||||
"file_category.file_id",
|
||||
])
|
||||
.select(sql<number>`cte.depth + 1`.as("depth")),
|
||||
),
|
||||
),
|
||||
)
|
||||
.selectFrom("cte")
|
||||
.select(["file_id", "depth"])
|
||||
.distinctOn("file_id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("file_id", "is not", null)
|
||||
.$narrowType<{ file_id: NotNull }>()
|
||||
.orderBy(["file_id", "depth"])
|
||||
.execute();
|
||||
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
|
||||
};
|
||||
|
||||
export const getAllFileIdsByContentHmac = async (
|
||||
userId: number,
|
||||
hskVersion: number,
|
||||
@@ -384,3 +426,60 @@ export const unregisterFile = async (userId: number, fileId: number) => {
|
||||
}
|
||||
return { path: file.path };
|
||||
};
|
||||
|
||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
try {
|
||||
await trx
|
||||
.insertInto("file_category")
|
||||
.values({ file_id: fileId, category_id: categoryId })
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("file_log")
|
||||
.values({
|
||||
file_id: fileId,
|
||||
timestamp: new Date(),
|
||||
action: "add-to-category",
|
||||
category_id: categoryId,
|
||||
})
|
||||
.execute();
|
||||
} catch (e) {
|
||||
if (e instanceof pg.DatabaseError && e.code === "23505") {
|
||||
throw new IntegrityError("File already added to category");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllFileCategories = async (fileId: number) => {
|
||||
const categories = await db
|
||||
.selectFrom("file_category")
|
||||
.select("category_id")
|
||||
.where("file_id", "=", fileId)
|
||||
.execute();
|
||||
return categories.map(({ category_id }) => ({ id: category_id }));
|
||||
};
|
||||
|
||||
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const res = await trx
|
||||
.deleteFrom("file_category")
|
||||
.where("file_id", "=", fileId)
|
||||
.where("category_id", "=", categoryId)
|
||||
.executeTakeFirst();
|
||||
if (res.numDeletedRows === 0n) {
|
||||
throw new IntegrityError("File not found in category");
|
||||
}
|
||||
|
||||
await trx
|
||||
.insertInto("file_log")
|
||||
.values({
|
||||
file_id: fileId,
|
||||
timestamp: new Date(),
|
||||
action: "remove-from-category",
|
||||
category_id: categoryId,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
65
src/lib/server/db/migrations/1737422340-AddFileCategory.ts
Normal file
65
src/lib/server/db/migrations/1737422340-AddFileCategory.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Kysely } from "kysely";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const up = async (db: Kysely<any>) => {
|
||||
// category.ts
|
||||
await db.schema
|
||||
.createTable("category")
|
||||
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
|
||||
.addColumn("parent_id", "integer", (col) => col.references("category.id").onDelete("cascade"))
|
||||
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
|
||||
.addColumn("master_encryption_key_version", "integer", (col) => col.notNull())
|
||||
.addColumn("encrypted_data_encryption_key", "text", (col) => col.unique().notNull())
|
||||
.addColumn("data_encryption_key_version", "timestamp(3)", (col) => col.notNull())
|
||||
.addColumn("encrypted_name", "json", (col) => col.notNull())
|
||||
.addForeignKeyConstraint(
|
||||
"category_fk01",
|
||||
["user_id", "master_encryption_key_version"],
|
||||
"master_encryption_key",
|
||||
["user_id", "version"],
|
||||
)
|
||||
.execute();
|
||||
await db.schema
|
||||
.createTable("category_log")
|
||||
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
|
||||
.addColumn("category_id", "integer", (col) =>
|
||||
col.references("category.id").onDelete("cascade").notNull(),
|
||||
)
|
||||
.addColumn("timestamp", "timestamp(3)", (col) => col.notNull())
|
||||
.addColumn("action", "text", (col) => col.notNull())
|
||||
.addColumn("new_name", "json")
|
||||
.execute();
|
||||
|
||||
// file.ts
|
||||
await db.schema
|
||||
.alterTable("file_log")
|
||||
.addColumn("category_id", "integer", (col) =>
|
||||
col.references("category.id").onDelete("set null"),
|
||||
)
|
||||
.execute();
|
||||
await db.schema
|
||||
.createTable("file_category")
|
||||
.addColumn("file_id", "integer", (col) =>
|
||||
col.references("file.id").onDelete("cascade").notNull(),
|
||||
)
|
||||
.addColumn("category_id", "integer", (col) =>
|
||||
col.references("category.id").onDelete("cascade").notNull(),
|
||||
)
|
||||
.addPrimaryKeyConstraint("file_category_pk", ["file_id", "category_id"])
|
||||
.execute();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const down = async (db: Kysely<any>) => {
|
||||
await db
|
||||
.deleteFrom("file_log")
|
||||
.where((eb) =>
|
||||
eb.or([eb("action", "=", "add-to-category"), eb("action", "=", "remove-from-category")]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema.dropTable("file_category").execute();
|
||||
await db.schema.alterTable("file_log").dropColumn("category_id").execute();
|
||||
await db.schema.dropTable("category_log").execute();
|
||||
await db.schema.dropTable("category").execute();
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as Initial1737357000 from "./1737357000-Initial";
|
||||
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
|
||||
|
||||
export default {
|
||||
"1737357000-Initial": Initial1737357000,
|
||||
"1737422340-AddFileCategory": AddFileCategory1737422340,
|
||||
};
|
||||
|
||||
27
src/lib/server/db/schema/category.ts
Normal file
27
src/lib/server/db/schema/category.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Generated } from "kysely";
|
||||
import type { Ciphertext } from "./util";
|
||||
|
||||
interface CategoryTable {
|
||||
id: Generated<number>;
|
||||
parent_id: number | null;
|
||||
user_id: number;
|
||||
master_encryption_key_version: number;
|
||||
encrypted_data_encryption_key: string; // Base64
|
||||
data_encryption_key_version: Date;
|
||||
encrypted_name: Ciphertext;
|
||||
}
|
||||
|
||||
interface CategoryLogTable {
|
||||
id: Generated<number>;
|
||||
category_id: number;
|
||||
timestamp: Date;
|
||||
action: "create" | "rename";
|
||||
new_name: Ciphertext | null;
|
||||
}
|
||||
|
||||
declare module "./index" {
|
||||
interface Database {
|
||||
category: CategoryTable;
|
||||
category_log: CategoryLogTable;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { ColumnType, Generated } from "kysely";
|
||||
|
||||
export type Ciphertext = {
|
||||
ciphertext: string; // Base64
|
||||
iv: string; // Base64
|
||||
};
|
||||
import type { Ciphertext } from "./util";
|
||||
|
||||
interface DirectoryTable {
|
||||
id: Generated<number>;
|
||||
@@ -45,8 +41,14 @@ interface FileLogTable {
|
||||
id: Generated<number>;
|
||||
file_id: number;
|
||||
timestamp: ColumnType<Date, Date, never>;
|
||||
action: "create" | "rename";
|
||||
action: "create" | "rename" | "add-to-category" | "remove-from-category";
|
||||
new_name: Ciphertext | null;
|
||||
category_id: number | null;
|
||||
}
|
||||
|
||||
interface FileCategoryTable {
|
||||
file_id: number;
|
||||
category_id: number;
|
||||
}
|
||||
|
||||
declare module "./index" {
|
||||
@@ -55,5 +57,6 @@ declare module "./index" {
|
||||
directory_log: DirectoryLogTable;
|
||||
file: FileTable;
|
||||
file_log: FileLogTable;
|
||||
file_category: FileCategoryTable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from "./category";
|
||||
export * from "./client";
|
||||
export * from "./file";
|
||||
export * from "./hsk";
|
||||
export * from "./mek";
|
||||
export * from "./session";
|
||||
export * from "./user";
|
||||
export * from "./util";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface Database {}
|
||||
|
||||
4
src/lib/server/db/schema/util.ts
Normal file
4
src/lib/server/db/schema/util.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type Ciphertext = {
|
||||
ciphertext: string; // Base64
|
||||
iv: string; // Base64
|
||||
};
|
||||
55
src/lib/server/schemas/category.ts
Normal file
55
src/lib/server/schemas/category.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const categoryIdSchema = z.union([z.enum(["root"]), z.number().int().positive()]);
|
||||
|
||||
export const categoryInfoResponse = z.object({
|
||||
metadata: z
|
||||
.object({
|
||||
parent: categoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
name: z.string().base64().nonempty(),
|
||||
nameIv: z.string().base64().nonempty(),
|
||||
})
|
||||
.optional(),
|
||||
subCategories: z.number().int().positive().array(),
|
||||
});
|
||||
export type CategoryInfoResponse = z.infer<typeof categoryInfoResponse>;
|
||||
|
||||
export const categoryFileAddRequest = z.object({
|
||||
file: z.number().int().positive(),
|
||||
});
|
||||
export type CategoryFileAddRequest = z.infer<typeof categoryFileAddRequest>;
|
||||
|
||||
export const categoryFileListResponse = z.object({
|
||||
files: z.array(
|
||||
z.object({
|
||||
file: z.number().int().positive(),
|
||||
isRecursive: z.boolean(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type CategoryFileListResponse = z.infer<typeof categoryFileListResponse>;
|
||||
|
||||
export const categoryFileRemoveRequest = z.object({
|
||||
file: z.number().int().positive(),
|
||||
});
|
||||
export type CategoryFileRemoveRequest = z.infer<typeof categoryFileRemoveRequest>;
|
||||
|
||||
export const categoryRenameRequest = z.object({
|
||||
dekVersion: z.string().datetime(),
|
||||
name: z.string().base64().nonempty(),
|
||||
nameIv: z.string().base64().nonempty(),
|
||||
});
|
||||
export type CategoryRenameRequest = z.infer<typeof categoryRenameRequest>;
|
||||
|
||||
export const categoryCreateRequest = z.object({
|
||||
parent: categoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
name: z.string().base64().nonempty(),
|
||||
nameIv: z.string().base64().nonempty(),
|
||||
});
|
||||
export type CategoryCreateRequest = z.infer<typeof categoryCreateRequest>;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const directoryIdSchema = z.union([z.enum(["root"]), z.number().int().positive()]);
|
||||
|
||||
export const directoryInfoResponse = z.object({
|
||||
metadata: z
|
||||
.object({
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
parent: directoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
@@ -29,7 +31,7 @@ export const directoryRenameRequest = z.object({
|
||||
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
||||
|
||||
export const directoryCreateRequest = z.object({
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
parent: directoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import mime from "mime";
|
||||
import { z } from "zod";
|
||||
import { directoryIdSchema } from "./directory";
|
||||
|
||||
export const fileInfoResponse = z.object({
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
parent: directoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
@@ -17,6 +18,7 @@ export const fileInfoResponse = z.object({
|
||||
createdAtIv: z.string().base64().nonempty().optional(),
|
||||
lastModifiedAt: z.string().base64().nonempty(),
|
||||
lastModifiedAtIv: z.string().base64().nonempty(),
|
||||
categories: z.number().int().positive().array(),
|
||||
});
|
||||
export type FileInfoResponse = z.infer<typeof fileInfoResponse>;
|
||||
|
||||
@@ -39,7 +41,7 @@ export const duplicateFileScanResponse = z.object({
|
||||
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
||||
|
||||
export const fileUploadRequest = z.object({
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
parent: directoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./auth";
|
||||
export * from "./category";
|
||||
export * from "./client";
|
||||
export * from "./directory";
|
||||
export * from "./file";
|
||||
|
||||
133
src/lib/server/services/category.ts
Normal file
133
src/lib/server/services/category.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import {
|
||||
registerCategory,
|
||||
getAllCategoriesByParent,
|
||||
getCategory,
|
||||
setCategoryEncName,
|
||||
unregisterCategory,
|
||||
type CategoryId,
|
||||
type NewCategory,
|
||||
} from "$lib/server/db/category";
|
||||
import { IntegrityError } from "$lib/server/db/error";
|
||||
import {
|
||||
getAllFilesByCategory,
|
||||
getFile,
|
||||
addFileToCategory,
|
||||
removeFileFromCategory,
|
||||
} from "$lib/server/db/file";
|
||||
import type { Ciphertext } from "$lib/server/db/schema";
|
||||
|
||||
export const getCategoryInformation = async (userId: number, categoryId: CategoryId) => {
|
||||
const category = categoryId !== "root" ? await getCategory(userId, categoryId) : undefined;
|
||||
if (category === null) {
|
||||
error(404, "Invalid category id");
|
||||
}
|
||||
|
||||
const categories = await getAllCategoriesByParent(userId, categoryId);
|
||||
return {
|
||||
metadata: category && {
|
||||
parentId: category.parentId ?? ("root" as const),
|
||||
mekVersion: category.mekVersion,
|
||||
encDek: category.encDek,
|
||||
dekVersion: category.dekVersion,
|
||||
encName: category.encName,
|
||||
},
|
||||
categories: categories.map(({ id }) => id),
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteCategory = async (userId: number, categoryId: number) => {
|
||||
try {
|
||||
await unregisterCategory(userId, categoryId);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Category not found") {
|
||||
error(404, "Invalid category id");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const addCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
|
||||
const category = await getCategory(userId, categoryId);
|
||||
const file = await getFile(userId, fileId);
|
||||
if (!category) {
|
||||
error(404, "Invalid category id");
|
||||
} else if (!file) {
|
||||
error(404, "Invalid file id");
|
||||
}
|
||||
|
||||
try {
|
||||
await addFileToCategory(fileId, categoryId);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "File already added to category") {
|
||||
error(400, "File already added");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryFiles = async (userId: number, categoryId: number, recurse: boolean) => {
|
||||
const category = await getCategory(userId, categoryId);
|
||||
if (!category) {
|
||||
error(404, "Invalid category id");
|
||||
}
|
||||
|
||||
const files = await getAllFilesByCategory(userId, categoryId, recurse);
|
||||
return { files };
|
||||
};
|
||||
|
||||
export const removeCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
|
||||
const category = await getCategory(userId, categoryId);
|
||||
const file = await getFile(userId, fileId);
|
||||
if (!category) {
|
||||
error(404, "Invalid category id");
|
||||
} else if (!file) {
|
||||
error(404, "Invalid file id");
|
||||
}
|
||||
|
||||
try {
|
||||
await removeFileFromCategory(fileId, categoryId);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "File not found in category") {
|
||||
error(400, "File not added");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const renameCategory = async (
|
||||
userId: number,
|
||||
categoryId: number,
|
||||
dekVersion: Date,
|
||||
newEncName: Ciphertext,
|
||||
) => {
|
||||
try {
|
||||
await setCategoryEncName(userId, categoryId, dekVersion, newEncName);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Category not found") {
|
||||
error(404, "Invalid category id");
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
error(400, "Invalid DEK version");
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const createCategory = async (params: NewCategory) => {
|
||||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
|
||||
error(400, "Invalid DEK version");
|
||||
}
|
||||
|
||||
try {
|
||||
await registerCategory(params);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
|
||||
error(400, "Inactive MEK version");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -8,11 +8,12 @@ import {
|
||||
setDirectoryEncName,
|
||||
unregisterDirectory,
|
||||
getAllFilesByParent,
|
||||
type DirectoryId,
|
||||
type NewDirectory,
|
||||
} from "$lib/server/db/file";
|
||||
import type { Ciphertext } from "$lib/server/db/schema";
|
||||
|
||||
export const getDirectoryInformation = async (userId: number, directoryId: "root" | number) => {
|
||||
export const getDirectoryInformation = async (userId: number, directoryId: DirectoryId) => {
|
||||
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
|
||||
if (directory === null) {
|
||||
error(404, "Invalid directory id");
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getFile,
|
||||
setFileEncName,
|
||||
unregisterFile,
|
||||
getAllFileCategories,
|
||||
type NewFile,
|
||||
} from "$lib/server/db/file";
|
||||
import type { Ciphertext } from "$lib/server/db/schema";
|
||||
@@ -24,6 +25,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
|
||||
error(404, "Invalid file id");
|
||||
}
|
||||
|
||||
const categories = await getAllFileCategories(fileId);
|
||||
return {
|
||||
parentId: file.parentId ?? ("root" as const),
|
||||
mekVersion: file.mekVersion,
|
||||
@@ -34,6 +36,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
|
||||
encName: file.encName,
|
||||
encCreatedAt: file.encCreatedAt,
|
||||
encLastModifiedAt: file.encLastModifiedAt,
|
||||
categories: categories.map(({ id }) => id),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
29
src/lib/services/category.ts
Normal file
29
src/lib/services/category.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { callPostApi } from "$lib/hooks";
|
||||
import { generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
|
||||
import type { CategoryCreateRequest, CategoryFileRemoveRequest } from "$lib/server/schemas";
|
||||
import type { MasterKey } from "$lib/stores";
|
||||
|
||||
export const requestCategoryCreation = async (
|
||||
name: string,
|
||||
parentId: "root" | number,
|
||||
masterKey: MasterKey,
|
||||
) => {
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
const nameEncrypted = await encryptString(name, dataKey);
|
||||
await callPostApi<CategoryCreateRequest>("/api/category/create", {
|
||||
parent: parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||
dekVersion: dataKeyVersion.toISOString(),
|
||||
name: nameEncrypted.ciphertext,
|
||||
nameIv: nameEncrypted.iv,
|
||||
});
|
||||
};
|
||||
|
||||
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
|
||||
const res = await callPostApi<CategoryFileRemoveRequest>(
|
||||
`/api/category/${categoryId}/file/remove`,
|
||||
{ file: fileId },
|
||||
);
|
||||
return res.ok;
|
||||
};
|
||||
@@ -2,15 +2,34 @@
|
||||
import FileSaver from "file-saver";
|
||||
import { untrack } from "svelte";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { TopBar } from "$lib/components";
|
||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||
import { EntryButton } from "$lib/components/buttons";
|
||||
import {
|
||||
getFileInfo,
|
||||
getCategoryInfo,
|
||||
type FileInfo,
|
||||
type CategoryInfo,
|
||||
} from "$lib/modules/filesystem";
|
||||
import Categories from "$lib/molecules/Categories";
|
||||
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
||||
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
|
||||
import DownloadStatus from "./DownloadStatus.svelte";
|
||||
import { requestFileDownload } from "./service";
|
||||
import {
|
||||
requestFileRemovalFromCategory,
|
||||
requestFileDownload,
|
||||
requestFileAdditionToCategory,
|
||||
} from "./service";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
import IconAddCircle from "~icons/material-symbols/add-circle";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let info: Writable<FileInfo | null> | undefined = $state();
|
||||
let categories: Writable<CategoryInfo | null>[] = $state([]);
|
||||
|
||||
let isAddToCategoryBottomSheetOpen = $state(false);
|
||||
|
||||
const downloadStatus = $derived(
|
||||
$fileDownloadStatusStore.find((statusStore) => {
|
||||
@@ -23,14 +42,7 @@
|
||||
let viewerType: "image" | "video" | undefined = $state();
|
||||
let fileBlobUrl: string | undefined = $state();
|
||||
|
||||
const updateViewer = async (info: FileInfo, buffer: ArrayBuffer) => {
|
||||
const contentType = info.contentType;
|
||||
if (contentType.startsWith("image")) {
|
||||
viewerType = "image";
|
||||
} else if (contentType.startsWith("video")) {
|
||||
viewerType = "video";
|
||||
}
|
||||
|
||||
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
|
||||
const fileBlob = new Blob([buffer], { type: contentType });
|
||||
if (contentType === "image/heic") {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
@@ -44,19 +56,42 @@
|
||||
return fileBlob;
|
||||
};
|
||||
|
||||
const addToCategory = async (categoryId: number) => {
|
||||
await requestFileAdditionToCategory(data.id, categoryId);
|
||||
isAddToCategoryBottomSheetOpen = false;
|
||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
const removeFromCategory = async (categoryId: number) => {
|
||||
await requestFileRemovalFromCategory(data.id, categoryId);
|
||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||
isDownloadRequested = false;
|
||||
viewerType = undefined;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
categories =
|
||||
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($info && $info.dataKey && $info.contentIv) {
|
||||
const contentType = $info.contentType;
|
||||
if (contentType.startsWith("image")) {
|
||||
viewerType = "image";
|
||||
} else if (contentType.startsWith("video")) {
|
||||
viewerType = "video";
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
if (!downloadStatus && !isDownloadRequested) {
|
||||
isDownloadRequested = true;
|
||||
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
|
||||
const blob = await updateViewer($info, buffer);
|
||||
const blob = await updateViewer(buffer, contentType);
|
||||
if (!viewerType) {
|
||||
FileSaver.saveAs(blob, $info.name);
|
||||
}
|
||||
@@ -68,7 +103,9 @@
|
||||
|
||||
$effect(() => {
|
||||
if ($info && $downloadStatus?.status === "decrypted") {
|
||||
untrack(() => !isDownloadRequested && updateViewer($info, $downloadStatus.result!));
|
||||
untrack(
|
||||
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,27 +118,51 @@
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<TopBar title={$info?.name} />
|
||||
<DownloadStatus status={downloadStatus} />
|
||||
<div class="flex w-full flex-grow flex-col items-center pb-4">
|
||||
{#snippet viewerLoading(message: string)}
|
||||
<div class="flex flex-grow items-center justify-center">
|
||||
<p class="text-gray-500">{message}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="space-y-4 pb-4">
|
||||
<DownloadStatus status={downloadStatus} />
|
||||
{#if $info && viewerType}
|
||||
<div class="flex w-full justify-center">
|
||||
{#snippet viewerLoading(message: string)}
|
||||
<p class="text-gray-500">{message}</p>
|
||||
{/snippet}
|
||||
|
||||
{#if $info && viewerType === "image"}
|
||||
{#if fileBlobUrl}
|
||||
<img src={fileBlobUrl} alt={$info.name} />
|
||||
{:else}
|
||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||
{/if}
|
||||
{:else if viewerType === "video"}
|
||||
{#if fileBlobUrl}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={fileBlobUrl} controls></video>
|
||||
{:else}
|
||||
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
||||
{/if}
|
||||
{#if viewerType === "image"}
|
||||
{#if fileBlobUrl}
|
||||
<img src={fileBlobUrl} alt={$info.name} />
|
||||
{:else}
|
||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||
{/if}
|
||||
{:else if viewerType === "video"}
|
||||
{#if fileBlobUrl}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={fileBlobUrl} controls></video>
|
||||
{:else}
|
||||
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="space-y-2">
|
||||
<p class="text-lg font-bold">카테고리</p>
|
||||
<div class="space-y-1">
|
||||
<Categories
|
||||
{categories}
|
||||
categoryMenuIcon={IconClose}
|
||||
onCategoryClick={({ id }) => goto(`/category/${id}`)}
|
||||
onCategoryMenuClick={({ id }) => removeFromCategory(id)}
|
||||
/>
|
||||
<EntryButton onclick={() => (isAddToCategoryBottomSheetOpen = true)}>
|
||||
<div class="flex h-8 items-center gap-x-4">
|
||||
<IconAddCircle class="text-lg text-gray-600" />
|
||||
<p class="font-medium text-gray-700">카테고리에 추가하기</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddToCategoryBottomSheet
|
||||
bind:isOpen={isAddToCategoryBottomSheetOpen}
|
||||
onAddToCategoryClick={addToCategory}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import { BottomSheet } from "$lib/components";
|
||||
import { Button } from "$lib/components/buttons";
|
||||
import { BottomDiv } from "$lib/components/divs";
|
||||
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||
import SubCategories from "$lib/molecules/SubCategories.svelte";
|
||||
import CreateCategoryModal from "$lib/organisms/CreateCategoryModal.svelte";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import { requestCategoryCreation } from "./service";
|
||||
|
||||
interface Props {
|
||||
onAddToCategoryClick: (categoryId: number) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
|
||||
|
||||
let category: Writable<CategoryInfo | null> | undefined = $state();
|
||||
|
||||
let isCreateCategoryModalOpen = $state(false);
|
||||
|
||||
const createCategory = async (name: string) => {
|
||||
if (!$category) return; // TODO: Error handling
|
||||
|
||||
await requestCategoryCreation(name, $category.id, $masterKeyStore?.get(1)!);
|
||||
isCreateCategoryModalOpen = false;
|
||||
category = getCategoryInfo($category.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<BottomSheet bind:isOpen>
|
||||
<div class="flex w-full flex-col justify-between">
|
||||
{#if $category}
|
||||
<SubCategories
|
||||
class="h-fit py-4"
|
||||
info={$category}
|
||||
onSubCategoryClick={({ id }) =>
|
||||
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
|
||||
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
|
||||
subCategoryCreatePosition="top"
|
||||
/>
|
||||
{#if $category.id !== "root"}
|
||||
<BottomDiv>
|
||||
<Button onclick={() => onAddToCategoryClick($category.id)}>이 카테고리에 추가하기</Button>
|
||||
</BottomDiv>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<CreateCategoryModal bind:isOpen={isCreateCategoryModalOpen} onCreateClick={createCategory} />
|
||||
@@ -1,4 +1,8 @@
|
||||
import { callPostApi } from "$lib/hooks";
|
||||
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
|
||||
import type { CategoryFileAddRequest } from "$lib/server/schemas";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
|
||||
|
||||
export const requestFileDownload = async (
|
||||
fileId: number,
|
||||
@@ -12,3 +16,10 @@ export const requestFileDownload = async (
|
||||
storeFileCache(fileId, fileBuffer); // Intended
|
||||
return fileBuffer;
|
||||
};
|
||||
|
||||
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
|
||||
const res = await callPostApi<CategoryFileAddRequest>(`/api/category/${categoryId}/file/add`, {
|
||||
file: fileId,
|
||||
});
|
||||
return res.ok;
|
||||
};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<div class="flex h-full items-center justify-center p-4">
|
||||
<p class="text-gray-500">아직 개발 중이에요.</p>
|
||||
</div>
|
||||
109
src/routes/(main)/category/[[id]]/+page.svelte
Normal file
109
src/routes/(main)/category/[[id]]/+page.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
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 CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
|
||||
import DeleteCategoryModal from "./DeleteCategoryModal.svelte";
|
||||
import RenameCategoryModal from "./RenameCategoryModal.svelte";
|
||||
import {
|
||||
requestCategoryCreation,
|
||||
requestFileRemovalFromCategory,
|
||||
requestCategoryRename,
|
||||
requestCategoryDeletion,
|
||||
} from "./service";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let info: Writable<CategoryInfo | null> | undefined = $state();
|
||||
let selectedSubCategory: SelectedCategory | undefined = $state();
|
||||
|
||||
let isFileRecursive = $state(false);
|
||||
|
||||
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)!);
|
||||
isCreateCategoryModalOpen = false;
|
||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>카테고리</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-full flex-col">
|
||||
{#if data.id !== "root"}
|
||||
<TopBar title={$info?.name} xPadding />
|
||||
{/if}
|
||||
<div class="flex-grow bg-gray-100 pb-[5.5em]">
|
||||
{#if $info}
|
||||
<Category
|
||||
bind:isFileRecursive
|
||||
info={$info}
|
||||
onFileClick={({ id }) => goto(`/file/${id}`)}
|
||||
onFileRemoveClick={async ({ id }) => {
|
||||
await requestFileRemovalFromCategory(id, data.id as number);
|
||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
}}
|
||||
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
|
||||
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
|
||||
onSubCategoryMenuClick={(subCategory) => {
|
||||
selectedSubCategory = subCategory;
|
||||
isSubCategoryMenuBottomSheetOpen = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateCategoryModal bind:isOpen={isCreateCategoryModalOpen} onCreateClick={createCategory} />
|
||||
|
||||
<CategoryMenuBottomSheet
|
||||
bind:isOpen={isSubCategoryMenuBottomSheetOpen}
|
||||
bind:selectedCategory={selectedSubCategory}
|
||||
onRenameClick={() => {
|
||||
isSubCategoryMenuBottomSheetOpen = false;
|
||||
isRenameCategoryModalOpen = true;
|
||||
}}
|
||||
onDeleteClick={() => {
|
||||
isSubCategoryMenuBottomSheetOpen = false;
|
||||
isDeleteCategoryModalOpen = true;
|
||||
}}
|
||||
/>
|
||||
<RenameCategoryModal
|
||||
bind:isOpen={isRenameCategoryModalOpen}
|
||||
bind:selectedCategory={selectedSubCategory}
|
||||
onRenameClick={async (newName) => {
|
||||
if (selectedSubCategory) {
|
||||
await requestCategoryRename(selectedSubCategory, newName);
|
||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
<DeleteCategoryModal
|
||||
bind:isOpen={isDeleteCategoryModalOpen}
|
||||
bind:selectedCategory={selectedSubCategory}
|
||||
onDeleteClick={async () => {
|
||||
if (selectedSubCategory) {
|
||||
await requestCategoryDeletion(selectedSubCategory);
|
||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
/>
|
||||
17
src/routes/(main)/category/[[id]]/+page.ts
Normal file
17
src/routes/(main)/category/[[id]]/+page.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(404, "Not found");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
return {
|
||||
id: id ? id : ("root" as const),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { BottomSheet } from "$lib/components";
|
||||
import { EntryButton } from "$lib/components/buttons";
|
||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
||||
|
||||
import IconCategory from "~icons/material-symbols/category";
|
||||
import IconEdit from "~icons/material-symbols/edit";
|
||||
import IconDelete from "~icons/material-symbols/delete";
|
||||
|
||||
interface Props {
|
||||
onRenameClick: () => void;
|
||||
onDeleteClick: () => void;
|
||||
isOpen: boolean;
|
||||
selectedCategory: SelectedCategory | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
onRenameClick,
|
||||
onDeleteClick,
|
||||
isOpen = $bindable(),
|
||||
selectedCategory = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const closeBottomSheet = () => {
|
||||
isOpen = false;
|
||||
selectedCategory = undefined;
|
||||
};
|
||||
</script>
|
||||
|
||||
<BottomSheet bind:isOpen onclose={closeBottomSheet}>
|
||||
<div class="w-full py-4">
|
||||
{#if selectedCategory}
|
||||
{@const { name } = selectedCategory}
|
||||
<div class="flex h-12 items-center gap-x-4 p-2">
|
||||
<div class="flex-shrink-0 text-lg">
|
||||
<IconCategory />
|
||||
</div>
|
||||
<p title={name} class="flex-grow truncate font-semibold">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
<div class="my-2 h-px w-full bg-gray-200"></div>
|
||||
{/if}
|
||||
<EntryButton onclick={onRenameClick}>
|
||||
<div class="flex h-8 items-center gap-x-4">
|
||||
<IconEdit class="text-lg" />
|
||||
<p class="font-medium">이름 바꾸기</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
<EntryButton onclick={onDeleteClick}>
|
||||
<div class="flex h-8 items-center gap-x-4 text-red-500">
|
||||
<IconDelete class="text-lg" />
|
||||
<p class="font-medium">삭제하기</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
48
src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte
Normal file
48
src/routes/(main)/category/[[id]]/DeleteCategoryModal.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Modal } from "$lib/components";
|
||||
import { Button } from "$lib/components/buttons";
|
||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
||||
|
||||
interface Props {
|
||||
onDeleteClick: () => Promise<boolean>;
|
||||
isOpen: boolean;
|
||||
selectedCategory: SelectedCategory | undefined;
|
||||
}
|
||||
|
||||
let { onDeleteClick, isOpen = $bindable(), selectedCategory = $bindable() }: Props = $props();
|
||||
|
||||
const closeModal = () => {
|
||||
isOpen = false;
|
||||
selectedCategory = undefined;
|
||||
};
|
||||
|
||||
const deleteEntry = async () => {
|
||||
// TODO: Validation
|
||||
|
||||
if (await onDeleteClick()) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:isOpen onclose={closeModal}>
|
||||
{#if selectedCategory}
|
||||
{@const { name } = selectedCategory}
|
||||
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2 break-keep">
|
||||
<p class="text-xl font-bold">
|
||||
'{nameShort}' 카테고리를 삭제할까요?
|
||||
</p>
|
||||
<p>
|
||||
모든 하위 카테고리도 함께 삭제돼요. <br />
|
||||
하지만 카테고리에 추가된 파일들은 삭제되지 않아요.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button color="gray" onclick={closeModal}>아니요</Button>
|
||||
<Button onclick={deleteEntry}>삭제할게요</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
47
src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte
Normal file
47
src/routes/(main)/category/[[id]]/RenameCategoryModal.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { Modal } from "$lib/components";
|
||||
import { Button } from "$lib/components/buttons";
|
||||
import { TextInput } from "$lib/components/inputs";
|
||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
||||
|
||||
interface Props {
|
||||
onRenameClick: (newName: string) => Promise<boolean>;
|
||||
isOpen: boolean;
|
||||
selectedCategory: SelectedCategory | undefined;
|
||||
}
|
||||
|
||||
let { onRenameClick, isOpen = $bindable(), selectedCategory = $bindable() }: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
|
||||
const closeModal = () => {
|
||||
name = "";
|
||||
isOpen = false;
|
||||
selectedCategory = undefined;
|
||||
};
|
||||
|
||||
const renameEntry = async () => {
|
||||
// TODO: Validation
|
||||
|
||||
if (await onRenameClick(name)) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (selectedCategory) {
|
||||
name = selectedCategory.name;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:isOpen onclose={closeModal}>
|
||||
<p class="text-xl font-bold">이름 바꾸기</p>
|
||||
<div class="mt-2 flex w-full">
|
||||
<TextInput bind:value={name} placeholder="이름" />
|
||||
</div>
|
||||
<div class="mt-7 flex gap-2">
|
||||
<Button color="gray" onclick={closeModal}>닫기</Button>
|
||||
<Button onclick={renameEntry}>바꾸기</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
22
src/routes/(main)/category/[[id]]/service.ts
Normal file
22
src/routes/(main)/category/[[id]]/service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { callPostApi } from "$lib/hooks";
|
||||
import { encryptString } from "$lib/modules/crypto";
|
||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
||||
import type { CategoryRenameRequest } from "$lib/server/schemas";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
|
||||
|
||||
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
|
||||
const newNameEncrypted = await encryptString(newName, category.dataKey);
|
||||
|
||||
const res = await callPostApi<CategoryRenameRequest>(`/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;
|
||||
};
|
||||
@@ -48,14 +48,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
color="gray"
|
||||
onclick={() => {
|
||||
isOpen = false;
|
||||
}}
|
||||
>
|
||||
아니요
|
||||
</Button>
|
||||
<Button color="gray" onclick={closeModal}>아니요</Button>
|
||||
<Button onclick={deleteEntry}>삭제할게요</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
type DirectoryInfo,
|
||||
type FileInfo,
|
||||
} from "$lib/modules/filesystem";
|
||||
import { SortBy, sortEntries } from "$lib/modules/util";
|
||||
import {
|
||||
fileUploadStatusStore,
|
||||
isFileUploading,
|
||||
@@ -15,7 +16,6 @@
|
||||
} from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
import SubDirectory from "./SubDirectory.svelte";
|
||||
import { SortBy, sortEntries } from "./service";
|
||||
import UploadingFile from "./UploadingFile.svelte";
|
||||
import type { SelectedDirectoryEntry } from "../service";
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default } from "./DirectoryEntries.svelte";
|
||||
export * from "./service";
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export enum SortBy {
|
||||
NAME_ASC,
|
||||
NAME_DESC,
|
||||
}
|
||||
|
||||
type SortFunc = (a?: string, b?: string) => number;
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
const sortByNameAsc: SortFunc = (a, b) => {
|
||||
if (a && b) return collator.compare(a, b);
|
||||
if (a) return -1;
|
||||
if (b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
||||
|
||||
export const sortEntries = <T extends { name?: string }>(
|
||||
entries: T[],
|
||||
sortBy: SortBy = SortBy.NAME_ASC,
|
||||
) => {
|
||||
let sortFunc: SortFunc;
|
||||
if (sortBy === SortBy.NAME_ASC) {
|
||||
sortFunc = sortByNameAsc;
|
||||
} else {
|
||||
sortFunc = sortByNameDesc;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => sortFunc(a.name, b.name));
|
||||
};
|
||||
33
src/routes/api/category/[id]/+server.ts
Normal file
33
src/routes/api/category/[id]/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { categoryInfoResponse, type CategoryInfoResponse } from "$lib/server/schemas";
|
||||
import { getCategoryInformation } from "$lib/server/services/category";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.union([z.enum(["root"]), z.coerce.number().int().positive()]),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
const { metadata, categories } = await getCategoryInformation(userId, id);
|
||||
return json(
|
||||
categoryInfoResponse.parse({
|
||||
metadata: metadata && {
|
||||
parent: metadata.parentId,
|
||||
mekVersion: metadata.mekVersion,
|
||||
dek: metadata.encDek,
|
||||
dekVersion: metadata.dekVersion.toISOString(),
|
||||
name: metadata.encName.ciphertext,
|
||||
nameIv: metadata.encName.iv,
|
||||
},
|
||||
subCategories: categories,
|
||||
} satisfies CategoryInfoResponse),
|
||||
);
|
||||
};
|
||||
20
src/routes/api/category/[id]/delete/+server.ts
Normal file
20
src/routes/api/category/[id]/delete/+server.ts
Normal file
@@ -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" } });
|
||||
};
|
||||
25
src/routes/api/category/[id]/file/add/+server.ts
Normal file
25
src/routes/api/category/[id]/file/add/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { categoryFileAddRequest } from "$lib/server/schemas";
|
||||
import { addCategoryFile } 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 = categoryFileAddRequest.safeParse(await request.json());
|
||||
if (!bodyZodRes.success) error(400, "Invalid request body");
|
||||
const { file } = bodyZodRes.data;
|
||||
|
||||
await addCategoryFile(userId, id, file);
|
||||
return text("File added", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
36
src/routes/api/category/[id]/file/list/+server.ts
Normal file
36
src/routes/api/category/[id]/file/list/+server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { categoryFileListResponse, type CategoryFileListResponse } from "$lib/server/schemas";
|
||||
import { getCategoryFiles } from "$lib/server/services/category";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url, params }) => {
|
||||
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 queryZodRes = z
|
||||
.object({
|
||||
recurse: z
|
||||
.enum(["true", "false"])
|
||||
.transform((value) => value === "true")
|
||||
.nullable(),
|
||||
})
|
||||
.safeParse({ recurse: url.searchParams.get("recurse") });
|
||||
if (!queryZodRes.success) error(400, "Invalid query parameters");
|
||||
const { recurse } = queryZodRes.data;
|
||||
|
||||
const { files } = await getCategoryFiles(userId, id, recurse ?? false);
|
||||
return json(
|
||||
categoryFileListResponse.parse({
|
||||
files: files.map(({ id, isRecursive }) => ({ file: id, isRecursive })),
|
||||
}) satisfies CategoryFileListResponse,
|
||||
);
|
||||
};
|
||||
25
src/routes/api/category/[id]/file/remove/+server.ts
Normal file
25
src/routes/api/category/[id]/file/remove/+server.ts
Normal file
@@ -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" } });
|
||||
};
|
||||
25
src/routes/api/category/[id]/rename/+server.ts
Normal file
25
src/routes/api/category/[id]/rename/+server.ts
Normal file
@@ -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" } });
|
||||
};
|
||||
23
src/routes/api/category/create/+server.ts
Normal file
23
src/routes/api/category/create/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { categoryCreateRequest } from "$lib/server/schemas";
|
||||
import { createCategory } from "$lib/server/services/category";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = categoryCreateRequest.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
|
||||
|
||||
await createCategory({
|
||||
userId,
|
||||
parentId: parent,
|
||||
mekVersion,
|
||||
encDek: dek,
|
||||
dekVersion: new Date(dekVersion),
|
||||
encName: { ciphertext: name, iv: nameIv },
|
||||
});
|
||||
return text("Category created", { headers: { "Content-Type": "text/plain" } });
|
||||
};
|
||||
@@ -26,6 +26,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
encName,
|
||||
encCreatedAt,
|
||||
encLastModifiedAt,
|
||||
categories,
|
||||
} = await getFileInformation(userId, id);
|
||||
return json(
|
||||
fileInfoResponse.parse({
|
||||
@@ -41,6 +42,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
createdAtIv: encCreatedAt?.iv,
|
||||
lastModifiedAt: encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: encLastModifiedAt.iv,
|
||||
categories,
|
||||
} satisfies FileInfoResponse),
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user