[web] share preview assets and uniformly scale icon views #190

Merged
eleith merged 2 commits from fix/preview-assets-and-icon-viewport into main 2026-11-04 00:27:01 +00:00
11 changed files with 150 additions and 46 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Share worker-generated preview files with the web service through a read-only preview mount in the production Compose examples. Document the directory setup and mount update required for existing deployments.
+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Use the same responsive icon viewport for Preview and Published views, scaling each complete composition uniformly instead of shrinking its individual parts on narrow screens.
+1
View File
@@ -12,6 +12,7 @@ services:
- "3000:3000"
volumes:
- ./demo/assets/pieces:/app/assets/pieces
- ./demo/assets/previews:/app/assets/previews:ro
- ./demo/config:/app/config:ro
- ./demo/data:/app/data
- ./demo/content:/app/content
+14
View File
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { expect, test } from 'vitest'
// Deployment-contract coverage, not a running-container smoke test.
test.each([
['../docker-compose.yml', './demo/assets'],
['../../../docs/web/docker-compose.yml', './assets']
])('%s shares preview storage between worker and web', (file, assets) => {
const { services } = parse(readFileSync(new URL(file, import.meta.url), 'utf8'))
expect(services['luzzle-worker'].volumes).toContain(`${assets}:/app/assets`)
expect(services['luzzle-web'].volumes).toContain(`${assets}/previews:/app/assets/previews:ro`)
expect(services['luzzle-web'].volumes).toContain(`${assets}/pieces:/app/assets/pieces`)
})
@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from 'svelte'
import type { TemplatedPiece } from '@luzzle/web.templates/pieces/fields'
import PieceIcon from '#lib/pieces/components/icon.svelte'
const renderWidth = 500
let { piece }: { piece: TemplatedPiece } = $props()
let container: HTMLDivElement | undefined = $state()
let scale = $state(1)
onMount(() => {
if (!container) return
const viewport = container
function updateScale() {
// A hidden viewport will be measured again when it becomes visible.
if (viewport.clientWidth > 0) scale = Math.min(1, viewport.clientWidth / renderWidth)
}
updateScale()
const observer = new ResizeObserver(updateScale)
observer.observe(viewport)
return () => observer.disconnect()
})
</script>
<main id="main-content" class="icon-viewport" aria-label="Piece icon">
<div class="scale-container" bind:this={container} style:max-width="{renderWidth}px">
<div class="icon-frame" style:width="{renderWidth}px" style:zoom={scale}>
<PieceIcon {piece} size={{ width: renderWidth }} lazy={false} />
</div>
</div>
</main>
<style>
.icon-viewport {
display: flex;
justify-content: center;
align-items: center;
padding: var(--space-8);
min-height: 80vh;
background: var(--color-surface-container-low);
}
.scale-container {
width: 100%;
min-width: 0;
}
.icon-frame {
/* Zoom scales layout and the complete icon, retaining natural height and 3D overflow. */
margin-inline: auto;
box-shadow: var(--shadow-raised);
outline: 1px solid var(--color-outline-variant);
}
</style>
@@ -0,0 +1,59 @@
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import IconViewport from './IconViewport.svelte'
import { publishedPiece as rawPublishedPiece } from './fixtures/published'
const piece = {
...rawPublishedPiece,
templates: {
icon: { name: 'default', fields: { image: 'cover' } },
page: { name: 'default', fields: { image: 'cover' } },
opengraph: { name: 'default', fields: { image: 'cover' } }
}
}
const { page } = vi.hoisted(() => ({
page: {
data: {
preview: false,
job: 'preview-job',
config: { url: { app: '/garden', luzzle_assets: '/cdn' } }
}
}
}))
vi.mock('$app/state', () => ({ page }))
vi.mock('$app/paths', () => ({
resolve: (route: string, params: Record<string, string>) =>
route.replace(/\[(?:\.\.\.)?(\w+)\]/g, (_, name: string) => params[name])
}))
// The synchronous renderer stub checks the viewport's SSR contract and real asset helpers.
// Node SSR cannot verify zoom layout, natural template heights, 3D paint/shadows,
// resize/orientation updates, or ResizeObserver mounting and cleanup in a browser.
vi.mock('#lib/pieces/components/icon.svelte', async () => ({
default: (await import('./fixtures/IconRenderer.svelte')).default
}))
test.each([
{ preview: false, src: '/cdn/pieces/assets/published/cover.jpg' },
{ preview: true, src: '/admin/preview/preview-job/asset/published/cover.jpg' }
])('SSR keeps eager 500px rendering and resource URLs (preview=$preview)', (state) => {
page.data.preview = state.preview
const html = render(IconViewport, { props: { piece } }).body
expect(html.match(/<main\b/g)).toHaveLength(1)
expect(html).toContain('id="main-content"')
expect(html).toContain('aria-label="Piece icon"')
expect(html.match(/<img\b/g)).toHaveLength(1)
expect(html).toContain('data-renderer="icon"')
expect(html).toContain('data-width="500"')
expect(html).toContain('loading="eager"')
expect(html).toContain(`src="${state.src}"`)
// SSR starts at full size; only the client can measure the available width.
expect(html).toContain('max-width: 500px')
const frame = html.match(/<div\b[^>]*class="icon-frame\b[^>]*>/)?.[0]
expect(frame).toBeDefined()
expect(frame).toContain('width: 500px; zoom: 1')
// Height belongs to the selected template, not a shared 750px canvas.
expect(frame).not.toContain('height:')
})
@@ -1,27 +1,10 @@
<script lang="ts">
import PieceIcon from '#lib/pieces/components/icon.svelte'
import IconViewport from '#lib/pieces/viewer/IconViewport.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
{#if data.status === 'completed' && data.piece}
<main id="main-content" class="icon-preview-viewport" aria-label="Icon">
<PieceIcon piece={data.piece} size={{ width: 500 }} lazy={false} />
</main>
<IconViewport piece={data.piece} />
{/if}
<style>
.icon-preview-viewport {
display: flex;
justify-content: center;
align-items: center;
padding: var(--space-8);
min-height: 80vh;
background-color: var(--color-surface-container-low);
}
:global(.icon-preview-viewport > *) {
box-shadow: var(--shadow-raised);
border: 1px solid var(--color-outline-variant);
}
</style>
@@ -1,29 +1,7 @@
<script lang="ts">
import PieceIcon from '#lib/pieces/components/icon.svelte'
import IconViewport from '#lib/pieces/viewer/IconViewport.svelte'
import type { PageProps } from './$types'
let { data }: PageProps = $props()
</script>
<main id="main-content" class="icon-viewport" aria-label="Piece icon">
<div class="icon-frame">
<PieceIcon piece={data.piece} size={{ width: 500 }} lazy={false} />
</div>
</main>
<style>
.icon-viewport {
display: flex;
justify-content: flex-start;
align-items: center;
padding: var(--space-8);
min-height: 80vh;
overflow-x: auto;
background: var(--color-surface-container-low);
}
.icon-frame {
margin-inline: auto;
flex-shrink: 0;
box-shadow: var(--shadow-raised);
border: 1px solid var(--color-outline-variant);
}
</style>
<IconViewport piece={data.piece} />
+8 -2
View File
@@ -7,7 +7,7 @@ images. No checkout or custom build is needed.
```sh
mkdir luzzle && cd luzzle
mkdir -p config archive assets/pieces data content rclone static nginx/cache
mkdir -p config archive assets/pieces assets/previews data content rclone static nginx/cache
touch .env
```
@@ -55,7 +55,13 @@ Changes to `.env` or mounts require
To update: back up your data, update `LUZZLE_VERSION` if pinned, then pull and
start again.
## Preview cleanup
## Preview storage and cleanup
The worker writes previews to `assets/previews`; the web service needs the same
directory mounted read-only. For existing deployments, first create this directory
with ownership and write permissions matching the worker's configured UID/GID.
Then add the preview mount from the Compose example and recreate `luzzle-web`;
updating the image alone does not change mounts.
The worker removes expired previews and app logs daily around 02:00 UTC.
Finished jobs are retained for seven days; workflow history stays.
+1 -1
View File
@@ -22,7 +22,7 @@ Set `LUZZLE_AUTH_SECRET`, `LUZZLE_AUTH_PASSWORD` and, if using AI, `GOOGLE_API_K
Generate a secret with `openssl rand -hex 32`. Keep `.env` out of Git.
```sh
mkdir -p demo/assets/pieces demo/data nginx/cache nginx/logs
mkdir -p demo/assets/pieces demo/assets/previews demo/data nginx/cache nginx/logs
docker compose -f docker-compose.dev.yml -f docker-compose.dev.local.yml up --build
```
+1
View File
@@ -17,6 +17,7 @@ services:
- ./config:/app/config:ro
- ./archive:/app/archive
- ./assets/pieces:/app/assets/pieces
- ./assets/previews:/app/assets/previews:ro
- ./data:/app/data
- ./content:/app/content:ro
healthcheck: