mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
Users want things they already run to appear on the dashboard: a NAS, a router UI, a printer, a Pi, a container they started themselves. Adds a tile they address themselves, with a name, one URL field, an optional description and an icon from the curated set. Chosen over adopting containers from the Docker daemon, which is what community PR #1239 proposed. Adoption only reaches containers on this Docker host and has to guess: it took the lowest published host port and assumed http, which picks the wrong port for anything publishing several (Portainer 8000 against 9443, Jellyfin 8096 against 8920) and breaks entirely for HTTPS. A tile the user addresses has no guessing in it, covers the whole LAN rather than one Docker host, and has no lifecycle that can break. One URL field rather than separate host, port and path, because people paste URLs and it gets https and sub-paths for free. A bare "192.168.1.50:8080" gains http automatically, which is what most LAN devices need. normalizeCustomUrl and getServiceLink already did exactly this work for the custom app URL override, so both are reused rather than duplicated. Stored as a service row with no container behind it: is_link_tile marks it, custom_url carries the destination, and the service_name is namespaced nomad_link_* so it can never collide with a curated entry. The seeder only iterates its own DEFAULT_SERVICES and never prunes unknown rows, so a reseed cannot touch or remove a tile. The tile is deliberately styled differently from a managed app, outlined and dashed with an external-link marker rather than filled, because if it looked the same users would expect Start, Stop and Update and file bugs when those controls are not there. Removing a tile arms a confirm first, since it is user-entered data with no undo. Icons come from a fixed 36 shared by the picker and the server-side validator, so the two cannot disagree about what is selectable. DynamicIcon renders nothing at all for a name it does not know, which would leave a silent hole in a tile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
42 lines
1.5 KiB
TypeScript
42 lines
1.5 KiB
TypeScript
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
|
import { LINK_TILE_ICONS } from '../../constants/link_tile_icons'
|
|
|
|
type LinkTileIconPickerProps = {
|
|
value: string | null
|
|
onChange: (icon: string) => void
|
|
}
|
|
|
|
/**
|
|
* The whole curated icon set, six across and six down.
|
|
*
|
|
* No search box and no paging: 36 icons fit on screen at once, which is the
|
|
* point of curating them rather than offering a library. The grid needs an
|
|
* explicit width, because a shrink-wrapped container collapses the six 1fr
|
|
* columns and the glyphs overlap into an unreadable smear.
|
|
*/
|
|
export default function LinkTileIconPicker({ value, onChange }: LinkTileIconPickerProps) {
|
|
return (
|
|
<div className="w-72 rounded border border-border-default bg-surface-primary p-2">
|
|
<div className="grid grid-cols-6 justify-items-center gap-1">
|
|
{LINK_TILE_ICONS.map((name) => (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
title={name.replace(/^Icon/, '')}
|
|
aria-label={name.replace(/^Icon/, '')}
|
|
aria-pressed={value === name}
|
|
onClick={() => onChange(name)}
|
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded transition-colors ${
|
|
value === name
|
|
? 'bg-desert-green text-white'
|
|
: 'text-text-secondary hover:bg-surface-secondary'
|
|
}`}
|
|
>
|
|
<DynamicIcon icon={name as DynamicIconName} className="!size-5" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|