feat: improved app cards and custom icons

This commit is contained in:
Jake Turner 2026-01-15 09:34:51 -08:00 committed by Jake Turner
parent 5793fc2139
commit bb67bab9a9
7 changed files with 53 additions and 46 deletions

View File

@ -26,6 +26,9 @@ export default class Service extends BaseModel {
@column()
declare description: string | null
@column()
declare icon: string | null // must be a TablerIcons name to be properly rendered in the UI (e.g. "IconBrandDocker")
@column({
serialize(value) {
return Boolean(value)

View File

@ -62,7 +62,7 @@ export class SystemService {
const query = Service.query()
.orderBy('friendly_name', 'asc')
.select('id', 'service_name', 'installed', 'ui_location', 'friendly_name', 'description')
.select('id', 'service_name', 'installed', 'installation_status', 'ui_location', 'friendly_name', 'description', 'icon')
.where('is_dependency_service', false)
if (installedOnly) {
query.where('installed', true)
@ -84,6 +84,7 @@ export class SystemService {
service_name: service.service_name,
friendly_name: service.friendly_name,
description: service.description,
icon: service.icon,
installed: service.installed,
installation_status: service.installation_status,
status: status ? status.status : 'unknown',

View File

@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('icon').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('icon')
})
}
}

View File

@ -10,6 +10,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.KIWIX_SERVICE_NAME,
friendly_name: 'Kiwix',
description: 'Offline Wikipedia, eBooks, and more',
icon: 'IconBooks',
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
container_command: '*.zim --address=all',
container_config: JSON.stringify({
@ -30,6 +31,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.OLLAMA_SERVICE_NAME,
friendly_name: 'Ollama',
description: 'Run local LLMs (AI models) with ease on your own hardware',
icon: 'IconRobot',
container_image: 'ollama/ollama:latest',
container_command: 'serve',
container_config: JSON.stringify({
@ -50,6 +52,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.OPEN_WEBUI_SERVICE_NAME,
friendly_name: 'Open WebUI',
description: 'A web interface for interacting with local AI models served by Ollama',
icon: 'IconWand',
container_image: 'ghcr.io/open-webui/open-webui:main',
container_command: null,
container_config: JSON.stringify({
@ -71,6 +74,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.CYBERCHEF_SERVICE_NAME,
friendly_name: 'CyberChef',
description: 'The Cyber Swiss Army Knife - a web app for encryption, encoding, and data analysis',
icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:latest',
container_command: null,
container_config: JSON.stringify({
@ -90,6 +94,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.FLATNOTES_SERVICE_NAME,
friendly_name: 'FlatNotes',
description: 'A simple note-taking app that stores all files locally',
icon: 'IconNotes',
container_image: 'dullage/flatnotes:latest',
container_command: null,
container_config: JSON.stringify({
@ -111,6 +116,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.KOLIBRI_SERVICE_NAME,
friendly_name: 'Kolibri',
description: 'An offline-first education platform for schools and learners',
icon: 'IconSchool',
container_image: 'treehouses/kolibri:latest',
container_command: null,
container_config: JSON.stringify({

View File

@ -1,15 +1,25 @@
import { IconBolt, IconHelp, IconMapRoute, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react'
import {
IconBolt,
IconHelp,
IconMapRoute,
IconPlus,
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head } from '@inertiajs/react'
import BouncingLogo from '~/components/BouncingLogo'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
const STATIC_ITEMS = [
{
label: 'Easy Setup',
to: '/easy-setup',
target: '',
description: "Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!",
description:
'Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!',
icon: <IconBolt size={48} />,
installed: true,
},
@ -49,17 +59,23 @@ const STATIC_ITEMS = [
export default function Home(props: {
system: {
services: { id: number; service_name: string; installed: boolean; ui_location: string }[]
services: ServiceSlim[]
}
}) {
const items = []
props.system.services.map((service) => {
props.system.services.map((service) => {
items.push({
label: service.service_name,
to: getServiceLink(service.ui_location),
label: service.friendly_name || service.service_name,
to: service.ui_location ? getServiceLink(service.ui_location) : '#',
target: '_blank',
description: `Access ${service.service_name} content`,
icon: <IconWifiOff size={48} />,
description:
service.description ||
`Access the ${service.friendly_name || service.service_name} application`,
icon: service.icon ? (
<DynamicIcon icon={service.icon as DynamicIconName} className="!size-12" />
) : (
<IconWifiOff size={48} />
),
installed: service.installed,
})
})

View File

@ -114,7 +114,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/application/-/application-8.4.1.tgz",
"integrity": "sha512-2vwO/8DoKJ9AR4Vvllz08RcomBoETc3FMf+q+ri1BVVjc76tLGV3KcYZp8+uKOuEreiK6poQ7NwJrR1P5ANA/w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/hooks": "^7.2.5",
"@poppinss/macroable": "^1.0.4",
@ -136,7 +135,6 @@
"integrity": "sha512-csLdMW58cwuRjdPEDE0dqwHZCT5snCh+1sQ19HPnQ/BLKPPAvQdDRdw0atoC8LVmouB8ghXVHp3SxnVxlvXYWQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@adonisjs/env": "^6.1.0",
"@antfu/install-pkg": "^0.4.1",
@ -231,7 +229,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-5.0.3.tgz",
"integrity": "sha512-dO7gkYxZsrsnR8n7d5KUpyi+Q5c6BnV2rmFDqEmEjz5AkOZLLzJJJbeHgMb+M27le7ifEUoa8MRu6RED8NMsJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/utils": "^6.9.4"
},
@ -244,7 +241,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/core/-/core-6.19.0.tgz",
"integrity": "sha512-qwGuapvMLYPna89Qji/MuD9xx6qqcqc/aLrSGgoFbOzBmd8Ycc9391w7sFrrGuJpHiNLBmf1NJsY3YS2AwyX0A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@adonisjs/ace": "^13.3.0",
"@adonisjs/application": "^8.4.1",
@ -348,7 +344,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/encryption/-/encryption-6.0.2.tgz",
"integrity": "sha512-37XqVPsZi6zXMbC0Me1/qlcTP0uE+KAtYOFx7D7Tvtz377NL/6gqxqgpW/BopgOSD+CVDXjzO/Wx3M2UrbkJRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/utils": "^6.7.3"
},
@ -441,7 +436,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/fold/-/fold-10.2.0.tgz",
"integrity": "sha512-VDBGrVz2viaCsmONLKYpMMeP3ds+fw+7kofeF/z9ic6cB3d7BLEB8VcIdGkfY0FCBbLK2Btee1tNPuUF1uMlmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/utils": "^7.0.0-next.1",
"parse-imports": "^2.2.1"
@ -508,7 +502,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/http-server/-/http-server-7.7.0.tgz",
"integrity": "sha512-qW1wsp7f1BqRO2qmJ8laUaq8vnLjEvhgkMusLEa2ju6RBMMsph5w3cEDTXAwQO8fSSqNXmRTzPRQ1lUm/FXq0A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"@poppinss/macroable": "^1.0.4",
@ -577,7 +570,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/logger/-/logger-6.0.6.tgz",
"integrity": "sha512-r5mLmmklSezzu3cu9QaXle2/gPNrgKpiIo+utYlwV3ITsW5JeIX/xcwwMTNM/9f1zU+SwOj5NccPTEFD3feRaw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/utils": "^6.9.2",
"abstract-logging": "^2.0.1",
@ -592,7 +584,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/lucid/-/lucid-21.6.1.tgz",
"integrity": "sha512-0TLCcPm9GHShJlsDAF5SHilafnvTxW25y5nD3bGJBSMEaNfGXcGRBbnyWoeNs7DsnqMCZ6ociT+0XMcKJWzQrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@adonisjs/presets": "^2.6.4",
"@faker-js/faker": "^9.6.0",
@ -676,7 +667,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/session/-/session-7.5.1.tgz",
"integrity": "sha512-b1E0W/1nnJfAq3Gv8yPywgsxJ7uzzOBJxxulonXI4t1eSdvJzZGNrFScfVLOcjTwlxwrEFA847tULIQxgR4Spw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/macroable": "^1.0.4",
"@poppinss/utils": "^6.9.2"
@ -719,7 +709,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/shield/-/shield-8.2.0.tgz",
"integrity": "sha512-RddRbs92y87GGFUgDSWD/Pg7qYHh8+MctUphFZwtbTblvDckrjZxuYyp+vmVATPuvDvK7sOlatuZHT4HQSz9zQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/utils": "^6.9.2",
"csrf": "^3.1.0",
@ -798,7 +787,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/vite/-/vite-4.0.0.tgz",
"integrity": "sha512-5kdE0qLIm2dj+XO0HiCohmh3tfZ+X468wkNXErQ15VS0fkDjZWns2VuiYpoToTKd4tdX/oGlpnpd8aIwAGB4ow==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/utils": "^6.8.3",
"@vavite/multibuild": "^5.1.0",
@ -897,7 +885,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz",
"integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@ -2206,7 +2193,6 @@
"integrity": "sha512-M2LUtHhKr4KgBfX73tDHNCD1IOmcXp9dvC+AinmRxsggIFnarsClcfjT/sXc3uNzjZW7Lk31LvcH76AxJHBmJQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.16.0"
},
@ -2235,7 +2221,6 @@
"integrity": "sha512-e3BFn1rca/OTiagilkmRTrLVhl00iC/LrY5j4Ns/VZDONYHs9BKAbHaImxjD1zoHMEhwQEF+ce7fgMO/BK+lfg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@japa/core": "^10.3.0",
"@japa/errors-printer": "^4.1.2",
@ -3325,7 +3310,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.21"
@ -3938,7 +3922,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz",
"integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.83.0"
},
@ -4239,7 +4222,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz",
"integrity": "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -4275,7 +4257,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4378,7 +4359,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@ -4607,7 +4587,6 @@
"resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz",
"integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/macroable": "^1.0.4",
"@types/validator": "^13.12.2",
@ -4747,7 +4726,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5123,7 +5101,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@ -6153,7 +6130,6 @@
"resolved": "https://registry.npmjs.org/edge.js/-/edge.js-6.2.1.tgz",
"integrity": "sha512-me875zh6YA0V429hywgQIpHgMvQkondv5XHaP6EsL2yIBpLcBWCl7Ba1cai0SwYhp8iD0IyV3KjpxLrnW7S2Ag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@poppinss/inspect": "^1.0.1",
"@poppinss/macroable": "^1.0.4",
@ -6424,7 +6400,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -6486,7 +6461,6 @@
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -7828,7 +7802,6 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
@ -8726,7 +8699,6 @@
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
}
@ -8752,7 +8724,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@ -9747,7 +9718,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -9811,7 +9781,6 @@
"integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -10174,7 +10143,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -10199,7 +10167,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -10248,7 +10215,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -11813,7 +11779,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -12073,7 +12038,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -12283,7 +12247,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View File

@ -10,4 +10,5 @@ export type ServiceSlim = Pick<
| 'ui_location'
| 'friendly_name'
| 'description'
| 'icon'
> & { status?: ServiceStatus }