[web] share preview assets and uniformly scale icon views #190
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user