mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-14 22:08:45 +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
|
pnpm-lock.yaml
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
# Output
|
|
||||||
/drizzle
|
|
||||||
|
|
||||||
# Documents
|
# Documents
|
||||||
*.md
|
*.md
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"dev:db": "docker compose -f docker-compose.dev.yaml -p arkvault-dev up -d",
|
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint .",
|
"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"
|
"db:migrate": "kysely migrate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -20,7 +21,6 @@
|
|||||||
"@sveltejs/adapter-node": "^5.2.11",
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
"@sveltejs/kit": "^2.15.2",
|
"@sveltejs/kit": "^2.15.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/ms": "^0.7.34",
|
"@types/ms": "^0.7.34",
|
||||||
"@types/node-schedule": "^2.1.7",
|
"@types/node-schedule": "^2.1.7",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.2",
|
"prettier-plugin-svelte": "^3.3.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
"svelte": "^5.17.1",
|
"svelte": "^5.19.1",
|
||||||
"svelte-check": "^4.1.3",
|
"svelte-check": "^4.1.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
92
pnpm-lock.yaml
generated
92
pnpm-lock.yaml
generated
@@ -41,16 +41,13 @@ importers:
|
|||||||
version: 1.2.12
|
version: 1.2.12
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.2.11
|
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':
|
'@sveltejs/kit':
|
||||||
specifier: ^2.15.2
|
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':
|
'@sveltejs/vite-plugin-svelte':
|
||||||
specifier: ^4.0.4
|
specifier: ^4.0.4
|
||||||
version: 4.0.4(svelte@5.17.1)(vite@5.4.11(@types/node@22.10.5))
|
version: 4.0.4(svelte@5.19.1)(vite@5.4.11(@types/node@22.10.5))
|
||||||
'@types/better-sqlite3':
|
|
||||||
specifier: ^7.6.12
|
|
||||||
version: 7.6.12
|
|
||||||
'@types/file-saver':
|
'@types/file-saver':
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7
|
version: 2.0.7
|
||||||
@@ -80,7 +77,7 @@ importers:
|
|||||||
version: 9.1.0(eslint@9.17.0(jiti@2.4.2))
|
version: 9.1.0(eslint@9.17.0(jiti@2.4.2))
|
||||||
eslint-plugin-svelte:
|
eslint-plugin-svelte:
|
||||||
specifier: ^2.46.1
|
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:
|
eslint-plugin-tailwindcss:
|
||||||
specifier: ^3.17.5
|
specifier: ^3.17.5
|
||||||
version: 3.17.5(tailwindcss@3.4.17)
|
version: 3.17.5(tailwindcss@3.4.17)
|
||||||
@@ -110,16 +107,16 @@ importers:
|
|||||||
version: 3.4.2
|
version: 3.4.2
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
specifier: ^3.3.2
|
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:
|
prettier-plugin-tailwindcss:
|
||||||
specifier: ^0.6.9
|
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:
|
svelte:
|
||||||
specifier: ^5.17.1
|
specifier: ^5.19.1
|
||||||
version: 5.17.1
|
version: 5.19.1
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.1.3
|
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:
|
tailwindcss:
|
||||||
specifier: ^3.4.17
|
specifier: ^3.4.17
|
||||||
version: 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)
|
version: 8.19.1(eslint@9.17.0(jiti@2.4.2))(typescript@5.7.3)
|
||||||
unplugin-icons:
|
unplugin-icons:
|
||||||
specifier: ^0.22.0
|
specifier: ^0.22.0
|
||||||
version: 0.22.0(svelte@5.17.1)
|
version: 0.22.0(svelte@5.19.1)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.11
|
specifier: ^5.4.11
|
||||||
version: 5.4.11(@types/node@22.10.5)
|
version: 5.4.11(@types/node@22.10.5)
|
||||||
@@ -717,9 +714,6 @@ packages:
|
|||||||
svelte: ^5.0.0-next.96 || ^5.0.0
|
svelte: ^5.0.0-next.96 || ^5.0.0
|
||||||
vite: ^5.0.0
|
vite: ^5.0.0
|
||||||
|
|
||||||
'@types/better-sqlite3@7.6.12':
|
|
||||||
resolution: {integrity: sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==}
|
|
||||||
|
|
||||||
'@types/cookie@0.6.0':
|
'@types/cookie@0.6.0':
|
||||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||||
|
|
||||||
@@ -1120,8 +1114,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
|
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
|
|
||||||
esrap@1.3.2:
|
esrap@1.4.3:
|
||||||
resolution: {integrity: sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==}
|
resolution: {integrity: sha512-Xddc1RsoFJ4z9nR7W7BFaEPIp4UXoeQ0+077UdWLxbafMQFyU79sQJMk7kxNgRwQ9/aVgaKacCHC2pUACGwmYw==}
|
||||||
|
|
||||||
esrecurse@4.3.0:
|
esrecurse@4.3.0:
|
||||||
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
|
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
|
||||||
@@ -1998,8 +1992,8 @@ packages:
|
|||||||
svelte:
|
svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
svelte@5.17.1:
|
svelte@5.19.1:
|
||||||
resolution: {integrity: sha512-HitqD0XhU9OEytPuux/XYzxle4+7D8+fIb1tHbwMzOtBzDZZO+ESEuwMbahJ/3JoklfmRPB/Gzp74L87Qrxfpw==}
|
resolution: {integrity: sha512-H/Vs2O51bwILZbaNUSdr4P1NbLpOGsxl4jJAjd88ELjzRgeRi1BHqexcVGannDr7D1pmTYWWajzHOM7bMbtB9Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tailwindcss@3.4.17:
|
tailwindcss@3.4.17:
|
||||||
@@ -2579,17 +2573,17 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.30.1':
|
'@rollup/rollup-win32-x64-msvc@4.30.1':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@rollup/plugin-commonjs': 28.0.2(rollup@4.30.1)
|
'@rollup/plugin-commonjs': 28.0.2(rollup@4.30.1)
|
||||||
'@rollup/plugin-json': 6.1.0(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)
|
'@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
|
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:
|
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
|
'@types/cookie': 0.6.0
|
||||||
cookie: 0.6.0
|
cookie: 0.6.0
|
||||||
devalue: 5.1.1
|
devalue: 5.1.1
|
||||||
@@ -2601,36 +2595,32 @@ snapshots:
|
|||||||
sade: 1.8.1
|
sade: 1.8.1
|
||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.1
|
||||||
sirv: 3.0.0
|
sirv: 3.0.0
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
tiny-glob: 0.2.9
|
tiny-glob: 0.2.9
|
||||||
vite: 5.4.11(@types/node@22.10.5)
|
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:
|
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
|
debug: 4.4.0
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
vite: 5.4.11(@types/node@22.10.5)
|
vite: 5.4.11(@types/node@22.10.5)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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
|
debug: 4.4.0
|
||||||
deepmerge: 4.3.1
|
deepmerge: 4.3.1
|
||||||
kleur: 4.1.5
|
kleur: 4.1.5
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
vite: 5.4.11(@types/node@22.10.5)
|
vite: 5.4.11(@types/node@22.10.5)
|
||||||
vitefu: 1.0.5(vite@5.4.11(@types/node@22.10.5))
|
vitefu: 1.0.5(vite@5.4.11(@types/node@22.10.5))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@types/better-sqlite3@7.6.12':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 22.10.5
|
|
||||||
|
|
||||||
'@types/cookie@0.6.0': {}
|
'@types/cookie@0.6.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.6': {}
|
'@types/estree@1.0.6': {}
|
||||||
@@ -3011,7 +3001,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.17.0(jiti@2.4.2)
|
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:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
|
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@2.4.2))
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
@@ -3024,9 +3014,9 @@ snapshots:
|
|||||||
postcss-safe-parser: 6.0.0(postcss@8.4.49)
|
postcss-safe-parser: 6.0.0(postcss@8.4.49)
|
||||||
postcss-selector-parser: 6.1.2
|
postcss-selector-parser: 6.1.2
|
||||||
semver: 7.6.3
|
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:
|
optionalDependencies:
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- ts-node
|
- ts-node
|
||||||
|
|
||||||
@@ -3109,7 +3099,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
|
|
||||||
esrap@1.3.2:
|
esrap@1.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
@@ -3693,16 +3683,16 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
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:
|
dependencies:
|
||||||
prettier: 3.4.2
|
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:
|
dependencies:
|
||||||
prettier: 3.4.2
|
prettier: 3.4.2
|
||||||
optionalDependencies:
|
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: {}
|
prettier@3.4.2: {}
|
||||||
|
|
||||||
@@ -3838,19 +3828,19 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
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:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
fdir: 6.4.2(picomatch@4.0.2)
|
fdir: 6.4.2(picomatch@4.0.2)
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
sade: 1.8.1
|
sade: 1.8.1
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
typescript: 5.7.3
|
typescript: 5.7.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- picomatch
|
- picomatch
|
||||||
|
|
||||||
svelte-eslint-parser@0.43.0(svelte@5.17.1):
|
svelte-eslint-parser@0.43.0(svelte@5.19.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
eslint-scope: 7.2.2
|
eslint-scope: 7.2.2
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
@@ -3858,9 +3848,9 @@ snapshots:
|
|||||||
postcss: 8.4.49
|
postcss: 8.4.49
|
||||||
postcss-scss: 4.0.9(postcss@8.4.49)
|
postcss-scss: 4.0.9(postcss@8.4.49)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
|
|
||||||
svelte@5.17.1:
|
svelte@5.19.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
@@ -3871,7 +3861,7 @@ snapshots:
|
|||||||
axobject-query: 4.1.0
|
axobject-query: 4.1.0
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
esm-env: 1.2.2
|
esm-env: 1.2.2
|
||||||
esrap: 1.3.2
|
esrap: 1.4.3
|
||||||
is-reference: 3.0.3
|
is-reference: 3.0.3
|
||||||
locate-character: 3.0.0
|
locate-character: 3.0.0
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
@@ -3967,7 +3957,7 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@6.20.0: {}
|
undici-types@6.20.0: {}
|
||||||
|
|
||||||
unplugin-icons@0.22.0(svelte@5.17.1):
|
unplugin-icons@0.22.0(svelte@5.19.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@antfu/install-pkg': 0.5.0
|
'@antfu/install-pkg': 0.5.0
|
||||||
'@antfu/utils': 0.7.10
|
'@antfu/utils': 0.7.10
|
||||||
@@ -3977,7 +3967,7 @@ snapshots:
|
|||||||
local-pkg: 0.5.1
|
local-pkg: 0.5.1
|
||||||
unplugin: 2.1.2
|
unplugin: 2.1.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
svelte: 5.17.1
|
svelte: 5.19.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<AdaptiveDiv>
|
<AdaptiveDiv>
|
||||||
<div
|
<div
|
||||||
onclick={(e) => e.stopPropagation()}
|
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 }}
|
transition:fly={{ y: 100, duration: 200 }}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -7,16 +7,20 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
onback?: () => void;
|
onback?: () => void;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
xPadding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children, onback, title }: Props = $props();
|
let { children, onback, title, xPadding = false }: Props = $props();
|
||||||
|
|
||||||
const back = $derived(() => {
|
const back = $derived(() => {
|
||||||
setTimeout(onback || (() => history.back()), 100);
|
setTimeout(onback || (() => history.back()), 100);
|
||||||
});
|
});
|
||||||
</script>
|
</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">
|
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
|
||||||
<IconArrowBack class="text-2xl" />
|
<IconArrowBack class="text-2xl" />
|
||||||
</button>
|
</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";
|
export { default as TextInput } from "./TextInput.svelte";
|
||||||
|
|||||||
@@ -15,16 +15,28 @@ interface FileInfo {
|
|||||||
contentType: string;
|
contentType: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
lastModifiedAt: 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 & {
|
const filesystem = new Dexie("filesystem") as Dexie & {
|
||||||
directory: EntityTable<DirectoryInfo, "id">;
|
directory: EntityTable<DirectoryInfo, "id">;
|
||||||
file: EntityTable<FileInfo, "id">;
|
file: EntityTable<FileInfo, "id">;
|
||||||
|
category: EntityTable<CategoryInfo, "id">;
|
||||||
};
|
};
|
||||||
|
|
||||||
filesystem.version(1).stores({
|
filesystem.version(2).stores({
|
||||||
directory: "id, parentId",
|
directory: "id, parentId",
|
||||||
file: "id, parentId",
|
file: "id, parentId",
|
||||||
|
category: "id, parentId",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
||||||
@@ -59,13 +71,29 @@ export const deleteFileInfo = async (id: number) => {
|
|||||||
await filesystem.file.delete(id);
|
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 () => {
|
export const cleanupDanglingInfos = async () => {
|
||||||
const validDirectoryIds: number[] = [];
|
const validDirectoryIds: number[] = [];
|
||||||
const validFileIds: number[] = [];
|
const validFileIds: number[] = [];
|
||||||
const queue: DirectoryId[] = ["root"];
|
const directoryQueue: DirectoryId[] = ["root"];
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const directoryId = queue.shift();
|
const directoryId = directoryQueue.shift();
|
||||||
if (!directoryId) break;
|
if (!directoryId) break;
|
||||||
|
|
||||||
const [subDirectories, files] = await Promise.all([
|
const [subDirectories, files] = await Promise.all([
|
||||||
@@ -74,13 +102,28 @@ export const cleanupDanglingInfos = async () => {
|
|||||||
]);
|
]);
|
||||||
subDirectories.forEach(({ id }) => {
|
subDirectories.forEach(({ id }) => {
|
||||||
validDirectoryIds.push(id);
|
validDirectoryIds.push(id);
|
||||||
queue.push(id);
|
directoryQueue.push(id);
|
||||||
});
|
});
|
||||||
files.forEach(({ id }) => validFileIds.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([
|
await Promise.all([
|
||||||
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
|
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
|
||||||
filesystem.file.where("id").noneOf(validFileIds).delete(),
|
filesystem.file.where("id").noneOf(validFileIds).delete(),
|
||||||
|
filesystem.category.where("id").noneOf(validCategoryIds).delete(),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,20 @@ import {
|
|||||||
getFileInfo as getFileInfoFromIndexedDB,
|
getFileInfo as getFileInfoFromIndexedDB,
|
||||||
storeFileInfo,
|
storeFileInfo,
|
||||||
deleteFileInfo,
|
deleteFileInfo,
|
||||||
|
getCategoryInfos as getCategoryInfosFromIndexedDB,
|
||||||
|
getCategoryInfo as getCategoryInfoFromIndexedDB,
|
||||||
|
storeCategoryInfo,
|
||||||
|
deleteCategoryInfo,
|
||||||
type DirectoryId,
|
type DirectoryId,
|
||||||
|
type CategoryId,
|
||||||
} from "$lib/indexedDB";
|
} from "$lib/indexedDB";
|
||||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
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 =
|
export type DirectoryInfo =
|
||||||
| {
|
| {
|
||||||
@@ -41,10 +51,30 @@ export interface FileInfo {
|
|||||||
name: string;
|
name: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
lastModifiedAt: 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 directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
||||||
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
||||||
|
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
|
||||||
|
|
||||||
const fetchDirectoryInfoFromIndexedDB = async (
|
const fetchDirectoryInfoFromIndexedDB = async (
|
||||||
id: DirectoryId,
|
id: DirectoryId,
|
||||||
@@ -124,7 +154,7 @@ export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
|
|||||||
directoryInfoStore.set(id, info);
|
directoryInfoStore.set(id, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchDirectoryInfo(id, info, masterKey);
|
fetchDirectoryInfo(id, info, masterKey); // Intended
|
||||||
return info;
|
return info;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,6 +208,7 @@ const fetchFileInfoFromServer = async (
|
|||||||
name,
|
name,
|
||||||
createdAt,
|
createdAt,
|
||||||
lastModifiedAt,
|
lastModifiedAt,
|
||||||
|
categoryIds: metadata.categories,
|
||||||
});
|
});
|
||||||
await storeFileInfo({
|
await storeFileInfo({
|
||||||
id,
|
id,
|
||||||
@@ -186,6 +217,7 @@ const fetchFileInfoFromServer = async (
|
|||||||
contentType: metadata.contentType,
|
contentType: metadata.contentType,
|
||||||
createdAt,
|
createdAt,
|
||||||
lastModifiedAt,
|
lastModifiedAt,
|
||||||
|
categoryIds: metadata.categories,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,6 +235,95 @@ export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
|||||||
fileInfoStore.set(fileId, info);
|
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;
|
return info;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,3 +27,32 @@ export const formatNetworkSpeed = (speed: number) => {
|
|||||||
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
|
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
|
||||||
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
|
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 =
|
type IntegrityErrorMessages =
|
||||||
|
// Category
|
||||||
|
| "Category not found"
|
||||||
// Challenge
|
// Challenge
|
||||||
| "Challenge already registered"
|
| "Challenge already registered"
|
||||||
// Client
|
// Client
|
||||||
@@ -7,6 +9,8 @@ type IntegrityErrorMessages =
|
|||||||
// File
|
// File
|
||||||
| "Directory not found"
|
| "Directory not found"
|
||||||
| "File not found"
|
| "File not found"
|
||||||
|
| "File not found in category"
|
||||||
|
| "File already added to category"
|
||||||
| "Invalid DEK version"
|
| "Invalid DEK version"
|
||||||
// HSK
|
// HSK
|
||||||
| "HSK already registered"
|
| "HSK already registered"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { sql, type NotNull } from "kysely";
|
||||||
|
import pg from "pg";
|
||||||
import { IntegrityError } from "./error";
|
import { IntegrityError } from "./error";
|
||||||
import db from "./kysely";
|
import db from "./kysely";
|
||||||
import type { Ciphertext } from "./schema";
|
import type { Ciphertext } from "./schema";
|
||||||
|
|
||||||
type DirectoryId = "root" | number;
|
export type DirectoryId = "root" | number;
|
||||||
|
|
||||||
interface Directory {
|
interface Directory {
|
||||||
id: number;
|
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 (
|
export const getAllFileIdsByContentHmac = async (
|
||||||
userId: number,
|
userId: number,
|
||||||
hskVersion: number,
|
hskVersion: number,
|
||||||
@@ -384,3 +426,60 @@ export const unregisterFile = async (userId: number, fileId: number) => {
|
|||||||
}
|
}
|
||||||
return { path: file.path };
|
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 Initial1737357000 from "./1737357000-Initial";
|
||||||
|
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
"1737357000-Initial": Initial1737357000,
|
"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";
|
import type { ColumnType, Generated } from "kysely";
|
||||||
|
import type { Ciphertext } from "./util";
|
||||||
export type Ciphertext = {
|
|
||||||
ciphertext: string; // Base64
|
|
||||||
iv: string; // Base64
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DirectoryTable {
|
interface DirectoryTable {
|
||||||
id: Generated<number>;
|
id: Generated<number>;
|
||||||
@@ -45,8 +41,14 @@ interface FileLogTable {
|
|||||||
id: Generated<number>;
|
id: Generated<number>;
|
||||||
file_id: number;
|
file_id: number;
|
||||||
timestamp: ColumnType<Date, Date, never>;
|
timestamp: ColumnType<Date, Date, never>;
|
||||||
action: "create" | "rename";
|
action: "create" | "rename" | "add-to-category" | "remove-from-category";
|
||||||
new_name: Ciphertext | null;
|
new_name: Ciphertext | null;
|
||||||
|
category_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCategoryTable {
|
||||||
|
file_id: number;
|
||||||
|
category_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "./index" {
|
declare module "./index" {
|
||||||
@@ -55,5 +57,6 @@ declare module "./index" {
|
|||||||
directory_log: DirectoryLogTable;
|
directory_log: DirectoryLogTable;
|
||||||
file: FileTable;
|
file: FileTable;
|
||||||
file_log: FileLogTable;
|
file_log: FileLogTable;
|
||||||
|
file_category: FileCategoryTable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
export * from "./category";
|
||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
export * from "./hsk";
|
export * from "./hsk";
|
||||||
export * from "./mek";
|
export * from "./mek";
|
||||||
export * from "./session";
|
export * from "./session";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
export * from "./util";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
export interface Database {}
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const directoryIdSchema = z.union([z.enum(["root"]), z.number().int().positive()]);
|
||||||
|
|
||||||
export const directoryInfoResponse = z.object({
|
export const directoryInfoResponse = z.object({
|
||||||
metadata: z
|
metadata: z
|
||||||
.object({
|
.object({
|
||||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
parent: directoryIdSchema,
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
@@ -29,7 +31,7 @@ export const directoryRenameRequest = z.object({
|
|||||||
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
||||||
|
|
||||||
export const directoryCreateRequest = z.object({
|
export const directoryCreateRequest = z.object({
|
||||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
parent: directoryIdSchema,
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { directoryIdSchema } from "./directory";
|
||||||
|
|
||||||
export const fileInfoResponse = z.object({
|
export const fileInfoResponse = z.object({
|
||||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
parent: directoryIdSchema,
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
@@ -17,6 +18,7 @@ export const fileInfoResponse = z.object({
|
|||||||
createdAtIv: z.string().base64().nonempty().optional(),
|
createdAtIv: z.string().base64().nonempty().optional(),
|
||||||
lastModifiedAt: z.string().base64().nonempty(),
|
lastModifiedAt: z.string().base64().nonempty(),
|
||||||
lastModifiedAtIv: z.string().base64().nonempty(),
|
lastModifiedAtIv: z.string().base64().nonempty(),
|
||||||
|
categories: z.number().int().positive().array(),
|
||||||
});
|
});
|
||||||
export type FileInfoResponse = z.infer<typeof fileInfoResponse>;
|
export type FileInfoResponse = z.infer<typeof fileInfoResponse>;
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export const duplicateFileScanResponse = z.object({
|
|||||||
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
||||||
|
|
||||||
export const fileUploadRequest = z.object({
|
export const fileUploadRequest = z.object({
|
||||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
parent: directoryIdSchema,
|
||||||
mekVersion: z.number().int().positive(),
|
mekVersion: z.number().int().positive(),
|
||||||
dek: z.string().base64().nonempty(),
|
dek: z.string().base64().nonempty(),
|
||||||
dekVersion: z.string().datetime(),
|
dekVersion: z.string().datetime(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./auth";
|
export * from "./auth";
|
||||||
|
export * from "./category";
|
||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * from "./directory";
|
export * from "./directory";
|
||||||
export * from "./file";
|
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,
|
setDirectoryEncName,
|
||||||
unregisterDirectory,
|
unregisterDirectory,
|
||||||
getAllFilesByParent,
|
getAllFilesByParent,
|
||||||
|
type DirectoryId,
|
||||||
type NewDirectory,
|
type NewDirectory,
|
||||||
} from "$lib/server/db/file";
|
} from "$lib/server/db/file";
|
||||||
import type { Ciphertext } from "$lib/server/db/schema";
|
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;
|
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
|
||||||
if (directory === null) {
|
if (directory === null) {
|
||||||
error(404, "Invalid directory id");
|
error(404, "Invalid directory id");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getFile,
|
getFile,
|
||||||
setFileEncName,
|
setFileEncName,
|
||||||
unregisterFile,
|
unregisterFile,
|
||||||
|
getAllFileCategories,
|
||||||
type NewFile,
|
type NewFile,
|
||||||
} from "$lib/server/db/file";
|
} from "$lib/server/db/file";
|
||||||
import type { Ciphertext } from "$lib/server/db/schema";
|
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");
|
error(404, "Invalid file id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categories = await getAllFileCategories(fileId);
|
||||||
return {
|
return {
|
||||||
parentId: file.parentId ?? ("root" as const),
|
parentId: file.parentId ?? ("root" as const),
|
||||||
mekVersion: file.mekVersion,
|
mekVersion: file.mekVersion,
|
||||||
@@ -34,6 +36,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
|
|||||||
encName: file.encName,
|
encName: file.encName,
|
||||||
encCreatedAt: file.encCreatedAt,
|
encCreatedAt: file.encCreatedAt,
|
||||||
encLastModifiedAt: file.encLastModifiedAt,
|
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 FileSaver from "file-saver";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { get, type Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
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 { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
||||||
|
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
|
||||||
import DownloadStatus from "./DownloadStatus.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 { data } = $props();
|
||||||
|
|
||||||
let info: Writable<FileInfo | null> | undefined = $state();
|
let info: Writable<FileInfo | null> | undefined = $state();
|
||||||
|
let categories: Writable<CategoryInfo | null>[] = $state([]);
|
||||||
|
|
||||||
|
let isAddToCategoryBottomSheetOpen = $state(false);
|
||||||
|
|
||||||
const downloadStatus = $derived(
|
const downloadStatus = $derived(
|
||||||
$fileDownloadStatusStore.find((statusStore) => {
|
$fileDownloadStatusStore.find((statusStore) => {
|
||||||
@@ -23,14 +42,7 @@
|
|||||||
let viewerType: "image" | "video" | undefined = $state();
|
let viewerType: "image" | "video" | undefined = $state();
|
||||||
let fileBlobUrl: string | undefined = $state();
|
let fileBlobUrl: string | undefined = $state();
|
||||||
|
|
||||||
const updateViewer = async (info: FileInfo, buffer: ArrayBuffer) => {
|
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
|
||||||
const contentType = info.contentType;
|
|
||||||
if (contentType.startsWith("image")) {
|
|
||||||
viewerType = "image";
|
|
||||||
} else if (contentType.startsWith("video")) {
|
|
||||||
viewerType = "video";
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileBlob = new Blob([buffer], { type: contentType });
|
const fileBlob = new Blob([buffer], { type: contentType });
|
||||||
if (contentType === "image/heic") {
|
if (contentType === "image/heic") {
|
||||||
const { default: heic2any } = await import("heic2any");
|
const { default: heic2any } = await import("heic2any");
|
||||||
@@ -44,19 +56,42 @@
|
|||||||
return fileBlob;
|
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(() => {
|
$effect(() => {
|
||||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||||
isDownloadRequested = false;
|
isDownloadRequested = false;
|
||||||
viewerType = undefined;
|
viewerType = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
categories =
|
||||||
|
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($info && $info.dataKey && $info.contentIv) {
|
if ($info && $info.dataKey && $info.contentIv) {
|
||||||
|
const contentType = $info.contentType;
|
||||||
|
if (contentType.startsWith("image")) {
|
||||||
|
viewerType = "image";
|
||||||
|
} else if (contentType.startsWith("video")) {
|
||||||
|
viewerType = "video";
|
||||||
|
}
|
||||||
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (!downloadStatus && !isDownloadRequested) {
|
if (!downloadStatus && !isDownloadRequested) {
|
||||||
isDownloadRequested = true;
|
isDownloadRequested = true;
|
||||||
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
|
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
|
||||||
const blob = await updateViewer($info, buffer);
|
const blob = await updateViewer(buffer, contentType);
|
||||||
if (!viewerType) {
|
if (!viewerType) {
|
||||||
FileSaver.saveAs(blob, $info.name);
|
FileSaver.saveAs(blob, $info.name);
|
||||||
}
|
}
|
||||||
@@ -68,7 +103,9 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($info && $downloadStatus?.status === "decrypted") {
|
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">
|
<div class="flex h-full flex-col">
|
||||||
<TopBar title={$info?.name} />
|
<TopBar title={$info?.name} />
|
||||||
<DownloadStatus status={downloadStatus} />
|
<div class="space-y-4 pb-4">
|
||||||
<div class="flex w-full flex-grow flex-col items-center pb-4">
|
<DownloadStatus status={downloadStatus} />
|
||||||
{#snippet viewerLoading(message: string)}
|
{#if $info && viewerType}
|
||||||
<div class="flex flex-grow items-center justify-center">
|
<div class="flex w-full justify-center">
|
||||||
<p class="text-gray-500">{message}</p>
|
{#snippet viewerLoading(message: string)}
|
||||||
</div>
|
<p class="text-gray-500">{message}</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if $info && viewerType === "image"}
|
{#if viewerType === "image"}
|
||||||
{#if fileBlobUrl}
|
{#if fileBlobUrl}
|
||||||
<img src={fileBlobUrl} alt={$info.name} />
|
<img src={fileBlobUrl} alt={$info.name} />
|
||||||
{:else}
|
{:else}
|
||||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if viewerType === "video"}
|
{:else if viewerType === "video"}
|
||||||
{#if fileBlobUrl}
|
{#if fileBlobUrl}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
<video src={fileBlobUrl} controls></video>
|
<video src={fileBlobUrl} controls></video>
|
||||||
{:else}
|
{:else}
|
||||||
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
</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 { 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 (
|
export const requestFileDownload = async (
|
||||||
fileId: number,
|
fileId: number,
|
||||||
@@ -12,3 +16,10 @@ export const requestFileDownload = async (
|
|||||||
storeFileCache(fileId, fileBuffer); // Intended
|
storeFileCache(fileId, fileBuffer); // Intended
|
||||||
return fileBuffer;
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button color="gray" onclick={closeModal}>아니요</Button>
|
||||||
color="gray"
|
|
||||||
onclick={() => {
|
|
||||||
isOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
아니요
|
|
||||||
</Button>
|
|
||||||
<Button onclick={deleteEntry}>삭제할게요</Button>
|
<Button onclick={deleteEntry}>삭제할게요</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
type DirectoryInfo,
|
type DirectoryInfo,
|
||||||
type FileInfo,
|
type FileInfo,
|
||||||
} from "$lib/modules/filesystem";
|
} from "$lib/modules/filesystem";
|
||||||
|
import { SortBy, sortEntries } from "$lib/modules/util";
|
||||||
import {
|
import {
|
||||||
fileUploadStatusStore,
|
fileUploadStatusStore,
|
||||||
isFileUploading,
|
isFileUploading,
|
||||||
@@ -15,7 +16,6 @@
|
|||||||
} from "$lib/stores";
|
} from "$lib/stores";
|
||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
import SubDirectory from "./SubDirectory.svelte";
|
import SubDirectory from "./SubDirectory.svelte";
|
||||||
import { SortBy, sortEntries } from "./service";
|
|
||||||
import UploadingFile from "./UploadingFile.svelte";
|
import UploadingFile from "./UploadingFile.svelte";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedDirectoryEntry } from "../service";
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export { default } from "./DirectoryEntries.svelte";
|
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,
|
encName,
|
||||||
encCreatedAt,
|
encCreatedAt,
|
||||||
encLastModifiedAt,
|
encLastModifiedAt,
|
||||||
|
categories,
|
||||||
} = await getFileInformation(userId, id);
|
} = await getFileInformation(userId, id);
|
||||||
return json(
|
return json(
|
||||||
fileInfoResponse.parse({
|
fileInfoResponse.parse({
|
||||||
@@ -41,6 +42,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
|||||||
createdAtIv: encCreatedAt?.iv,
|
createdAtIv: encCreatedAt?.iv,
|
||||||
lastModifiedAt: encLastModifiedAt.ciphertext,
|
lastModifiedAt: encLastModifiedAt.ciphertext,
|
||||||
lastModifiedAtIv: encLastModifiedAt.iv,
|
lastModifiedAtIv: encLastModifiedAt.iv,
|
||||||
|
categories,
|
||||||
} satisfies FileInfoResponse),
|
} satisfies FileInfoResponse),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user