Add MDI icon picker to all entity types
Some checks failed
Validate / Hassfest (push) Has been cancelled

- Install @mdi/js (~7000 Material Design Icons)
- IconPicker component: dropdown with search, popular icons grid,
  clear option. Stores icon name as string (e.g. "mdiCamera")
- MdiIcon component: renders SVG from icon name
- Backend: add `icon` field to ImmichServer, TelegramBot,
  TrackingConfig, TemplateConfig, NotificationTarget, AlbumTracker
- All 6 entity pages: icon picker next to name input in create/edit
  forms, icon displayed on entity cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:01:22 +03:00
parent af9bfb7b22
commit 5a0b0b78f6
11 changed files with 182 additions and 24 deletions

View File

@@ -7,6 +7,9 @@
"": {
"name": "frontend",
"version": "0.0.1",
"dependencies": {
"@mdi/js": "^7.4.47"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
@@ -521,6 +524,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2550,6 +2558,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"@mdi/js": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz",
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ=="
},
"@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",

View File

@@ -27,5 +27,8 @@
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@mdi/js": "^7.4.47"
}
}

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { t } from '$lib/i18n';
import * as mdi from '@mdi/js';
let { value = '', onselect } = $props<{
value: string;
onselect: (icon: string) => void;
}>();
let open = $state(false);
let search = $state('');
// Build searchable icon list from @mdi/js exports
// Each export is like mdiAccount = "M12 4..." (SVG path)
const allIcons = Object.keys(mdi).filter(k => k.startsWith('mdi') && k !== 'default');
// Popular icons shown first when search is empty
const popular = [
'mdiServer', 'mdiCamera', 'mdiImage', 'mdiVideo', 'mdiBell', 'mdiSend',
'mdiRobot', 'mdiHome', 'mdiStar', 'mdiHeart', 'mdiAccount', 'mdiFolder',
'mdiFolderImage', 'mdiAlbum', 'mdiImageMultiple', 'mdiCloudUpload',
'mdiEye', 'mdiCog', 'mdiTelegram', 'mdiWebhook', 'mdiMessageText',
'mdiCalendar', 'mdiClock', 'mdiMapMarker', 'mdiTag', 'mdiFilter',
'mdiSort', 'mdiMagnify', 'mdiPencil', 'mdiDelete', 'mdiPlus',
'mdiCheck', 'mdiClose', 'mdiAlert', 'mdiInformation', 'mdiShield',
'mdiLink', 'mdiDownload', 'mdiUpload', 'mdiRefresh', 'mdiPlay',
'mdiPause', 'mdiStop', 'mdiSkipNext', 'mdiMusic', 'mdiMovie',
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
];
function filtered(): string[] {
if (!search) return popular.filter(p => allIcons.includes(p));
const q = search.toLowerCase();
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
}
function getPath(iconName: string): string {
return (mdi as any)[iconName] || '';
}
function select(iconName: string) {
onselect(iconName);
open = false;
search = '';
}
</script>
<div class="inline-block relative">
<button type="button" onclick={() => open = !open}
class="flex items-center gap-1 px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
{#if value && getPath(value)}
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getPath(value)} /></svg>
{:else}
<span class="text-[var(--color-muted-foreground)]">Icon</span>
{/if}
<span class="text-xs text-[var(--color-muted-foreground)]"></span>
</button>
{#if open}
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998;"
onclick={() => { open = false; search = ''; }}></div>
<div style="position: absolute; z-index: 9999; top: 100%; left: 0; margin-top: 0.25rem;"
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3 w-72">
<input type="text" bind:value={search} placeholder="Search icons..."
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
<div class="grid grid-cols-8 gap-1 max-h-48 overflow-y-auto">
<!-- Clear option -->
<button type="button" onclick={() => select('')}
class="flex items-center justify-center w-8 h-8 rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
title="No icon"></button>
{#each filtered() as iconName}
<button type="button" onclick={() => select(iconName)}
class="flex items-center justify-center w-8 h-8 rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
title={iconName.replace('mdi', '')}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getPath(iconName)} /></svg>
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import * as mdi from '@mdi/js';
let { name = '', size = 18 } = $props<{ name: string; size?: number }>();
function getPath(iconName: string): string {
return (mdi as any)[iconName] || '';
}
</script>
{#if name && getPath(name)}
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor"><path d={getPath(name)} /></svg>
{/if}

View File

@@ -5,11 +5,13 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let servers = $state<any[]>([]);
let showForm = $state(false);
let editing = $state<number | null>(null);
let form = $state({ name: 'Immich', url: '', api_key: '' });
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
let error = $state('');
let submitting = $state(false);
let loaded = $state(false);
@@ -27,11 +29,11 @@
}
function openNew() {
form = { name: 'Immich', url: '', api_key: '' };
form = { name: 'Immich', url: '', api_key: '', icon: '' };
editing = null; showForm = true;
}
function edit(s: any) {
form = { name: s.name, url: s.url, api_key: '' };
form = { name: s.name, url: s.url, api_key: '', icon: s.icon || '' };
editing = s.id; showForm = true;
}
@@ -72,8 +74,13 @@
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-3">
<div>
<label for="srv-name" class="block text-sm font-medium mb-1">{t('servers.name')}</label>
<input id="srv-name" bind:value={form.name} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="flex items-end gap-2">
<label for="srv-name" class="block text-sm font-medium mb-1">{t('servers.name')}</label>
</div>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="srv-name" bind:value={form.name} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="srv-url" class="block text-sm font-medium mb-1">{t('servers.url')}</label>
@@ -100,6 +107,7 @@
<div class="flex items-center gap-2">
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
title={health[server.id] === true ? 'Online' : health[server.id] === false ? 'Offline' : 'Checking...'}></span>
{#if server.icon}<MdiIcon name={server.icon} />{/if}
<div>
<p class="font-medium">{server.name}</p>
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>

View File

@@ -5,6 +5,8 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let targets = $state<any[]>([]);
let trackingConfigs = $state<any[]>([]);
@@ -14,7 +16,7 @@
let showForm = $state(false);
let editing = $state<number | null>(null);
let formType = $state<'telegram' | 'webhook'>('telegram');
const defaultForm = () => ({ name: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false,
tracking_config_id: 0, template_config_id: 0 });
@@ -42,7 +44,7 @@
formType = tgt.type;
const c = tgt.config || {};
form = {
name: tgt.name, bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
name: tgt.name, icon: tgt.icon || '', bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
@@ -118,7 +120,10 @@
</div>
<div>
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#if formType === 'telegram'}
<!-- Bot selector (required) -->
@@ -222,6 +227,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if target.icon}<MdiIcon name={target.icon} />{/if}
<p class="font-medium">{target.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
</div>

View File

@@ -5,11 +5,13 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let bots = $state<any[]>([]);
let loaded = $state(false);
let showForm = $state(false);
let form = $state({ name: '', token: '' });
let form = $state({ name: '', icon: '', token: '' });
let error = $state('');
let submitting = $state(false);
@@ -25,7 +27,7 @@
e.preventDefault(); error = ''; submitting = true;
try {
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
form = { name: '', token: '' }; showForm = false; await load();
form = { name: '', icon: '', token: '' }; showForm = false; await load();
} catch (err: any) { error = err.message; }
submitting = false;
}
@@ -56,7 +58,7 @@
</script>
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
<button onclick={() => { showForm ? (showForm = false) : (showForm = true, form = { name: '', token: '' }); }}
<button onclick={() => { showForm ? (showForm = false) : (showForm = true, form = { name: '', icon: '', token: '' }); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
</button>
@@ -70,8 +72,11 @@
<form onsubmit={create} class="space-y-3">
<div>
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
@@ -95,6 +100,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
<p class="font-medium">{bot.name}</p>
{#if bot.bot_username}
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>

View File

@@ -5,6 +5,8 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
@@ -16,7 +18,7 @@
let previewId = $state<number | null>(null);
const defaultForm = () => ({
name: '',
name: '', icon: '',
message_assets_added: '📷 {added_count} new photo(s) added to album "{album_name}"{common_date}{common_location}.{people}{assets}{video_warning}',
message_assets_removed: '🗑️ {removed_count} photo(s) removed from album "{album_name}".',
message_album_renamed: '✏️ Album "{old_name}" renamed to "{new_name}".',
@@ -119,8 +121,11 @@
<form onsubmit={save} class="space-y-5">
<div>
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
{#each templateSlots as group}
@@ -153,7 +158,10 @@
<Card>
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="font-medium">{config.name}</p>
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<p class="font-medium">{config.name}</p>
</div>
<pre class="text-xs text-[var(--color-muted-foreground)] mt-1 whitespace-pre-wrap font-mono bg-[var(--color-muted)] rounded p-2">{config.message_assets_added?.slice(0, 120)}...</pre>
{#if previewResult && previewId === config.id}
<div class="mt-2 p-2 bg-[var(--color-success-bg)] rounded text-sm">

View File

@@ -5,6 +5,8 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let loaded = $state(false);
let trackers = $state<any[]>([]);
@@ -15,7 +17,7 @@
let editing = $state<number | null>(null);
let albumFilter = $state('');
const defaultForm = () => ({
name: '', server_id: 0, album_ids: [] as string[],
name: '', icon: '', server_id: 0, album_ids: [] as string[],
target_ids: [] as number[], scan_interval: 60,
});
let form = $state(defaultForm());
@@ -30,7 +32,7 @@
function openNew() { form = defaultForm(); editing = null; showForm = true; albums = []; }
async function edit(trk: any) {
form = {
name: trk.name, server_id: trk.server_id, album_ids: [...trk.album_ids],
name: trk.name, icon: trk.icon || '', server_id: trk.server_id, album_ids: [...trk.album_ids],
target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
};
editing = trk.id; showForm = true;
@@ -74,7 +76,10 @@
<form onsubmit={save} class="space-y-4">
<div>
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="trk-server" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
@@ -138,6 +143,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
<p class="font-medium">{tracker.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}

View File

@@ -5,6 +5,8 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
let configs = $state<any[]>([]);
let loaded = $state(false);
@@ -13,7 +15,7 @@
let error = $state('');
const defaultForm = () => ({
name: '', track_assets_added: true, track_assets_removed: false,
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
track_album_renamed: true, track_album_deleted: true,
track_images: true, track_videos: true, notify_favorites_only: false,
include_people: true, include_asset_details: false,
@@ -66,8 +68,11 @@
<form onsubmit={save} class="space-y-5">
<div>
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<!-- Event tracking -->
@@ -183,7 +188,10 @@
<Card>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">{config.name}</p>
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<p class="font-medium">{config.name}</p>
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_album_renamed && 'renamed', config.track_album_deleted && 'deleted'].filter(Boolean).join(', ')}
{config.periodic_enabled ? ' · periodic' : ''}

View File

@@ -33,6 +33,7 @@ class ImmichServer(SQLModel, table=True):
url: str
api_key: str
external_domain: str | None = None
icon: str = Field(default="") # MDI icon name (e.g. "mdiServer")
created_at: datetime = Field(default_factory=_utcnow)
@@ -45,6 +46,7 @@ class TelegramBot(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id")
name: str # User-given display name
token: str # Bot API token
icon: str = Field(default="") # MDI icon name
bot_username: str = Field(default="") # @username from getMe
bot_id: int = Field(default=0) # Numeric bot ID from getMe
created_at: datetime = Field(default_factory=_utcnow)
@@ -58,6 +60,7 @@ class TrackingConfig(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str
icon: str = Field(default="")
# Event-driven tracking
track_assets_added: bool = Field(default=True)
@@ -112,6 +115,7 @@ class TemplateConfig(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
name: str # e.g. "Default EN", "Default RU"
icon: str = Field(default="")
# Event messages
message_assets_added: str = Field(
@@ -172,6 +176,7 @@ class NotificationTarget(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id")
type: str # "telegram" or "webhook"
name: str
icon: str = Field(default="")
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id")
template_config_id: int | None = Field(default=None, foreign_key="template_config.id")
@@ -187,6 +192,7 @@ class AlbumTracker(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id")
server_id: int = Field(foreign_key="immich_server.id")
name: str
icon: str = Field(default="")
album_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON))
target_ids: list[int] = Field(default_factory=list, sa_column=Column(JSON))
scan_interval: int = Field(default=60)