48 Commits

Author SHA1 Message Date
static
3906ec4371 Merge pull request #17 from kmc7468/dev
v0.7.0
2026-01-06 07:50:16 +09:00
static
5d130204a6 사소한 버그 수정 2026-01-06 07:46:07 +09:00
static
4997b1f38c 불필요하게 분리된 컴포넌트 삭제 2026-01-06 07:17:58 +09:00
static
1d3704bfad 디렉터리 및 카테고리 페이지에서 탐색시의 깜빡임 현상 완화 2026-01-06 06:48:35 +09:00
static
ae1d34fc6b 파일, 카테고리, 디렉터리 정보를 불러올 때 특정 조건에서 네트워크 요청이 여러 번 발생할 수 있는 버그 수정 2026-01-05 06:49:12 +09:00
static
f10a0a2da3 썸네일 로딩 로직 최적화 2026-01-04 20:01:30 +09:00
static
0eb1d29259 Scheduler 클래스의 스케쥴링 로직 개선 2026-01-04 17:54:42 +09:00
static
cf0f8fe0b9 누락된 @eslint/js 패키지 추가 2026-01-04 01:50:02 +09:00
static
30c56e0926 삭제된 파일, 카테고리, 디렉터리에 대한 정보가 IndexedDB에서 삭제되지 않는 버그 수정 2026-01-03 00:54:32 +09:00
static
83d595636b 동시에 업로드할 수 있는 파일의 메모리 용량을 제한하여 메모리 부족으로 인해 발생하던 크래시 해결 2026-01-02 23:00:25 +09:00
static
008c8ad6ba 디렉터리 페이지 하단에 여백이 생기지 않는 버그 수정 2026-01-02 18:24:09 +09:00
static
5729af380d 모바일 환경에서 갤러리 페이지에서의 스크롤이 부자연스럽게 이뤄지는 버그 수정 2026-01-02 17:04:08 +09:00
static
c0e71993e9 Merge pull request #16 from kmc7468/optimize-networking
네트워크 호출 최적화
2026-01-02 15:04:45 +09:00
static
280d46b48d 사소한 리팩토링 2 2026-01-02 14:55:26 +09:00
static
d1f9018213 사소한 리팩토링 2026-01-02 00:31:58 +09:00
static
2e3cd4f8a2 네트워크 호출 결과가 IndexedDB에 캐시되지 않던 버그 수정 2026-01-01 23:52:47 +09:00
static
d98be331ad 홈, 갤러리, 캐시 설정, 썸네일 설정 페이지에서의 네트워크 호출 최적화 2026-01-01 23:31:01 +09:00
static
841c57e8fc 삭제된 파일의 캐시가 존재하는 경우 캐시 페이지의 로딩이 끝나지 않는 버그 수정 2026-01-01 21:41:53 +09:00
static
182ec18a2b 사소한 리팩토링 2025-12-31 02:43:07 +09:00
static
7b666cf692 파일이 다운로드/업로드된 직후에 다운로드/업로드 페이지의 목록에서 바로 사라지던 버그 수정 2025-12-31 01:32:54 +09:00
static
26323c2d4d 프론트엔드 파일시스템 모듈 리팩토링 2025-12-31 00:43:12 +09:00
static
e4413ddbf6 파일 페이지에서의 네트워크 호출 최적화 2025-12-30 23:30:50 +09:00
static
b5522a4c6d 카테고리 페이지에서의 네트워크 호출 최적화 2025-12-30 20:53:20 +09:00
static
1e57941f4c 디렉터리 페이지에서 하위 디렉터리도 가상 리스트로 표시하도록 개선 2025-12-30 18:44:46 +09:00
static
409ae09f4f 디렉터리 페이지에서의 네트워크 호출 최적화 2025-12-30 17:21:54 +09:00
static
cdb652cacf 사진 또는 동영상이 없을 때 홈 페이지의 레이아웃이 깨지는 버그 수정 2025-12-29 19:43:25 +09:00
static
15b6a53710 사소한 리팩토링 2 2025-12-29 18:14:42 +09:00
static
174305ca1b 파일 페이지와 카테고리 페이지에서 파일 목록을 표시할 때도 가상 리스트를 사용하여 효율적으로 랜더링하도록 개선 2025-12-27 23:27:57 +09:00
static
90ac5ba4c3 Merge pull request #15 from kmc7468/dev
v0.6.0
2025-12-27 14:22:26 +09:00
static
0d13d3baef 사소한 리팩토링 2025-12-27 14:10:33 +09:00
static
576d41da7f 디렉터리 페이지에 상위 디렉터리로 이동 버튼 추가 2025-12-27 03:04:09 +09:00
static
9eb67d5877 파일 페이지에 다운로드 및 폴더로 이동 메뉴 추가 2025-12-27 02:37:56 +09:00
static
a9da8435cb tRPC 클라이언트에 최대 URL 길이 설정 2025-12-26 23:54:49 +09:00
static
3e98e3d591 갤러리 페이지에서 파일이 표시되지 않던 버그 수정 2025-12-26 23:29:29 +09:00
static
27a46bcc2e eslint.config.js 파일 업데이트 2025-12-26 23:12:37 +09:00
static
a1f30ee154 홈 페이지와 갤러리 페이지에서 사진 및 동영상만 표시되도록 개선 2025-12-26 22:58:09 +09:00
static
6d02178c69 홈 페이지 구현 2025-12-26 22:47:31 +09:00
static
ed21a9cd31 갤러리 페이지 구현 2025-12-26 22:29:44 +09:00
static
b7a7536461 Merge pull request #14 from kmc7468/migrate-to-trpc
tRPC 도입
2025-12-26 15:58:24 +09:00
static
3eb7411438 사소한 리팩토링 3 2025-12-26 15:57:05 +09:00
static
c9d4b10356 사소한 리팩토링 2 2025-12-26 15:45:03 +09:00
static
d94d14cf83 사소한 리팩토링 2025-12-26 15:07:59 +09:00
static
dfffa004ac Merge pull request #13 from kmc7468/dev
v0.5.1
2025-07-12 19:56:12 +09:00
static
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
105 changed files with 2666 additions and 2085 deletions

View File

@@ -1,21 +1,24 @@
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat"; import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import prettier from "eslint-config-prettier";
import svelte from "eslint-plugin-svelte"; import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss"; import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals"; import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint"; import ts from "typescript-eslint";
import { fileURLToPath } from "url";
import svelteConfig from "./svelte.config.js";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs["flat/recommended"], ...svelte.configs.recommended,
...tailwind.configs["flat/recommended"], ...tailwind.configs["flat/recommended"],
prettier, prettier,
...svelte.configs["flat/prettier"], ...svelte.configs.prettier,
{ {
languageOptions: { languageOptions: {
globals: { globals: {
@@ -23,13 +26,18 @@ export default ts.config(
...globals.node, ...globals.node,
}, },
}, },
rules: {
"no-undef": "off",
},
}, },
{ {
files: ["**/*.svelte"], files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: true,
extraFileExtensions: [".svelte"],
parser: ts.parser, parser: ts.parser,
svelteConfig,
}, },
}, },
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.5.1", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -16,11 +16,13 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.1", "@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.2",
"@iconify-json/material-symbols": "^1.2.50", "@iconify-json/material-symbols": "^1.2.50",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2", "@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/svelte-virtual": "^3.13.16",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
@@ -48,7 +50,7 @@
"svelte-check": "^4.3.5", "svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.50.1", "typescript-eslint": "^8.51.0",
"unplugin-icons": "^22.5.0", "unplugin-icons": "^22.5.0",
"vite": "^7.3.0" "vite": "^7.3.0"
}, },
@@ -62,7 +64,7 @@
"pg": "^8.16.3", "pg": "^8.16.3",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.2.1" "zod": "^4.3.5"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",

223
pnpm-lock.yaml generated
View File

@@ -36,12 +36,15 @@ importers:
specifier: ^13.0.0 specifier: ^13.0.0
version: 13.0.0 version: 13.0.0
zod: zod:
specifier: ^4.2.1 specifier: ^4.3.5
version: 4.2.1 version: 4.3.5
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.4.1 specifier: ^2.0.0
version: 1.4.1(eslint@9.39.2(jiti@1.21.7)) version: 2.0.0(eslint@9.39.2(jiti@1.21.7))
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
'@iconify-json/material-symbols': '@iconify-json/material-symbols':
specifier: ^1.2.50 specifier: ^1.2.50
version: 1.2.50 version: 1.2.50
@@ -54,6 +57,9 @@ importers:
'@sveltejs/vite-plugin-svelte': '@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0)) version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0))
'@tanstack/svelte-virtual':
specifier: ^3.13.16
version: 3.13.16(svelte@5.46.1)
'@trpc/client': '@trpc/client':
specifier: ^11.8.1 specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3) version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)
@@ -136,8 +142,8 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
typescript-eslint: typescript-eslint:
specifier: ^8.50.1 specifier: ^8.51.0
version: 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
unplugin-icons: unplugin-icons:
specifier: ^22.5.0 specifier: ^22.5.0
version: 22.5.0(svelte@5.46.1) version: 22.5.0(svelte@5.46.1)
@@ -313,8 +319,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@eslint-community/eslint-utils@4.9.0': '@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies: peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
@@ -323,9 +329,9 @@ packages:
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/compat@1.4.1': '@eslint/compat@2.0.0':
resolution: {integrity: sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==} resolution: {integrity: sha512-T9AfE1G1uv4wwq94ozgTGio5EUQBqAVe1X9qsQtSNVEYW6j3hvtZVm8Smr4qL1qDPFg+lOB2cL5RxTRMzq4CTA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^20.19.0 || ^22.13.0 || >=24}
peerDependencies: peerDependencies:
eslint: ^8.40 || 9 eslint: ^8.40 || 9
peerDependenciesMeta: peerDependenciesMeta:
@@ -344,6 +350,10 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@1.0.0':
resolution: {integrity: sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/eslintrc@3.3.3': '@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -610,6 +620,14 @@ packages:
svelte: ^5.0.0 svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0 vite: ^6.3.0 || ^7.0.0
'@tanstack/svelte-virtual@3.13.16':
resolution: {integrity: sha512-LRDPRzAPTIiDjiCA9lhNlFnZRLj/XsNhzNRsT5JEA8hzcBmZw8avdYYVjydPAy0ObFJgG1zBAm9Dtvwqju36sg==}
peerDependencies:
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
'@tanstack/virtual-core@3.13.16':
resolution: {integrity: sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w==}
'@trpc/client@11.8.1': '@trpc/client@11.8.1':
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==} resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
peerDependencies: peerDependencies:
@@ -648,63 +666,63 @@ packages:
'@types/resolve@1.20.2': '@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@typescript-eslint/eslint-plugin@8.50.1': '@typescript-eslint/eslint-plugin@8.51.0':
resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.50.1 '@typescript-eslint/parser': ^8.51.0
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.50.1': '@typescript-eslint/parser@8.51.0':
resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.50.1': '@typescript-eslint/project-service@8.51.0':
resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.50.1': '@typescript-eslint/scope-manager@8.51.0':
resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.50.1': '@typescript-eslint/tsconfig-utils@8.51.0':
resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.50.1': '@typescript-eslint/type-utils@8.51.0':
resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.50.1': '@typescript-eslint/types@8.51.0':
resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.50.1': '@typescript-eslint/typescript-estree@8.51.0':
resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.50.1': '@typescript-eslint/utils@8.51.0':
resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.50.1': '@typescript-eslint/visitor-keys@8.51.0':
resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@xmldom/xmldom@0.9.8': '@xmldom/xmldom@0.9.8':
@@ -812,8 +830,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
caniuse-lite@1.0.30001761: caniuse-lite@1.0.30001762:
resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
@@ -1024,8 +1042,8 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
esquery@1.6.0: esquery@1.7.0:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'} engines: {node: '>=0.10'}
esrap@2.2.1: esrap@2.2.1:
@@ -1839,8 +1857,8 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
ts-api-utils@2.1.0: ts-api-utils@2.4.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
@@ -1862,8 +1880,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.50.1: typescript-eslint@8.51.0:
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} resolution: {integrity: sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
@@ -2007,8 +2025,8 @@ packages:
zimmerframe@1.1.4: zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
zod@4.2.1: zod@4.3.5:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
snapshots: snapshots:
@@ -2099,16 +2117,16 @@ snapshots:
'@esbuild/win32-x64@0.27.2': '@esbuild/win32-x64@0.27.2':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))': '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))':
dependencies: dependencies:
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
'@eslint/compat@1.4.1(eslint@9.39.2(jiti@1.21.7))': '@eslint/compat@2.0.0(eslint@9.39.2(jiti@1.21.7))':
dependencies: dependencies:
'@eslint/core': 0.17.0 '@eslint/core': 1.0.0
optionalDependencies: optionalDependencies:
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
@@ -2128,6 +2146,10 @@ snapshots:
dependencies: dependencies:
'@types/json-schema': 7.0.15 '@types/json-schema': 7.0.15
'@eslint/core@1.0.0':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.3': '@eslint/eslintrc@3.3.3':
dependencies: dependencies:
ajv: 6.12.6 ajv: 6.12.6
@@ -2367,6 +2389,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@tanstack/svelte-virtual@3.13.16(svelte@5.46.1)':
dependencies:
'@tanstack/virtual-core': 3.13.16
svelte: 5.46.1
'@tanstack/virtual-core@3.13.16': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)': '@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)':
dependencies: dependencies:
'@trpc/server': 11.8.1(typescript@5.9.3) '@trpc/server': 11.8.1(typescript@5.9.3)
@@ -2402,95 +2431,95 @@ snapshots:
'@types/resolve@1.20.2': {} '@types/resolve@1.20.2': {}
'@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.50.1 '@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.50.1 '@typescript-eslint/visitor-keys': 8.51.0
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 2.1.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.50.1 '@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.50.1 '@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': '@typescript-eslint/project-service@8.51.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
debug: 4.4.3 debug: 4.4.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.50.1': '@typescript-eslint/scope-manager@8.51.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
'@typescript-eslint/visitor-keys': 8.50.1 '@typescript-eslint/visitor-keys': 8.51.0
'@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
ts-api-utils: 2.1.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/types@8.50.1': {} '@typescript-eslint/types@8.51.0': {}
'@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3)
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
'@typescript-eslint/visitor-keys': 8.50.1 '@typescript-eslint/visitor-keys': 8.51.0
debug: 4.4.3 debug: 4.4.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.7.3 semver: 7.7.3
tinyglobby: 0.2.15 tinyglobby: 0.2.15
ts-api-utils: 2.1.0(typescript@5.9.3) ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.50.1 '@typescript-eslint/scope-manager': 8.51.0
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/visitor-keys@8.50.1': '@typescript-eslint/visitor-keys@8.51.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.50.1 '@typescript-eslint/types': 8.51.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
'@xmldom/xmldom@0.9.8': '@xmldom/xmldom@0.9.8':
@@ -2538,7 +2567,7 @@ snapshots:
autoprefixer@10.4.23(postcss@8.5.6): autoprefixer@10.4.23(postcss@8.5.6):
dependencies: dependencies:
browserslist: 4.28.1 browserslist: 4.28.1
caniuse-lite: 1.0.30001761 caniuse-lite: 1.0.30001762
fraction.js: 5.3.4 fraction.js: 5.3.4
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.6 postcss: 8.5.6
@@ -2576,7 +2605,7 @@ snapshots:
browserslist@4.28.1: browserslist@4.28.1:
dependencies: dependencies:
baseline-browser-mapping: 2.9.11 baseline-browser-mapping: 2.9.11
caniuse-lite: 1.0.30001761 caniuse-lite: 1.0.30001762
electron-to-chromium: 1.5.267 electron-to-chromium: 1.5.267
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -2605,7 +2634,7 @@ snapshots:
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001761: {} caniuse-lite@1.0.30001762: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
@@ -2769,7 +2798,7 @@ snapshots:
eslint-plugin-svelte@3.13.1(eslint@9.39.2(jiti@1.21.7))(svelte@5.46.1): eslint-plugin-svelte@3.13.1(eslint@9.39.2(jiti@1.21.7))(svelte@5.46.1):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
esutils: 2.0.3 esutils: 2.0.3
@@ -2802,7 +2831,7 @@ snapshots:
eslint@9.39.2(jiti@1.21.7): eslint@9.39.2(jiti@1.21.7):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1 '@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2 '@eslint/config-helpers': 0.4.2
@@ -2822,7 +2851,7 @@ snapshots:
eslint-scope: 8.4.0 eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
espree: 10.4.0 espree: 10.4.0
esquery: 1.6.0 esquery: 1.7.0
esutils: 2.0.3 esutils: 2.0.3
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0 file-entry-cache: 8.0.0
@@ -2849,7 +2878,7 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.15.0) acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
esquery@1.6.0: esquery@1.7.0:
dependencies: dependencies:
estraverse: 5.3.0 estraverse: 5.3.0
@@ -3575,7 +3604,7 @@ snapshots:
totalist@3.0.1: {} totalist@3.0.1: {}
ts-api-utils@2.1.0(typescript@5.9.3): ts-api-utils@2.4.0(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -3589,12 +3618,12 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): typescript-eslint@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7) eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3678,4 +3707,4 @@ snapshots:
zimmerframe@1.1.4: {} zimmerframe@1.1.4: {}
zod@4.2.1: {} zod@4.3.5: {}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
itemGap?: number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, itemGap, placeholder }: Props = $props();
let element: HTMLElement | undefined = $state();
let scrollMargin = $state(0);
let virtualizer = $derived(
createWindowVirtualizer({
count,
estimateSize: itemHeight,
gap: itemGap,
scrollMargin,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => {
if (!element) return;
const observer = new ResizeObserver(() => {
scrollMargin = Math.round(element!.getBoundingClientRect().top + window.scrollY);
});
observer.observe(element.parentElement!);
return () => observer.disconnect();
});
</script>
<div bind:this={element} class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div
class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start - scrollMargin}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && count === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
interface Props {
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#if $thumbnail}
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
</button>

View File

@@ -1,5 +1,6 @@
export { default as ActionEntryButton } from "./ActionEntryButton.svelte"; export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
export { default as Button } from "./Button.svelte"; export { default as Button } from "./Button.svelte";
export { default as EntryButton } from "./EntryButton.svelte"; export { default as EntryButton } from "./EntryButton.svelte";
export { default as FileThumbnailButton } from "./FileThumbnailButton.svelte";
export { default as FloatingButton } from "./FloatingButton.svelte"; export { default as FloatingButton } from "./FloatingButton.svelte";
export { default as TextButton } from "./TextButton.svelte"; export { default as TextButton } from "./TextButton.svelte";

View File

@@ -3,3 +3,4 @@ export * from "./buttons";
export * from "./divs"; export * from "./divs";
export * from "./inputs"; export * from "./inputs";
export { default as Modal } from "./Modal.svelte"; export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

@@ -0,0 +1,44 @@
<script module lang="ts">
import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory {
id: number;
dataKey?: DataKey;
name: string;
}
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
interface Props {
categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
}
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries([...categories]));
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category (category.id)}
<ActionEntryButton
class="h-12"
onclick={() => onCategoryClick(category)}
actionButtonIcon={categoryMenuIcon}
onActionButtonClick={() => onCategoryMenuClick?.(category)}
>
<CategoryLabel name={category.name} />
</ActionEntryButton>
{/each}
</div>
{/if}

