feat(ui): avatar crop modal, health endpoint, leaner Dockerfile
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 24s
Release / Scraper / Test (push) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 46s
Release / UI / Build (push) Successful in 15s
Release / UI / Docker (push) Successful in 41s
Release / Scraper / Docker (push) Successful in 5m32s
CI / UI / Docker Push (push) Successful in 8m13s
iOS CI / Build (pull_request) Successful in 8m38s
iOS CI / Test (pull_request) Successful in 13m23s
All checks were successful
CI / Scraper / Lint (pull_request) Successful in 8s
CI / Scraper / Test (pull_request) Successful in 17s
CI / UI / Build (pull_request) Successful in 24s
Release / Scraper / Test (push) Successful in 17s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / UI / Build (push) Successful in 46s
Release / UI / Build (push) Successful in 15s
Release / UI / Docker (push) Successful in 41s
Release / Scraper / Docker (push) Successful in 5m32s
CI / UI / Docker Push (push) Successful in 8m13s
iOS CI / Build (pull_request) Successful in 8m38s
iOS CI / Test (pull_request) Successful in 13m23s
- Add AvatarCropModal.svelte using cropperjs v1: 1:1 crop, 400×400 output,
JPEG/WebP output, dark glassmorphic UI
- Rewrite profile page avatar upload to use presigned PUT flow (POST→PUT→PATCH)
instead of sending raw FormData directly; crop modal opens on file select
- Add GET /health → {status:ok} for Docker healthcheck
- Simplify Dockerfile: drop runtime npm ci (adapter-node bundles all deps)
- Fix docker-compose UI healthcheck: /health route, 127.0.0.1 to avoid
IPv6 localhost resolution failure in alpine busybox wget
This commit is contained in:
@@ -149,7 +149,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${UI_PORT:-5252}:3000"
|
- "${UI_PORT:-5252}:3000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -8,16 +8,11 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||||
|
# adapter-node bundles all dependencies into build/ — no npm install needed.
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# adapter-node produces a standalone build/
|
|
||||||
COPY --from=builder /app/build ./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 NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
@@ -25,3 +20,4 @@ ENV HOST=0.0.0.0
|
|||||||
|
|
||||||
EXPOSE $PORT
|
EXPOSE $PORT
|
||||||
CMD ["node", "build"]
|
CMD ["node", "build"]
|
||||||
|
|
||||||
|
|||||||
7
ui/package-lock.json
generated
7
ui/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1005.0",
|
"@aws-sdk/client-s3": "^3.1005.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"pocketbase": "^0.26.8"
|
"pocketbase": "^0.26.8"
|
||||||
},
|
},
|
||||||
@@ -3114,6 +3115,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1005.0",
|
"@aws-sdk/client-s3": "^3.1005.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"pocketbase": "^0.26.8"
|
"pocketbase": "^0.26.8"
|
||||||
}
|
}
|
||||||
|
|||||||
91
ui/src/lib/components/AvatarCropModal.svelte
Normal file
91
ui/src/lib/components/AvatarCropModal.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Cropper from 'cropperjs';
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File;
|
||||||
|
onconfirm: (blob: Blob, mimeType: string) => void;
|
||||||
|
oncancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { file, onconfirm, oncancel }: Props = $props();
|
||||||
|
|
||||||
|
let imgEl: HTMLImageElement;
|
||||||
|
let cropper: Cropper | null = null;
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
cropper = new Cropper(imgEl, {
|
||||||
|
aspectRatio: 1,
|
||||||
|
viewMode: 1,
|
||||||
|
dragMode: 'move',
|
||||||
|
autoCropArea: 0.8,
|
||||||
|
restore: false,
|
||||||
|
guides: false,
|
||||||
|
center: true,
|
||||||
|
highlight: false,
|
||||||
|
cropBoxMovable: true,
|
||||||
|
cropBoxResizable: true,
|
||||||
|
toggleDragModeOnDblclick: false,
|
||||||
|
background: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cropper?.destroy();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (!cropper) return;
|
||||||
|
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
|
||||||
|
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob: Blob | null) => {
|
||||||
|
if (blob) onconfirm(blob, mimeType);
|
||||||
|
},
|
||||||
|
mimeType,
|
||||||
|
0.9
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Crop profile picture"
|
||||||
|
>
|
||||||
|
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
|
||||||
|
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
|
||||||
|
|
||||||
|
<!-- Cropper image container -->
|
||||||
|
<div class="rounded-xl overflow-hidden bg-zinc-800" style="max-height: 340px;">
|
||||||
|
<img
|
||||||
|
bind:this={imgEl}
|
||||||
|
src={objectUrl}
|
||||||
|
alt="Crop preview"
|
||||||
|
style="display:block; max-width:100%;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
onclick={oncancel}
|
||||||
|
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={confirm}
|
||||||
|
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Use photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
6
ui/src/routes/health/+server.ts
Normal file
6
ui/src/routes/health/+server.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = () => {
|
||||||
|
return json({ status: 'ok' });
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { PageData, ActionData } from './$types';
|
import type { PageData, ActionData } from './$types';
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
|
import AvatarCropModal from '$lib/components/AvatarCropModal.svelte';
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
@@ -12,32 +13,71 @@
|
|||||||
let avatarError = $state('');
|
let avatarError = $state('');
|
||||||
let fileInput: HTMLInputElement | null = null;
|
let fileInput: HTMLInputElement | null = null;
|
||||||
|
|
||||||
async function handleAvatarChange(e: Event) {
|
// Crop modal state
|
||||||
|
let cropFile = $state<File | null>(null);
|
||||||
|
|
||||||
|
function handleAvatarChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) return;
|
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;
|
avatarUploading = true;
|
||||||
avatarError = '';
|
avatarError = '';
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
// Step 1: get presigned PUT URL
|
||||||
fd.append('file', file);
|
const presignRes = await fetch('/api/profile/avatar', {
|
||||||
const res = await fetch('/api/profile/avatar', { method: 'POST', body: fd });
|
method: 'POST',
|
||||||
if (!res.ok) {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const body = await res.json().catch(() => ({})) as { message?: string };
|
body: JSON.stringify({ mime_type: mimeType })
|
||||||
avatarError = body.message ?? `Upload failed (${res.status})`;
|
});
|
||||||
|
if (!presignRes.ok) {
|
||||||
|
const body = await presignRes.json().catch(() => ({})) as { message?: string };
|
||||||
|
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
|
||||||
return;
|
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;
|
avatarUrl = result.avatar_url;
|
||||||
} catch {
|
} catch {
|
||||||
avatarError = 'Network error during upload';
|
avatarError = 'Network error during upload';
|
||||||
} finally {
|
} finally {
|
||||||
avatarUploading = false;
|
avatarUploading = false;
|
||||||
if (fileInput) fileInput.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCropCancel() {
|
||||||
|
cropFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────────────────────────────────────
|
// ── Settings ────────────────────────────────────────────────────────────────
|
||||||
let voices = $state<string[]>([]);
|
let voices = $state<string[]>([]);
|
||||||
let voicesLoaded = $state(false);
|
let voicesLoaded = $state(false);
|
||||||
@@ -173,6 +213,14 @@
|
|||||||
<title>Profile — libnovel</title>
|
<title>Profile — libnovel</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if cropFile}
|
||||||
|
<AvatarCropModal
|
||||||
|
file={cropFile}
|
||||||
|
onconfirm={handleCropConfirm}
|
||||||
|
oncancel={handleCropCancel}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Hidden logout form used when user ends their own session -->
|
<!-- Hidden logout form used when user ends their own session -->
|
||||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||||
|
|
||||||
@@ -210,14 +258,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onchange={handleAvatarChange}
|
onchange={handleAvatarChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
|
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user