mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
feat(dashboard): let a link tile carry a brand colour
A link tile rendered on a flat surface colour, which read as a stark white card against the filled green app tiles. Tiles now take one of six colours from the brand palette, chosen when creating or editing one, and the card is tinted from it. Tiles stay visually distinct from managed apps regardless of colour: the dashed border and the external-link marker carry that, not the hue, and the tint is deliberately light so the distinction survives every option. Class names are written out in full rather than interpolated, because Tailwind scans source text for literal class strings and would silently drop a computed one, leaving the tile untinted. A test asserts that. Added as a separate migration from the one introducing is_link_tile, since that has already run on the test box and editing it in place would leave fresh and upgraded installs with different schemas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
This commit is contained in:
parent
a6edaf7df7
commit
400d64ab55
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
)
|
||||
|
||||
|
|
|
|||
70
admin/constants/link_tile_colors.ts
Normal file
70
admin/constants/link_tile_colors.ts
Normal file
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<string>(DEFAULT_LINK_TILE_ICON)
|
||||
const [color, setColor] = useState<string>(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({
|
|||
</p>
|
||||
<LinkTileIconPicker value={icon} onChange={setIcon} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 font-medium text-text-primary">Colour</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{LINK_TILE_COLORS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
title={option.label}
|
||||
aria-label={option.label}
|
||||
aria-pressed={color === option.id}
|
||||
onClick={() => setColor(option.id)}
|
||||
className={`h-7 w-7 rounded border-2 border-dashed transition-transform ${option.border} ${option.bg} ${
|
||||
color === option.id ? 'scale-110 ring-2 ring-offset-1 ring-desert-green' : ''
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-text-muted">
|
||||
Links stay outlined rather than filled whichever colour you pick, so they
|
||||
are still tellable apart from apps NOMAD manages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
<div
|
||||
className={
|
||||
isLinkTile
|
||||
? 'relative rounded border-2 border-dashed border-desert-green/70 bg-surface-primary text-text-primary hover:bg-surface-secondary transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4'
|
||||
? `relative rounded border-2 border-dashed ${tileColor.border} ${tileColor.bg} text-text-primary hover:bg-surface-secondary transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4`
|
||||
: 'relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4'
|
||||
}
|
||||
>
|
||||
{isLinkTile && (
|
||||
<span
|
||||
className="absolute top-2 left-2 text-text-muted"
|
||||
className={`absolute top-2 left-2 ${tileColor.marker}`}
|
||||
title="A shortcut you added. NOMAD does not manage this."
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,5 +20,6 @@ export type ServiceSlim = Pick<
|
|||
| 'is_user_modified'
|
||||
| 'is_deprecated'
|
||||
| 'is_link_tile'
|
||||
| 'link_color'
|
||||
| 'category'
|
||||
> & { status?: string }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user