View File

@@ -1,63 +0,0 @@
<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}

View File

@@ -1,43 +0,0 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
}
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onMenuClick!({ id, dataKey, dataKeyVersion, name });
};
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,2 +0,0 @@
export { default } from "./Categories.svelte";
export * from "./service";

View File

@@ -1,6 +0,0 @@
export interface SelectedCategory {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules"; import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import type { CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle"; import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -27,14 +25,6 @@
subCategoryCreatePosition = "bottom", subCategoryCreatePosition = "bottom",
subCategoryMenuIcon, subCategoryMenuIcon,
}: Props = $props(); }: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</script> </script>
<div class={["space-y-1", className]}> <div class={["space-y-1", className]}>
@@ -53,14 +43,12 @@
{#if subCategoryCreatePosition === "top"} {#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}
{#key info} <Categories
<Categories categories={info.subCategories}
categories={subCategories} categoryMenuIcon={subCategoryMenuIcon}
categoryMenuIcon={subCategoryMenuIcon} onCategoryClick={onSubCategoryClick}
onCategoryClick={onSubCategoryClick} onCategoryMenuClick={onSubCategoryMenuClick}
onCategoryMenuClick={onSubCategoryMenuClick} />
/>
{/key}
{#if subCategoryCreatePosition === "bottom"} {#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}

View File

@@ -1,7 +1,7 @@
export * from "./ActionModal.svelte"; export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte"; export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories"; export * from "./Categories.svelte";
export { default as Categories } from "./Categories"; export { default as Categories } from "./Categories.svelte";
export { default as IconEntryButton } from "./IconEntryButton.svelte"; export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels"; export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte"; export { default as SubCategories } from "./SubCategories.svelte";

View File

@@ -3,6 +3,7 @@
import { IconLabel } from "$lib/components/molecules"; import { IconLabel } from "$lib/components/molecules";
import IconFolder from "~icons/material-symbols/folder"; import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
interface Props { interface Props {
@@ -11,7 +12,7 @@
subtext?: string; subtext?: string;
textClass?: ClassValue; textClass?: ClassValue;
thumbnail?: string; thumbnail?: string;
type: "directory" | "file"; type: "directory" | "parent-directory" | "file";
} }
let { let {
@@ -30,6 +31,8 @@
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" /> <img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
{:else if type === "directory"} {:else if type === "directory"}
<IconFolder /> <IconFolder />
{:else if type === "parent-directory"}
<IconDriveFolderUpload class="text-yellow-500" />
{:else} {:else}
<IconDraft class="text-blue-400" /> <IconDraft class="text-blue-400" />
{/if} {/if}

View File

@@ -1,107 +0,0 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
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>

View File

@@ -1,59 +0,0 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
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();
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}

View File

@@ -1,2 +0,0 @@
export { default } from "./Category.svelte";
export * from "./service";

View File

@@ -1,8 +0,0 @@
export { requestFileThumbnailDownload } from "$lib/services/file";
export interface SelectedFile {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -1,3 +1 @@
export * from "./Category";
export { default as Category } from "./Category";
export * from "./modals"; export * from "./modals";

View File

@@ -1,11 +0,0 @@
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(input);
};
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
return await fetchInternal(input, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
});
};

View File

@@ -1,2 +0,0 @@
export * from "./callApi";
export * from "./gotoStateful";

View File

@@ -1,7 +1,5 @@
import { Dexie, type EntityTable } from "dexie"; import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo { interface DirectoryInfo {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
@@ -15,17 +13,15 @@ interface FileInfo {
contentType: string; contentType: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
categoryIds: number[]; categoryIds?: number[];
} }
export type CategoryId = "root" | number;
interface CategoryInfo { interface CategoryInfo {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;
name: string; name: string;
files: { id: number; isRecursive: boolean }[]; files?: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean; isFileRecursive?: boolean;
} }
const filesystem = new Dexie("filesystem") as Dexie & { const filesystem = new Dexie("filesystem") as Dexie & {
@@ -59,13 +55,23 @@ export const getDirectoryInfo = async (id: number) => {
}; };
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => { export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo); await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo });
}; };
export const deleteDirectoryInfo = async (id: number) => { export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id); await filesystem.directory.delete(id);
}; };
export const deleteDanglingDirectoryInfos = async (
parentId: DirectoryId,
validIds: Set<number>,
) => {
await filesystem.directory
.where({ parentId })
.and((directory) => !validIds.has(directory.id))
.delete();
};
export const getAllFileInfos = async () => { export const getAllFileInfos = async () => {
return await filesystem.file.toArray(); return await filesystem.file.toArray();
}; };
@@ -78,14 +84,29 @@ export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id); return await filesystem.file.get(id);
}; };
export const bulkGetFileInfos = async (ids: number[]) => {
return await filesystem.file.bulkGet(ids);
};
export const storeFileInfo = async (fileInfo: FileInfo) => { export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo); await filesystem.file.upsert(fileInfo.id, { ...fileInfo });
}; };
export const deleteFileInfo = async (id: number) => { export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id); await filesystem.file.delete(id);
}; };
export const bulkDeleteFileInfos = async (ids: number[]) => {
await filesystem.file.bulkDelete(ids);
};
export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set<number>) => {
await filesystem.file
.where({ parentId })
.and((file) => !validIds.has(file.id))
.delete();
};
export const getCategoryInfos = async (parentId: CategoryId) => { export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray(); return await filesystem.category.where({ parentId }).toArray();
}; };
@@ -95,7 +116,7 @@ export const getCategoryInfo = async (id: number) => {
}; };
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => { export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo); await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo });
}; };
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => { export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
@@ -106,6 +127,13 @@ export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id); await filesystem.category.delete(id);
}; };
export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set<number>) => {
await filesystem.category
.where({ parentId })
.and((category) => !validIds.has(category.id))
.delete();
};
export const cleanupDanglingInfos = async () => { export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = []; const validDirectoryIds: number[] = [];
const validFileIds: number[] = []; const validFileIds: number[] = [];

View File

@@ -1,15 +1,12 @@
import { LRUCache } from "lru-cache";
import { import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB, getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex, storeFileCacheIndex,
deleteFileCacheIndex, deleteFileCacheIndex,
type FileCacheIndex, type FileCacheIndex,
} from "$lib/indexedDB"; } from "$lib/indexedDB";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
const fileCacheIndex = new Map<number, FileCacheIndex>(); const fileCacheIndex = new Map<number, FileCacheIndex>();
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
export const prepareFileCache = async () => { export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) { for (const cache of await getFileCacheIndexFromIndexedDB()) {
@@ -51,30 +48,3 @@ export const deleteFileCache = async (fileId: number) => {
await deleteFile(`/cache/${fileId}`); await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId); await deleteFileCacheIndex(fileId);
}; };
export const getFileThumbnailCache = async (fileId: number) => {
const thumbnail = loadedThumbnails.get(fileId);
if (thumbnail) return thumbnail;
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (!thumbnailBuffer) return null;
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
loadedThumbnails.set(fileId, thumbnailUrl);
return thumbnailUrl;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory("/thumbnail/file");
};

View File

@@ -0,0 +1,95 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { decryptData } from "$lib/modules/crypto";
export interface FileDownloadState {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
type LiveFileDownloadState = FileDownloadState & {
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
};
let downloadingFiles: FileDownloadState[] = $state([]);
export const isFileDownloading = (
status: FileDownloadState["status"],
): status is LiveFileDownloadState["status"] =>
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
export const getFileDownloadState = (fileId: number) => {
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
};
export const getDownloadingFiles = () => {
return downloadingFiles.filter((file) => isFileDownloading(file.status));
};
export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
const requestFileDownload = limitFunction(
async (state: FileDownloadState, id: number) => {
state.status = "downloading";
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
},
});
const fileEncrypted: ArrayBuffer = res.data;
state.status = "decryption-pending";
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
state.status = "decrypting";
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
state.status = "decrypted";
state.result = fileBuffer;
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
downloadingFiles.push({
id,
status: "download-pending",
});
const state = downloadingFiles.at(-1)!;
try {
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
} catch (e) {
state.status = "error";
throw e;
}
};

View File

@@ -1,84 +0,0 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { decryptData } from "$lib/modules/crypto";
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
const requestFileDownload = limitFunction(
async (status: Writable<FileDownloadStatus>, id: number) => {
status.update((value) => {
value.status = "downloading";
return value;
});
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const fileEncrypted: ArrayBuffer = res.data;
status.update((value) => {
value.status = "decryption-pending";
return value;
});
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
status: Writable<FileDownloadStatus>,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
status.update((value) => {
value.status = "decrypting";
return value;
});
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
status.update((value) => {
value.status = "decrypted";
value.result = fileBuffer;
return value;
});
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
const status = writable<FileDownloadStatus>({
id,
status: "download-pending",
});
fileDownloadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
return await decryptFile(
status,
await requestFileDownload(status, id),
fileEncryptedIv,
dataKey,
);
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -1,3 +1,4 @@
export * from "./cache"; export * from "./cache";
export * from "./download"; export * from "./download.svelte";
export * from "./upload"; export * from "./thumbnail";
export * from "./upload.svelte";

View File

@@ -0,0 +1,90 @@
import { LRUCache } from "lru-cache";
import { writable, type Writable } from "svelte/store";
import { browser } from "$app/environment";
import { decryptData } from "$lib/modules/crypto";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import { isTRPCClientError, trpc } from "$trpc/client";
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
const fetchFromOpfs = async (fileId: number) => {
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (thumbnailBuffer) {
return getThumbnailUrl(thumbnailBuffer);
}
};
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
try {
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
fetch(`/api/file/${fileId}/thumbnail/download`),
trpc().file.thumbnail.query({ id: fileId }),
]);
const thumbnailBuffer = await decryptData(
await thumbnailEncrypted.arrayBuffer(),
thumbnailEncryptedIv,
dataKey,
);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
return null;
}
throw e;
}
};
export const getFileThumbnail = (file: SummarizedFileInfo) => {
if (
!browser ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
return undefined;
}
const thumbnail = loadedThumbnails.get(file.id);
if (thumbnail) return thumbnail;
let loadingThumbnail = loadingThumbnails.get(file.id);
if (loadingThumbnail) return loadingThumbnail;
loadingThumbnail = writable(undefined);
loadingThumbnails.set(file.id, loadingThumbnail);
fetchFromOpfs(file.id)
.then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
.then((thumbnail) => {
if (thumbnail) {
loadingThumbnail.set(thumbnail);
loadedThumbnails.set(file.id, loadingThumbnail as Writable<string>);
}
loadingThumbnails.delete(file.id);
});
return loadingThumbnail;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
const oldThumbnail = loadedThumbnails.get(fileId);
if (oldThumbnail) {
oldThumbnail.set(getThumbnailUrl(thumbnailBuffer));
} else {
loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer)));
}
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory(`/thumbnail/file`);
};

View File

@@ -1,7 +1,6 @@
import axios from "axios"; import axios from "axios";
import ExifReader from "exifreader"; import ExifReader from "exifreader";
import { limitFunction } from "p-limit"; import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { import {
encodeToBase64, encodeToBase64,
generateDataKey, generateDataKey,
@@ -11,27 +10,62 @@ import {
digestMessage, digestMessage,
signMessageHmac, signMessageHmac,
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail } from "$lib/modules/thumbnail"; import { generateThumbnail } from "$lib/modules/thumbnail";
import type { import type {
FileThumbnailUploadRequest, FileThumbnailUploadRequest,
FileUploadRequest, FileUploadRequest,
FileUploadResponse, FileUploadResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import { import type { MasterKey, HmacSecret } from "$lib/stores";
fileUploadStatusStore, import { trpc } from "$trpc/client";
type MasterKey,
type HmacSecret, export interface FileUploadState {
type FileUploadStatus, name: string;
} from "$lib/stores"; parentId: DirectoryId;
import { useTRPC } from "$trpc/client"; status:
| "queued"
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export type LiveFileUploadState = FileUploadState & {
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
};
const scheduler = new Scheduler<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
>();
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (status: FileUploadState["status"]) =>
["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
export const getUploadingFiles = (parentId?: DirectoryId) => {
return uploadingFiles.filter(
(file) =>
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
);
};
export const clearUploadedFiles = () => {
uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
};
const requestDuplicateFileScan = limitFunction( const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => { async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const trpc = useTRPC();
const fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret)); const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const files = await trpc.file.listByHash.query({ const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version, hskVersion: hmacSecret.version,
contentHmac: fileSigned, contentHmac: fileSigned,
}); });
@@ -77,16 +111,8 @@ const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
}; };
const encryptFile = limitFunction( const encryptFile = limitFunction(
async ( async (state: FileUploadState, file: File, fileBuffer: ArrayBuffer, masterKey: MasterKey) => {
status: Writable<FileUploadStatus>, state.status = "encrypting";
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file); const fileType = getFileType(file);
@@ -110,10 +136,7 @@ const encryptFile = limitFunction(
const thumbnailBuffer = await thumbnail?.arrayBuffer(); const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey)); const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
status.update((value) => { state.status = "upload-pending";
value.status = "upload-pending";
return value;
});
return { return {
dataKeyWrapped, dataKeyWrapped,
@@ -131,20 +154,14 @@ const encryptFile = limitFunction(
); );
const requestFileUpload = limitFunction( const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => { async (state: FileUploadState, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => { state.status = "uploading";
value.status = "uploading";
return value;
});
const res = await axios.post("/api/file/upload", form, { const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => { onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => { state.progress = progress;
value.progress = progress; state.rate = rate;
value.rate = rate; state.estimated = estimated;
value.estimated = estimated;
return value;
});
}, },
}); });
const { file }: FileUploadResponse = res.data; const { file }: FileUploadResponse = res.data;
@@ -158,10 +175,7 @@ const requestFileUpload = limitFunction(
} }
} }
status.update((value) => { state.status = "uploaded";
value.status = "uploaded";
return value;
});
return { fileId: file }; return { fileId: file };
}, },
@@ -174,92 +188,82 @@ export const uploadFile = async (
hmacSecret: HmacSecret, hmacSecret: HmacSecret,
masterKey: MasterKey, masterKey: MasterKey,
onDuplicate: () => Promise<boolean>, onDuplicate: () => Promise<boolean>,
): Promise< ) => {
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined uploadingFiles.push({
> => {
const status = writable<FileUploadStatus>({
name: file.name, name: file.name,
parentId, parentId,
status: "encryption-pending", status: "queued",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
}); });
const state = uploadingFiles.at(-1)!;
try { return await scheduler.schedule(file.size, async () => {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan( state.status = "encryption-pending";
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined;
}
const { try {
dataKeyWrapped, const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
dataKeyVersion, file,
fileType, hmacSecret,
fileEncrypted, onDuplicate,
fileEncryptedHash, );
nameEncrypted, if (!fileBuffer || !fileSigned) {
createdAtEncrypted, state.status = "canceled";
lastModifiedAtEncrypted, uploadingFiles = uploadingFiles.filter((file) => file !== state);
thumbnail, return undefined;
} = await encryptFile(status, file, fileBuffer, masterKey); }
const form = new FormData(); const {
form.set( dataKeyWrapped,
"metadata", dataKeyVersion,
JSON.stringify({ fileType,
parent: parentId, fileEncrypted,
mekVersion: masterKey.version, fileEncryptedHash,
dek: dataKeyWrapped, nameEncrypted,
dekVersion: dataKeyVersion.toISOString(), createdAtEncrypted,
hskVersion: hmacSecret.version, lastModifiedAtEncrypted,
contentHmac: fileSigned, thumbnail,
contentType: fileType, } = await encryptFile(state, file, fileBuffer, masterKey);
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash);
let thumbnailForm = null; const form = new FormData();
if (thumbnail) { form.set(
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata", "metadata",
JSON.stringify({ JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(), dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnail.iv, hskVersion: hmacSecret.version,
} satisfies FileThumbnailUploadRequest), contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
); );
thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); form.set("content", new Blob([fileEncrypted.ciphertext]));
} form.set("checksum", fileEncryptedHash);
const { fileId } = await requestFileUpload(status, form, thumbnailForm); let thumbnailForm = null;
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; if (thumbnail) {
} catch (e) { thumbnailForm = new FormData();
status.update((value) => { thumbnailForm.set(
value.status = "error"; "metadata",
return value; JSON.stringify({
}); dekVersion: dataKeyVersion.toISOString(),
throw e; contentIv: thumbnail.iv,
} } satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(state, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
state.status = "error";
throw e;
}
});
}; };

View File

@@ -1,362 +0,0 @@
import { TRPCClientError } from "@trpc/client";
import { get, writable, type Writable } from "svelte/store";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { useTRPC } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
const trpc = useTRPC();
let data;
try {
data = await trpc.directory.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
const trpc = useTRPC();
let metadata;
try {
metadata = await trpc.file.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
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,
isFileRecursive: category.isFileRecursive,
});
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
const trpc = useTRPC();
let data;
try {
data = await trpc.category.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories } = data;
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);
let files;
try {
files = await trpc.category.files.query({ id, recurse: true });
} catch {
throw new Error("Failed to fetch category files");
}
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
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;
};
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

View File

@@ -0,0 +1,121 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { CategoryInfo, MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
async fetchFromIndexedDB(id) {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category?.files
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
parentId: fileInfo.parentId,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true,
subCategories,
};
} else if (category) {
return {
id,
exists: true,
parentId: category.parentId,
name: category.name,
subCategories,
files: files?.filter((file) => !!file) ?? [],
isFileRecursive: category.isFileRecursive ?? false,
};
}
},
async fetchFromServer(id, cachedInfo, masterKey) {
try {
const category = await trpc().category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(
category.subCategories.map(async (category) => ({
id: category.id,
parentId: id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
category.files &&
Promise.all(
category.files.map(async (file) => ({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)),
})),
),
category.metadata && decryptCategoryMetadata(category.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: category.metadata!.parent,
subCategories,
files: files!,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
...metadata!,
}
: { id, subCategories },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false };
}
throw e;
}
},
});
const storeToIndexedDB = (info: CategoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeCategoryInfo(info);
// TODO: Bulk Upsert
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
}
// TODO: Bulk Upsert
info.subCategories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
void IndexedDB.deleteDanglingCategoryInfos(
info.id,
new Set(info.subCategories.map(({ id }) => id)),
);
return { ...info, exists: true as const };
};
export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};

View File

@@ -0,0 +1,102 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
async fetchFromIndexedDB(id) {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id),
IndexedDB.getFileInfos(id),
]);
if (id === "root") {
return {
id,
exists: true,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
};
}
},
async fetchFromServer(id, _cachedInfo, masterKey) {
try {
const directory = await trpc().directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(
directory.subDirectories.map(async (directory) => ({
id: directory.id,
parentId: id,
...(await decryptDirectoryMetadata(directory, masterKey)),
})),
),
Promise.all(
directory.files.map(async (file) => ({
id: file.id,
parentId: id,
contentType: file.contentType,
...(await decryptFileMetadata(file, masterKey)),
})),
),
directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: directory.metadata!.parent,
subDirectories,
files,
...metadata!,
}
: { id, subDirectories, files },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
},
});
const storeToIndexedDB = (info: DirectoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeDirectoryInfo(info);
}
// TODO: Bulk Upsert
info.subDirectories.forEach((subDirectory) => {
void IndexedDB.storeDirectoryInfo(subDirectory);
});
// TODO: Bulk Upsert
info.files.forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
void IndexedDB.deleteDanglingDirectoryInfos(
info.id,
new Set(info.subDirectories.map(({ id }) => id)),
);
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
return { ...info, exists: true as const };
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};

View File

@@ -0,0 +1,177 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { FileInfo, MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
const categories = file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined;
if (file) {
return {
id,
exists: true,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories?.filter((category) => !!category) ?? [],
};
}
},
async fetchFromServer(id, _cachedInfo, masterKey) {
try {
const file = await trpc().file.get.query({ id });
const [categories, metadata] = await Promise.all([
Promise.all(
file.categories.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(file, masterKey),
]);
return storeToIndexedDB({
id,
parentId: file.parent,
dataKey: metadata.dataKey,
contentType: file.contentType,
contentIv: file.contentIv,
name: metadata.name,
createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt,
categories,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
},
async bulkFetchFromIndexedDB(ids) {
const files = await IndexedDB.bulkGetFileInfos([...ids]);
const categories = await Promise.all(
files.map(async (file) =>
file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined,
),
);
return new Map(
files
.filter((file) => !!file)
.map((file, index) => [
file.id,
{
...file,
exists: true,
categories: categories[index]?.filter((category) => !!category) ?? [],
},
]),
);
},
async bulkFetchFromServer(ids, masterKey) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
const [categories, metadata] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(metadataRaw, masterKey),
]);
return {
id,
exists: true as const,
parentId: metadataRaw.parent,
contentType: metadataRaw.contentType,
contentIv: metadataRaw.contentIv,
categories,
...metadata,
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
void IndexedDB.bulkDeleteFileInfos(deletedIds);
return new Map<number, MaybeFileInfo>([
...bulkStoreToIndexedDB(files),
...deletedIds.map((id) => [id, { id, exists: false }] as const),
]);
},
});
const storeToIndexedDB = (info: FileInfo) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
info.categories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return { ...info, exists: true as const };
};
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
// TODO: Bulk Upsert
infos.forEach((info) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
});
// TODO: Bulk Upsert
new Map(
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
).forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
};
export const getFileInfo = (id: number, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};
export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => {
return cache.bulkGet(new Set(ids), masterKey);
};

View File

@@ -0,0 +1,4 @@
export * from "./category";
export * from "./directory";
export * from "./file";
export * from "./types";

View File

@@ -0,0 +1,172 @@
import { untrack } from "svelte";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (
keys: Map<K, { cachedValue: V | undefined }>,
masterKey: CryptoKey,
) => Promise<Map<K, V>>;
}
export class FilesystemCache<K, V extends object> {
private map = new Map<K, { value?: V; promise?: Promise<V> }>();
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
get(key: K, masterKey: CryptoKey) {
return untrack(() => {
let state = this.map.get(key);
if (state?.promise) return state.value ?? state.promise;
const { promise: newPromise, resolve } = Promise.withResolvers<V>();
if (!state) {
const newState = $state({});
state = newState;
this.map.set(key, newState);
}
(state.value
? Promise.resolve(state.value)
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
if (loadedInfo) {
state.value = loadedInfo;
resolve(state.value);
}
return loadedInfo;
})
)
.then((cachedInfo) => this.options.fetchFromServer(key, cachedInfo, masterKey))
.then((loadedInfo) => {
if (state.value) {
Object.assign(state.value, loadedInfo);
} else {
state.value = loadedInfo;
}
resolve(state.value);
})
.finally(() => {
state.promise = undefined;
});
state.promise = newPromise;
return state.value ?? newPromise;
});
}
bulkGet(keys: Set<K>, masterKey: CryptoKey) {
return untrack(() => {
const newPromises = new Map(
keys
.keys()
.filter((key) => this.map.get(key)?.promise === undefined)
.map((key) => [key, Promise.withResolvers<V>()]),
);
newPromises.forEach(({ promise }, key) => {
const state = this.map.get(key);
if (state) {
state.promise = promise;
} else {
const newState = $state({ promise });
this.map.set(key, newState);
}
});
const resolve = (loadedInfos: Map<K, V>) => {
loadedInfos.forEach((loadedInfo, key) => {
const state = this.map.get(key)!;
if (state.value) {
Object.assign(state.value, loadedInfo);
} else {
state.value = loadedInfo;
}
newPromises.get(key)!.resolve(state.value);
});
return loadedInfos;
};
this.options.bulkFetchFromIndexedDB!(
new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)),
)
.then(resolve)
.then(() =>
this.options.bulkFetchFromServer!(
new Map(
newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]),
),
masterKey,
),
)
.then(resolve)
.finally(() => {
newPromises.forEach((_, key) => {
this.map.get(key)!.promise = undefined;
});
});
const bottleneckPromises = Array.from(
keys
.keys()
.filter((key) => this.map.get(key)!.value === undefined)
.map((key) => this.map.get(key)!.promise!),
);
const makeResult = () =>
new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const));
return bottleneckPromises.length > 0
? Promise.all(bottleneckPromises).then(makeResult)
: makeResult();
});
}
}
export const decryptDirectoryMetadata = async (
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
};
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const decryptFileMetadata = async (
metadata: {
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
},
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(metadata.name, metadata.nameIv, dataKey),
metadata.createdAt
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
: undefined,
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
]);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
createdAt,
lastModifiedAt,
};
};
export const decryptCategoryMetadata = decryptDirectoryMetadata;

View File

@@ -0,0 +1,77 @@
export type DataKey = { key: CryptoKey; version: Date };
type AllUndefined<T> = { [K in keyof T]?: undefined };
interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: FileCategoryInfo[];
}
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;

View File

