diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 91ebfbbc..c8eedeba 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -38,6 +38,7 @@ import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' import logger from '@adonisjs/core/services/logger' import Service from '#models/service' +import { DEFAULT_LINK_TILE_COLOR } from '../../constants/link_tile_colors.js' @inject() export default class SystemController { @@ -593,6 +594,7 @@ export default class SystemController { is_custom: false, is_user_modified: true, is_link_tile: true, + link_color: payload.link_color ?? DEFAULT_LINK_TILE_COLOR, category: 'Links', }) @@ -628,6 +630,7 @@ export default class SystemController { service.icon = payload.icon || DEFAULT_LINK_TILE_ICON service.display_order = payload.display_order ?? service.display_order ?? 90 service.custom_url = normalized + service.link_color = payload.link_color ?? service.link_color ?? DEFAULT_LINK_TILE_COLOR await service.save() return response.send({ success: true }) diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 8204e052..4966d326 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -93,6 +93,9 @@ export default class Service extends BaseModel { }) declare is_link_tile: boolean + @column() + declare link_color: string | null + @column() declare category: string | null diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index edf5baf2..56371ece 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -344,6 +344,7 @@ export class SystemService { 'is_user_modified', 'is_deprecated', 'is_link_tile', + 'link_color', 'category' ) .where('is_dependency_service', false) @@ -385,6 +386,7 @@ export class SystemService { is_user_modified: service.is_user_modified, is_deprecated: service.is_deprecated, is_link_tile: service.is_link_tile, + link_color: service.link_color, category: service.category, }) } diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 8dbef640..1153931d 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -1,4 +1,5 @@ import vine from '@vinejs/vine' +import { LINK_TILE_COLOR_IDS } from '../../constants/link_tile_colors.js' export const installServiceValidator = vine.compile( vine.object({ @@ -137,6 +138,7 @@ export const createLinkTileValidator = vine.compile( description: vine.string().trim().maxLength(200).nullable().optional(), icon: vine.string().trim().maxLength(60).nullable().optional(), display_order: vine.number().min(0).max(999).optional(), + link_color: vine.enum(LINK_TILE_COLOR_IDS).optional(), }) ) @@ -149,6 +151,7 @@ export const updateLinkTileValidator = vine.compile( description: vine.string().trim().maxLength(200).nullable().optional(), icon: vine.string().trim().maxLength(60).nullable().optional(), display_order: vine.number().min(0).max(999).optional(), + link_color: vine.enum(LINK_TILE_COLOR_IDS).optional(), }) ) diff --git a/admin/constants/link_tile_colors.ts b/admin/constants/link_tile_colors.ts new file mode 100644 index 00000000..ef03e106 --- /dev/null +++ b/admin/constants/link_tile_colors.ts @@ -0,0 +1,70 @@ +/** + * The colours a user can give a dashboard link tile. + * + * Drawn from the brand palette rather than arbitrary hex, so a tile always sits + * on-theme next to the managed app cards. The default white-on-green contrast + * read as jarring on the dashboard, which is why this exists. + * + * Tiles stay visually distinct from managed apps regardless of colour: the + * dashed border and the external-link marker are what carry that, not the hue. + * The tint is deliberately light so the distinction survives every option. + * + * Class names are written out in full and never interpolated. Tailwind scans + * source text for literal class strings, so a computed name like + * `bg-desert-${id}/10` would be silently dropped from the build. + */ +export const LINK_TILE_COLORS = [ + { + id: 'green', + label: 'Green', + border: 'border-desert-green/70', + bg: 'bg-desert-green/10', + marker: 'text-desert-green', + }, + { + id: 'olive', + label: 'Olive', + border: 'border-desert-olive/70', + bg: 'bg-desert-olive/10', + marker: 'text-desert-olive', + }, + { + id: 'orange', + label: 'Orange', + border: 'border-desert-orange/70', + bg: 'bg-desert-orange/10', + marker: 'text-desert-orange', + }, + { + id: 'red', + label: 'Red', + border: 'border-desert-red/70', + bg: 'bg-desert-red/10', + marker: 'text-desert-red', + }, + { + id: 'sand', + label: 'Sand', + border: 'border-desert-sand/70', + bg: 'bg-desert-sand/20', + marker: 'text-desert-stone-dark', + }, + { + id: 'stone', + label: 'Stone', + border: 'border-desert-stone/70', + bg: 'bg-desert-stone/10', + marker: 'text-desert-stone-dark', + }, +] as const + +export type LinkTileColorId = (typeof LINK_TILE_COLORS)[number]['id'] + +export const DEFAULT_LINK_TILE_COLOR: LinkTileColorId = 'green' + +export const LINK_TILE_COLOR_IDS: readonly string[] = LINK_TILE_COLORS.map((c) => c.id) + +/** Resolve a stored id, falling back to the default for anything unknown. */ +export function linkTileColor(id?: string | null) { + return LINK_TILE_COLORS.find((c) => c.id === id) ?? LINK_TILE_COLORS[0] +} diff --git a/admin/database/migrations/1786000000002_add_link_color_to_services.ts b/admin/database/migrations/1786000000002_add_link_color_to_services.ts new file mode 100644 index 00000000..493002e6 --- /dev/null +++ b/admin/database/migrations/1786000000002_add_link_color_to_services.ts @@ -0,0 +1,21 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + // Brand-palette colour for a dashboard link tile. Null means the default. + // A separate migration from the one that added is_link_tile, because that + // one has already run on the test box; editing it in place would leave + // fresh installs and upgraded ones with different schemas. + table.string('link_color', 20).nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('link_color') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index bc0c7edc..8b5e85d3 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -16,6 +16,7 @@ type ServiceSeedRecord = Omit< | 'is_user_modified' | 'is_deprecated' | 'is_link_tile' + | 'link_color' | 'custom_url' | 'auto_update_enabled' | 'available_update_first_seen_at' diff --git a/admin/inertia/components/LinkTileModal.tsx b/admin/inertia/components/LinkTileModal.tsx index 2f175cce..fcf7b548 100644 --- a/admin/inertia/components/LinkTileModal.tsx +++ b/admin/inertia/components/LinkTileModal.tsx @@ -5,6 +5,7 @@ import LinkTileIconPicker from './LinkTileIconPicker' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import { normalizeCustomUrl } from '~/lib/navigation' import { DEFAULT_LINK_TILE_ICON } from '../../constants/link_tile_icons' +import { DEFAULT_LINK_TILE_COLOR, LINK_TILE_COLORS } from '../../constants/link_tile_colors' import { ServiceSlim } from '../../types/services' import api from '~/lib/api' @@ -35,6 +36,7 @@ export default function LinkTileModal({ const [url, setUrl] = useState('') const [description, setDescription] = useState('') const [icon, setIcon] = useState(DEFAULT_LINK_TILE_ICON) + const [color, setColor] = useState(DEFAULT_LINK_TILE_COLOR) const [submitting, setSubmitting] = useState(false) const isEdit = Boolean(tile) @@ -45,6 +47,7 @@ export default function LinkTileModal({ setUrl(tile?.custom_url ?? '') setDescription(tile?.description ?? '') setIcon(tile?.icon ?? DEFAULT_LINK_TILE_ICON) + setColor(tile?.link_color ?? DEFAULT_LINK_TILE_COLOR) }, [open, tile]) const trimmedName = name.trim() @@ -62,6 +65,7 @@ export default function LinkTileModal({ url: trimmedUrl, description: description.trim() ? description.trim() : null, icon, + link_color: color, } const result = isEdit @@ -151,6 +155,29 @@ export default function LinkTileModal({

+ +
+

Colour

+
+ {LINK_TILE_COLORS.map((option) => ( +
+

+ Links stay outlined rather than filled whichever colour you pick, so they + are still tellable apart from apps NOMAD manages. +

+
) diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index 6ddf9a58..bc93cffe 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1137,6 +1137,7 @@ class API { description?: string | null icon?: string | null display_order?: number + link_color?: string }) { return catchInternal(async () => { const response = await this.client.post<{ success: boolean; service_name: string }>( @@ -1154,6 +1155,7 @@ class API { description?: string | null icon?: string | null display_order?: number + link_color?: string }) { return catchInternal(async () => { const response = await this.client.put<{ success: boolean }>('/system/services/links', data) diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index 3b70aff3..9a33f910 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -26,6 +26,7 @@ import { SERVICE_NAMES } from '../../constants/service_names' import LinkTileModal from '~/components/LinkTileModal' import { IconPlus, IconPencil, IconTrash, IconExternalLink } from '@tabler/icons-react' import { useState } from 'react' +import { linkTileColor } from '../../constants/link_tile_colors' // Maps is a Core Capability (display_order: 4) const MAPS_ITEM = { @@ -271,6 +272,9 @@ export default function Home(props: { const shouldHighlight = isEasySetup && shouldHighlightEasySetup const isLinkTile = Boolean(item.linkTile) + // A flat surface colour read as a stark white card next to the filled + // app tiles. Tint from the brand palette instead, user-chosen. + const tileColor = linkTileColor(item.linkTile?.link_color) // Link tiles are deliberately not styled like managed apps: outlined // rather than filled, with an external-link marker. If they looked the @@ -280,13 +284,13 @@ export default function Home(props: {
{isLinkTile && ( diff --git a/admin/tests/unit/link_tile.spec.ts b/admin/tests/unit/link_tile.spec.ts index e99b3bb1..964ec07b 100644 --- a/admin/tests/unit/link_tile.spec.ts +++ b/admin/tests/unit/link_tile.spec.ts @@ -18,6 +18,12 @@ import { LINK_TILE_ICONS, isLinkTileIcon, } from '../../constants/link_tile_icons.js' +import { + DEFAULT_LINK_TILE_COLOR, + LINK_TILE_COLORS, + LINK_TILE_COLOR_IDS, + linkTileColor, +} from '../../constants/link_tile_colors.js' test('a bare host:port gains http, which is what most LAN devices need', () => { assert.equal(normalizeCustomUrl('192.168.1.50:8080'), 'http://192.168.1.50:8080/') @@ -101,3 +107,25 @@ test('isLinkTileIcon accepts only names in the set', () => { test('the default icon is part of the offered set', () => { assert.ok((LINK_TILE_ICONS as readonly string[]).includes(DEFAULT_LINK_TILE_ICON)) }) + +test('link tile colours resolve from the brand palette', () => { + // Unknown, null and empty all fall back rather than rendering an untinted card. + assert.equal(linkTileColor('orange').id, 'orange') + assert.equal(linkTileColor(null).id, DEFAULT_LINK_TILE_COLOR) + assert.equal(linkTileColor('chartreuse').id, DEFAULT_LINK_TILE_COLOR) +}) + +test('every link tile colour ships complete, literal Tailwind classes', () => { + // Tailwind scans for literal class strings, so an interpolated name would be + // dropped from the build and the tile would render untinted. + for (const option of LINK_TILE_COLORS) { + assert.ok(option.border.startsWith('border-desert-'), option.id) + assert.ok(option.bg.startsWith('bg-desert-'), option.id) + assert.ok(option.marker.startsWith('text-desert-'), option.id) + assert.ok(!`${option.border}${option.bg}${option.marker}`.includes('${'), option.id) + } +}) + +test('the default colour is one of the offered options', () => { + assert.ok(LINK_TILE_COLOR_IDS.includes(DEFAULT_LINK_TILE_COLOR)) +}) diff --git a/admin/types/services.ts b/admin/types/services.ts index 062f881a..5452f5f7 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -20,5 +20,6 @@ export type ServiceSlim = Pick< | 'is_user_modified' | 'is_deprecated' | 'is_link_tile' + | 'link_color' | 'category' > & { status?: string }