mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 16:16:55 +00:00
Compare commits
7 Commits
v0.5.0
...
328baba395
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328baba395 | ||
|
|
4e91cdad95 | ||
|
|
9f53874d1d | ||
|
|
af20f6ec4e | ||
|
|
301216915e | ||
|
|
393bba45db | ||
|
|
3ebfcdaa7d |
@@ -2,11 +2,7 @@
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
RUN npm install -g pnpm@10
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
# Build Stage
|
||||
@@ -29,4 +25,4 @@ COPY --from=build /app/build ./build
|
||||
|
||||
EXPOSE 3000
|
||||
ENV BODY_SIZE_LIMIT=Infinity
|
||||
CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"]
|
||||
CMD ["node", "./build/index.js"]
|
||||
|
||||
@@ -3,7 +3,8 @@ services:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- database
|
||||
database:
|
||||
condition: service_healthy
|
||||
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
|
||||
volumes:
|
||||
- ./data/library:/app/data/library
|
||||
@@ -35,3 +36,8 @@ services:
|
||||
environment:
|
||||
- POSTGRES_USER=arkvault
|
||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
66
package.json
66
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "arkvault",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -16,53 +16,53 @@
|
||||
"db:migrate": "kysely migrate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.3.1",
|
||||
"@iconify-json/material-symbols": "^1.2.29",
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"@sveltejs/kit": "^2.22.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@iconify-json/material-symbols": "^1.2.44",
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@sveltejs/kit": "^2.48.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/node-schedule": "^2.1.8",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/pg": "^8.15.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.10.0",
|
||||
"dexie": "^4.0.11",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-svelte": "^3.10.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
"exifreader": "^4.31.1",
|
||||
"axios": "^1.13.1",
|
||||
"dexie": "^4.2.1",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"exifreader": "^4.32.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"globals": "^16.3.0",
|
||||
"globals": "^16.5.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"kysely-ctl": "^0.13.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"mime": "^4.0.7",
|
||||
"p-limit": "^6.2.0",
|
||||
"kysely-ctl": "^0.19.0",
|
||||
"lru-cache": "^11.2.2",
|
||||
"mime": "^4.1.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"svelte": "^5.35.6",
|
||||
"svelte-check": "^4.2.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.36.0",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"vite": "^5.4.19"
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"svelte": "^5.43.2",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"unplugin-icons": "^22.5.0",
|
||||
"vite": "^7.1.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^3.1.1",
|
||||
"argon2": "^0.43.0",
|
||||
"kysely": "^0.28.2",
|
||||
"@fastify/busboy": "^3.2.0",
|
||||
"argon2": "^0.44.0",
|
||||
"kysely": "^0.28.8",
|
||||
"ms": "^2.1.3",
|
||||
"node-schedule": "^2.1.1",
|
||||
"pg": "^8.16.3",
|
||||
"uuid": "^11.1.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
"pnpm": "^9.0.0"
|
||||
"pnpm": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
1790
pnpm-lock.yaml
generated
1790
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if ($info?.dataKey) {
|
||||
if ($info) {
|
||||
requestFileThumbnailDownload($info.id, $info.dataKey)
|
||||
.then((thumbnailUrl) => {
|
||||
thumbnail = thumbnailUrl ?? undefined;
|
||||
|
||||
@@ -25,6 +25,7 @@ interface CategoryInfo {
|
||||
parentId: CategoryId;
|
||||
name: string;
|
||||
files: { id: number; isRecursive: boolean }[];
|
||||
isFileRecursive: boolean;
|
||||
}
|
||||
|
||||
const filesystem = new Dexie("filesystem") as Dexie & {
|
||||
@@ -33,11 +34,21 @@ const filesystem = new Dexie("filesystem") as Dexie & {
|
||||
category: EntityTable<CategoryInfo, "id">;
|
||||
};
|
||||
|
||||
filesystem.version(2).stores({
|
||||
filesystem
|
||||
.version(3)
|
||||
.stores({
|
||||
directory: "id, parentId",
|
||||
file: "id, parentId",
|
||||
category: "id, parentId",
|
||||
});
|
||||
})
|
||||
.upgrade(async (trx) => {
|
||||
await trx
|
||||
.table("category")
|
||||
.toCollection()
|
||||
.modify((category) => {
|
||||
category.isFileRecursive = false;
|
||||
});
|
||||
});
|
||||
|
||||
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
||||
return await filesystem.directory.where({ parentId }).toArray();
|
||||
@@ -87,6 +98,10 @@ export const storeCategoryInfo = async (categoryInfo: 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) => {
|
||||
await filesystem.category.delete(id);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getCategoryInfos as getCategoryInfosFromIndexedDB,
|
||||
getCategoryInfo as getCategoryInfoFromIndexedDB,
|
||||
storeCategoryInfo,
|
||||
updateCategoryInfo as updateCategoryInfoInIndexedDB,
|
||||
deleteCategoryInfo,
|
||||
type DirectoryId,
|
||||
type CategoryId,
|
||||
@@ -62,6 +63,7 @@ export type CategoryInfo =
|
||||
name?: undefined;
|
||||
subCategoryIds: number[];
|
||||
files?: undefined;
|
||||
isFileRecursive?: undefined;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
@@ -70,6 +72,7 @@ export type CategoryInfo =
|
||||
name: string;
|
||||
subCategoryIds: number[];
|
||||
files: { id: number; isRecursive: boolean }[];
|
||||
isFileRecursive: boolean;
|
||||
};
|
||||
|
||||
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
||||
@@ -255,7 +258,13 @@ const fetchCategoryInfoFromIndexedDB = async (
|
||||
info.set({ id, subCategoryIds });
|
||||
} else {
|
||||
if (!category) return;
|
||||
info.set({ id, name: category.name, subCategoryIds, files: category.files });
|
||||
info.set({
|
||||
id,
|
||||
name: category.name,
|
||||
subCategoryIds,
|
||||
files: category.files,
|
||||
isFileRecursive: category.isFileRecursive,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,20 +297,28 @@ const fetchCategoryInfoFromServer = async (
|
||||
|
||||
const { files }: CategoryFileListResponse = await res.json();
|
||||
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
|
||||
let isFileRecursive: boolean | undefined = undefined;
|
||||
|
||||
info.set({
|
||||
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!,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -327,3 +344,17 @@ export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) =>
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ const capture = (
|
||||
|
||||
drawer(context, scaledWidth, scaledHeight);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
if (blob && blob.type === "image/webp") {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Failed to generate thumbnail"));
|
||||
@@ -67,10 +67,15 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
video.onloadedmetadata = () => {
|
||||
video.currentTime = Math.min(time, video.duration);
|
||||
video.requestVideoFrameCallback(() => {
|
||||
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.onerror = reject;
|
||||
|
||||
@@ -83,18 +88,26 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
||||
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
|
||||
let url;
|
||||
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") {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
url = URL.createObjectURL(
|
||||
(await heic2any({
|
||||
blob: new Blob([fileBuffer], { type: fileType }),
|
||||
toType: "image/png",
|
||||
})) as Blob,
|
||||
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
|
||||
);
|
||||
return await generateImageThumbnail(url);
|
||||
} else if (fileType.startsWith("image/")) {
|
||||
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
|
||||
return await generateImageThumbnail(url);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (fileType.startsWith("video/")) {
|
||||
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
|
||||
return await generateVideoThumbnail(url);
|
||||
|
||||
@@ -48,9 +48,9 @@ export const requestFileThumbnailUpload = async (
|
||||
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);
|
||||
if (cache) return cache;
|
||||
if (cache || !dataKey) return cache;
|
||||
|
||||
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
|
||||
let oldPassword = $state("");
|
||||
let newPassword = $state("");
|
||||
let confirmPassword = $state("");
|
||||
|
||||
const changePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
// TODO: Alert
|
||||
return;
|
||||
}
|
||||
|
||||
if (await requestPasswordChange(oldPassword, newPassword)) {
|
||||
await goto("/menu");
|
||||
}
|
||||
@@ -30,6 +36,7 @@
|
||||
|
||||
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
|
||||
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
|
||||
<TextInput bind:value={confirmPassword} placeholder="새 비밀번호 확인" type="password" />
|
||||
</TitledDiv>
|
||||
<BottomDiv>
|
||||
<Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button>
|
||||
|
||||
@@ -43,20 +43,29 @@
|
||||
let isDownloadRequested = $state(false);
|
||||
let viewerType: "image" | "video" | undefined = $state();
|
||||
let fileBlobUrl: string | undefined = $state();
|
||||
let heicBlob: Blob | undefined = $state();
|
||||
let videoElement: HTMLVideoElement | undefined = $state();
|
||||
|
||||
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
|
||||
const fileBlob = new Blob([buffer], { type: contentType });
|
||||
if (contentType === "image/heic") {
|
||||
if (viewerType) {
|
||||
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");
|
||||
fileBlobUrl = URL.createObjectURL(
|
||||
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
|
||||
(await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob,
|
||||
);
|
||||
} else if (viewerType) {
|
||||
fileBlobUrl = URL.createObjectURL(fileBlob);
|
||||
}
|
||||
|
||||
return fileBlob;
|
||||
heicBlob = undefined;
|
||||
};
|
||||
|
||||
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
|
||||
@@ -136,7 +145,7 @@
|
||||
|
||||
{#if viewerType === "image"}
|
||||
{#if fileBlobUrl}
|
||||
<img src={fileBlobUrl} alt={$info.name} />
|
||||
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
|
||||
{:else}
|
||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { TopBar } from "$lib/components/molecules";
|
||||
import { Category, CategoryCreateModal } from "$lib/components/organisms";
|
||||
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
|
||||
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
let info: Writable<CategoryInfo | null> | undefined = $state();
|
||||
|
||||
let isFileRecursive = $state(false);
|
||||
let isFileRecursive: boolean | undefined = $state();
|
||||
|
||||
let isCategoryCreateModalOpen = $state(false);
|
||||
let isCategoryMenuBottomSheetOpen = $state(false);
|
||||
@@ -30,6 +30,19 @@
|
||||
|
||||
$effect(() => {
|
||||
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>
|
||||
|
||||
@@ -41,7 +54,7 @@
|
||||
<TopBar title={$info?.name} />
|
||||
{/if}
|
||||
<div class="min-h-full bg-gray-100 pb-[5.5em]">
|
||||
{#if $info}
|
||||
{#if $info && isFileRecursive !== undefined}
|
||||
<Category
|
||||
bind:isFileRecursive
|
||||
info={$info}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if ($info?.dataKey) {
|
||||
if ($info) {
|
||||
requestFileThumbnailDownload($info.id, $info.dataKey)
|
||||
.then((thumbnailUrl) => {
|
||||
thumbnail = thumbnailUrl ?? undefined;
|
||||
|
||||
Reference in New Issue
Block a user