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 @@ + + + + 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}