5 Commits

Author SHA1 Message Date
static
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
13 changed files with 921 additions and 1127 deletions

View File

@@ -2,7 +2,11 @@
FROM node:22-alpine AS base FROM node:22-alpine AS base
WORKDIR /app WORKDIR /app
RUN npm install -g pnpm@10 RUN apk add --no-cache bash curl && \
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
RUN npm install -g pnpm@9
COPY pnpm-lock.yaml . COPY pnpm-lock.yaml .
# Build Stage # Build Stage
@@ -25,4 +29,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000 EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "./build/index.js"] CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"]

View File

@@ -3,8 +3,7 @@ services:
build: . build: .
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
database: - database
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes: volumes:
- ./data/library:/app/data/library - ./data/library:/app/data/library
@@ -36,8 +35,3 @@ services:
environment: environment:
- POSTGRES_USER=arkvault - POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?} - POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.5.1", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -16,53 +16,53 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.1", "@eslint/compat": "^1.3.1",
"@iconify-json/material-symbols": "^1.2.44", "@iconify-json/material-symbols": "^1.2.29",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.48.4", "@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^2.1.0", "@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8", "@types/node-schedule": "^2.1.8",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.4",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"axios": "^1.13.1", "axios": "^1.10.0",
"dexie": "^4.2.1", "dexie": "^4.0.11",
"eslint": "^9.39.0", "eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.13.0", "eslint-plugin-svelte": "^3.10.1",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.0",
"exifreader": "^4.32.0", "exifreader": "^4.31.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^16.5.0", "globals": "^16.3.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.19.0", "kysely-ctl": "^0.13.1",
"lru-cache": "^11.2.2", "lru-cache": "^11.1.0",
"mime": "^4.1.0", "mime": "^4.0.7",
"p-limit": "^7.2.0", "p-limit": "^6.2.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.43.2", "svelte": "^5.35.6",
"svelte-check": "^4.3.3", "svelte-check": "^4.2.2",
"tailwindcss": "^3.4.18", "tailwindcss": "^3.4.17",
"typescript": "^5.9.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.36.0",
"unplugin-icons": "^22.5.0", "unplugin-icons": "^22.1.0",
"vite": "^7.1.12" "vite": "^5.4.19"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.2.0", "@fastify/busboy": "^3.1.1",
"argon2": "^0.44.0", "argon2": "^0.43.0",
"kysely": "^0.28.8", "kysely": "^0.28.2",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"uuid": "^13.0.0", "uuid": "^11.1.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",
"pnpm": "^10.0.0" "pnpm": "^9.0.0"
} }
} }

1790
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
}; };
$effect(() => { $effect(() => {
if ($info) { if ($info?.dataKey) {
requestFileThumbnailDownload($info.id, $info.dataKey) requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => { .then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined; thumbnail = thumbnailUrl ?? undefined;

View File

@@ -25,7 +25,6 @@ interface CategoryInfo {
parentId: CategoryId; parentId: CategoryId;
name: string; name: string;
files: { id: number; isRecursive: boolean }[]; files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
} }
const filesystem = new Dexie("filesystem") as Dexie & { const filesystem = new Dexie("filesystem") as Dexie & {
@@ -34,20 +33,10 @@ const filesystem = new Dexie("filesystem") as Dexie & {
category: EntityTable<CategoryInfo, "id">; category: EntityTable<CategoryInfo, "id">;
}; };
filesystem filesystem.version(2).stores({
.version(3)
.stores({
directory: "id, parentId", directory: "id, parentId",
file: "id, parentId", file: "id, parentId",
category: "id, parentId", category: "id, parentId",
})
.upgrade(async (trx) => {
await trx
.table("category")
.toCollection()
.modify((category) => {
category.isFileRecursive = false;
});
}); });
export const getDirectoryInfos = async (parentId: DirectoryId) => { export const getDirectoryInfos = async (parentId: DirectoryId) => {
@@ -98,10 +87,6 @@ export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo); await filesystem.category.put(categoryInfo);
}; };
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};
export const deleteCategoryInfo = async (id: number) => { export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id); await filesystem.category.delete(id);
}; };

View File

@@ -12,7 +12,6 @@ import {
getCategoryInfos as getCategoryInfosFromIndexedDB, getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB, getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo, storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo, deleteCategoryInfo,
type DirectoryId, type DirectoryId,
type CategoryId, type CategoryId,
@@ -63,7 +62,6 @@ export type CategoryInfo =
name?: undefined; name?: undefined;
subCategoryIds: number[]; subCategoryIds: number[];
files?: undefined; files?: undefined;
isFileRecursive?: undefined;
} }
| { | {
id: number; id: number;
@@ -72,7 +70,6 @@ export type CategoryInfo =
name: string; name: string;
subCategoryIds: number[]; subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[]; files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
}; };
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>(); const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
@@ -258,13 +255,7 @@ const fetchCategoryInfoFromIndexedDB = async (
info.set({ id, subCategoryIds }); info.set({ id, subCategoryIds });
} else { } else {
if (!category) return; if (!category) return;
info.set({ info.set({ id, name: category.name, subCategoryIds, files: category.files });
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
} }
}; };
@@ -297,28 +288,20 @@ const fetchCategoryInfoFromServer = async (
const { files }: CategoryFileListResponse = await res.json(); const { files }: CategoryFileListResponse = await res.json();
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive })); const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => { info.set({
const newValue = {
isFileRecursive: false,
...value,
id, id,
dataKey, dataKey,
dataKeyVersion: new Date(metadata!.dekVersion), dataKeyVersion: new Date(metadata!.dekVersion),
name, name,
subCategoryIds: subCategories, subCategoryIds: subCategories,
files: filesMapped, files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
}); });
await storeCategoryInfo({ await storeCategoryInfo({
id, id,
parentId: metadata!.parent, parentId: metadata!.parent,
name, name,
files: filesMapped, files: filesMapped,
isFileRecursive: isFileRecursive!,
}); });
} }
}; };
@@ -344,17 +327,3 @@ export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) =>
fetchCategoryInfo(categoryId, info, masterKey); // Intended fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info; 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