@@ -0,0 +1,48 @@
export class Scheduler<T = void> {
private isEstimating = false;
private memoryUsage = 0;
private queue: (() => void)[] = [];
constructor(public readonly memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {}
private next() {
if (!this.isEstimating && this.memoryUsage < this.memoryLimit) {
const resolve = this.queue.shift();
if (resolve) {
this.isEstimating = true;
resolve();
}
}
}
async schedule(
estimateMemoryUsage: number | (() => number | Promise<number>),
task: () => Promise<T>,
) {
if (this.isEstimating || this.memoryUsage >= this.memoryLimit) {
await new Promise<void>((resolve) => {
this.queue.push(resolve);
});
} else {
this.isEstimating = true;
}
let taskMemoryUsage = 0;
try {
taskMemoryUsage =
typeof estimateMemoryUsage === "number" ? estimateMemoryUsage : await estimateMemoryUsage();
this.memoryUsage += taskMemoryUsage;
} finally {
this.isEstimating = false;
this.next();
}
try {
return await task();
} finally {
this.memoryUsage -= taskMemoryUsage;
this.next();
}
}
}

View File

@@ -2,8 +2,6 @@ import { IntegrityError } from "./error";
import db from "./kysely"; import db from "./kysely";
import type { Ciphertext } from "./schema"; import type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category { interface Category {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;

View File

@@ -1,11 +1,10 @@
import { sql, type NotNull } from "kysely"; import { sql } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import pg from "pg"; 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";
export type DirectoryId = "root" | number;
interface Directory { interface Directory {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
@@ -38,6 +37,15 @@ interface File {
export type NewFile = Omit<File, "id">; export type NewFile = Omit<File, "id">;
interface FileCategory {
id: number;
parentId: CategoryId;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export const registerDirectory = async (params: NewDirectory) => { export const registerDirectory = async (params: NewDirectory) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx const mek = await trx
@@ -306,39 +314,51 @@ export const getAllFilesByCategory = async (
recurse: boolean, recurse: boolean,
) => { ) => {
const files = await db const files = await db
.withRecursive("cte", (db) => .withRecursive("category_tree", (db) =>
db db
.selectFrom("category") .selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id") .select(["id", sql<number>`0`.as("depth")])
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.where("id", "=", categoryId) .where("id", "=", categoryId)
.where("user_id", "=", userId)
.$if(recurse, (qb) => .$if(recurse, (qb) =>
qb.unionAll((db) => qb.unionAll((db) =>
db db
.selectFrom("category") .selectFrom("category")
.leftJoin("file_category", "category.id", "file_category.category_id") .innerJoin("category_tree", "category.parent_id", "category_tree.id")
.innerJoin("cte", "category.parent_id", "cte.id") .select(["category.id", sql<number>`depth + 1`.as("depth")]),
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
), ),
), ),
) )
.selectFrom("cte") .selectFrom("category_tree")
.innerJoin("file_category", "category_tree.id", "file_category.category_id")
.innerJoin("file", "file_category.file_id", "file.id")
.select(["file_id", "depth"]) .select(["file_id", "depth"])
.selectAll("file")
.distinctOn("file_id") .distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id") .orderBy("file_id")
.orderBy("depth") .orderBy("depth")
.execute(); .execute();
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 })); return files.map(
(file) =>
({
id: file.file_id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
isRecursive: file.depth > 0,
}) satisfies File & { isRecursive: boolean },
);
}; };
export const getAllFileIds = async (userId: number) => { export const getAllFileIds = async (userId: number) => {
@@ -390,6 +410,52 @@ export const getFile = async (userId: number, fileId: number) => {
: null; : null;
}; };
export const getFilesWithCategories = async (userId: number, fileIds: number[]) => {
const files = await db
.selectFrom("file")
.selectAll()
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("file_category")
.innerJoin("category", "file_category.category_id", "category.id")
.where("file_category.file_id", "=", eb.ref("file.id"))
.selectAll("category"),
).as("categories"),
)
.where("id", "=", (eb) => eb.fn.any(eb.val(fileIds)))
.where("user_id", "=", userId)
.execute();
return files.map(
(file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
categories: file.categories.map((category) => ({
id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: new Date(category.data_encryption_key_version),
encName: category.encrypted_name,
})),
}) satisfies File & { categories: FileCategory[] },
);
};
export const setFileEncName = async ( export const setFileEncName = async (
userId: number, userId: number,
fileId: number, fileId: number,
@@ -476,10 +542,21 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => { export const getAllFileCategories = async (fileId: number) => {
const categories = await db const categories = await db
.selectFrom("file_category") .selectFrom("file_category")
.select("category_id") .innerJoin("category", "file_category.category_id", "category.id")
.selectAll("category")
.where("file_id", "=", fileId) .where("file_id", "=", fileId)
.execute(); .execute();
return categories.map(({ category_id }) => ({ id: category_id })); return categories.map(
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies FileCategory,
);
}; };
export const removeFileFromCategory = async (fileId: number, categoryId: number) => { export const removeFileFromCategory = async (fileId: number, categoryId: number) => {

View File

@@ -1,6 +1,5 @@
import { error, redirect, type Handle } from "@sveltejs/kit"; import { error, redirect, type Handle } from "@sveltejs/kit";
import env from "$lib/server/loadenv"; import { cookieOptions, authenticate, AuthenticationError } from "$lib/server/modules/auth";
import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => { export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
try { try {
@@ -11,12 +10,7 @@ export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { ip, userAgent } = event.locals; const { ip, userAgent } = event.locals;
event.locals.session = await authenticate(sessionIdSigned, ip, userAgent); event.locals.session = await authenticate(sessionIdSigned, ip, userAgent);
event.cookies.set("sessionId", sessionIdSigned, { event.cookies.set("sessionId", sessionIdSigned, cookieOptions);
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
});
} catch (e) { } catch (e) {
if (e instanceof AuthenticationError) { if (e instanceof AuthenticationError) {
const { pathname, search } = event.url; const { pathname, search } = event.url;

View File

@@ -1,11 +1,9 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { getUserClient } from "$lib/server/db/client"; import { ClientRepo, SessionRepo, IntegrityError } from "$lib/server/db";
import { IntegrityError } from "$lib/server/db/error";
import { createSession, refreshSession } from "$lib/server/db/session";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
import { issueSessionId, verifySessionId } from "$lib/server/modules/crypto"; import { verifySessionId } from "$lib/server/modules/crypto";
interface Session { export interface Session {
sessionId: string; sessionId: string;
userId: number; userId: number;
clientId?: number; clientId?: number;
@@ -42,11 +40,12 @@ export class AuthorizationError extends Error {
} }
} }
export const startSession = async (userId: number, ip: string, userAgent: string) => { export const cookieOptions = {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); path: "/",
await createSession(userId, sessionId, ip, userAgent); maxAge: env.session.exp / 1000,
return sessionIdSigned; secure: true,
}; sameSite: "strict",
} as const;
export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => { export const authenticate = async (sessionIdSigned: string, ip: string, userAgent: string) => {
const sessionId = verifySessionId(sessionIdSigned, env.session.secret); const sessionId = verifySessionId(sessionIdSigned, env.session.secret);
@@ -55,7 +54,7 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
} }
try { try {
const { userId, clientId } = await refreshSession(sessionId, ip, userAgent); const { userId, clientId } = await SessionRepo.refreshSession(sessionId, ip, userAgent);
return { return {
id: sessionId, id: sessionId,
userId, userId,
@@ -96,7 +95,7 @@ export const authorizeInternal = async (
if (!clientId) { if (!clientId) {
throw new AuthorizationError(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await ClientRepo.getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
throw new AuthorizationError(500, "Invalid session id"); throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "pending") { } else if (userClient.state !== "pending") {
@@ -108,7 +107,7 @@ export const authorizeInternal = async (
if (!clientId) { if (!clientId) {
throw new AuthorizationError(403, "Forbidden"); throw new AuthorizationError(403, "Forbidden");
} }
const userClient = await getUserClient(userId, clientId); const userClient = await ClientRepo.getUserClient(userId, clientId);
if (!userClient) { if (!userClient) {
throw new AuthorizationError(500, "Invalid session id"); throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "active") { } else if (userClient.state !== "active") {

View File

@@ -1,6 +1,5 @@
import { TRPCClientError } from "@trpc/client";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import { useTRPC } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
export const requestSessionUpgrade = async ( export const requestSessionUpgrade = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -9,15 +8,14 @@ export const requestSessionUpgrade = async (
signKey: CryptoKey, signKey: CryptoKey,
force = false, force = false,
) => { ) => {
const trpc = useTRPC();
let id, challenge; let id, challenge;
try { try {
({ id, challenge } = await trpc.auth.upgradeSession.mutate({ ({ id, challenge } = await trpc().auth.upgrade.mutate({
encPubKey: encryptKeyBase64, encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
})); }));
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") { if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const; return [false, "Unregistered client"] as const;
} }
return [false] as const; return [false] as const;
@@ -26,13 +24,13 @@ export const requestSessionUpgrade = async (
const answerSig = await signMessageRSA(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
try { try {
await trpc.auth.verifySessionUpgrade.mutate({ await trpc().auth.verifyUpgrade.mutate({
id, id,
answerSig: encodeToBase64(answerSig), answerSig: encodeToBase64(answerSig),
force, force,
}); });
} catch (e) { } catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "CONFLICT") { if (isTRPCClientError(e) && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const; return [false, "Already logged in"] as const;
} }
return [false] as const; return [false] as const;
@@ -42,9 +40,8 @@ export const requestSessionUpgrade = async (
}; };
export const requestLogout = async () => { export const requestLogout = async () => {
const trpc = useTRPC();
try { try {
await trpc.auth.logout.mutate(); await trpc().auth.logout.mutate();
return true; return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,18 +1,17 @@
import { generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto"; import { generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
import type { MasterKey } from "$lib/stores"; import type { MasterKey } from "$lib/stores";
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestCategoryCreation = async ( export const requestCategoryCreation = async (
name: string, name: string,
parentId: "root" | number, parentId: "root" | number,
masterKey: MasterKey, masterKey: MasterKey,
) => { ) => {
const trpc = useTRPC();
const { dataKey, dataKeyVersion } = await generateDataKey(); const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey); const nameEncrypted = await encryptString(name, dataKey);
try { try {
await trpc.category.create.mutate({ await trpc().category.create.mutate({
parent: parentId, parent: parentId,
mekVersion: masterKey.version, mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key), dek: await wrapDataKey(dataKey, masterKey.key),
@@ -28,10 +27,8 @@ export const requestCategoryCreation = async (
}; };
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => { export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
const trpc = useTRPC();
try { try {
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId }); await trpc().category.removeFile.mutate({ id: categoryId, file: fileId });
return true; return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,18 +1,13 @@
import { callGetApi } from "$lib/hooks";
import { getAllFileInfos } from "$lib/indexedDB/filesystem"; import { getAllFileInfos } from "$lib/indexedDB/filesystem";
import { decryptData } from "$lib/modules/crypto";
import { import {
getFileCache, getFileCache,
storeFileCache, storeFileCache,
deleteFileCache, deleteFileCache,
getFileThumbnailCache,
storeFileThumbnailCache,
deleteFileThumbnailCache,
downloadFile, downloadFile,
deleteFileThumbnailCache,
} from "$lib/modules/file"; } from "$lib/modules/file";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestFileDownload = async ( export const requestFileDownload = async (
fileId: number, fileId: number,
@@ -45,35 +40,10 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form }); return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
}; };
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache || !dataKey) return cache;
const trpc = useTRPC();
let thumbnailInfo;
try {
thumbnailInfo = await trpc.file.thumbnail.query({ id: fileId });
} catch {
// TODO: Error Handling
return null;
}
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
const res = await callGetApi(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailEncrypted = await res.arrayBuffer();
const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
return getThumbnailUrl(thumbnailBuffer);
};
export const requestDeletedFilesCleanup = async () => { export const requestDeletedFilesCleanup = async () => {
const trpc = useTRPC();
let liveFiles; let liveFiles;
try { try {
liveFiles = await trpc.file.list.query(); liveFiles = await trpc().file.list.query();
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling
return; return;

View File

@@ -1,4 +1,3 @@
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -11,7 +10,7 @@ import {
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { requestSessionUpgrade } from "$lib/services/auth"; import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores"; import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { useTRPC } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -19,16 +18,14 @@ export const requestClientRegistration = async (
verifyKeyBase64: string, verifyKeyBase64: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
const trpc = useTRPC();
try { try {
const { id, challenge } = await trpc.client.register.mutate({ const { id, challenge } = await trpc().client.register.mutate({
encPubKey: encryptKeyBase64, encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
}); });
const answer = await decryptChallenge(challenge, decryptKey); const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey); const answerSig = await signMessageRSA(answer, signKey);
await trpc.client.verify.mutate({ await trpc().client.verify.mutate({
id, id,
answerSig: encodeToBase64(answerSig), answerSig: encodeToBase64(answerSig),
}); });
@@ -69,11 +66,9 @@ export const requestClientRegistrationAndSessionUpgrade = async (
}; };
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => { export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const trpc = useTRPC();
let masterKeysWrapped; let masterKeysWrapped;
try { try {
masterKeysWrapped = await trpc.mek.list.query(); masterKeysWrapped = await trpc().mek.list.query();
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling
return false; return false;
@@ -110,18 +105,13 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string, hmacSecretWrapped: string,
signKey: CryptoKey, signKey: CryptoKey,
) => { ) => {
const trpc = useTRPC();
try { try {
await trpc.mek.registerInitial.mutate({ await trpc().mek.registerInitial.mutate({
mek: masterKeyWrapped, mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
} catch (e) { } catch (e) {
if ( if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) {
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true; return true;
} }
// TODO: Error Handling // TODO: Error Handling
@@ -129,7 +119,7 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
} }
try { try {
await trpc.hsk.registerInitial.mutate({ await trpc().hsk.registerInitial.mutate({
mekVersion: 1, mekVersion: 1,
hsk: hmacSecretWrapped, hsk: hmacSecretWrapped,
}); });

View File

@@ -1,49 +0,0 @@
import { writable, type Writable } from "svelte/store";
export interface FileUploadStatus {
name: string;
parentId: "root" | number;
status:
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export interface FileDownloadStatus {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
export const isFileUploading = (
status: FileUploadStatus["status"],
): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
};
export const isFileDownloading = (
status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
};

View File

@@ -1,2 +1 @@
export * from "./file";
export * from "./key"; export * from "./key";

2
src/lib/types/filesystem.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
type DirectoryId = "root" | number;
type CategoryId = "root" | number;

View File

@@ -0,0 +1,93 @@
type MaybePromise<T> = T | Promise<T> | HybridPromise<T>;
type HybridPromiseState<T> =
| { mode: "sync"; status: "fulfilled"; value: T }
| { mode: "sync"; status: "rejected"; reason: unknown }
| { mode: "async"; promise: Promise<T> };
export class HybridPromise<T> implements PromiseLike<T> {
private isConsumed = false;
private constructor(private readonly state: HybridPromiseState<T>) {
if (state.mode === "sync" && state.status === "rejected") {
queueMicrotask(() => {
if (!this.isConsumed) {
throw state.reason;
}
});
}
}
isSync(): boolean {
return this.state.mode === "sync";
}
toPromise(): Promise<T> {
this.isConsumed = true;
if (this.state.mode === "async") return this.state.promise;
return this.state.status === "fulfilled"
? Promise.resolve(this.state.value)
: Promise.reject(this.state.reason);
}
static resolve<T>(value: MaybePromise<T>): HybridPromise<T> {
if (value instanceof HybridPromise) return value;
return new HybridPromise(
value instanceof Promise
? { mode: "async", promise: value }
: { mode: "sync", status: "fulfilled", value },
);
}
static reject<T = never>(reason?: unknown): HybridPromise<T> {
return new HybridPromise({ mode: "sync", status: "rejected", reason });
}
then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => MaybePromise<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => MaybePromise<TResult2>) | null | undefined,
): HybridPromise<TResult1 | TResult2> {
this.isConsumed = true;
if (this.state.mode === "async") {
return new HybridPromise({
mode: "async",
promise: this.state.promise.then(onfulfilled, onrejected) as any,
});
}
try {
if (this.state.status === "fulfilled") {
if (!onfulfilled) return HybridPromise.resolve(this.state.value as any);
return HybridPromise.resolve(onfulfilled(this.state.value));
} else {
if (!onrejected) return HybridPromise.reject(this.state.reason);
return HybridPromise.resolve(onrejected(this.state.reason));
}
} catch (e) {
return HybridPromise.reject(e);
}
}
catch<TResult = never>(
onrejected?: ((reason: unknown) => MaybePromise<TResult>) | null | undefined,
): HybridPromise<T | TResult> {
return this.then<T, TResult>(null, onrejected);
}
finally(onfinally?: (() => void) | null | undefined): HybridPromise<T> {
this.isConsumed = true;
if (this.state.mode === "async") {
return new HybridPromise({ mode: "async", promise: this.state.promise.finally(onfinally) });
}
try {
onfinally?.();
return new HybridPromise(this.state);
} catch (e) {
return HybridPromise.reject(e);
}
}
}

View File

@@ -7,6 +7,13 @@ export const formatDate = (date: Date) => {
return `${year}. ${month}. ${day}.`; return `${year}. ${month}. ${day}.`;
}; };
export const formatDateSortable = (date: Date) => {
const year = date.getFullYear();
const month = pad2(date.getMonth() + 1);
const day = pad2(date.getDate());
return `${year}${month}${day}`;
};
export const formatDateTime = (date: Date) => { export const formatDateTime = (date: Date) => {
const dateFormatted = formatDate(date); const dateFormatted = formatDate(date);
const hours = date.getHours(); const hours = date.getHours();
@@ -32,32 +39,3 @@ export const truncateString = (str: string, maxLength = 20) => {
if (str.length <= maxLength) return str; if (str.length <= maxLength) return str;
return `${str.slice(0, maxLength)}...`; return `${str.slice(0, maxLength)}...`;
}; };
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));
};

4
src/lib/utils/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from "./format";
export * from "./gotoStateful";
export * from "./HybridPromise";
export * from "./sort";

59
src/lib/utils/sort.ts Normal file
View File

@@ -0,0 +1,59 @@
interface SortEntry {
name?: string;
date?: Date;
}
export enum SortBy {
NAME_ASC,
NAME_DESC,
DATE_ASC,
DATE_DESC,
}
type SortFunc = (a: SortEntry, b: SortEntry) => number;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = ({ name: a }, { name: 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);
const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => {
if (a && b) return a.getTime() - b.getTime();
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b);
export const sortEntries = <T extends SortEntry>(entries: T[], sortBy = SortBy.NAME_ASC) => {
let sortFunc: SortFunc;
switch (sortBy) {
case SortBy.NAME_ASC:
sortFunc = sortByNameAsc;
break;
case SortBy.NAME_DESC:
sortFunc = sortByNameDesc;
break;
case SortBy.DATE_ASC:
sortFunc = sortByDateAsc;
break;
case SortBy.DATE_DESC:
sortFunc = sortByDateDesc;
break;
default: {
const exhaustive: never = sortBy;
sortFunc = exhaustive;
}
}
entries.sort(sortFunc);
return entries;
};

View File

@@ -1,10 +1,8 @@
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestPasswordChange = async (oldPassword: string, newPassword: string) => { export const requestPasswordChange = async (oldPassword: string, newPassword: string) => {
const trpc = useTRPC();
try { try {
await trpc.auth.changePassword.mutate({ oldPassword, newPassword }); await trpc().auth.changePassword.mutate({ oldPassword, newPassword });
return true; return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,4 +1,4 @@
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export { requestLogout } from "$lib/services/auth"; export { requestLogout } from "$lib/services/auth";
export { requestDeletedFilesCleanup } from "$lib/services/file"; export { requestDeletedFilesCleanup } from "$lib/services/file";
@@ -8,10 +8,8 @@ export {
} from "$lib/services/key"; } from "$lib/services/key";
export const requestLogin = async (email: string, password: string) => { export const requestLogin = async (email: string, password: string) => {
const trpc = useTRPC();
try { try {
await trpc.auth.login.mutate({ email, password }); await trpc().auth.login.mutate({ email, password });
return true; return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,18 +1,15 @@
<script lang="ts"> <script lang="ts">
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 { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores"; import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
import { HybridPromise } from "$lib/utils";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte"; import DownloadStatus from "./DownloadStatus.svelte";
import { import {
@@ -21,51 +18,43 @@
requestThumbnailUpload, requestThumbnailUpload,
requestFileAdditionToCategory, requestFileAdditionToCategory,
} from "./service"; } from "./service";
import TopBarMenu from "./TopBarMenu.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
import IconCamera from "~icons/material-symbols/camera"; import IconCamera from "~icons/material-symbols/camera";
import IconClose from "~icons/material-symbols/close"; import IconClose from "~icons/material-symbols/close";
import IconAddCircle from "~icons/material-symbols/add-circle"; import IconAddCircle from "~icons/material-symbols/add-circle";
let { data } = $props(); let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state(); let info: MaybeFileInfo | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]); let downloadState = $derived(getFileDownloadState(data.id));
let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false); let isAddToCategoryBottomSheetOpen = $state(false);
let downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
const { id, status } = get(statusStore);
return id === data.id && isFileDownloading(status);
}),
);
let isDownloadRequested = $state(false); let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state(); let viewerType: "image" | "video" | undefined = $state();
let fileBlob: Blob | undefined = $state();
let fileBlobUrl: string | undefined = $state(); let fileBlobUrl: string | undefined = $state();
let heicBlob: Blob | undefined = $state();
let videoElement: HTMLVideoElement | undefined = $state(); let videoElement: HTMLVideoElement | undefined = $state();
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => { const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
const fileBlob = new Blob([buffer], { type: contentType }); fileBlob = new Blob([buffer], { type: contentType });
if (viewerType) { fileBlobUrl = URL.createObjectURL(fileBlob);
fileBlobUrl = URL.createObjectURL(fileBlob);
heicBlob = contentType === "image/heic" ? fileBlob : undefined;
}
return fileBlob; return fileBlob;
}; };
const convertHeicToJpeg = async () => { const convertHeicToJpeg = async () => {
if (!heicBlob) return; if (fileBlob?.type !== "image/heic") return;
URL.revokeObjectURL(fileBlobUrl!); URL.revokeObjectURL(fileBlobUrl!);
fileBlobUrl = undefined; fileBlobUrl = undefined;
const { default: heic2any } = await import("heic2any"); const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL( fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob, (await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
); );
heicBlob = undefined;
}; };
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => { const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
@@ -76,28 +65,27 @@
const addToCategory = async (categoryId: number) => { const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId); await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false; isAddToCategoryBottomSheetOpen = false;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
const removeFromCategory = async (categoryId: number) => { const removeFromCategory = async (categoryId: number) => {
await requestFileRemovalFromCategory(data.id, categoryId); await requestFileRemovalFromCategory(data.id, categoryId);
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
$effect(() => { $effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => {
if (data.id === result.id) {
info = result;
}
});
isDownloadRequested = false; isDownloadRequested = false;
viewerType = undefined; viewerType = undefined;
}); });
$effect(() => { $effect(() => {
categories = if (info?.dataKey) {
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? []; const contentType = info.contentType;
});
$effect(() => {
if ($info && $info.dataKey && $info.contentIv) {
const contentType = $info.contentType;
if (contentType.startsWith("image")) { if (contentType.startsWith("image")) {
viewerType = "image"; viewerType = "image";
} else if (contentType.startsWith("video")) { } else if (contentType.startsWith("video")) {
@@ -105,24 +93,24 @@
} }
untrack(() => { untrack(() => {
if (!downloadStatus && !isDownloadRequested) { if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true; isDownloadRequested = true;
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => { requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
const blob = await updateViewer(buffer, contentType); async (buffer) => {
if (!viewerType) { const blob = await updateViewer(buffer, contentType);
FileSaver.saveAs(blob, $info.name); if (!viewerType) {
} FileSaver.saveAs(blob, info!.name);
}); }
},
);
} }
}); });
} }
}); });
$effect(() => { $effect(() => {
if ($info && $downloadStatus?.status === "decrypted") { if (info?.exists && downloadState?.status === "decrypted") {
untrack( untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!));
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
} }
}); });
@@ -133,11 +121,32 @@
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<TopBar title={$info?.name} /> <TopBar title={info?.name}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={(e) => e.stopPropagation()}>
<button
onclick={() => (isMenuOpen = !isMenuOpen)}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
>
<IconMoreVert class="text-2xl" />
</button>
<TopBarMenu
bind:isOpen={isMenuOpen}
directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? info?.parentId
: undefined}
{fileBlob}
filename={info?.name}
/>
</div>
</TopBar>
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-4 pb-4"> <div class="space-y-4 pb-4">
<DownloadStatus status={downloadStatus} /> {#if downloadState}
{#if $info && viewerType} <DownloadStatus state={downloadState} />
{/if}
{#if info && viewerType}
<div class="flex w-full justify-center"> <div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)} {#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p> <p class="text-gray-500">{message}</p>
@@ -145,18 +154,17 @@
{#if viewerType === "image"} {#if viewerType === "image"}
{#if fileBlobUrl} {#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} /> <img src={fileBlobUrl} alt={info.name} onerror={convertHeicToJpeg} />
{:else} {:else}
{@render viewerLoading("이미지를 불러오고 있어요.")} {@render viewerLoading("이미지를 불러오고 있어요.")}
{/if} {/if}
{:else if viewerType === "video"} {:else if viewerType === "video"}
{#if fileBlobUrl} {#if fileBlobUrl}
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video> <video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton <IconEntryButton
icon={IconCamera} icon={IconCamera}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)} onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
class="w-full" class="w-full"
> >
이 장면을 썸네일로 설정하기 이 장면을 썸네일로 설정하기
@@ -172,7 +180,7 @@
<p class="text-lg font-bold">카테고리</p> <p class="text-lg font-bold">카테고리</p>
<div class="space-y-1"> <div class="space-y-1">
<Categories <Categories
{categories} categories={info?.categories ?? []}
categoryMenuIcon={IconClose} categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)} onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)} onCategoryMenuClick={({ id }) => removeFromCategory(id)}

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms"; import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
import { SubCategories } from "$lib/components/molecules"; import { SubCategories } from "$lib/components/molecules";
import { CategoryCreateModal } from "$lib/components/organisms"; import { CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { HybridPromise } from "$lib/utils";
import { requestCategoryCreation } from "./service"; import { requestCategoryCreation } from "./service";
interface Props { interface Props {
@@ -14,32 +14,36 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let category: Writable<CategoryInfo | null> | undefined = $state(); let categoryInfo: MaybeCategoryInfo | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!); HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then(
(result) => (categoryInfo = result),
);
} }
}); });
</script> </script>
{#if $category} {#if categoryInfo?.exists}
<BottomSheet bind:isOpen class="flex flex-col"> <BottomSheet bind:isOpen class="flex flex-col">
<FullscreenDiv> <FullscreenDiv>
<SubCategories <SubCategories
class="py-4" class="py-4"
info={$category} info={categoryInfo}
onSubCategoryClick={({ id }) => onSubCategoryClick={({ id }) =>
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then(
(result) => (categoryInfo = result),
)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
subCategoryCreatePosition="top" subCategoryCreatePosition="top"
/> />
{#if $category.id !== "root"} {#if categoryInfo.id !== "root"}
<BottomDiv> <BottomDiv>
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full"> <Button onclick={() => onAddToCategoryClick(categoryInfo!.id as number)} class="w-full">
카테고리에 추가하기 {categoryInfo!.name} 카테고리에 추가하기
</Button> </Button>
</BottomDiv> </BottomDiv>
{/if} {/if}
@@ -50,8 +54,8 @@
<CategoryCreateModal <CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen} bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => { onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) { if (await requestCategoryCreation(name, categoryInfo!.id, $masterKeyStore?.get(1)!)) {
category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getCategoryInfo(categoryInfo!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;

View File

@@ -1,32 +1,31 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { isFileDownloading, type FileDownloadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/modules/util"; import { formatNetworkSpeed } from "$lib/utils";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props { interface Props {
status?: Writable<FileDownloadStatus>; state: FileDownloadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
{#if $status && isFileDownloading($status.status)} {#if isFileDownloading(state.status)}
<div class="w-full rounded-xl bg-gray-100 p-3"> <div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium"> <p class="font-medium">
{#if $status.status === "download-pending"} {#if state.status === "download-pending"}
다운로드를 기다리는 중 다운로드를 기다리는 중
{:else if $status.status === "downloading"} {:else if state.status === "downloading"}
다운로드하는 중 다운로드하는 중
{:else if $status.status === "decryption-pending"} {:else if state.status === "decryption-pending"}
복호화를 기다리는 중 복호화를 기다리는 중
{:else if $status.status === "decrypting"} {:else if state.status === "decrypting"}
복호화하는 중 복호화하는 중
{/if} {/if}
</p> </p>
<p class="text-xs"> <p class="text-xs">
{#if $status.status === "downloading"} {#if state.status === "downloading"}
전송됨 전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if} {/if}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import FileSaver from "file-saver";
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { fly } from "svelte/transition";
import { goto } from "$app/navigation";
import IconFolderOpen from "~icons/material-symbols/folder-open";
import IconCloudDownload from "~icons/material-symbols/cloud-download";
interface Props {
directoryId?: "root" | number;
fileBlob?: Blob;
filename?: string;
isOpen: boolean;
}
let { directoryId, fileBlob, filename, isOpen = $bindable() }: Props = $props();
</script>
<svelte:window onclick={() => (isOpen = false)} />
{#if isOpen && (directoryId || fileBlob)}
<div
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
transition:fly={{ y: -8, duration: 200 }}
>
<p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p>
<div class="flex flex-col">
{#snippet menuButton(
Icon: Component<SvelteHTMLElements["svg"]>,
text: string,
onclick: () => void,
)}
<button {onclick} class="rounded-xl active:bg-gray-100">
<div
class="flex items-center gap-x-3 px-3 py-2 text-lg text-gray-700 transition active:scale-95"
>
<Icon />
<p class="font-medium">{text}</p>
</div>
</button>
{/snippet}
{#if directoryId}
{@render menuButton(IconFolderOpen, "폴더에서 보기", () =>
goto(
directoryId === "root" ? "/directory?from=file" : `/directory/${directoryId}?from=file`,
),
)}
{/if}
{#if fileBlob}
{@render menuButton(IconCloudDownload, "다운로드", () => {
FileSaver.saveAs(fileBlob, filename);
})}
{/if}
</div>
</div>
{/if}

View File

@@ -1,7 +1,7 @@
import { encryptData } from "$lib/modules/crypto"; import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file"; import { storeFileThumbnailCache } from "$lib/modules/file";
import { requestFileThumbnailUpload } from "$lib/services/file"; import { requestFileThumbnailUpload } from "$lib/services/file";
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export { requestFileDownload } from "$lib/services/file"; export { requestFileDownload } from "$lib/services/file";
@@ -22,10 +22,8 @@ export const requestThumbnailUpload = async (
}; };
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => { export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
const trpc = useTRPC();
try { try {
await trpc.category.addFile.mutate({ id: categoryId, file: fileId }); await trpc().category.addFile.mutate({ id: categoryId, file: fileId });
return true; return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,19 +1,31 @@
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store"; import { onMount } from "svelte";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores"; import {
getDownloadingFiles,
clearDownloadedFiles,
type FileDownloadState,
} from "$lib/modules/file";
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
let downloadingFiles = $derived( let downloadingFiles: { info: MaybeFileInfo; state: FileDownloadState }[] = $state([]);
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
);
$effect(() => () => { onMount(async () => {
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) => const states = getDownloadingFiles();
isFileDownloading(get(status).status), const infos = await bulkGetFileInfo(
states.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!,
); );
downloadingFiles = states.map((state) => ({
info: infos.get(state.id)!,
state,
}));
}); });
$effect(() => clearDownloadedFiles);
</script> </script>
<svelte:head> <svelte:head>
@@ -23,8 +35,10 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-2 pb-4"> <div class="space-y-2 pb-4">
{#each downloadingFiles as status} {#each downloadingFiles as { info, state } (info.id)}
<File {status} /> {#if info.exists}
<File {info} {state} />
{/if}
{/each} {/each}
</div> </div>
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { get, type Writable } from "svelte/store"; import type { FileDownloadState } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util"; import { formatNetworkSpeed } from "$lib/utils";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import IconCloud from "~icons/material-symbols/cloud"; import IconCloud from "~icons/material-symbols/cloud";
import IconCloudDownload from "~icons/material-symbols/cloud-download"; import IconCloudDownload from "~icons/material-symbols/cloud-download";
@@ -12,56 +11,49 @@
import IconError from "~icons/material-symbols/error"; import IconError from "~icons/material-symbols/error";
interface Props { interface Props {
status: Writable<FileDownloadStatus>; info: SummarizedFileInfo;
state: FileDownloadState;
} }
let { status }: Props = $props(); let { info, state }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
</script> </script>
{#if $fileInfo} <div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex h-14 items-center gap-x-4 p-2"> <div class="flex-shrink-0 text-lg text-gray-600">
<div class="flex-shrink-0 text-lg text-gray-600"> {#if state.status === "download-pending"}
{#if $status.status === "download-pending"} <IconCloud />
<IconCloud /> {:else if state.status === "downloading"}
{:else if $status.status === "downloading"} <IconCloudDownload />
<IconCloudDownload /> {:else if state.status === "decryption-pending"}
{:else if $status.status === "decryption-pending"} <IconLock />
<IconLock /> {:else if state.status === "decrypting"}
{:else if $status.status === "decrypting"} <IconLockClock />
<IconLockClock /> {:else if state.status === "decrypted"}
{:else if $status.status === "decrypted"} <IconCheckCircle class="text-green-500" />
<IconCheckCircle class="text-green-500" /> {:else if state.status === "error"}
{:else if $status.status === "error"} <IconError class="text-red-500" />
<IconError class="text-red-500" /> {/if}
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
복호화하는 중
{:else if $status.status === "decrypted"}
다운로드 완료
{:else if $status.status === "error"}
다운로드 실패
{/if}
</p>
</div>
</div> </div>
{/if} <div class="flex-grow overflow-hidden">
<p title={info.name} class="truncate font-medium">
{info.name}
</p>
<p class="text-xs text-gray-800">
{#if state.status === "download-pending"}
다운로드를 기다리는 중
{:else if state.status === "downloading"}
전송됨
{Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if state.status === "decryption-pending"}
복호화를 기다리는 중
{:else if state.status === "decrypting"}
복호화하는 중
{:else if state.status === "decrypted"}
다운로드 완료
{:else if state.status === "error"}
다운로드 실패
{/if}
</p>
</div>
</div>

View File

@@ -1,19 +1,12 @@
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { fileUploadStatusStore, isFileUploading } from "$lib/stores"; import { getUploadingFiles, clearUploadedFiles } from "$lib/modules/file";
import File from "./File.svelte"; import File from "./File.svelte";
let uploadingFiles = $derived( const uploadingFiles = getUploadingFiles();
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
);
$effect(() => () => { $effect(() => clearUploadedFiles);
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
isFileUploading(get(status).status),
);
});
</script> </script>
<svelte:head> <svelte:head>
@@ -23,8 +16,8 @@
<TopBar /> <TopBar />
<FullscreenDiv> <FullscreenDiv>
<div class="space-y-2 pb-4"> <div class="space-y-2 pb-4">
{#each uploadingFiles as status} {#each uploadingFiles as file}
<File {status} /> <File state={file} />
{/each} {/each}
</div> </div>
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { FileUploadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/modules/util"; import { formatNetworkSpeed } from "$lib/utils";
import type { FileUploadStatus } from "$lib/stores";
import IconPending from "~icons/material-symbols/pending"; import IconPending from "~icons/material-symbols/pending";
import IconLockClock from "~icons/material-symbols/lock-clock"; import IconLockClock from "~icons/material-symbols/lock-clock";
@@ -11,45 +10,47 @@
import IconError from "~icons/material-symbols/error"; import IconError from "~icons/material-symbols/error";
interface Props { interface Props {
status: Writable<FileUploadStatus>; state: FileUploadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
<div class="flex h-14 items-center gap-x-4 p-2"> <div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600"> <div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "encryption-pending"} {#if state.status === "queued" || state.status === "encryption-pending"}
<IconPending /> <IconPending />
{:else if $status.status === "encrypting"} {:else if state.status === "encrypting"}
<IconLockClock /> <IconLockClock />
{:else if $status.status === "upload-pending"} {:else if state.status === "upload-pending"}
<IconCloud /> <IconCloud />
{:else if $status.status === "uploading"} {:else if state.status === "uploading"}
<IconCloudUpload /> <IconCloudUpload />
{:else if $status.status === "uploaded"} {:else if state.status === "uploaded"}
<IconCloudDone class="text-blue-500" /> <IconCloudDone class="text-blue-500" />
{:else if $status.status === "error"} {:else if state.status === "error"}
<IconError class="text-red-500" /> <IconError class="text-red-500" />
{/if} {/if}
</div> </div>
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
<p title={$status.name} class="truncate font-medium"> <p title={state.name} class="truncate font-medium">
{$status.name} {state.name}
</p> </p>
<p class="text-xs text-gray-800"> <p class="text-xs text-gray-800">
{#if $status.status === "encryption-pending"} {#if state.status === "queued"}
대기 중
{:else if state.status === "encryption-pending"}
준비 중 준비 중
{:else if $status.status === "encrypting"} {:else if state.status === "encrypting"}
암호화하는 중 암호화하는 중
{:else if $status.status === "upload-pending"} {:else if state.status === "upload-pending"}
업로드를 기다리는 중 업로드를 기다리는 중
{:else if $status.status === "uploading"} {:else if state.status === "uploading"}
전송됨 전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)} {Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if $status.status === "uploaded"} {:else if state.status === "uploaded"}
업로드 완료 업로드 완료
{:else if $status.status === "error"} {:else if state.status === "error"}
업로드 실패 업로드 실패
{/if} {/if}
</p> </p>

View File

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const files = await createCaller(event).file.list();
return { files };
};

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { FileThumbnailButton, FullscreenDiv, RowVirtualizer } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import {
bulkGetFileInfo,
type MaybeFileInfo,
type SummarizedFileInfo,
} from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
let { data } = $props();
type Row =
| { type: "header"; label: string }
| { type: "items"; files: SummarizedFileInfo[]; isLast: boolean };
let files: MaybeFileInfo[] = $state([]);
let rows: Row[] = $derived.by(() => {
const groups = Map.groupBy(
files
.filter((file) => file.exists)
.filter(
(file) => file.contentType.startsWith("image/") || file.contentType.startsWith("video/"),
)
.map((file) => ({ ...file, date: file.createdAt ?? file.lastModifiedAt })),
(file) => formatDateSortable(file.date),
);
return Array.from(groups.entries())
.sort(([dateA], [dateB]) => dateB.localeCompare(dateA))
.flatMap(([, entries]) => {
const sortedEntries = sortEntries([...entries], SortBy.DATE_DESC);
return [
{
type: "header",
label: formatDate(sortedEntries[0]!.date),
},
...Array.from({ length: Math.ceil(sortedEntries.length / 4) }, (_, i) => {
const start = i * 4;
const end = start + 4;
return {
type: "items" as const,
files: sortedEntries.slice(start, end),
isLast: end >= sortedEntries.length,
};
}),
];
});
});
onMount(async () => {
files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values());
});
</script>
<svelte:head>
<title>사진 및 동영상</title>
</svelte:head>
<TopBar title="사진 및 동영상" />
<FullscreenDiv>
<RowVirtualizer
count={rows.length}
itemHeight={(index) =>
rows[index]!.type === "header" ? 28 : 181 + (rows[index]!.isLast ? 16 : 4)}
class="flex flex-grow flex-col"
>
{#snippet item(index)}
{@const row = rows[index]!}
{#if row.type === "header"}
<p class="pb-2 text-sm font-medium">{row.label}</p>
{:else}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.files as file (file.id)}
<FileThumbnailButton
info={file}
onclick={() => goto(`/file/${file.id}?from=gallery`)}
/>
{/each}
</div>
{/if}
{/snippet}
{#snippet placeholder()}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/snippet}
</RowVirtualizer>
</FullscreenDiv>

View File

@@ -1,5 +1,5 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { keyExportState } from "$lib/hooks/gotoStateful"; import { keyExportState } from "$lib/utils";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async () => { export const load: PageLoad = async () => {

View File

@@ -4,9 +4,9 @@
import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms"; import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms";
import { TitledDiv } from "$lib/components/molecules"; import { TitledDiv } from "$lib/components/molecules";
import { ForceLoginModal } from "$lib/components/organisms"; import { ForceLoginModal } from "$lib/components/organisms";
import { gotoStateful } from "$lib/hooks";
import { storeClientKeys } from "$lib/modules/key"; import { storeClientKeys } from "$lib/modules/key";
import { clientKeyStore } from "$lib/stores"; import { clientKeyStore } from "$lib/stores";
import { gotoStateful } from "$lib/utils";
import Order from "./Order.svelte"; import Order from "./Order.svelte";
import { import {
generateClientKeys, generateClientKeys,

View File

@@ -1,42 +1,39 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import type { FileCacheIndex } from "$lib/indexedDB"; import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file"; import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
interface FileCache { interface FileCache {
index: FileCacheIndex; index: FileCacheIndex;
fileInfo: Writable<FileInfo | null>; info: MaybeFileInfo;
} }
let fileCache: FileCache[] | undefined = $state(); let fileCache: FileCache[] | undefined = $state();
let fileCacheTotalSize = $state(0); let fileCacheTotalSize = $derived(
fileCache?.reduce((acc, { index }) => acc + index.size, 0) ?? 0,
);
const deleteFileCache = async (fileId: number) => { const deleteFileCache = async (fileId: number) => {
await doDeleteFileCache(fileId); await doDeleteFileCache(fileId);
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId); fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
}; };
onMount(() => { onMount(async () => {
fileCache = getFileCacheIndex() const indexes = getFileCacheIndex();
.map((index) => ({ const infos = await bulkGetFileInfo(
index, indexes.map(({ fileId }) => fileId),
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!), $masterKeyStore?.get(1)?.key!,
})) );
fileCache = indexes
.map((index) => ({ index, info: infos.get(index.fileId)! }))
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime()); .sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
}); });
$effect(() => {
if (fileCache) {
fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0);
}
});
</script> </script>
<svelte:head> <svelte:head>
@@ -55,8 +52,8 @@
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p> <p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
{#each fileCache as { index, fileInfo }} {#each fileCache as { index, info } (info.id)}
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} /> <File {index} {info} onDeleteClick={deleteFileCache} />
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB"; import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfo } from "$lib/modules/filesystem"; import type { MaybeFileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/modules/util"; import { formatDate, formatFileSize } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
import IconScanDelete from "~icons/material-symbols/scan-delete"; import IconScanDelete from "~icons/material-symbols/scan-delete";
@@ -10,7 +9,7 @@
interface Props { interface Props {
index: FileCacheIndex; index: FileCacheIndex;
info: Writable<FileInfo | null>; info: MaybeFileInfo;
onDeleteClick: (fileId: number) => void; onDeleteClick: (fileId: number) => void;
} }
@@ -18,7 +17,7 @@
</script> </script>
<div class="flex h-14 items-center gap-x-4 p-2"> <div class="flex h-14 items-center gap-x-4 p-2">
{#if $info} {#if info.exists}
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl"> <div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
<IconDraft class="text-blue-400" /> <IconDraft class="text-blue-400" />
</div> </div>
@@ -28,8 +27,8 @@
</div> </div>
{/if} {/if}
<div class="flex-grow overflow-hidden"> <div class="flex-grow overflow-hidden">
{#if $info} {#if info.exists}
<p title={$info.name} class="truncate font-medium">{$info.name}</p> <p title={info.name} class="truncate font-medium">{info.name}</p>
{:else} {:else}
<p class="font-medium">삭제된 파일</p> <p class="font-medium">삭제된 파일</p>
{/if} {/if}

View File

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const files = await createCaller(event).file.listWithoutThumbnail();
return { files };
};

View File

@@ -1,39 +1,52 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms"; import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules"; import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file"; import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { sortEntries } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
import { import {
persistentStates, getThumbnailGenerationStatus,
getGenerationStatus, clearThumbnailGenerationStatuses,
requestThumbnailGeneration, requestThumbnailGeneration,
} from "./service.svelte"; type GenerationStatus,
} from "./service";
import IconDelete from "~icons/material-symbols/delete"; import IconDelete from "~icons/material-symbols/delete";
let { data } = $props(); let { data } = $props();
let fileInfos: MaybeFileInfo[] = $state([]);
let files = $derived(
fileInfos
.map((info) => ({
info,
status: getThumbnailGenerationStatus(info.id),
}))
.filter(
(file): file is { info: MaybeFileInfo; status: Exclude<GenerationStatus, "uploaded"> } =>
file.status !== "uploaded",
),
);
const generateAllThumbnails = () => { const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => { files.forEach(({ info }) => {
const fileInfo = get(info); if (info.exists) {
if (fileInfo) { requestThumbnailGeneration(info);
requestThumbnailGeneration(fileInfo);
} }
}); });
}; };
onMount(() => { onMount(async () => {
persistentStates.files = data.files.map((fileId) => ({ fileInfos = sortEntries(
id: fileId, Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()),
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!), );
status: getGenerationStatus(fileId),
}));
}); });
$effect(() => clearThumbnailGenerationStatuses);
</script> </script>
<svelte:head> <svelte:head>
@@ -48,28 +61,30 @@
저장된 썸네일 모두 삭제하기 저장된 썸네일 모두 삭제하기
</IconEntryButton> </IconEntryButton>
</div> </div>
{#if persistentStates.files.length > 0} {#if files.length > 0}
<div class="flex-grow space-y-2 bg-white p-4"> <div class="flex-grow space-y-2 bg-white p-4">
<p class="text-lg font-bold text-gray-800">썸네일이 누락된 파일</p> <p class="text-lg font-bold text-gray-800">썸네일이 누락된 파일</p>
<div class="space-y-4"> <div class="space-y-4">
<p class="break-keep text-gray-800"> <p class="break-keep text-gray-800">
{persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요. {files.length}개 파일의 썸네일이 존재하지 않아요.
</p> </p>
<div class="space-y-2"> <div class="space-y-2">
{#each persistentStates.files as { info, status }} {#each files as { info, status } (info.id)}
<File {#if info.exists}
{info} <File
generationStatus={status} {info}
onclick={({ id }) => goto(`/file/${id}`)} {status}
onGenerateThumbnailClick={requestThumbnailGeneration} onclick={({ id }) => goto(`/file/${id}`)}
/> onGenerateThumbnailClick={requestThumbnailGeneration}
/>
{/if}
{/each} {/each}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{#if persistentStates.files.length > 0} {#if files.length > 0}
<BottomDiv class="px-4"> <BottomDiv class="px-4">
<Button onclick={generateAllThumbnails} class="w-full">모두 썸네일 생성하기</Button> <Button onclick={generateAllThumbnails} class="w-full">모두 썸네일 생성하기</Button>
</BottomDiv> </BottomDiv>

View File

@@ -1,14 +0,0 @@
import { error } from "@sveltejs/kit";
import { useTRPC } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const trpc = useTRPC(fetch);
try {
const files = await trpc.file.listWithoutThumbnail.query();
return { files };
} catch {
error(500, "Internal server error");
}
};

View File

@@ -10,37 +10,33 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem"; import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util"; import { formatDateTime } from "$lib/utils";
import type { GenerationStatus } from "./service.svelte"; import type { GenerationStatus } from "./service";
import IconCamera from "~icons/material-symbols/camera"; import IconCamera from "~icons/material-symbols/camera";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: FileInfo;
onclick: (selectedFile: FileInfo) => void; onclick: (file: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void; onGenerateThumbnailClick: (file: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>; status: Exclude<GenerationStatus, "uploaded"> | undefined;
} }
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props(); let { info, onclick, onGenerateThumbnailClick, status }: Props = $props();
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton class="h-14"
class="h-14" onclick={() => onclick(info)}
onclick={() => onclick($info)} actionButtonIcon={!status || status === "error" ? IconCamera : undefined}
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} onActionButtonClick={() => onGenerateThumbnailClick(info)}
onActionButtonClick={() => onGenerateThumbnailClick($info)} actionButtonClass="text-gray-800"
actionButtonClass="text-gray-800" >
> {@const subtext = status
{@const subtext = ? subtexts[status]
$generationStatus && $generationStatus !== "uploaded" : formatDateTime(info.createdAt ?? info.lastModifiedAt)}
? subtexts[$generationStatus] <DirectoryEntryLabel type="file" name={info.name} {subtext} />
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)} </ActionEntryButton>
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
</ActionEntryButton>
{/if}

View File

@@ -1,158 +0,0 @@
import { limitFunction } from "p-limit";
import { get, writable, type Writable } from "svelte/store";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
export type GenerationStatus =
| "queued"
| "generation-pending"
| "generating"
| "upload-pending"
| "uploading"
| "uploaded"
| "error";
interface File {
id: number;
info: Writable<FileInfo | null>;
status?: Writable<GenerationStatus>;
}
const workingFiles = new Map<number, Writable<GenerationStatus>>();
let queue: (() => void)[] = [];
let memoryUsage = 0;
const memoryLimit = 100 * 1024 * 1024; // 100 MiB
export const persistentStates = $state({
files: [] as File[],
});
export const getGenerationStatus = (fileId: number) => {
return workingFiles.get(fileId);
};
const generateThumbnail = limitFunction(
async (
status: Writable<GenerationStatus>,
fileBuffer: ArrayBuffer,
fileType: string,
dataKey: CryptoKey,
) => {
status.set("generating");
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
if (!thumbnail) return null;
const thumbnailBuffer = await thumbnail.arrayBuffer();
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
status.set("upload-pending");
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
},
{ concurrency: 4 },
);
const requestThumbnailUpload = limitFunction(
async (
status: Writable<GenerationStatus>,
fileId: number,
dataKeyVersion: Date,
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
) => {
status.set("uploading");
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
if (!res.ok) return false;
status.set("uploaded");
workingFiles.delete(fileId);
persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId);
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
return true;
},
{ concurrency: 4 },
);
const enqueue = async (
status: Writable<GenerationStatus> | undefined,
fileInfo: FileInfo,
priority = false,
) => {
if (status) {
status.set("queued");
} else {
status = writable("queued");
workingFiles.set(fileInfo.id, status);
persistentStates.files = persistentStates.files.map((file) =>
file.id === fileInfo.id ? { ...file, status } : file,
);
}
let resolver;
const promise = new Promise((resolve) => {
resolver = resolve;
});
if (priority) {
queue = [resolver!, ...queue];
} else {
queue.push(resolver!);
}
await promise;
};
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
let status = workingFiles.get(fileInfo.id);
if (status && get(status) !== "error") return;
if (workingFiles.values().some((status) => get(status) !== "error")) {
await enqueue(status, fileInfo);
}
while (memoryUsage >= memoryLimit) {
await enqueue(status, fileInfo, true);
}
if (status) {
status.set("generation-pending");
} else {
status = writable("generation-pending");
workingFiles.set(fileInfo.id, status);
persistentStates.files = persistentStates.files.map((file) =>
file.id === fileInfo.id ? { ...file, status } : file,
);
}
let fileSize = 0;
try {
const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!);
fileSize = file.byteLength;
memoryUsage += fileSize;
if (memoryUsage < memoryLimit) {
queue.shift()?.();
}
const thumbnail = await generateThumbnail(
status,
file,
fileInfo.contentType,
fileInfo.dataKey!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail))
) {
status.set("error");
}
} catch {
status.set("error");
} finally {
memoryUsage -= fileSize;
queue.shift()?.();
}
};

View File

@@ -0,0 +1,102 @@
import { limitFunction } from "p-limit";
import { SvelteMap } from "svelte/reactivity";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import { Scheduler } from "$lib/modules/scheduler";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
export type GenerationStatus =
| "queued"
| "generation-pending"
| "generating"
| "upload-pending"
| "uploading"
| "uploaded"
| "error";
const scheduler = new Scheduler();
const statuses = new SvelteMap<number, GenerationStatus>();
export const getThumbnailGenerationStatus = (fileId: number) => {
return statuses.get(fileId);
};
export const clearThumbnailGenerationStatuses = () => {
for (const [id, status] of statuses) {
if (status === "uploaded" || status === "error") {
statuses.delete(id);
}
}
};
const generateThumbnail = limitFunction(
async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => {
statuses.set(fileId, "generating");
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
if (!thumbnail) return null;
const thumbnailBuffer = await thumbnail.arrayBuffer();
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
statuses.set(fileId, "upload-pending");
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
},
{ concurrency: 4 },
);
const requestThumbnailUpload = limitFunction(
async (
fileId: number,
dataKeyVersion: Date,
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
) => {
statuses.set(fileId, "uploading");
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
if (!res.ok) return false;
statuses.set(fileId, "uploaded");
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
return true;
},
{ concurrency: 4 },
);
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
const status = statuses.get(fileInfo.id);
if (status) {
if (status !== "error") return;
} else {
statuses.set(fileInfo.id, "queued");
}
try {
let file: ArrayBuffer | undefined;
await scheduler.schedule(
async () => {
statuses.set(fileInfo.id, "generation-pending");
file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!);
return file.byteLength;
},
async () => {
const thumbnail = await generateThumbnail(
fileInfo.id,
file!,
fileInfo.contentType,
fileInfo.dataKey?.key!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
) {
statuses.set(fileInfo.id, "error");
}
},
);
} catch (e) {
statuses.set(fileInfo.id, "error");
throw e;
}
};

View File

@@ -22,7 +22,7 @@
<AdaptiveDiv class="flex justify-evenly px-4 py-2"> <AdaptiveDiv class="flex justify-evenly px-4 py-2">
{#each pages as { path, label, icon: Icon }} {#each pages as { path, label, icon: Icon }}
<button <button
onclick={() => goto(path)} onclick={() => goto(path, { replaceState: true })}
class={[ class={[
"w-16 active:rounded-xl active:bg-gray-100", "w-16 active:rounded-xl active:bg-gray-100",
!page.url.pathname.startsWith(path) && "text-gray-600", !page.url.pathname.startsWith(path) && "text-gray-600",

View File

@@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules"; import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { Category, CategoryCreateModal } from "$lib/components/organisms"; import { SubCategories, TopBar } from "$lib/components/molecules";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { CategoryCreateModal } from "$lib/components/organisms";
import { updateCategoryInfo } from "$lib/indexedDB";
import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { HybridPromise, sortEntries } from "$lib/utils";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
import CategoryRenameModal from "./CategoryRenameModal.svelte"; import CategoryRenameModal from "./CategoryRenameModal.svelte";
import File from "./File.svelte";
import { import {
createContext, createContext,
requestCategoryCreation, requestCategoryCreation,
@@ -16,33 +19,50 @@
requestCategoryDeletion, requestCategoryDeletion,
} from "./service.svelte"; } from "./service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<CategoryInfo | null> | undefined = $state(); let info: MaybeCategoryInfo | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
let isCategoryRenameModalOpen = $state(false); let isCategoryRenameModalOpen = $state(false);
let isCategoryDeleteModalOpen = $state(false); let isCategoryDeleteModalOpen = $state(false);
let lastCategoryId: CategoryId | undefined = $state();
let lastIsFileRecursive: boolean | undefined = $state();
let files = $derived(
sortEntries(
info?.files
?.map((file) => ({ name: file.name, details: file }))
.filter(({ details }) => info?.isFileRecursive || !details.isRecursive) ?? [],
),
);
$effect(() => { $effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); if (!info || info.id === "root" || info.isFileRecursive === undefined) return;
isFileRecursive = undefined; if (lastCategoryId !== info.id) {
lastCategoryId = info.id;
lastIsFileRecursive = info.isFileRecursive;
return;
}
if (lastIsFileRecursive === info.isFileRecursive) return;
lastIsFileRecursive = info.isFileRecursive;
void updateCategoryInfo(info.id, { isFileRecursive: info.isFileRecursive });
}); });
$effect(() => { $effect(() => {
if ($info && isFileRecursive === undefined) { HybridPromise.resolve(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then(
isFileRecursive = $info.isFileRecursive ?? false; (result) => {
} if (data.id === result.id) {
}); info = result;
}
$effect(() => { },
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) { );
updateCategoryInfo(data.id as number, { isFileRecursive });
}
}); });
</script> </script>
@@ -50,26 +70,56 @@
<title>카테고리</title> <title>카테고리</title>
</svelte:head> </svelte:head>
{#if data.id !== "root"} {#if info?.id !== "root"}
<TopBar title={$info?.name} /> <TopBar title={info?.name} />
{/if} {/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]"> <div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info && isFileRecursive !== undefined} {#if info?.exists}
<Category <div class="space-y-4">
bind:isFileRecursive <div class="space-y-4 bg-white p-4">
info={$info} {#if info.id !== "root"}
onFileClick={({ id }) => goto(`/file/${id}`)} <p class="text-lg font-bold text-gray-800">하위 카테고리</p>
onFileRemoveClick={async ({ id }) => { {/if}
await requestFileRemovalFromCategory(id, data.id as number); <SubCategories
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME {info}
}} onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} onSubCategoryMenuClick={(subCategory) => {
onSubCategoryMenuClick={(subCategory) => { context.selectedCategory = subCategory;
context.selectedCategory = subCategory; isCategoryMenuBottomSheetOpen = true;
isCategoryMenuBottomSheetOpen = true; }}
}} 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={info.isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
</div>
<RowVirtualizer count={files.length} itemHeight={() => 48} itemGap={4}>
{#snippet item(index)}
{@const { details } = files[index]!}
<File
info={details}
onclick={({ id }) => goto(`/file/${id}?from=category`)}
onRemoveClick={!details.isRecursive
? async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);
void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}
: undefined}
/>
{/snippet}
{#snippet placeholder()}
<p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
{/snippet}
</RowVirtualizer>
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
@@ -77,7 +127,7 @@
bind:isOpen={isCategoryCreateModalOpen} bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => { onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -99,7 +149,7 @@
bind:isOpen={isCategoryRenameModalOpen} bind:isOpen={isCategoryRenameModalOpen}
onRenameClick={async (newName: string) => { onRenameClick={async (newName: string) => {
if (await requestCategoryRename(context.selectedCategory!, newName)) { if (await requestCategoryRename(context.selectedCategory!, newName)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -109,7 +159,7 @@
bind:isOpen={isCategoryDeleteModalOpen} bind:isOpen={isCategoryDeleteModalOpen}
onDeleteClick={async () => { onDeleteClick={async () => {
if (await requestCategoryDeletion(context.selectedCategory!)) { if (await requestCategoryDeletion(context.selectedCategory!)) {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ActionModal } from "$lib/components/molecules"; import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/modules/util"; import { truncateString } from "$lib/utils";
import { useContext } from "./service.svelte"; import { useContext } from "./service.svelte";
interface Props { interface Props {

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file";
import type { CategoryFileInfo } from "$lib/modules/filesystem";
import type { SelectedFile } from "./service.svelte";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: CategoryFileInfo;
onclick: (file: SelectedFile) => void;
onRemoveClick?: (file: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info));
</script>
<ActionEntryButton
class="h-12"
onclick={() => onclick(info)}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={() => onRemoveClick?.(info)}
>
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
</ActionEntryButton>

View File

@@ -1,10 +1,15 @@
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { encryptString } from "$lib/modules/crypto"; import { encryptString } from "$lib/modules/crypto";
import type { SelectedCategory } from "$lib/components/molecules"; import type { SelectedCategory } from "$lib/components/molecules";
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export interface SelectedFile {
id: number;
name: string;
}
export const createContext = () => { export const createContext = () => {
const context = $state({ const context = $state({
selectedCategory: undefined as SelectedCategory | undefined, selectedCategory: undefined as SelectedCategory | undefined,
@@ -17,13 +22,17 @@ export const useContext = () => {
}; };
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
const trpc = useTRPC(); if (!category.dataKey) {
const newNameEncrypted = await encryptString(newName, category.dataKey); // TODO: Error Handling
return false;
}
const newNameEncrypted = await encryptString(newName, category.dataKey.key);
try { try {
await trpc.category.rename.mutate({ await trpc().category.rename.mutate({
id: category.id, id: category.id,
dekVersion: category.dataKeyVersion, dekVersion: category.dataKey.version,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });
@@ -35,10 +44,8 @@ export const requestCategoryRename = async (category: SelectedCategory, newName:
}; };
export const requestCategoryDeletion = async (category: SelectedCategory) => { export const requestCategoryDeletion = async (category: SelectedCategory) => {
const trpc = useTRPC();
try { try {
await trpc.category.delete.mutate({ id: category.id }); await trpc().category.delete.mutate({ id: category.id });
return true; return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FloatingButton } from "$lib/components/atoms"; import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules"; import { TopBar } from "$lib/components/molecules";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem"; import { getDirectoryInfo, type MaybeDirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores"; import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import { HybridPromise } from "$lib/utils";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
import DownloadStatusCard from "./DownloadStatusCard.svelte"; import DownloadStatusCard from "./DownloadStatusCard.svelte";
@@ -29,7 +30,7 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<DirectoryInfo | null> | undefined = $state(); let info: MaybeDirectoryInfo | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state(); let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -42,6 +43,9 @@
let isEntryRenameModalOpen = $state(false); let isEntryRenameModalOpen = $state(false);
let isEntryDeleteModalOpen = $state(false); let isEntryDeleteModalOpen = $state(false);
let isFromFilePage = $derived(page.url.searchParams.get("from") === "file");
let showTopBar = $derived(data.id !== "root" || isFromFilePage);
const uploadFile = () => { const uploadFile = () => {
const files = fileInput?.files; const files = fileInput?.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@@ -57,7 +61,7 @@
.then((res) => { .then((res) => {
if (!res) return; if (!res) return;
// TODO: FIXME // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}) })
.catch((e: Error) => { .catch((e: Error) => {
// TODO: FIXME // TODO: FIXME
@@ -75,7 +79,13 @@
}); });
$effect(() => { $effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); HybridPromise.resolve(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then(
(result) => {
if (data.id === result.id) {
info = result;
}
},
);
}); });
</script> </script>
@@ -86,23 +96,30 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" /> <input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
{#if data.id !== "root"} {#if showTopBar}
<TopBar title={$info?.name} class="flex-shrink-0" /> <TopBar title={info?.name} class="flex-shrink-0" />
{/if} {/if}
{#if $info} {#if info?.exists}
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}> <div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "pt-4"]}>
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} /> <UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} /> <DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div> </div>
{#key $info} {#key info.id}
<DirectoryEntries <DirectoryEntries
info={$info} {info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)} onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => { onEntryMenuClick={(entry) => {
context.selectedEntry = entry; context.selectedEntry = entry;
isEntryMenuBottomSheetOpen = true; isEntryMenuBottomSheetOpen = true;
}} }}
showParentEntry={isFromFilePage && info.parentId !== undefined}
onParentClick={() =>
goto(
info!.parentId === "root"
? "/directory?from=file"
: `/directory/${info!.parentId}?from=file`,
)}
/> />
{/key} {/key}
</div> </div>
@@ -131,7 +148,7 @@
bind:isOpen={isDirectoryCreateModalOpen} bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => { onCreateClick={async (name) => {
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -165,7 +182,7 @@
bind:isOpen={isEntryRenameModalOpen} bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => { onRenameClick={async (newName: string) => {
if (await requestEntryRename(context.selectedEntry!, newName)) { if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -175,7 +192,7 @@
bind:isOpen={isEntryDeleteModalOpen} bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => { onDeleteClick={async () => {
if (await requestEntryDeletion(context.selectedEntry!)) { if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;

View File

@@ -1,19 +1,9 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { get, type Writable } from "svelte/store"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import { import { getUploadingFiles, type LiveFileUploadState } from "$lib/modules/file";
getDirectoryInfo, import type { DirectoryInfo } from "$lib/modules/filesystem";
getFileInfo, import { sortEntries } from "$lib/utils";
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import File from "./File.svelte"; import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte"; import SubDirectory from "./SubDirectory.svelte";
import UploadingFile from "./UploadingFile.svelte"; import UploadingFile from "./UploadingFile.svelte";
@@ -23,102 +13,59 @@
info: DirectoryInfo; info: DirectoryInfo;
onEntryClick: (entry: SelectedEntry) => void; onEntryClick: (entry: SelectedEntry) => void;
onEntryMenuClick: (entry: SelectedEntry) => void; onEntryMenuClick: (entry: SelectedEntry) => void;
sortBy?: SortBy; onParentClick?: () => void;
showParentEntry?: boolean;
} }
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props(); let {
info,
onEntryClick,
onEntryMenuClick,
onParentClick,
showParentEntry = false,
}: Props = $props();
interface DirectoryEntry { type Entry =
name?: string; | { type: "parent" }
info: Writable<DirectoryInfo | null>; | { type: "directory"; name: string; details: (typeof info.subDirectories)[number] }
} | { type: "file"; name: string; details: (typeof info.files)[number] }
| { type: "uploading-file"; name: string; details: LiveFileUploadState };
type FileEntry = const toEntry =
| { <T extends Exclude<Entry["type"], "parent">>(type: T) =>
type: "file"; (details: Extract<Entry, { type: T }>["details"]) => ({ type, name: details.name, details });
name?: string;
info: Writable<FileInfo | null>;
}
| {
type: "uploading-file";
name: string;
info: Writable<FileUploadStatus>;
};
let subDirectories: DirectoryEntry[] = $state([]); let entries = $derived([
let files: FileEntry[] = $state([]); ...(showParentEntry ? ([{ type: "parent" }] as const) : []),
...sortEntries(info.subDirectories.map(toEntry("directory"))),
$effect(() => { ...sortEntries([
// TODO: Fix duplicated requests ...info.files.map(toEntry("file")),
...(getUploadingFiles(info.id) as LiveFileUploadState[]).map(toEntry("uploading-file")),
subDirectories = info.subDirectoryIds.map((id) => { ]),
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!); ]);
return { name: get(info)?.name, info };
});
files = info.fileIds
.map((id): FileEntry => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
type: "file",
name: get(info)?.name,
info,
};
})
.concat(
$fileUploadStatusStore
.filter((statusStore) => {
const { parentId, status } = get(statusStore);
return parentId === info.id && isFileUploading(status);
})
.map((status) => ({
type: "uploading-file",
name: get(status).name,
info: status,
})),
);
const sort = () => {
sortEntries(subDirectories, sortBy);
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if subDirectories.length + files.length > 0} {#if entries.length > 0}
<div class="space-y-1 pb-[4.5rem]"> <RowVirtualizer count={entries.length} itemHeight={() => 56} itemGap={4} class="pb-[4.5rem]">
{#each subDirectories as { info }} {#snippet item(index)}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> {@const entry = entries[index]!}
{/each} {#if entry.type === "parent"}
{#each files as file} <ActionEntryButton class="h-14" onclick={onParentClick}>
{#if file.type === "file"} <DirectoryEntryLabel type="parent-directory" name=".." />
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> </ActionEntryButton>
{:else if entry.type === "directory"}
<SubDirectory
info={entry.details}
onclick={onEntryClick}
onOpenMenuClick={onEntryMenuClick}
/>
{:else if entry.type === "file"}
<File info={entry.details} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else} {:else}
<UploadingFile status={file.info} /> <UploadingFile state={entry.details} />
{/if} {/if}
{/each} {/snippet}
</div> </RowVirtualizer>
{:else} {:else}
<div class="flex flex-grow items-center justify-center"> <div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">폴더가 비어 있어요.</p> <p class="text-gray-500">폴더가 비어 있어요.</p>

View File

@@ -1,66 +1,38 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem"; import { getFileThumbnail } from "$lib/modules/file";
import { formatDateTime } from "$lib/modules/util"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "./service"; import { formatDateTime } from "$lib/utils";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert"; import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: SummarizedFileInfo;
onclick: (selectedEntry: SelectedEntry) => void; onclick: (entry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void; onOpenMenuClick: (entry: SelectedEntry) => void;
} }
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
let thumbnail: string | undefined = $state(); let thumbnail = $derived(getFileThumbnail(info));
const openFile = () => { const action = (callback: typeof onclick) => {
const { id, dataKey, dataKeyVersion, name } = $info!; callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
}; };
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton class="h-14"
class="h-14" onclick={() => action(onclick)}
onclick={openFile} actionButtonIcon={IconMoreVert}
actionButtonIcon={IconMoreVert} onActionButtonClick={() => action(onOpenMenuClick)}
onActionButtonClick={openMenu} >
> <DirectoryEntryLabel
<DirectoryEntryLabel type="file"
type="file" thumbnail={$thumbnail}
{thumbnail} name={info.name}
name={$info.name} subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)} />
/> </ActionEntryButton>
</ActionEntryButton>
{/if}

View File

@@ -1,44 +1,29 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms"; import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { DirectoryInfo } from "$lib/modules/filesystem"; import type { SubDirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert"; import IconMoreVert from "~icons/material-symbols/more-vert";
type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props { interface Props {
info: Writable<DirectoryInfo | null>; info: SubDirectoryInfo;
onclick: (selectedEntry: SelectedEntry) => void; onclick: (entry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void; onOpenMenuClick: (entry: SelectedEntry) => void;
} }
let { info, onclick, onOpenMenuClick }: Props = $props(); let { info, onclick, onOpenMenuClick }: Props = $props();
const openDirectory = () => { const action = (callback: typeof onclick) => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo; callback({ type: "directory", id: info.id, dataKey: info.dataKey, name: info.name });
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
}; };
</script> </script>
{#if $info} <ActionEntryButton
<ActionEntryButton class="h-14"
class="h-14" onclick={() => action(onclick)}
onclick={openDirectory} actionButtonIcon={IconMoreVert}
actionButtonIcon={IconMoreVert} onActionButtonClick={() => action(onOpenMenuClick)}
onActionButtonClick={openMenu} >
> <DirectoryEntryLabel type="directory" name={info.name} />
<DirectoryEntryLabel type="directory" name={$info.name!} /> </ActionEntryButton>
</ActionEntryButton>
{/if}

View File

@@ -1,38 +1,37 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { LiveFileUploadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/modules/util"; import { formatNetworkSpeed } from "$lib/utils";
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
interface Props { interface Props {
status: Writable<FileUploadStatus>; state: LiveFileUploadState;
} }
let { status }: Props = $props(); let { state }: Props = $props();
</script> </script>
{#if isFileUploading($status.status)} <div class="flex h-14 gap-x-4 p-2">
<div class="flex h-14 gap-x-4 p-2"> <div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl"> <IconDraft class="text-gray-600" />
<IconDraft class="text-gray-600" />
</div>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs">
{#if $status.status === "encryption-pending"}
준비 중
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if $status.status === "uploading"}
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>
</div> </div>
{/if} <div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={state.name} class="truncate font-medium">
{state.name}
</p>
<p class="text-xs">
{#if state.status === "queued"}
대기 중
{:else if state.status === "encryption-pending"}
준비 중
{:else if state.status === "encrypting"}
암호화하는 중
{:else if state.status === "upload-pending"}
업로드를 기다리는 중
{:else if state.status === "uploading"}
전송됨 {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if}
</p>
</div>
</div>

View File

@@ -1 +0,0 @@
export { requestFileThumbnailDownload } from "$lib/services/file";

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { getDownloadingFiles } from "$lib/modules/file";
import { get, type Writable } from "svelte/store";
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -9,23 +7,7 @@
let { onclick }: Props = $props(); let { onclick }: Props = $props();
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]); let downloadingFiles = $derived(getDownloadingFiles());
$effect(() => {
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
return untrack(() => {
const unsubscribes = downloadingFiles.map((downloadingFile) =>
downloadingFile.subscribe(({ status }) => {
if (!isFileDownloading(status)) {
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if downloadingFiles.length > 0} {#if downloadingFiles.length > 0}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ActionModal } from "$lib/components/molecules"; import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/modules/util"; import { truncateString } from "$lib/utils";
interface Props { interface Props {
file: File | undefined; file: File | undefined;

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ActionModal } from "$lib/components/molecules"; import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/modules/util"; import { truncateString } from "$lib/utils";
import { useContext } from "./service.svelte"; import { useContext } from "./service.svelte";
interface Props { interface Props {

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { getUploadingFiles } from "$lib/modules/file";
import { get, type Writable } from "svelte/store";
import { fileUploadStatusStore, isFileUploading, type FileUploadStatus } from "$lib/stores";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -9,21 +7,7 @@
let { onclick }: Props = $props(); let { onclick }: Props = $props();
let uploadingFiles: Writable<FileUploadStatus>[] = $state([]); let uploadingFiles = $derived(getUploadingFiles());
$effect(() => {
uploadingFiles = $fileUploadStatusStore.filter((status) => isFileUploading(get(status).status));
return untrack(() => {
const unsubscribes = uploadingFiles.map((uploadingFile) =>
uploadingFile.subscribe(({ status }) => {
if (!isFileUploading(status)) {
uploadingFiles = uploadingFiles.filter((file) => file !== uploadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script> </script>
{#if uploadingFiles.length > 0} {#if uploadingFiles.length > 0}

View File

@@ -8,14 +8,14 @@ import {
deleteFileThumbnailCache, deleteFileThumbnailCache,
uploadFile, uploadFile,
} from "$lib/modules/file"; } from "$lib/modules/file";
import type { DataKey } from "$lib/modules/filesystem";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { useTRPC } from "$trpc/client"; import { trpc } from "$trpc/client";
export interface SelectedEntry { export interface SelectedEntry {
type: "directory" | "file"; type: "directory" | "file";
id: number; id: number;
dataKey: CryptoKey; dataKey: DataKey | undefined;
dataKeyVersion: Date;
name: string; name: string;
} }
@@ -33,11 +33,9 @@ export const useContext = () => {
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => { export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation // TODO: MEK rotation
const trpc = useTRPC();
let hmacSecretsWrapped; let hmacSecretsWrapped;
try { try {
hmacSecretsWrapped = await trpc.hsk.list.query(); hmacSecretsWrapped = await trpc().hsk.list.query();
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling
return false; return false;
@@ -61,12 +59,11 @@ export const requestDirectoryCreation = async (
parentId: "root" | number, parentId: "root" | number,
masterKey: MasterKey, masterKey: MasterKey,
) => { ) => {
const trpc = useTRPC();
const { dataKey, dataKeyVersion } = await generateDataKey(); const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey); const nameEncrypted = await encryptString(name, dataKey);
try { try {
await trpc.directory.create.mutate({ await trpc().directory.create.mutate({
parent: parentId, parent: parentId,
mekVersion: masterKey.version, mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key), dek: await wrapDataKey(dataKey, masterKey.key),
@@ -100,21 +97,25 @@ export const requestFileUpload = async (
}; };
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
const trpc = useTRPC(); if (!entry.dataKey) {
const newNameEncrypted = await encryptString(newName, entry.dataKey); // TODO: Error Handling
return false;
}
const newNameEncrypted = await encryptString(newName, entry.dataKey.key);
try { try {
if (entry.type === "directory") { if (entry.type === "directory") {
await trpc.directory.rename.mutate({ await trpc().directory.rename.mutate({
id: entry.id, id: entry.id,
dekVersion: entry.dataKeyVersion, dekVersion: entry.dataKey.version,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });
} else { } else {
await trpc.file.rename.mutate({ await trpc().file.rename.mutate({
id: entry.id, id: entry.id,
dekVersion: entry.dataKeyVersion, dekVersion: entry.dataKey.version,
name: newNameEncrypted.ciphertext, name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv, nameIv: newNameEncrypted.iv,
}); });
@@ -127,11 +128,9 @@ export const requestEntryRename = async (entry: SelectedEntry, newName: string)
}; };
export const requestEntryDeletion = async (entry: SelectedEntry) => { export const requestEntryDeletion = async (entry: SelectedEntry) => {
const trpc = useTRPC();
try { try {
if (entry.type === "directory") { if (entry.type === "directory") {
const { deletedFiles } = await trpc.directory.delete.mutate({ id: entry.id }); const { deletedFiles } = await trpc().directory.delete.mutate({ id: entry.id });
await Promise.all( await Promise.all(
deletedFiles.flatMap((fileId) => [ deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId), deleteFileCache(fileId),
@@ -139,7 +138,7 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => {
]), ]),
); );
} else { } else {
await trpc.file.delete.mutate({ id: entry.id }); await trpc().file.delete.mutate({ id: entry.id });
await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]); await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]);
} }
return true; return true;

View File

@@ -1,3 +1,33 @@
<div class="flex h-full items-center justify-center p-4"> <script lang="ts">
<p class="text-gray-500">아직 개발 중이에요.</p> import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { EntryButton, FileThumbnailButton } from "$lib/components/atoms";
import { type SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFreshMediaFilesRetrieval } from "./service";
let mediaFiles: SummarizedFileInfo[] = $state([]);
onMount(async () => {
mediaFiles = await requestFreshMediaFilesRetrieval();
});
</script>
<svelte:head>
<title></title>
</svelte:head>
<div class="min-h-full space-y-4 bg-gray-100 px-4 pb-[5.5em] pt-4">
<p class="px-2 text-2xl font-bold text-gray-800">ArkVault</p>
<div class="rounded-xl bg-white p-2">
<EntryButton onclick={() => goto("/gallery")} class="w-full">
<p class="text-left font-semibold">사진 및 동영상</p>
</EntryButton>
{#if mediaFiles.length > 0}
<div class="grid grid-cols-4 gap-2 p-2">
{#each mediaFiles as file (file.id)}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/each}
</div>
{/if}
</div>
</div> </div>

View File

@@ -0,0 +1,14 @@
import { getAllFileInfos } from "$lib/indexedDB";
export const requestFreshMediaFilesRetrieval = async (limit = 8) => {
const files = await getAllFileInfos();
files.sort(
(a, b) =>
(b.createdAt ?? b.lastModifiedAt).getTime() - (a.createdAt ?? a.lastModifiedAt).getTime(),
);
return files
.filter(
({ contentType }) => contentType.startsWith("image/") || contentType.startsWith("video/"),
)
.slice(0, limit);
};

View File

@@ -0,0 +1,7 @@
import { createCaller } from "$trpc/router.server";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async (event) => {
const { nickname } = await createCaller(event).user.get();
return { nickname };
};

View File

@@ -1,14 +0,0 @@
import { error } from "@sveltejs/kit";
import { useTRPC } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const trpc = useTRPC(fetch);
try {
const { nickname } = await trpc.user.get.query();
return { nickname };
} catch {
error(500, "Internal server error");
}
};

View File

@@ -1,24 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto as svelteGoto } from "$app/navigation"; import { goto as svelteGoto } from "$app/navigation";
import { import { getDownloadingFiles, getUploadingFiles } from "$lib/modules/file";
fileUploadStatusStore, import { clientKeyStore, masterKeyStore } from "$lib/stores";
fileDownloadStatusStore,
isFileUploading,
isFileDownloading,
clientKeyStore,
masterKeyStore,
} from "$lib/stores";
import "../app.css"; import "../app.css";
let { children } = $props(); let { children } = $props();
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => { const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if ( if (getDownloadingFiles().length > 0 || getUploadingFiles().length > 0) {
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
) {
e.preventDefault(); e.preventDefault();
} }
}; };

View File

@@ -6,6 +6,7 @@ import type { RequestHandler } from "./$types";
const trpcHandler: RequestHandler = (event) => const trpcHandler: RequestHandler = (event) =>
fetchRequestHandler({ fetchRequestHandler({
endpoint: "/api/trpc", endpoint: "/api/trpc",
allowMethodOverride: true,
req: event.request, req: event.request,
router: appRouter, router: appRouter,
createContext: () => createContext(event), createContext: () => createContext(event),

View File

@@ -1,4 +1,4 @@
import { createTRPCClient, httpBatchLink } from "@trpc/client"; import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client";
import superjson from "superjson"; import superjson from "superjson";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import type { AppRouter } from "./router.server"; import type { AppRouter } from "./router.server";
@@ -8,6 +8,8 @@ const createClient = (fetch: typeof globalThis.fetch) =>
links: [ links: [
httpBatchLink({ httpBatchLink({
url: "/api/trpc", url: "/api/trpc",
maxURLLength: 4096,
methodOverride: "POST",
transformer: superjson, transformer: superjson,
fetch, fetch,
}), }),
@@ -16,10 +18,14 @@ const createClient = (fetch: typeof globalThis.fetch) =>
let browserClient: ReturnType<typeof createClient>; let browserClient: ReturnType<typeof createClient>;
export const useTRPC = (fetch = globalThis.fetch) => { export const trpc = (fetch = globalThis.fetch) => {
const client = browserClient ?? createClient(fetch); const client = browserClient ?? createClient(fetch);
if (browser) { if (browser) {
browserClient ??= client; browserClient ??= client;
} }
return client; return client;
}; };
export const isTRPCClientError = (e: unknown): e is TRPCClientError<AppRouter> => {
return e instanceof TRPCClientError;
};

View File

@@ -3,11 +3,10 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import { authorizeMiddleware, authorizeClientMiddleware } from "./middlewares/authorize"; import { authorizeMiddleware, authorizeClientMiddleware } from "./middlewares/authorize";
export const createContext = (event: RequestEvent) => event;
export type Context = Awaited<ReturnType<typeof createContext>>; export type Context = Awaited<ReturnType<typeof createContext>>;
export const createContext = (event: RequestEvent) => event;
export const t = initTRPC.context<Context>().create({ transformer: superjson }); export const t = initTRPC.context<Context>().create({ transformer: superjson });
export const router = t.router; export const router = t.router;
export const publicProcedure = t.procedure; export const publicProcedure = t.procedure;

Some files were not shown because too many files have changed in this diff Show More