23 Commits

Author SHA1 Message Date
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
80 changed files with 2381 additions and 2206 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.6.0", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -17,11 +17,12 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.0", "@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.13", "@tanstack/svelte-virtual": "^3.13.15",
"@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",
@@ -49,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"
}, },
@@ -63,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.4"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",

201
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.4
version: 4.2.1 version: 4.3.4
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0(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
@@ -55,8 +58,8 @@ importers:
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': '@tanstack/svelte-virtual':
specifier: ^3.13.13 specifier: ^3.13.15
version: 3.13.13(svelte@5.46.1) version: 3.13.15(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)
@@ -139,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)
@@ -316,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
@@ -617,13 +620,13 @@ 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.13': '@tanstack/svelte-virtual@3.13.15':
resolution: {integrity: sha512-VDOvbRw3R+XBQdFodEJ4E7AOmEyo3Bmr4zL4DLVnJ0fxICdbvY5F5t8zSwJ4f7lqjckXi0yKFzY8WBtjaNbsGQ==} resolution: {integrity: sha512-3PPLI3hsyT70zSZhBkSIZXIarlN+GjFNKeKr2Wk1UR7EuEVtXgNlB/Zk0sYtaeJ4CvGvldQNakOvbdETnWAgeA==}
peerDependencies: peerDependencies:
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
'@tanstack/virtual-core@3.13.13': '@tanstack/virtual-core@3.13.15':
resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} resolution: {integrity: sha512-8cG3acM2cSIm3h8WxboHARAhQAJbYUhvmadvnN8uz8aziDwrbYb9KiARni+uY2qrLh49ycn+poGoxvtIAKhjog==}
'@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==}
@@ -663,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':
@@ -827,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==}
@@ -1039,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:
@@ -1854,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'
@@ -1877,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
@@ -2022,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.4:
resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==}
snapshots: snapshots:
@@ -2114,7 +2117,7 @@ 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
@@ -2386,12 +2389,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@tanstack/svelte-virtual@3.13.13(svelte@5.46.1)': '@tanstack/svelte-virtual@3.13.15(svelte@5.46.1)':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.13 '@tanstack/virtual-core': 3.13.15
svelte: 5.46.1 svelte: 5.46.1
'@tanstack/virtual-core@3.13.13': {} '@tanstack/virtual-core@3.13.15': {}
'@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:
@@ -2428,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':
@@ -2564,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
@@ -2602,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)
@@ -2631,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:
@@ -2795,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
@@ -2828,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
@@ -2848,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
@@ -2875,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
@@ -3601,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
@@ -3615,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:
@@ -3704,4 +3707,4 @@ snapshots:
zimmerframe@1.1.4: {} zimmerframe@1.1.4: {}
zod@4.2.1: {} zod@4.3.4: {}

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 && $virtualizer.getVirtualItems().length === 0}
{@render placeholder()}
{/if}
</div>