@@ -32,7 +32,7 @@ const capture = (
drawer(context, scaledWidth, scaledHeight); drawer(context, scaledWidth, scaledHeight);
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob && blob.type === "image/webp") { if (blob) {
resolve(blob); resolve(blob);
} else { } else {
reject(new Error("Failed to generate thumbnail")); reject(new Error("Failed to generate thumbnail"));
@@ -67,15 +67,10 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => { return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video"); const video = document.createElement("video");
video.onloadedmetadata = () => { video.onloadedmetadata = () => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
});
video.currentTime = Math.min(time, video.duration); video.currentTime = Math.min(time, video.duration);
video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
});
}; };
video.onerror = reject; video.onerror = reject;
@@ -88,26 +83,18 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => { export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
let url; let url;
try { try {
if (fileType.startsWith("image/")) {
const fileBlob = new Blob([fileBuffer], { type: fileType });
url = URL.createObjectURL(fileBlob);
try {
return await generateImageThumbnail(url);
} catch {
URL.revokeObjectURL(url);
url = undefined;
if (fileType === "image/heic") { if (fileType === "image/heic") {
const { default: heic2any } = await import("heic2any"); const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL( url = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob, (await heic2any({
blob: new Blob([fileBuffer], { type: fileType }),
toType: "image/png",
})) as Blob,
); );
return await generateImageThumbnail(url); return await generateImageThumbnail(url);
} else { } else if (fileType.startsWith("image/")) {
return null; url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
} return await generateImageThumbnail(url);
}
} else if (fileType.startsWith("video/")) { } else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType })); url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
return await generateVideoThumbnail(url); return await generateVideoThumbnail(url);

View File

@@ -48,9 +48,9 @@ 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) => { export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId); const cache = await getFileThumbnailCache(fileId);
if (cache || !dataKey) return cache; if (cache) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`); let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null; if (!res.ok) return null;

View File

@@ -6,14 +6,8 @@
let oldPassword = $state(""); let oldPassword = $state("");
let newPassword = $state(""); let newPassword = $state("");
let confirmPassword = $state("");
const changePassword = async () => { const changePassword = async () => {
if (newPassword !== confirmPassword) {
// TODO: Alert
return;
}
if (await requestPasswordChange(oldPassword, newPassword)) { if (await requestPasswordChange(oldPassword, newPassword)) {
await goto("/menu"); await goto("/menu");
} }
@@ -36,7 +30,6 @@
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" /> <TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" /> <TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
<TextInput bind:value={confirmPassword} placeholder="새 비밀번호 확인" type="password" />
</TitledDiv> </TitledDiv>
<BottomDiv> <BottomDiv>
<Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button> <Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button>

View File

@@ -43,29 +43,20 @@
let isDownloadRequested = $state(false); let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state(); let viewerType: "image" | "video" | undefined = $state();
let fileBlobUrl: string | undefined = $state(); let fileBlobUrl: string | undefined = $state();
let heicBlob: Blob | undefined = $state();
let videoElement: HTMLVideoElement | undefined = $state(); let videoElement: HTMLVideoElement | undefined = $state();
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => { const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
const fileBlob = new Blob([buffer], { type: contentType }); const fileBlob = new Blob([buffer], { type: contentType });
if (viewerType) { if (contentType === "image/heic") {
fileBlobUrl = URL.createObjectURL(fileBlob);
heicBlob = contentType === "image/heic" ? fileBlob : undefined;
}
return fileBlob;
};
const convertHeicToJpeg = async () => {
if (!heicBlob) return;
URL.revokeObjectURL(fileBlobUrl!);
fileBlobUrl = undefined;
const { default: heic2any } = await import("heic2any"); const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL( fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob, (await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
); );
heicBlob = undefined; } else if (viewerType) {
fileBlobUrl = URL.createObjectURL(fileBlob);
}
return fileBlob;
}; };
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => { const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
@@ -145,7 +136,7 @@
{#if viewerType === "image"} {#if viewerType === "image"}
{#if fileBlobUrl} {#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} /> <img src={fileBlobUrl} alt={$info.name} />
{:else} {:else}
{@render viewerLoading("이미지를 불러오고 있어요.")} {@render viewerLoading("이미지를 불러오고 있어요.")}
{/if} {/if}

View File

@@ -3,7 +3,7 @@
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 CategoryInfo } 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";
@@ -21,7 +21,7 @@
let info: Writable<CategoryInfo | null> | undefined = $state(); let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive: boolean | undefined = $state(); let isFileRecursive = $state(false);
let isCategoryCreateModalOpen = $state(false); let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false);
@@ -30,19 +30,6 @@
$effect(() => { $effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); info = 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>
@@ -54,7 +41,7 @@
<TopBar title={$info?.name} /> <TopBar title={$info?.name} />
{/if} {/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]"> <div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info && isFileRecursive !== undefined} {#if $info}
<Category <Category
bind:isFileRecursive bind:isFileRecursive
info={$info} info={$info}

View File

@@ -34,7 +34,7 @@
}; };
$effect(() => { $effect(() => {
if ($info) { if ($info?.dataKey) {
requestFileThumbnailDownload($info.id, $info.dataKey) requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => { .then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined; thumbnail = thumbnailUrl ?? undefined;