diff --git a/docker-compose.yml b/docker-compose.yml
index f2f6eb0..da50956 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -149,7 +149,7 @@ services:
ports:
- "${UI_PORT:-5252}:3000"
healthcheck:
- test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s
timeout: 5s
retries: 3
diff --git a/ui/Dockerfile b/ui/Dockerfile
index f291b92..fd24108 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -8,16 +8,11 @@ COPY . .
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
+# adapter-node bundles all dependencies into build/ — no npm install needed.
FROM node:22-alpine
WORKDIR /app
-# adapter-node produces a standalone build/
COPY --from=builder /app/build ./build
-COPY --from=builder /app/package.json ./
-COPY --from=builder /app/package-lock.json ./
-
-# Install production dependencies (e.g. marked) that are imported at runtime
-RUN npm ci --omit=dev
ENV NODE_ENV=production
ENV PORT=3000
@@ -25,3 +20,4 @@ ENV HOST=0.0.0.0
EXPOSE $PORT
CMD ["node", "build"]
+
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 51c4a59..1c8f9ab 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
+ "cropperjs": "^1.6.2",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
},
@@ -3114,6 +3115,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/cropperjs": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
+ "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
+ "license": "MIT"
+ },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index 435c1b4..bd67878 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -27,6 +27,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
+ "cropperjs": "^1.6.2",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
}
diff --git a/ui/src/lib/components/AvatarCropModal.svelte b/ui/src/lib/components/AvatarCropModal.svelte
new file mode 100644
index 0000000..7e0c44e
--- /dev/null
+++ b/ui/src/lib/components/AvatarCropModal.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+
+
Crop profile picture
+
+
+
+

+
+
+
Drag to reposition · pinch or scroll to zoom · drag corners to resize
+
+
+
+
+
+
+
diff --git a/ui/src/routes/health/+server.ts b/ui/src/routes/health/+server.ts
new file mode 100644
index 0000000..a7b2832
--- /dev/null
+++ b/ui/src/routes/health/+server.ts
@@ -0,0 +1,6 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = () => {
+ return json({ status: 'ok' });
+};
diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte
index 8a2933a..9a1657d 100644
--- a/ui/src/routes/profile/+page.svelte
+++ b/ui/src/routes/profile/+page.svelte
@@ -3,6 +3,7 @@
import { invalidateAll } from '$app/navigation';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
+ import AvatarCropModal from '$lib/components/AvatarCropModal.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -12,32 +13,71 @@
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
- async function handleAvatarChange(e: Event) {
+ // Crop modal state
+ let cropFile = $state(null);
+
+ function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
+ // Reset input so the same file can be re-selected after cancel
+ if (fileInput) fileInput.value = '';
+ cropFile = file;
+ }
+ async function handleCropConfirm(blob: Blob, mimeType: string) {
+ cropFile = null;
avatarUploading = true;
avatarError = '';
try {
- const fd = new FormData();
- fd.append('file', file);
- const res = await fetch('/api/profile/avatar', { method: 'POST', body: fd });
- if (!res.ok) {
- const body = await res.json().catch(() => ({})) as { message?: string };
- avatarError = body.message ?? `Upload failed (${res.status})`;
+ // Step 1: get presigned PUT URL
+ const presignRes = await fetch('/api/profile/avatar', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mime_type: mimeType })
+ });
+ if (!presignRes.ok) {
+ const body = await presignRes.json().catch(() => ({})) as { message?: string };
+ avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
return;
}
- const result = await res.json() as { avatar_url: string | null };
+ const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
+
+ // Step 2: PUT blob directly to MinIO
+ const putRes = await fetch(upload_url, {
+ method: 'PUT',
+ headers: { 'Content-Type': mimeType },
+ body: blob
+ });
+ if (!putRes.ok) {
+ avatarError = `Upload failed (${putRes.status})`;
+ return;
+ }
+
+ // Step 3: record key in PocketBase and get fresh presigned GET URL
+ const patchRes = await fetch('/api/profile/avatar', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key })
+ });
+ if (!patchRes.ok) {
+ const body = await patchRes.json().catch(() => ({})) as { message?: string };
+ avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
+ return;
+ }
+ const result = await patchRes.json() as { avatar_url: string | null };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';
} finally {
avatarUploading = false;
- if (fileInput) fileInput.value = '';
}
}
+ function handleCropCancel() {
+ cropFile = null;
+ }
+
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state([]);
let voicesLoaded = $state(false);
@@ -173,6 +213,14 @@
Profile — libnovel
+{#if cropFile}
+
+{/if}
+
@@ -210,14 +258,14 @@
{/if}
-
-
+
+
{data.user.username}