View File

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

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($state.snapshot(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/utils";
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={subCategories} categories={info.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

@@ -1,11 +1,9 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules"; import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem"; import { updateCategoryInfo } from "$lib/indexedDB";
import { masterKeyStore } from "$lib/stores"; import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils"; import { sortEntries } from "$lib/utils";
import File from "./File.svelte"; import File from "./File.svelte";
import type { SelectedFile } from "./service"; import type { SelectedFile } from "./service";
@@ -13,13 +11,12 @@
interface Props { interface Props {
info: CategoryInfo; info: CategoryInfo;
isFileRecursive: boolean | undefined;
onFileClick: (file: SelectedFile) => void; onFileClick: (file: SelectedFile) => void;
onFileRemoveClick: (file: SelectedFile) => void; onFileRemoveClick: (file: SelectedFile) => void;
onSubCategoryClick: (subCategory: SelectedCategory) => void; onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void; onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void; onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
} }
let { let {
@@ -29,42 +26,31 @@
onSubCategoryClick, onSubCategoryClick,
onSubCategoryCreateClick, onSubCategoryCreateClick,
onSubCategoryMenuClick, onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(), isFileRecursive = $bindable(),
}: Props = $props(); }: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state( let lastCategoryId = $state<CategoryInfo["id"] | undefined>();
[], let lastIsFileRecursive = $state<boolean | undefined>();
let files = $derived(
sortEntries(
info.files
?.map((file) => ({ name: file.name, details: file }))
.filter(({ details }) => isFileRecursive || !details.isRecursive) ?? [],
),
); );
$effect(() => { $effect(() => {
files = if (info.id === "root" || isFileRecursive === undefined) return;
info.files if (lastCategoryId !== info.id) {
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive) lastCategoryId = info.id;
.map(({ id, isRecursive }) => { lastIsFileRecursive = isFileRecursive;
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!); return;
return { }
name: get(info)?.name, if (lastIsFileRecursive === isFileRecursive) return;
info,
isRecursive,
};
}) ?? [];
const sort = () => { lastIsFileRecursive = isFileRecursive;
sortEntries(files, sortBy); void updateCategoryInfo(info.id, { isFileRecursive });
};
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> </script>
@@ -89,19 +75,19 @@
<p class="font-medium">하위 카테고리의 파일</p> <p class="font-medium">하위 카테고리의 파일</p>
</CheckBox> </CheckBox>
</div> </div>
<div class="space-y-1"> <RowVirtualizer count={files.length} itemHeight={() => 48} itemGap={4}>
{#key info} {#snippet item(index)}
{#each files as { info, isRecursive }} {@const { details } = files[index]!}
<File <File
{info} info={details}
onclick={onFileClick} onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined} onRemoveClick={!details.isRecursive ? onFileRemoveClick : undefined}
/> />
{:else} {/snippet}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p> {#snippet placeholder()}
{/each} <p class="text-center text-gray-500">이 카테고리에 추가된 파일이 없어요.</p>
{/key} {/snippet}
</div> </RowVirtualizer>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,59 +1,28 @@
<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 { requestFileThumbnailDownload, type SelectedFile } from "./service"; import type { CategoryFileInfo } from "$lib/modules/filesystem";
import type { SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close"; import IconClose from "~icons/material-symbols/close";
interface Props { interface Props {
info: Writable<FileInfo | null>; info: CategoryFileInfo;
onclick: (selectedFile: SelectedFile) => void; onclick: (file: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void; onRemoveClick?: (file: SelectedFile) => void;
} }
let { info, onclick, onRemoveClick }: Props = $props(); let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state(); let thumbnail = $derived(getFileThumbnail(info));
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> </script>
{#if $info}
<ActionEntryButton <ActionEntryButton
class="h-12" class="h-12"
onclick={openFile} onclick={() => onclick(info)}
actionButtonIcon={onRemoveClick && IconClose} actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile} onActionButtonClick={() => onRemoveClick?.(info)}
> >
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} /> <DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
</ActionEntryButton> </ActionEntryButton>
{/if}

View File

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

View File

@@ -1,148 +1,78 @@
<script lang="ts"> <script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual"; import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import { untrack } from "svelte"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils"; import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props { interface Props {
files: Writable<FileInfo | null>[]; files: SummarizedFileInfo[];
onFileClick?: (file: FileInfo) => void; onFileClick?: (file: SummarizedFileInfo) => void;
} }
let { files, onFileClick }: Props = $props(); let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row = type Row =
| { type: "header"; key: string; label: string } | { type: "header"; label: string }
| { type: "items"; key: string; items: FileEntry[] }; | { type: "items"; files: SummarizedFileInfo[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]); let rows = $derived.by(() => {
let rows: Row[] = $state([]); const groups = Map.groupBy(
let listElement: HTMLDivElement | undefined = $state(); files.filter(
(file) => file.contentType.startsWith("image/") || file.contentType.startsWith("video/"),
const virtualizer = createWindowVirtualizer({ ),
count: 0, (file) => formatDateSortable(file.createdAt ?? file.lastModifiedAt),
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$effect(() => {
filesWithDate = files.map((file) => {
const info = get(file);
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!),
});
newRows.push({
type: "items",
key: `items-${date}`,
items: entries,
});
}
rows = newRows;
$virtualizer.setOptions({ count: rows.length });
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
); );
return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); return Array.from(groups.entries())
.sort(([dateA], [dateB]) => dateB.localeCompare(dateA))
.flatMap(([, entries]) => {
const sortedEntries = [...entries];
sortEntries(sortedEntries, SortBy.DATE_DESC);
return [
{
type: "header",
label: formatDate(sortedEntries[0]!.createdAt ?? sortedEntries[0]!.lastModifiedAt),
},
...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,
};
}),
] satisfies Row[];
}); });
}); });
</script> </script>
<div bind:this={listElement} class="relative flex flex-grow flex-col"> <RowVirtualizer
<div style="height: {$virtualizer.getTotalSize()}px;"> count={rows.length}
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)} itemHeight={(index) =>
{@const row = rows[virtualRow.index]!} rows[index]!.type === "header" ? 28 : 181 + (rows[index]!.isLast ? 16 : 4)}
<div class="flex flex-grow flex-col"
use:measureRow
data-index={virtualRow.index}
class="absolute left-0 top-0 w-full"
style="transform: translateY({virtualRow.start}px);"
> >
{#snippet item(index)}
{@const row = rows[index]!}
{#if row.type === "header"} {#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p> <p class="pb-2 text-sm font-medium">{row.label}</p>
{:else} {:else}
<div class="grid grid-cols-4 gap-1 pb-4"> <div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.items as { info }} {#each row.files as file (file.id)}
<FileThumbnailButton {info} onclick={onFileClick} /> <FileThumbnailButton info={file} onclick={onFileClick} />
{/each} {/each}
</div> </div>
{/if} {/if}
</div> {/snippet}
{/each} {#snippet placeholder()}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<div class="flex h-full flex-grow items-center justify-center"> <div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500"> <p class="text-gray-500">
{#if files.length === 0} {#if files.length === 0}
업로드된 파일이 없어요. 업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else} {:else}
사진 또는 동영상이 없어요. 사진 또는 동영상이 없어요.
{/if} {/if}
</p> </p>
</div> </div>
{/if} {/snippet}
</div> </RowVirtualizer>

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;
@@ -18,8 +16,6 @@ interface FileInfo {
categoryIds: number[]; categoryIds: number[];
} }
export type CategoryId = "root" | number;
interface CategoryInfo { interface CategoryInfo {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;
@@ -66,6 +62,16 @@ 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,6 +84,10 @@ 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.put(fileInfo);
}; };
@@ -86,6 +96,13 @@ export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id); await filesystem.file.delete(id);
}; };
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();
}; };
@@ -106,6 +123,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,20 +10,56 @@ 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,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
export interface FileUploadState {
name: string;
parentId: DirectoryId;
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 fileBuffer = await file.arrayBuffer(); const fileBuffer = await file.arrayBuffer();
@@ -76,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);
@@ -109,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,
@@ -130,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;
@@ -157,10 +175,7 @@ const requestFileUpload = limitFunction(
} }
} }
status.update((value) => { state.status = "uploaded";
value.status = "uploaded";
return value;
});
return { fileId: file }; return { fileId: file };
}, },
@@ -173,18 +188,16 @@ 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)!;
return await scheduler.schedule(file.size, async () => {
state.status = "encryption-pending";
try { try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan( const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
@@ -193,14 +206,8 @@ export const uploadFile = async (
onDuplicate, onDuplicate,
); );
if (!fileBuffer || !fileSigned) { if (!fileBuffer || !fileSigned) {
status.update((value) => { state.status = "canceled";
value.status = "canceled"; uploadingFiles = uploadingFiles.filter((file) => file !== state);
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined; return undefined;
} }
@@ -214,7 +221,7 @@ export const uploadFile = async (
createdAtEncrypted, createdAtEncrypted,
lastModifiedAtEncrypted, lastModifiedAtEncrypted,
thumbnail, thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey); } = await encryptFile(state, file, fileBuffer, masterKey);
const form = new FormData(); const form = new FormData();
form.set( form.set(
@@ -252,13 +259,11 @@ export const uploadFile = async (
thumbnailForm.set("content", new Blob([thumbnail.ciphertext])); thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
} }
const { fileId } = await requestFileUpload(status, form, thumbnailForm); const { fileId } = await requestFileUpload(state, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext }; return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) { } catch (e) {
status.update((value) => { state.status = "error";
value.status = "error";
return value;
});
throw e; throw e;
} }
});
}; };

