feat(builder): announcement/feedback bar (#762)

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
bo0tzz
2026-09-02 19:02:06 +00:00
committed by GitHub
co-authored by Jason Rasmussen
parent f7d703eb95
commit 6c2f17986b
2 changed files with 359 additions and 342 deletions
@@ -30,6 +30,7 @@
mdiScriptTextOutline,
mdiShoppingOutline,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { siGithub } from 'simple-icons';
import { type Snippet } from 'svelte';
import { MediaQuery } from 'svelte/reactivity';
@@ -61,7 +62,10 @@
<TooltipProvider>
<AppShell>
<AppShellHeader>
<AppShellHeader class="block">
<AnnouncementBanner center size="small" until={DateTime.fromObject({ year: 2026, month: 9, day: 30 })}>
Try our new tool: <Link href="/docker-compose-builder">Docker Compose Builder</Link>
</AnnouncementBanner>
<div class="w-full">
{#if !page.url.pathname.startsWith('/blog') && featuredPost}
<AnnouncementBanner until={featuredPost.publishedAt.plus({ week: 1 })}>
@@ -8,6 +8,7 @@
import {
ActionBar,
ActionButton,
Alert,
AppShell,
AppShellBar,
Button,
@@ -45,6 +46,7 @@
mdiDownload,
mdiEyeOffOutline,
mdiEyeOutline,
mdiPartyPopper,
mdiPlus,
} from '@mdi/js';
import { onMount } from 'svelte';
@@ -131,6 +133,8 @@
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const FEEDBACK_URL = 'https://github.com/immich-app/immich/discussions/31232';
const pageMetadata = {
title: 'Docker Compose Builder',
description: 'Build a custom docker-compose.yml file for Immich.',
@@ -173,358 +177,367 @@
<SiteMetadata site={siteMetadata} page={pageMetadata} />
<AppShell>
<AppShellBar>
<ActionBar
closeIcon={mdiArrowLeft}
translations={{ close: 'Back' }}
closeOnEsc={false}
onClose={() => goto('/download')}
static
shape="round"
variant="filled"
>
<ControlBarHeader>
<ControlBarTitle>{pageMetadata.title}</ControlBarTitle>
</ControlBarHeader>
</ActionBar>
</AppShellBar>
<PageContent>
<Container size="giant" center>
<div class="my-4 flex justify-end gap-2">
<ActionButton action={Advanced} />
<ActionButton action={Copy} />
<ActionButton action={Download} type="button" variant="filled" size="medium" shape="round" color="primary" />
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[clamp(20rem,34%,31.25rem)_1fr]">
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<Stack gap={6}>
<Card color="secondary">
<CardHeader>
<CardTitle>General</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
<Field>
<Stack gap={2}>
<Label label="Immich Version Tag" size="small" />
<div class="grid grid-cols-2 gap-1 rounded-2xl border p-1">
<Button
size="small"
fullWidth
variant={versionPinned ? 'ghost' : 'filled'}
color={versionPinned ? 'secondary' : 'primary'}
onclick={() => (versionPinned = false)}
>
Rolling
</Button>
<Button
size="small"
fullWidth
variant={versionPinned ? 'filled' : 'ghost'}
color={versionPinned ? 'primary' : 'secondary'}
onclick={() => (versionPinned = true)}
>
Pinned
</Button>
</div>
{#if versionPinned}
<Input
bind:value={pinnedVersion}
placeholder={latestVersion}
aria-label="Pinned Immich version tag"
/>
{/if}
{#if !version}
<Text size="small" color="muted">
{versionFailed ? 'Version unavailable.' : 'Checking for the latest release...'}
</Text>
{:else if versionPinned}
<Text size="small" color="muted">
Locked to <Code>{version}</Code>, updated manually.
</Text>
{:else}
<Text size="small" color="muted">
The newest <Code>{majorVersion}</Code> release each time you pull.
</Text>
{/if}
</Stack>
</Field>
<div class="flex h-dvh flex-col">
<AppShell class="h-auto min-h-0 grow">
<AppShellBar>
<ActionBar
closeIcon={mdiArrowLeft}
translations={{ close: 'Back' }}
closeOnEsc={false}
onClose={() => goto('/download')}
static
shape="round"
variant="filled"
>
<ControlBarHeader>
<ControlBarTitle>{pageMetadata.title}</ControlBarTitle>
</ControlBarHeader>
</ActionBar>
</AppShellBar>
<PageContent>
<Container size="giant" center>
<Alert color="primary" icon={mdiPartyPopper} title="New feature" shape="rectangle" class="my-3">
<div>
This tool is new. Please give us <Link href={FEEDBACK_URL}>your feedback</Link>!
</div>
</Alert>
<Field label="Rootless mode">
<Switch bind:checked={config.rootless.enabled} class="flex justify-between gap-4" />
</Field>
{#if config.rootless.enabled}
<div class="grid grid-cols-2 gap-4">
<Field label="UID" invalid={!!errors['rootless.uid']}>
<NumberInput
bind:value={config.rootless.uid}
min={0}
placeholder={String(DEFAULT_CONFIG.rootless.uid)}
/>
{@render fieldError(errors['rootless.uid'])}
</Field>
<Field label="GID" invalid={!!errors['rootless.gid']}>
<NumberInput
bind:value={config.rootless.gid}
min={0}
placeholder={String(DEFAULT_CONFIG.rootless.gid)}
/>
{@render fieldError(errors['rootless.gid'])}
</Field>
</div>
{/if}
{#if advanced}
<Field
label="Container names"
description="Turn off to run more than one Immich stack on the same host."
>
<Switch bind:checked={config.containerNames} class="flex justify-between gap-4" />
</Field>
{/if}
</Stack>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<CardTitle>Server</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
<Field label="Server Timezone">
<Input bind:value={config.timezone} list="timezones" placeholder="Etc/UTC" />
<!-- TODO: Combobox in @immich/ui -->
<datalist id="timezones">
{#each timezones as timezone (timezone)}
<option value={timezone}></option>
{/each}
</datalist>
</Field>
<Field label="Upload Location" invalid={!!errors['storage.uploadLocation']}>
<Input
bind:value={config.storage.uploadLocation}
placeholder={DEFAULT_CONFIG.storage.uploadLocation}
/>
{@render fieldError(errors['storage.uploadLocation'])}
</Field>
{#if advanced}
<Field
label="Custom folder locations"
description="Mount individual subfolders on separate storage."
>
<Switch bind:checked={config.storage.customFolders} class="flex justify-between gap-4" />
</Field>
{#if config.storage.customFolders}
<Text size="small" color="muted">Leave a field blank to keep it under the upload location.</Text>
{#each FOLDER_OVERRIDES as folder (folder.key)}
{@const error = errors[`storage.overrides.${folder.key}`]}
<Field label={folder.label} invalid={!!error}>
<Input bind:value={config.storage.overrides[folder.key]} placeholder="Optional host path" />
{@render fieldError(error)}
</Field>
{/each}
{/if}
<Stack gap={2}>
<Label label="External libraries" size="small" />
<Text size="small" color="muted"
>Existing media, mounted at the same path inside the container.</Text
>
{#each config.storage.externalLibraries as library, index (index)}
{@const error = errors[`storage.externalLibraries.${index}.path`]}
{@const name = library.path.trim() || `row ${index + 1}`}
<Field invalid={!!error}>
<div class="flex items-center gap-2">
<div class="grow">
<Input
bind:value={library.path}
placeholder="/mnt/media/photos"
aria-label={`External library path ${index + 1}`}
/>
</div>
<Field class="w-auto">
<Checkbox bind:checked={library.readOnly} aria-label={`Read-only for ${name}`} />
</Field>
<Text size="small" color="muted">Read-only</Text>
<IconButton
icon={mdiClose}
onclick={() => removeLibrary(index)}
aria-label={`Remove ${name}`}
variant="ghost"
color="secondary"
/>
</div>
{@render fieldError(error)}
</Field>
{/each}
{#if config.storage.externalLibraries.some(({ readOnly }) => !readOnly)}
<Text size="small" color="muted">
Where read-only is off, Immich can delete files and write XMP sidecars.
</Text>
{/if}
<Button
size="small"
variant="outline"
color="secondary"
leadingIcon={mdiPlus}
class="self-start"
onclick={addLibrary}
>
Add library
</Button>
</Stack>
<Field label="Host Port" invalid={!!errors.port}>
<NumberInput
bind:value={config.port}
min={1}
max={65_535}
placeholder={String(DEFAULT_CONFIG.port)}
/>
{@render fieldError(errors.port)}
</Field>
<Field label="External network" description="Attach the server to a network you already created.">
<Switch bind:checked={config.network.external} class="flex justify-between gap-4" />
</Field>
{#if config.network.external}
<Field label="Network name" invalid={!!errors['network.name']}>
<Input bind:value={config.network.name} placeholder="proxy" />
{@render fieldError(errors['network.name'])}
</Field>
{/if}
{/if}
<Field label="Transcoding Hardware Acceleration">
<Select bind:value={config.hwaccel.transcoding} options={TRANSCODE_ACCELS} />
</Field>
</Stack>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<CardTitle>Machine Learning</CardTitle>
</CardHeader>
<CardBody>
<Field label="Hardware Acceleration">
<Select bind:value={config.hwaccel.ml} options={ML_ACCELS} />
</Field>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<CardTitle>Database</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
{#if advanced}
<Field label="External Postgres">
<Switch bind:checked={config.database.external} class="flex justify-between gap-4" />
</Field>
{/if}
{#if advanced && config.database.external}
<Text size="small" color="muted">
The VectorChord extension must be installed. See the
<Link href="https://docs.immich.app/administration/postgres-standalone"
>standalone Postgres guide</Link
>.
</Text>
<Field label="Connection URL" invalid={!!errors['database.externalUrl']}>
<Input
bind:value={config.database.externalUrl}
placeholder="postgresql://user:password@host:5432/immich"
/>
{@render fieldError(errors['database.externalUrl'])}
</Field>
{:else}
<Field
label="Use a named volume"
description="Docker manages the storage location. Recommended on Windows and macOS."
>
<Switch
checked={config.database.mount.type === 'volume'}
onCheckedChange={(value) =>
(config.database.mount = value
? { type: 'volume' }
: structuredClone(DEFAULT_CONFIG.database.mount))}
class="flex justify-between gap-4"
/>
</Field>
{#if config.database.mount.type === 'bind'}
<Field label="Database Location" invalid={!!errors['database.mount.location']}>
<Input bind:value={config.database.mount.location} placeholder={defaultDatabaseLocation} />
{@render fieldError(errors['database.mount.location'])}
</Field>
{/if}
<Field label="Database storage type">
<Select bind:value={config.database.storageType} options={Object.values(StorageType)} />
</Field>
<Field label="Database Password" invalid={!!errors['database.password']}>
<div class="flex items-end gap-2">
<div class="grow">
<Input bind:value={config.database.password} />
</div>
<IconButton
icon={mdiDiceMultiple}
onclick={generatePassword}
aria-label="Generate a random password"
variant="ghost"
color="secondary"
/>
</div>
{@render fieldError(errors['database.password'])}
</Field>
{/if}
</Stack>
</CardBody>
</Card>
{#if advanced}
<div class="my-4 flex justify-end gap-2">
<ActionButton action={Advanced} />
<ActionButton action={Copy} />
<ActionButton action={Download} type="button" variant="filled" size="medium" shape="round" color="primary" />
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[clamp(20rem,34%,31.25rem)_1fr]">
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<Stack gap={6}>
<Card color="secondary">
<CardHeader>
<CardTitle>Redis</CardTitle>
<CardTitle>General</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
<Field label="External Redis">
<Switch bind:checked={config.redis.external} class="flex justify-between gap-4" />
<Field>
<Stack gap={2}>
<Label label="Immich Version Tag" size="small" />
<div class="grid grid-cols-2 gap-1 rounded-2xl border p-1">
<Button
size="small"
fullWidth
variant={versionPinned ? 'ghost' : 'filled'}
color={versionPinned ? 'secondary' : 'primary'}
onclick={() => (versionPinned = false)}
>
Rolling
</Button>
<Button
size="small"
fullWidth
variant={versionPinned ? 'filled' : 'ghost'}
color={versionPinned ? 'primary' : 'secondary'}
onclick={() => (versionPinned = true)}
>
Pinned
</Button>
</div>
{#if versionPinned}
<Input
bind:value={pinnedVersion}
placeholder={latestVersion}
aria-label="Pinned Immich version tag"
/>
{/if}
{#if !version}
<Text size="small" color="muted">
{versionFailed ? 'Version unavailable.' : 'Checking for the latest release...'}
</Text>
{:else if versionPinned}
<Text size="small" color="muted">
Locked to <Code>{version}</Code>, updated manually.
</Text>
{:else}
<Text size="small" color="muted">
The newest <Code>{majorVersion}</Code> release each time you pull.
</Text>
{/if}
</Stack>
</Field>
{#if config.redis.external}
<Field label="Host" invalid={!!errors['redis.host']}>
<Input bind:value={config.redis.host} placeholder="redis.example.com" />
{@render fieldError(errors['redis.host'])}
</Field>
<Field label="Port" invalid={!!errors['redis.port']}>
<NumberInput bind:value={config.redis.port} min={1} max={65_535} placeholder="6379" />
{@render fieldError(errors['redis.port'])}
</Field>
<Field label="Password">
<Input bind:value={config.redis.password} placeholder="Optional" />
<Field label="Rootless mode">
<Switch bind:checked={config.rootless.enabled} class="flex justify-between gap-4" />
</Field>
{#if config.rootless.enabled}
<div class="grid grid-cols-2 gap-4">
<Field label="UID" invalid={!!errors['rootless.uid']}>
<NumberInput
bind:value={config.rootless.uid}
min={0}
placeholder={String(DEFAULT_CONFIG.rootless.uid)}
/>
{@render fieldError(errors['rootless.uid'])}
</Field>
<Field label="GID" invalid={!!errors['rootless.gid']}>
<NumberInput
bind:value={config.rootless.gid}
min={0}
placeholder={String(DEFAULT_CONFIG.rootless.gid)}
/>
{@render fieldError(errors['rootless.gid'])}
</Field>
</div>
{/if}
{#if advanced}
<Field
label="Container names"
description="Turn off to run more than one Immich stack on the same host."
>
<Switch bind:checked={config.containerNames} class="flex justify-between gap-4" />
</Field>
{/if}
</Stack>
</CardBody>
</Card>
{/if}
</Stack>
</form>
<div class="min-w-0 self-start lg:sticky lg:top-6">
{#if versionFailed}
<Text color="danger">Could not reach the Immich version service. Reload the page to try again.</Text>
{:else if version}
<CodeBlock code={compose} language={yamlLanguage} lineNumbers copy={!hasErrors} />
{:else}
<LoadingSpinner />
{/if}
<Card color="secondary">
<CardHeader>
<CardTitle>Server</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
<Field label="Server Timezone">
<Input bind:value={config.timezone} list="timezones" placeholder="Etc/UTC" />
<!-- TODO: Combobox in @immich/ui -->
<datalist id="timezones">
{#each timezones as timezone (timezone)}
<option value={timezone}></option>
{/each}
</datalist>
</Field>
<Field label="Upload Location" invalid={!!errors['storage.uploadLocation']}>
<Input
bind:value={config.storage.uploadLocation}
placeholder={DEFAULT_CONFIG.storage.uploadLocation}
/>
{@render fieldError(errors['storage.uploadLocation'])}
</Field>
{#if advanced}
<Field
label="Custom folder locations"
description="Mount individual subfolders on separate storage."
>
<Switch bind:checked={config.storage.customFolders} class="flex justify-between gap-4" />
</Field>
{#if config.storage.customFolders}
<Text size="small" color="muted">Leave a field blank to keep it under the upload location.</Text
>
{#each FOLDER_OVERRIDES as folder (folder.key)}
{@const error = errors[`storage.overrides.${folder.key}`]}
<Field label={folder.label} invalid={!!error}>
<Input bind:value={config.storage.overrides[folder.key]} placeholder="Optional host path" />
{@render fieldError(error)}
</Field>
{/each}
{/if}
<Stack gap={2}>
<Label label="External libraries" size="small" />
<Text size="small" color="muted"
>Existing media, mounted at the same path inside the container.</Text
>
{#each config.storage.externalLibraries as library, index (index)}
{@const error = errors[`storage.externalLibraries.${index}.path`]}
{@const name = library.path.trim() || `row ${index + 1}`}
<Field invalid={!!error}>
<div class="flex items-center gap-2">
<div class="grow">
<Input
bind:value={library.path}
placeholder="/mnt/media/photos"
aria-label={`External library path ${index + 1}`}
/>
</div>
<Field class="w-auto">
<Checkbox bind:checked={library.readOnly} aria-label={`Read-only for ${name}`} />
</Field>
<Text size="small" color="muted">Read-only</Text>
<IconButton
icon={mdiClose}
onclick={() => removeLibrary(index)}
aria-label={`Remove ${name}`}
variant="ghost"
color="secondary"
/>
</div>
{@render fieldError(error)}
</Field>
{/each}
{#if config.storage.externalLibraries.some(({ readOnly }) => !readOnly)}
<Text size="small" color="muted">
Where read-only is off, Immich can delete files and write XMP sidecars.
</Text>
{/if}
<Button
size="small"
variant="outline"
color="secondary"
leadingIcon={mdiPlus}
class="self-start"
onclick={addLibrary}
>
Add library
</Button>
</Stack>
<Field label="Host Port" invalid={!!errors.port}>
<NumberInput
bind:value={config.port}
min={1}
max={65_535}
placeholder={String(DEFAULT_CONFIG.port)}
/>
{@render fieldError(errors.port)}
</Field>
<Field label="External network" description="Attach the server to a network you already created.">
<Switch bind:checked={config.network.external} class="flex justify-between gap-4" />
</Field>
{#if config.network.external}
<Field label="Network name" invalid={!!errors['network.name']}>
<Input bind:value={config.network.name} placeholder="proxy" />
{@render fieldError(errors['network.name'])}
</Field>
{/if}
{/if}
<Field label="Transcoding Hardware Acceleration">
<Select bind:value={config.hwaccel.transcoding} options={TRANSCODE_ACCELS} />
</Field>
</Stack>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<CardTitle>Machine Learning</CardTitle>
</CardHeader>
<CardBody>
<Field label="Hardware Acceleration">
<Select bind:value={config.hwaccel.ml} options={ML_ACCELS} />
</Field>
</CardBody>
</Card>
<Card color="secondary">
<CardHeader>
<CardTitle>Database</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
{#if advanced}
<Field label="External Postgres">
<Switch bind:checked={config.database.external} class="flex justify-between gap-4" />
</Field>
{/if}
{#if advanced && config.database.external}
<Text size="small" color="muted">
The VectorChord extension must be installed. See the
<Link href="https://docs.immich.app/administration/postgres-standalone"
>standalone Postgres guide</Link
>.
</Text>
<Field label="Connection URL" invalid={!!errors['database.externalUrl']}>
<Input
bind:value={config.database.externalUrl}
placeholder="postgresql://user:password@host:5432/immich"
/>
{@render fieldError(errors['database.externalUrl'])}
</Field>
{:else}
<Field
label="Use a named volume"
description="Docker manages the storage location. Recommended on Windows and macOS."
>
<Switch
checked={config.database.mount.type === 'volume'}
onCheckedChange={(value) =>
(config.database.mount = value
? { type: 'volume' }
: structuredClone(DEFAULT_CONFIG.database.mount))}
class="flex justify-between gap-4"
/>
</Field>
{#if config.database.mount.type === 'bind'}
<Field label="Database Location" invalid={!!errors['database.mount.location']}>
<Input bind:value={config.database.mount.location} placeholder={defaultDatabaseLocation} />
{@render fieldError(errors['database.mount.location'])}
</Field>
{/if}
<Field label="Database storage type">
<Select bind:value={config.database.storageType} options={Object.values(StorageType)} />
</Field>
<Field label="Database Password" invalid={!!errors['database.password']}>
<div class="flex items-end gap-2">
<div class="grow">
<Input bind:value={config.database.password} />
</div>
<IconButton
icon={mdiDiceMultiple}
onclick={generatePassword}
aria-label="Generate a random password"
variant="ghost"
color="secondary"
/>
</div>
{@render fieldError(errors['database.password'])}
</Field>
{/if}
</Stack>
</CardBody>
</Card>
{#if advanced}
<Card color="secondary">
<CardHeader>
<CardTitle>Redis</CardTitle>
</CardHeader>
<CardBody>
<Stack gap={4}>
<Field label="External Redis">
<Switch bind:checked={config.redis.external} class="flex justify-between gap-4" />
</Field>
{#if config.redis.external}
<Field label="Host" invalid={!!errors['redis.host']}>
<Input bind:value={config.redis.host} placeholder="redis.example.com" />
{@render fieldError(errors['redis.host'])}
</Field>
<Field label="Port" invalid={!!errors['redis.port']}>
<NumberInput bind:value={config.redis.port} min={1} max={65_535} placeholder="6379" />
{@render fieldError(errors['redis.port'])}
</Field>
<Field label="Password">
<Input bind:value={config.redis.password} placeholder="Optional" />
</Field>
{/if}
</Stack>
</CardBody>
</Card>
{/if}
</Stack>
</form>
<div class="min-w-0 self-start lg:sticky lg:top-6">
{#if versionFailed}
<Text color="danger">Could not reach the Immich version service. Reload the page to try again.</Text>
{:else if version}
<CodeBlock code={compose} language={yamlLanguage} lineNumbers copy={!hasErrors} />
{:else}
<LoadingSpinner />
{/if}
</div>
</div>
</div>
</Container>
</PageContent>
</AppShell>
</Container>
</PageContent>
</AppShell>
</div>