View File

@@ -1,370 +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 { trpc } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
parentId: DirectoryId;
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,
parentId: directory.parentId,
name: directory.name,
subDirectoryIds,
fileIds,
});
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
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,
parentId: metadata!.parent,
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,
) => {
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,
parentId: metadata.parent,
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,
) => {
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,167 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo, Partial<MaybeCategoryInfo>>();
const fetchFromIndexedDB = async (id: CategoryId) => {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true as const,
subCategories,
};
} else if (category) {
return {
id,
exists: true as const,
name: category.name,
subCategories,
files: files!.filter((file) => !!file),
isFileRecursive: category.isFileRecursive,
};
}
};
const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => {
try {
const {
metadata,
subCategories: subCategoriesRaw,
files: filesRaw,
} = await trpc().category.get.query({ id, recurse: true });
void IndexedDB.deleteDanglingCategoryInfos(id, new Set(subCategoriesRaw.map(({ id }) => id)));
const subCategories = await Promise.all(
subCategoriesRaw.map(async (category) => {
const decrypted = await decryptCategoryMetadata(category, masterKey);
const existing = await IndexedDB.getCategoryInfo(category.id);
await IndexedDB.storeCategoryInfo({
id: category.id,
parentId: id,
name: decrypted.name,
files: existing?.files ?? [],
isFileRecursive: existing?.isFileRecursive ?? false,
});
return {
id: category.id,
...decrypted,
};
}),
);
const existingFiles = filesRaw
? await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id))
: [];
const files = filesRaw
? await Promise.all(
filesRaw.map(async (file, index) => {
const decrypted = await decryptFileMetadata(file, masterKey);
const existing = existingFiles[index];
if (existing) {
const categoryIds = file.isRecursive
? existing.categoryIds
: Array.from(new Set([...existing.categoryIds, id as number]));
await IndexedDB.storeFileInfo({
id: file.id,
parentId: existing.parentId,
contentType: file.contentType,
name: decrypted.name,
createdAt: decrypted.createdAt,
lastModifiedAt: decrypted.lastModifiedAt,
categoryIds,
});
}
return {
id: file.id,
contentType: file.contentType,
isRecursive: file.isRecursive,
...decrypted,
};
}),
)
: undefined;
const decryptedMetadata = metadata
? await decryptCategoryMetadata(metadata, masterKey)
: undefined;
if (id !== "root" && metadata && decryptedMetadata) {
const existingCategory = await IndexedDB.getCategoryInfo(id);
await IndexedDB.storeCategoryInfo({
id: id as number,
parentId: metadata.parent,
name: decryptedMetadata.name,
files:
files?.map((file) => ({
id: file.id,
isRecursive: file.isRecursive,
})) ??
existingCategory?.files ??
[],
isFileRecursive: existingCategory?.isFileRecursive ?? false,
});
}
if (id === "root") {
return {
id,
exists: true as const,
subCategories,
};
} else {
return {
id,
exists: true as const,
subCategories,
files,
...decryptedMetadata!,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
};
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
return await cache.get(id, async (isInitial, resolve) => {
if (isInitial) {
const info = await fetchFromIndexedDB(id);
if (info) {
resolve(info);
}
}
const info = await fetchFromServer(id, masterKey);
if (info) {
resolve(info);
}
});
};

View File

@@ -0,0 +1,125 @@
import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>();
const fetchFromIndexedDB = async (id: DirectoryId) => {
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 as const,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true as const,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
};
}
};
const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
try {
const {
metadata,
subDirectories: subDirectoriesRaw,
files: filesRaw,
} = await trpc().directory.get.query({ id });
void IndexedDB.deleteDanglingDirectoryInfos(id, new Set(subDirectoriesRaw.map(({ id }) => id)));
void IndexedDB.deleteDanglingFileInfos(id, new Set(filesRaw.map(({ id }) => id)));
const existingFiles = await IndexedDB.bulkGetFileInfos(filesRaw.map((file) => file.id));
const [subDirectories, files, decryptedMetadata] = await Promise.all([
Promise.all(
subDirectoriesRaw.map(async (directory) => {
const decrypted = await decryptDirectoryMetadata(directory, masterKey);
await IndexedDB.storeDirectoryInfo({
id: directory.id,
parentId: id,
name: decrypted.name,
});
return {
id: directory.id,
...decrypted,
};
}),
),
Promise.all(
filesRaw.map(async (file, index) => {
const decrypted = await decryptFileMetadata(file, masterKey);
await IndexedDB.storeFileInfo({
id: file.id,
parentId: id,
contentType: file.contentType,
name: decrypted.name,
createdAt: decrypted.createdAt,
lastModifiedAt: decrypted.lastModifiedAt,
categoryIds: existingFiles[index]?.categoryIds ?? [],
});
return {
id: file.id,
contentType: file.contentType,
...decrypted,
};
}),
),
metadata ? decryptDirectoryMetadata(metadata, masterKey) : undefined,
]);
if (id !== "root" && metadata && decryptedMetadata) {
await IndexedDB.storeDirectoryInfo({
id,
parentId: metadata.parent,
name: decryptedMetadata.name,
});
}
if (id === "root") {
return {
id,
exists: true as const,
subDirectories,
files,
};
} else {
return {
id,
exists: true as const,
parentId: metadata!.parent,
subDirectories,
files,
...decryptedMetadata!,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
};
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};

View File

@@ -0,0 +1,175 @@
import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>();
const fetchFromIndexedDB = async (id: number) => {
const file = await IndexedDB.getFileInfo(id);
const categories = file
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined;
}),
)
: undefined;
if (file) {
return {
id,
exists: true as const,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories!.filter((category) => !!category),
};
}
};
const bulkFetchFromIndexedDB = async (ids: number[]) => {
const files = await IndexedDB.bulkGetFileInfos(ids);
const categories = await Promise.all(
files.map(async (file) =>
file
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined;
}),
)
: undefined,
),
);
return new Map(
files
.map((file, index) =>
file
? ([
file.id,
{
...file,
exists: true,
categories: categories[index]!.filter((category) => !!category),
},
] as const)
: undefined,
)
.filter((file) => !!file),
);
};
const fetchFromServer = async (id: number, masterKey: CryptoKey) => {
try {
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
const [categories, decryptedMetadata] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(metadata, masterKey),
]);
await IndexedDB.storeFileInfo({
id,
parentId: metadata.parent,
contentType: metadata.contentType,
name: decryptedMetadata.name,
createdAt: decryptedMetadata.createdAt,
lastModifiedAt: decryptedMetadata.lastModifiedAt,
categoryIds: categories.map((category) => category.id),
});
return {
id,
exists: true as const,
parentId: metadata.parent,
contentType: metadata.contentType,
contentIv: metadata.contentIv,
categories,
...decryptedMetadata,
};
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
};
const bulkFetchFromServer = async (ids: number[], masterKey: CryptoKey) => {
const filesRaw = await trpc().file.bulkGet.query({ ids });
const files = await Promise.all(
filesRaw.map(async (file) => {
const [categories, decryptedMetadata] = await Promise.all([
Promise.all(
file.categories.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(file, masterKey),
]);
await IndexedDB.storeFileInfo({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
name: decryptedMetadata.name,
createdAt: decryptedMetadata.createdAt,
lastModifiedAt: decryptedMetadata.lastModifiedAt,
categoryIds: categories.map((category) => category.id),
});
return {
id: file.id,
exists: true as const,
parentId: file.parent,
contentType: file.contentType,
contentIv: file.contentIv,
categories,
...decryptedMetadata,
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
return new Map<number, MaybeFileInfo>([
...files.map((file) => [file.id, file] as const),
...ids.filter((id) => !existingIds.has(id)).map((id) => [id, { id, exists: false }] as const),
]);
};
export const getFileInfo = async (id: number, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};
export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => {
return await cache.bulkGet(new Set(ids), (keys, resolve) =>
monotonicResolve(
[
bulkFetchFromIndexedDB(
Array.from(
keys
.entries()
.filter(([, isInitial]) => isInitial)
.map(([key]) => key),
),
),
bulkFetchFromServer(Array.from(keys.keys()), masterKey),
],
resolve,
),
);
};

View File

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

View File

@@ -0,0 +1,130 @@
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
export class FilesystemCache<K, V extends RV, RV = V> {
private map = new Map<K, V | Promise<V>>();
get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) {
const info = this.map.get(key);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<V>();
if (!info) {
this.map.set(key, promise);
}
loader(!info, (loadedInfo) => {
if (!loadedInfo) return;
const info = this.map.get(key)!;
if (info instanceof Promise) {
const state = $state(loadedInfo);
this.map.set(key, state as V);
resolve(state as V);
} else {
Object.assign(info, loadedInfo);
resolve(info);
}
});
return info ?? promise;
}
async bulkGet(
keys: Set<K>,
loader: (keys: Map<K, boolean>, resolve: (values: Map<K, RV>) => void) => void,
) {
const states = new Map<K, V>();
const promises = new Map<K, Promise<V>>();
const resolvers = new Map<K, (value: V) => void>();
keys.forEach((key) => {
const info = this.map.get(key);
if (info instanceof Promise) {
promises.set(key, info);
} else if (info) {
states.set(key, info);
} else {
const { promise, resolve } = Promise.withResolvers<V>();
this.map.set(key, promise);
promises.set(key, promise);
resolvers.set(key, resolve);
}
});
loader(
new Map([
...states.keys().map((key) => [key, false] as const),
...resolvers.keys().map((key) => [key, true] as const),
]),
(loadedInfos) =>
loadedInfos.forEach((loadedInfo, key) => {
const info = this.map.get(key)!;
const resolve = resolvers.get(key);
if (info instanceof Promise) {
const state = $state(loadedInfo);
this.map.set(key, state as V);
resolve?.(state as V);
} else {
Object.assign(info, loadedInfo);
resolve?.(info);
}
}),
);
const newStates = await Promise.all(
promises.entries().map(async ([key, promise]) => [key, await promise] as const),
);
return new Map([...states, ...newStates]);
}
}
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,71 @@
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 SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: { id: number; name: string }[];
}
export type SummarizedFileInfo = Omit<FileInfo, "parentId" | "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
interface LocalCategoryInfo {
id: number;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
interface RootCategoryInfo {
id: "root";
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);

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,14 @@ interface File {
export type NewFile = Omit<File, "id">; export type NewFile = Omit<File, "id">;
interface FileCategory {
id: number;
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 +313,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 +409,51 @@ 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,
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 +540,20 @@ 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,
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 { TRPCClientError } from "@trpc/client";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import { trpc } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
export const requestSessionUpgrade = async ( export const requestSessionUpgrade = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -16,7 +15,7 @@ export const requestSessionUpgrade = async (
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;
@@ -31,7 +30,7 @@ export const requestSessionUpgrade = async (
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;

View File

@@ -1,15 +1,11 @@
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 { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
@@ -44,29 +40,6 @@ 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;
let thumbnailInfo;
try {
thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId });
} catch {
// TODO: Error Handling
return null;
}
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
const res = await fetch(`/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 () => {
let liveFiles; let liveFiles;
try { try {

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 { trpc } from "$trpc/client"; import { trpc, isTRPCClientError } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -112,10 +111,7 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
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

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

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

16
src/lib/utils/promise.ts Normal file
View File

@@ -0,0 +1,16 @@
export const monotonicResolve = <T>(
promises: (Promise<T> | false)[],
callback: (value: T) => void,
) => {
let latestResolvedIndex = -1;
promises
.filter((promise) => !!promise)
.forEach((promise, index) => {
promise.then((value) => {
if (index > latestResolvedIndex) {
latestResolvedIndex = index;
callback(value);
}
});
});
};

View File

@@ -32,7 +32,7 @@ const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => {
const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b); const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b);
export const sortEntries = <T extends SortEntry>(entries: T[], sortBy: SortBy) => { export const sortEntries = <T extends SortEntry>(entries: T[], sortBy = SortBy.NAME_ASC) => {
let sortFunc: SortFunc; let sortFunc: SortFunc;
switch (sortBy) { switch (sortBy) {
@@ -48,10 +48,12 @@ export const sortEntries = <T extends SortEntry>(entries: T[], sortBy: SortBy) =
case SortBy.DATE_DESC: case SortBy.DATE_DESC:
sortFunc = sortByDateDesc; sortFunc = sortByDateDesc;
break; break;
default: default: {
const exhaustive: never = sortBy; const exhaustive: never = sortBy;
sortFunc = exhaustive; sortFunc = exhaustive;
} }
}
entries.sort(sortFunc); entries.sort(sortFunc);
return entries;
}; };

View File

@@ -1,19 +1,14 @@
<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 { 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 AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte"; import DownloadStatus from "./DownloadStatus.svelte";
import { import {
@@ -31,19 +26,13 @@
let { data } = $props(); let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state(); let infoPromise: Promise<MaybeFileInfo> | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]); let info: FileInfo | null = $state(null);
let downloadState = $derived(getFileDownloadState(data.id));
let isMenuOpen = $state(false); 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 fileBlob: Blob | undefined = $state();
@@ -76,28 +65,29 @@
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 infoPromise = 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 infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
$effect(() => { $effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => {
if (fileInfo.exists) {
info = fileInfo;
}
return fileInfo;
});
info = null;
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 +95,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(
async (buffer) => {
const blob = await updateViewer(buffer, contentType); const blob = await updateViewer(buffer, contentType);
if (!viewerType) { if (!viewerType) {
FileSaver.saveAs(blob, $info.name); FileSaver.saveAs(blob, info!.name);
} }
}); },
);
} }
}); });
} }
}); });
$effect(() => { $effect(() => {
if ($info && $downloadStatus?.status === "decrypted") { if (info && downloadState?.status === "decrypted") {
untrack( untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType));
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
} }
}); });
@@ -133,7 +123,8 @@
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<TopBar title={$info?.name}> {#if info}
<TopBar title={info.name}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={(e) => e.stopPropagation()}> <div onclick={(e) => e.stopPropagation()}>
@@ -145,16 +136,20 @@
</button> </button>
<TopBarMenu <TopBarMenu
bind:isOpen={isMenuOpen} bind:isOpen={isMenuOpen}
directoryId={page.url.searchParams.get("from") === "category" ? $info?.parentId : undefined} directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? info.parentId
: undefined}
{fileBlob} {fileBlob}
filename={$info?.name} filename={info.name}
/> />
</div> </div>
</TopBar> </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 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>
@@ -162,18 +157,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"
> >
이 장면을 썸네일로 설정하기 이 장면을 썸네일로 설정하기
@@ -189,7 +183,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)}
@@ -212,3 +206,4 @@
bind:isOpen={isAddToCategoryBottomSheetOpen} bind:isOpen={isAddToCategoryBottomSheetOpen}
onAddToCategoryClick={addToCategory} onAddToCategoryClick={addToCategory}
/> />
{/if}

View File

@@ -1,9 +1,8 @@
<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 { requestCategoryCreation } from "./service"; import { requestCategoryCreation } from "./service";
@@ -14,46 +13,48 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let category: Writable<CategoryInfo | null> | undefined = $state(); let categoryInfoPromise: Promise<MaybeCategoryInfo> | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
$effect(() => { $effect(() => {
if (isOpen) { if (isOpen) {
category = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!); categoryInfoPromise = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!);
} }
}); });
</script> </script>
{#if $category} {#await categoryInfoPromise then categoryInfo}
{#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!))} (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
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)} class="w-full">
이 카테고리에 추가하기 이 카테고리에 추가하기
</Button> </Button>
</BottomDiv> </BottomDiv>
{/if} {/if}
</FullscreenDiv> </FullscreenDiv>
</BottomSheet> </BottomSheet>
{/if}
<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 categoryInfoPromise = getCategoryInfo(categoryInfo.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

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 { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
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

@@ -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,7 +1,6 @@
<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 { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud"; import IconCloud from "~icons/material-symbols/cloud";
@@ -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 $status.status === "download-pending"} {#if state.status === "download-pending"}
<IconCloud /> <IconCloud />
{:else if $status.status === "downloading"} {:else if state.status === "downloading"}
<IconCloudDownload /> <IconCloudDownload />
{:else if $status.status === "decryption-pending"} {:else if state.status === "decryption-pending"}
<IconLock /> <IconLock />
{:else if $status.status === "decrypting"} {:else if state.status === "decrypting"}
<IconLockClock /> <IconLockClock />
{:else if $status.status === "decrypted"} {:else if state.status === "decrypted"}
<IconCheckCircle class="text-green-500" /> <IconCheckCircle class="text-green-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={$fileInfo.name} class="truncate font-medium"> <p title={info.name} class="truncate font-medium">
{$fileInfo.name} {info.name}
</p> </p>
<p class="text-xs text-gray-800"> <p class="text-xs text-gray-800">
{#if $status.status === "download-pending"} {#if state.status === "download-pending"}
다운로드를 기다리는 중 다운로드를 기다리는 중
{:else if $status.status === "downloading"} {:else if state.status === "downloading"}
전송됨 전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)} {formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if $status.status === "decryption-pending"} {:else if state.status === "decryption-pending"}
복호화를 기다리는 중 복호화를 기다리는 중
{:else if $status.status === "decrypting"} {:else if state.status === "decrypting"}
복호화하는 중 복호화하는 중
{:else if $status.status === "decrypted"} {:else if state.status === "decrypted"}
다운로드 완료 다운로드 완료
{:else if $status.status === "error"} {:else if state.status === "error"}
다운로드 실패 다운로드 실패
{/if} {/if}
</p> </p>
</div> </div>
</div> </div>
{/if}

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,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import type { FileUploadState } from "$lib/modules/file";
import type { FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
import IconPending from "~icons/material-symbols/pending"; import IconPending from "~icons/material-symbols/pending";
@@ -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

@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
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 { Gallery } from "$lib/components/organisms"; import { Gallery } from "$lib/components/organisms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
let { data } = $props(); let { data } = $props();
let files: Writable<FileInfo | null>[] = $state([]); let files: MaybeFileInfo[] = $state([]);
$effect(() => { onMount(async () => {
files = data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!)); files = Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values());
}); });
</script> </script>
@@ -22,5 +22,8 @@
<TopBar title="사진 및 동영상" /> <TopBar title="사진 및 동영상" />
<FullscreenDiv> <FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} /> <Gallery
files={files.filter((file) => file?.exists)}
onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)}
/>
</FullscreenDiv> </FullscreenDiv>

View File

@@ -1,7 +0,0 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const files = await trpc(fetch).file.list.query();
return { files };
};

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 { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils"; 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,7 +1,6 @@
<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/utils"; import { formatDate, formatFileSize } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
@@ -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)}
{#if info.exists}
<File <File
{info} {info}
generationStatus={status} {status}
onclick={({ id }) => goto(`/file/${id}`)} onclick={({ id }) => goto(`/file/${id}`)}
onGenerateThumbnailClick={requestThumbnailGeneration} 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,7 +0,0 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const files = await trpc(fetch).file.listWithoutThumbnail.query();
return { files };
};

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/utils"; 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={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined} actionButtonIcon={!status || status === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick($info)} onActionButtonClick={() => onGenerateThumbnailClick(info)}
actionButtonClass="text-gray-800" actionButtonClass="text-gray-800"
> >
{@const subtext = {@const subtext = status
$generationStatus && $generationStatus !== "uploaded" ? subtexts[status]
? subtexts[$generationStatus] : formatDateTime(info.createdAt ?? info.lastModifiedAt)}
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)} <DirectoryEntryLabel type="file" name={info.name} {subtext} />
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
</ActionEntryButton> </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

@@ -1,9 +1,8 @@
<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 { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms"; import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, updateCategoryInfo, 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 CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -19,9 +18,7 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<CategoryInfo | null> | undefined = $state(); let infoPromise: Promise<MaybeCategoryInfo> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
@@ -29,20 +26,7 @@
let isCategoryDeleteModalOpen = $state(false); let isCategoryDeleteModalOpen = $state(false);
$effect(() => { $effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
}); });
</script> </script>
@@ -50,18 +34,19 @@
<title>카테고리</title> <title>카테고리</title>
</svelte:head> </svelte:head>
{#if data.id !== "root"} {#await infoPromise then info}
<TopBar title={$info?.name} /> {#if info?.exists}
{#if info.id !== "root"}
<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}
<Category <Category
bind:isFileRecursive bind:isFileRecursive={info.isFileRecursive}
info={$info} {info}
onFileClick={({ id }) => goto(`/file/${id}?from=category`)} onFileClick={({ id }) => goto(`/file/${id}?from=category`)}
onFileRemoveClick={async ({ id }) => { onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number); await requestFileRemovalFromCategory(id, data.id as number);
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}} }}
onSubCategoryClick={({ id }) => goto(`/category/${id}`)} onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
@@ -70,14 +55,13 @@
isCategoryMenuBottomSheetOpen = true; isCategoryMenuBottomSheetOpen = true;
}} }}
/> />
{/if}
</div> </div>
<CategoryCreateModal <CategoryCreateModal
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 infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -99,7 +83,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 infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -109,9 +93,11 @@
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 infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

View File

@@ -17,12 +17,17 @@ export const useContext = () => {
}; };
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => { export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
const newNameEncrypted = await encryptString(newName, category.dataKey); if (!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,
}); });

View File

@@ -1,11 +1,10 @@
<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 { 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 DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
@@ -30,7 +29,7 @@
let { data } = $props(); let { data } = $props();
let context = createContext(); let context = createContext();
let info: Writable<DirectoryInfo | null> | undefined = $state(); let infoPromise: Promise<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();
@@ -61,7 +60,7 @@
.then((res) => { .then((res) => {
if (!res) return; if (!res) return;
// TODO: FIXME // TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}) })
.catch((e: Error) => { .catch((e: Error) => {
// TODO: FIXME // TODO: FIXME
@@ -79,7 +78,7 @@
}); });
$effect(() => { $effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
}); });
</script> </script>
@@ -89,35 +88,33 @@
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" /> <input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
{#await infoPromise then info}
{#if info?.exists}
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
{#if showTopBar} {#if showTopBar}
<TopBar title={$info?.name} class="flex-shrink-0" /> <TopBar title={info.name} class="flex-shrink-0" />
{/if} {/if}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", !showTopBar && "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}
<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} showParentEntry={isFromFilePage && info.parentId !== undefined}
onParentClick={() => onParentClick={() =>
goto( goto(
$info.parentId === "root" info.parentId === "root"
? "/directory?from=file" ? "/directory?from=file"
: `/directory/${$info.parentId}?from=file`, : `/directory/${info.parentId}?from=file`,
)} )}
/> />
{/key}
</div> </div>
{/if}
</div> </div>
<FloatingButton <FloatingButton
@@ -142,7 +139,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 infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -176,7 +173,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 infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
@@ -186,9 +183,11 @@
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 infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true; return true;
} }
return false; return false;
}} }}
/> />
{/if}
{/await}

View File

@@ -1,21 +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 { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules"; 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 {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
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";
@@ -27,7 +15,6 @@
onEntryMenuClick: (entry: SelectedEntry) => void; onEntryMenuClick: (entry: SelectedEntry) => void;
onParentClick?: () => void; onParentClick?: () => void;
showParentEntry?: boolean; showParentEntry?: boolean;
sortBy?: SortBy;
} }
let { let {
@@ -36,105 +23,49 @@
onEntryMenuClick, onEntryMenuClick,
onParentClick, onParentClick,
showParentEntry = false, showParentEntry = false,
sortBy = SortBy.NAME_ASC,
}: Props = $props(); }: 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 || showParentEntry} {#if entries.length > 0}
<div class="space-y-1 pb-[4.5rem]"> <RowVirtualizer count={entries.length} itemHeight={() => 56} itemGap={4} class="pb-[4.5rem]">
{#if showParentEntry} {#snippet item(index)}
{@const entry = entries[index]!}
{#if entry.type === "parent"}
<ActionEntryButton class="h-14" onclick={onParentClick}> <ActionEntryButton class="h-14" onclick={onParentClick}>
<DirectoryEntryLabel type="parent-directory" name=".." /> <DirectoryEntryLabel type="parent-directory" name=".." />
</ActionEntryButton> </ActionEntryButton>
{/if} {:else if entry.type === "directory"}
{#each subDirectories as { info }} <SubDirectory
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} /> info={entry.details}
{/each} onclick={onEntryClick}
{#each files as file} onOpenMenuClick={onEntryMenuClick}
{#if file.type === "file"} />
<File info={file.info} 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 type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/utils"; import { formatDateTime } from "$lib/utils";
import { requestFileThumbnailDownload } from "./service";
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={openFile} onclick={() => action(onclick)}
actionButtonIcon={IconMoreVert} actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu} onActionButtonClick={() => action(onOpenMenuClick)}
> >
<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={openDirectory} onclick={() => action(onclick)}
actionButtonIcon={IconMoreVert} actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu} onActionButtonClick={() => action(onOpenMenuClick)}
> >
<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 { isFileUploading, type FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils"; import { formatNetworkSpeed } from "$lib/utils";
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>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800"> <div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<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"> <p class="text-xs">
{#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)}% · 전송됨 {Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)} {formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if} {/if}
</p> </p>
</div> </div>
</div> </div>
{/if}

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,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 { trpc } 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;
} }
@@ -97,20 +97,25 @@ export const requestFileUpload = async (
}; };
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => { export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
const newNameEncrypted = await encryptString(newName, entry.dataKey); if (!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,
}); });

View File

@@ -1,17 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { Writable } from "svelte/store"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { EntryButton, FileThumbnailButton } from "$lib/components/atoms"; import { EntryButton, FileThumbnailButton } from "$lib/components/atoms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem"; import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
import { requestFreshMediaFilesRetrieval } from "./service"; import { requestFreshMediaFilesRetrieval } from "./service";
let mediaFiles: Writable<FileInfo | null>[] = $state([]); let mediaFiles: MaybeFileInfo[] = $state([]);
$effect(() => { onMount(async () => {
requestFreshMediaFilesRetrieval().then((files) => { const files = await requestFreshMediaFilesRetrieval();
mediaFiles = files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)); mediaFiles = Array.from(
}); (
await bulkGetFileInfo(
files.map(({ id }) => id),
$masterKeyStore?.get(1)?.key!,
)
).values(),
);
}); });
</script> </script>
@@ -21,14 +27,18 @@
<div class="min-h-full space-y-4 bg-gray-100 px-4 pb-[5.5em] pt-4"> <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> <p class="px-2 text-2xl font-bold text-gray-800">ArkVault</p>
<div class="space-y-2 rounded-xl bg-white px-2 pb-4 pt-2"> <div class="rounded-xl bg-white p-2">
<EntryButton onclick={() => goto("/gallery")} class="w-full"> <EntryButton onclick={() => goto("/gallery")} class="w-full">
<p class="text-left font-semibold">사진 및 동영상</p> <p class="text-left font-semibold">사진 및 동영상</p>
</EntryButton> </EntryButton>
<div class="grid grid-cols-4 gap-2 px-2"> {#if mediaFiles.length > 0}
{#each mediaFiles as file} <div class="grid grid-cols-4 gap-2 p-2">
{#each mediaFiles as file (file.id)}
{#if file.exists}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} /> <FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/if}
{/each} {/each}
</div> </div>
{/if}
</div> </div>
</div> </div>

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,7 +0,0 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const { nickname } = await trpc(fetch).user.get.query();
return { nickname };
};

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";
@@ -9,6 +9,7 @@ const createClient = (fetch: typeof globalThis.fetch) =>
httpBatchLink({ httpBatchLink({
url: "/api/trpc", url: "/api/trpc",
maxURLLength: 4096, maxURLLength: 4096,
methodOverride: "POST",
transformer: superjson, transformer: superjson,
fetch, fetch,
}), }),
@@ -24,3 +25,7 @@ export const trpc = (fetch = globalThis.fetch) => {
} }
return client; return client;
}; };
export const isTRPCClientError = (e: unknown): e is TRPCClientError<AppRouter> => {
return e instanceof TRPCClientError;
};

View File

@@ -9,6 +9,7 @@ const categoryRouter = router({
.input( .input(
z.object({ z.object({
id: categoryIdSchema, id: categoryIdSchema,
recurse: z.boolean().default(false),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -20,7 +21,12 @@ const categoryRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
} }
const categories = await CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id); const [categories, files] = await Promise.all([
CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id),
input.id !== "root"
? FileRepo.getAllFilesByCategory(ctx.session.userId, input.id, input.recurse)
: undefined,
]);
return { return {
metadata: category && { metadata: category && {
parent: category.parentId, parent: category.parentId,
@@ -30,7 +36,28 @@ const categoryRouter = router({
name: category.encName.ciphertext, name: category.encName.ciphertext,
nameIv: category.encName.iv, nameIv: category.encName.iv,
}, },
subCategories: categories.map(({ id }) => id), subCategories: categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
files: files?.map((file) => ({
id: file.id,
mekVersion: file.mekVersion,
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
isRecursive: file.isRecursive,
})),
}; };
}), }),
@@ -113,27 +140,6 @@ const categoryRouter = router({
} }
}), }),
files: roleProcedure["activeClient"]
.input(
z.object({
id: z.int().positive(),
recurse: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
const category = await CategoryRepo.getCategory(ctx.session.userId, input.id);
if (!category) {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" });
}
const files = await FileRepo.getAllFilesByCategory(
ctx.session.userId,
input.id,
input.recurse,
);
return files.map(({ id, isRecursive }) => ({ file: id, isRecursive }));
}),
addFile: roleProcedure["activeClient"] addFile: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({

View File

@@ -32,8 +32,27 @@ const directoryRouter = router({
name: directory.encName.ciphertext, name: directory.encName.ciphertext,
nameIv: directory.encName.iv, nameIv: directory.encName.iv,
}, },
subDirectories: directories.map(({ id }) => id), subDirectories: directories.map((directory) => ({
files: files.map(({ id }) => id), id: directory.id,
mekVersion: directory.mekVersion,
dek: directory.encDek,
dekVersion: directory.dekVersion,
name: directory.encName.ciphertext,
nameIv: directory.encName.iv,
})),
files: files.map((file) => ({
id: file.id,
mekVersion: file.mekVersion,
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
})),
}; };
}), }),

View File

@@ -31,10 +31,50 @@ const fileRouter = router({
createdAtIv: file.encCreatedAt?.iv, createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv, lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: categories.map(({ id }) => id), categories: categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
}; };
}), }),
bulkGet: roleProcedure["activeClient"]
.input(
z.object({
ids: z.number().positive().array(),
}),
)
.query(async ({ ctx, input }) => {
const files = await FileRepo.getFilesWithCategories(ctx.session.userId, input.ids);
return files.map((file) => ({
id: file.id,
parent: file.parentId,
mekVersion: file.mekVersion,
dek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
contentIv: file.encContentIv,
name: file.encName.ciphertext,
nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext,
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: file.categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
}));
}),
list: roleProcedure["activeClient"].query(async ({ ctx }) => { list: roleProcedure["activeClient"].query(async ({ ctx }) => {
return await FileRepo.getAllFileIds(ctx.session.userId); return await FileRepo.getAllFileIds(ctx.session.userId);
}), }),