mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
731 lines
30 KiB
TypeScript
731 lines
30 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import useSWR from "swr"
|
||
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
|
||
import { fetchApi } from "../lib/api-config"
|
||
import { useT } from "../lib/i18n/provider"
|
||
import { ThemeAwareLogo } from "./lxc-app-panel"
|
||
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
|
||
import { Button } from "./ui/button"
|
||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||
import { getCategoryLabel } from "../lib/category-label"
|
||
|
||
// ─── Local subset of /api/vms shape ─────────────────────────────
|
||
// Kept narrow on purpose — this component only needs what feeds a
|
||
// launcher card. Full VMData / LxcAppWatch types live in
|
||
// virtual-machines.tsx.
|
||
|
||
interface AppPort {
|
||
port: number
|
||
description?: string
|
||
scheme?: "http" | "https"
|
||
web_path?: string
|
||
logo_url?: string | null
|
||
category?: string
|
||
custom_url?: string
|
||
}
|
||
|
||
interface AppWatch {
|
||
id: string
|
||
name: string | null
|
||
logo_url?: string | null
|
||
ports?: AppPort[]
|
||
installed_version: string | null
|
||
latest_version: string | null
|
||
update_available: boolean | null
|
||
managed_oci_app_id?: string | null
|
||
helper_slug?: string
|
||
}
|
||
|
||
interface DockerImageUpdate {
|
||
reference: string
|
||
display_name?: string | null
|
||
used_by?: string[]
|
||
update_available: boolean | null
|
||
}
|
||
|
||
// Locate the docker_inventory image whose lifecycle matches a given
|
||
// Web Link. The port's `description` is a user-typed label (e.g.
|
||
// "Paperless") so exact match on `used_by` (real container names
|
||
// like "paperless-webserver-1") almost never hits. Fall back through:
|
||
// 1. exact match in `used_by`
|
||
// 2. case-insensitive substring either way in `used_by`
|
||
// 3. substring in `display_name`
|
||
// 4. substring in `reference` (the full image path)
|
||
// Returns undefined when nothing matches — the caller treats that as
|
||
// "no upstream update signal for this port".
|
||
function findDockerImageForPort(
|
||
port: AppPort,
|
||
images: DockerImageUpdate[],
|
||
): DockerImageUpdate | undefined {
|
||
const desc = (port.description || "").trim().toLowerCase()
|
||
if (!desc || !images.length) return undefined
|
||
const exact = images.find((i) =>
|
||
(i.used_by || []).some((c) => c.toLowerCase() === desc),
|
||
)
|
||
if (exact) return exact
|
||
const inclUsedBy = images.find((i) =>
|
||
(i.used_by || []).some((c) => {
|
||
const cl = c.toLowerCase()
|
||
return cl.includes(desc) || desc.includes(cl)
|
||
}),
|
||
)
|
||
if (inclUsedBy) return inclUsedBy
|
||
const byDisplay = images.find((i) => {
|
||
const d = (i.display_name || "").toLowerCase()
|
||
return !!d && (d.includes(desc) || desc.includes(d))
|
||
})
|
||
if (byDisplay) return byDisplay
|
||
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
|
||
}
|
||
|
||
interface VM {
|
||
vmid: number
|
||
name: string
|
||
ip?: string
|
||
type: string
|
||
app_watches?: AppWatch[]
|
||
docker_inventory?: { images?: DockerImageUpdate[] }
|
||
}
|
||
|
||
interface LaunchLink {
|
||
key: string
|
||
// Present for LXC-registered apps and for custom links with a
|
||
// guest binding. Absent when the link is an unbound custom entry
|
||
// (e.g. an external service).
|
||
vmid: number | null
|
||
guestType: "lxc" | "qemu" | null
|
||
ctName: string
|
||
appName: string
|
||
logoUrl: string | null
|
||
weblink: string
|
||
category: string
|
||
updateAvailable: boolean
|
||
// Set for user-defined custom links so the card can offer edit
|
||
// and delete actions in edit mode.
|
||
isCustom: boolean
|
||
customId?: string
|
||
}
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────
|
||
|
||
// Same URL construction as the Web Link row in the App tab.
|
||
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
|
||
// is scoped to that module, and copying keeps this component free of
|
||
// hidden cross-file dependencies. A per-port `custom_url` overrides
|
||
// the ip:port composition entirely — used for apps served behind a
|
||
// reverse-proxy domain.
|
||
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
|
||
const custom = (port.custom_url || "").trim()
|
||
if (custom) return custom
|
||
const raw = (ip || "").trim().split("/")[0]
|
||
if (!raw || raw === "DHCP" || !port?.port) return null
|
||
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
|
||
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
|
||
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
|
||
return `${scheme}://${host}:${port.port}${path}`
|
||
}
|
||
|
||
type SortMode = "name" | "ct" | "category"
|
||
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
|
||
const ALL_CATEGORIES = "__all__"
|
||
|
||
const fetcher = async (url: string) => fetchApi(url)
|
||
|
||
// ─── Component ───────────────────────────────────────────────────
|
||
|
||
export function AppsDashboard() {
|
||
const t = useT()
|
||
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
|
||
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
|
||
// and rarely changes, so we don't poll on an interval — mutate() is
|
||
// called explicitly after create / update / delete.
|
||
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
|
||
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
|
||
)
|
||
// Category presets for the "+ Add link" modal.
|
||
const { data: categoryPresets } = useSWR<string[]>(
|
||
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
|
||
)
|
||
|
||
const isLightTheme = useIsLightTheme()
|
||
|
||
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
|
||
// for LXC-registered apps, plus one card per user-defined custom
|
||
// link. A custom link with a binding resolves its ctName from the
|
||
// matching guest in `vms` so renames stay in sync automatically.
|
||
const links = useMemo<LaunchLink[]>(() => {
|
||
const out: LaunchLink[] = []
|
||
const vmsList = Array.isArray(vms) ? vms : []
|
||
for (const vm of vmsList) {
|
||
const apps = vm.app_watches || []
|
||
if (!apps.length) continue
|
||
for (const app of apps) {
|
||
// Skip the synthetic entry ProxMenux inserts for managed
|
||
// OCI apps (Secure Gateway) — it has no user-assigned
|
||
// Web Link and doesn't belong in a launcher.
|
||
if (app.managed_oci_app_id) continue
|
||
// `app.update_available` refers to the app itself. For a
|
||
// Docker registration that app is the Docker engine, and its
|
||
// ports are containers running INSIDE Docker (Portainer,
|
||
// Frigate…) — each with an independent image update
|
||
// lifecycle in `vm.docker_inventory.images[]`. Propagating
|
||
// the engine-level flag to every container card would falsely
|
||
// mark Portainer/Frigate as updatable when only the engine
|
||
// needs bumping; missing the per-image flag would hide real
|
||
// Portainer/Frigate updates that ARE tracked in the App tab
|
||
// and fire notifications. Resolution: for each Docker port,
|
||
// find the image entry whose `used_by` includes the port's
|
||
// container name (== `port.description`) and use THAT image's
|
||
// update_available. Engine update stays out of the port cards
|
||
// — it belongs in the Updates tab.
|
||
const isDockerApp = app.helper_slug === "docker"
|
||
const dockerImages = vm.docker_inventory?.images || []
|
||
for (const port of app.ports || []) {
|
||
const url = buildWebUrl(vm.ip, port)
|
||
if (!url) continue
|
||
let updateAvailable = false
|
||
if (isDockerApp) {
|
||
const img = findDockerImageForPort(port, dockerImages)
|
||
updateAvailable = img?.update_available === true
|
||
} else {
|
||
updateAvailable = app.update_available === true
|
||
}
|
||
out.push({
|
||
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
|
||
vmid: vm.vmid,
|
||
guestType: "lxc",
|
||
ctName: vm.name,
|
||
appName: (port.description || app.name || vm.name || "").trim(),
|
||
logoUrl: port.logo_url || app.logo_url || null,
|
||
weblink: url,
|
||
category: (port.category || "").trim(),
|
||
updateAvailable,
|
||
isCustom: false,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
// Merge user-defined custom links. Their `binding` decides how the
|
||
// CT/VM reference renders and where clicking it navigates.
|
||
const guestByVmid = new Map<number, { name: string; type: string }>()
|
||
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
|
||
for (const link of customLinks || []) {
|
||
let ctName = ""
|
||
let vmid: number | null = null
|
||
let guestType: "lxc" | "qemu" | null = null
|
||
if (link.binding) {
|
||
const guest = guestByVmid.get(link.binding.vmid)
|
||
vmid = link.binding.vmid
|
||
guestType = link.binding.guest_type
|
||
ctName = guest?.name || ""
|
||
}
|
||
out.push({
|
||
key: `custom-${link.id}`,
|
||
vmid,
|
||
guestType,
|
||
ctName,
|
||
appName: link.name,
|
||
logoUrl: link.logo_url || null,
|
||
weblink: link.url,
|
||
category: (link.category || "").trim(),
|
||
updateAvailable: false,
|
||
isCustom: true,
|
||
customId: link.id,
|
||
})
|
||
}
|
||
return out
|
||
}, [vms, customLinks])
|
||
|
||
// Category list for the filter dropdown — built from the data so
|
||
// it always reflects reality (presets and custom-entered names).
|
||
const categoryCounts = useMemo(() => {
|
||
const map = new Map<string, number>()
|
||
for (const l of links) {
|
||
const key = l.category || t("apps.uncategorized")
|
||
map.set(key, (map.get(key) || 0) + 1)
|
||
}
|
||
return map
|
||
}, [links, t])
|
||
const sortedCategoryEntries = useMemo(
|
||
() => Array.from(categoryCounts.entries()).sort((a, b) => getCategoryLabel(t, a[0]).localeCompare(getCategoryLabel(t, b[0]))),
|
||
[categoryCounts, t],
|
||
)
|
||
|
||
// ─── Controls state ────────────────────────────────────────────
|
||
|
||
const [query, setQuery] = useState("")
|
||
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
|
||
const [sortMode, setSortMode] = useState<SortMode>("name")
|
||
const [searchExpanded, setSearchExpanded] = useState(false)
|
||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||
|
||
// Restore sort from localStorage on mount.
|
||
useEffect(() => {
|
||
try {
|
||
const saved = localStorage.getItem(SORT_STORAGE_KEY)
|
||
if (saved === "name" || saved === "ct" || saved === "category") {
|
||
setSortMode(saved)
|
||
}
|
||
} catch (_) { /* private mode / storage disabled — silent */ }
|
||
}, [])
|
||
|
||
// Persist sort choice — only this one preference survives reload;
|
||
// category filter and search reset each visit so the dashboard
|
||
// always opens showing every app.
|
||
useEffect(() => {
|
||
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
|
||
}, [sortMode])
|
||
|
||
// Filter category resets if the user removes/renames the currently
|
||
// selected one and it disappears from the list.
|
||
useEffect(() => {
|
||
if (currentCat === ALL_CATEGORIES) return
|
||
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
|
||
}, [currentCat, categoryCounts])
|
||
|
||
// ─── Custom link editor state ──────────────────────────────────
|
||
|
||
const [editorOpen, setEditorOpen] = useState(false)
|
||
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
|
||
const [editMode, setEditMode] = useState(false)
|
||
|
||
// Guest list feeds the binding dropdown in the editor modal.
|
||
const guestOptions = useMemo<GuestOption[]>(() => {
|
||
if (!Array.isArray(vms)) return []
|
||
return vms
|
||
.filter((v) => v.type === "lxc" || v.type === "qemu")
|
||
.map((v) => ({
|
||
vmid: v.vmid,
|
||
name: v.name,
|
||
type: v.type as "lxc" | "qemu",
|
||
}))
|
||
}, [vms])
|
||
|
||
const openNewLink = () => {
|
||
setEditingLink(null)
|
||
setEditorOpen(true)
|
||
}
|
||
const openEditForLink = (customId: string) => {
|
||
const found = (customLinks || []).find((l) => l.id === customId)
|
||
if (!found) return
|
||
setEditingLink(found)
|
||
setEditorOpen(true)
|
||
}
|
||
|
||
// ─── Filter + sort ─────────────────────────────────────────────
|
||
|
||
const shown = useMemo(() => {
|
||
const q = query.trim().toLowerCase()
|
||
const uncatKey = t("apps.uncategorized")
|
||
let filtered = links
|
||
if (currentCat !== ALL_CATEGORIES) {
|
||
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
|
||
}
|
||
if (q) {
|
||
filtered = filtered.filter((l) =>
|
||
l.appName.toLowerCase().includes(q) ||
|
||
(l.ctName || "").toLowerCase().includes(q) ||
|
||
(l.vmid != null && String(l.vmid).includes(q)) ||
|
||
(l.category || "").toLowerCase().includes(q) ||
|
||
getCategoryLabel(t, l.category || "").toLowerCase().includes(q)
|
||
)
|
||
}
|
||
const sorted = [...filtered]
|
||
sorted.sort((a, b) => {
|
||
if (sortMode === "name") return a.appName.localeCompare(b.appName)
|
||
if (sortMode === "ct") {
|
||
// Unbound custom links have no vmid; sort them after every
|
||
// bound entry, ordered alphabetically by app name.
|
||
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
|
||
if (a.vmid == null) return 1
|
||
if (b.vmid == null) return -1
|
||
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
|
||
}
|
||
// category — grouped alphabetically, then by app name inside
|
||
const catA = a.category || uncatKey
|
||
const catB = b.category || uncatKey
|
||
const c = getCategoryLabel(t, catA).localeCompare(getCategoryLabel(t, catB))
|
||
return c !== 0 ? c : a.appName.localeCompare(b.appName)
|
||
})
|
||
return sorted
|
||
}, [links, query, currentCat, sortMode, t])
|
||
|
||
// ─── Empty state ───────────────────────────────────────────────
|
||
|
||
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
|
||
if (vms && !hasAnyData) {
|
||
return (
|
||
<>
|
||
<div className="text-center text-muted-foreground py-16">
|
||
<div className="text-sm">{t("apps.emptyTitle")}</div>
|
||
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
|
||
<Button
|
||
onClick={openNewLink}
|
||
variant="outline"
|
||
className="mt-4"
|
||
>
|
||
<Plus className="h-4 w-4 mr-1.5" />
|
||
{t("apps.customLinkAdd")}
|
||
</Button>
|
||
</div>
|
||
<CustomLinkEditor
|
||
open={editorOpen}
|
||
onOpenChange={setEditorOpen}
|
||
editing={editingLink}
|
||
guests={guestOptions}
|
||
categoryPresets={categoryPresets || []}
|
||
onSaved={() => mutateCustomLinks()}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── Render ────────────────────────────────────────────────────
|
||
|
||
const countLabel = shown.length === 1
|
||
? t("apps.countOne")
|
||
: t("apps.countMany", { n: shown.length })
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Toolbar */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{/* Search — icon-only until tapped on narrow screens */}
|
||
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
|
||
{!searchExpanded && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSearchExpanded(true)
|
||
requestAnimationFrame(() => searchInputRef.current?.focus())
|
||
}}
|
||
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
|
||
aria-label={t("apps.searchAriaLabel")}
|
||
>
|
||
<Search className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
|
||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||
<input
|
||
ref={searchInputRef}
|
||
type="search"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
|
||
placeholder={t("apps.searchPlaceholder")}
|
||
aria-label={t("apps.searchAriaLabel")}
|
||
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Category filter */}
|
||
<select
|
||
value={currentCat}
|
||
onChange={(e) => setCurrentCat(e.target.value)}
|
||
aria-label={t("apps.filterAriaLabel")}
|
||
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
|
||
style={{
|
||
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
|
||
}}
|
||
>
|
||
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
|
||
{sortedCategoryEntries.map(([cat, n]) => (
|
||
<option key={cat} value={cat}>{`${getCategoryLabel(t, cat)} · ${n}`}</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* Sort */}
|
||
<select
|
||
value={sortMode}
|
||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||
aria-label={t("apps.sortAriaLabel")}
|
||
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
|
||
style={{
|
||
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
|
||
}}
|
||
>
|
||
<option value="name">{t("apps.sortName")}</option>
|
||
<option value="ct">{t("apps.sortId")}</option>
|
||
<option value="category">{t("apps.sortCategory")}</option>
|
||
</select>
|
||
|
||
{/* Count — desktop only. Mobile gives the horizontal room to
|
||
the + button instead so everything stays on one line. */}
|
||
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
|
||
{countLabel}
|
||
</span>
|
||
|
||
{/* + Add custom link. Icon-only on mobile (mirrors the search
|
||
icon-toggle pattern) so the toolbar fits in one line even
|
||
in the narrowest viewport. On desktop shows label + icon. */}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={openNewLink}
|
||
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
|
||
aria-label={t("apps.customLinkAdd")}
|
||
>
|
||
<Plus className="h-4 w-4 sm:mr-1.5" />
|
||
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
|
||
</Button>
|
||
|
||
{/* Edit mode toggle — only shown when at least one custom link
|
||
exists, since it's the only card type that carries per-card
|
||
edit/delete actions. LXC-registered apps are edited in the
|
||
LXC App tab of their guest modal. */}
|
||
{(customLinks && customLinks.length > 0) && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setEditMode((v) => !v)}
|
||
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
|
||
aria-pressed={editMode}
|
||
aria-label={t("apps.editModeToggle")}
|
||
>
|
||
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
|
||
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Grid — grouped headers when sorted by category */}
|
||
<CardsGrid
|
||
links={shown}
|
||
grouped={sortMode === "category"}
|
||
uncategorizedLabel={t("apps.uncategorized")}
|
||
openLabel={t("apps.openAriaLabel")}
|
||
isLightTheme={isLightTheme}
|
||
editMode={editMode}
|
||
onEditCustom={openEditForLink}
|
||
/>
|
||
|
||
<CustomLinkEditor
|
||
open={editorOpen}
|
||
onOpenChange={setEditorOpen}
|
||
editing={editingLink}
|
||
guests={guestOptions}
|
||
categoryPresets={categoryPresets || []}
|
||
onSaved={() => mutateCustomLinks()}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Cards grid + card ───────────────────────────────────────────
|
||
|
||
function CardsGrid({
|
||
links,
|
||
grouped,
|
||
uncategorizedLabel,
|
||
openLabel,
|
||
isLightTheme,
|
||
editMode,
|
||
onEditCustom,
|
||
}: {
|
||
links: LaunchLink[]
|
||
grouped: boolean
|
||
uncategorizedLabel: string
|
||
openLabel: string
|
||
isLightTheme: boolean
|
||
editMode: boolean
|
||
onEditCustom: (customId: string) => void
|
||
}) {
|
||
const t = useT()
|
||
if (!links.length) {
|
||
return (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
|
||
{/* No results after filter/search */}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!grouped) {
|
||
return (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||
{links.map((link) => (
|
||
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Group by category, insert header rows spanning the full grid width.
|
||
const groups: Array<[string, LaunchLink[]]> = []
|
||
let currentCat: string | null = null
|
||
let bucket: LaunchLink[] = []
|
||
for (const link of links) {
|
||
const cat = link.category || uncategorizedLabel
|
||
if (cat !== currentCat) {
|
||
if (bucket.length) groups.push([currentCat!, bucket])
|
||
currentCat = cat
|
||
bucket = []
|
||
}
|
||
bucket.push(link)
|
||
}
|
||
if (bucket.length) groups.push([currentCat!, bucket])
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||
{groups.map(([cat, items]) => (
|
||
<div key={`grp-${cat}`} className="contents">
|
||
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
|
||
<span>{getCategoryLabel(t, cat)}</span>
|
||
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
|
||
</h3>
|
||
{items.map((link) => (
|
||
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
|
||
// lib/category-color.ts so the LXC App tab can render the same chip.
|
||
|
||
// Dispatch the pair of events that jumps from the Apps dashboard to
|
||
// the VMs modal on the App tab for a given CT. Two events by design:
|
||
// `changeTab` switches the outer tab (dashboard-level) and
|
||
// `openLxcAppModal` tells VirtualMachines which guest to open and on
|
||
// which inner tab to land. Both fire in the same tick.
|
||
function openLxcModalOnAppTab(vmid: number) {
|
||
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
|
||
}
|
||
|
||
// Same pattern for a QEMU guest: land on the modal's Status tab
|
||
// (QEMU guests don't have the App tab). Used by custom links whose
|
||
// binding is a VM instead of an LXC.
|
||
function openVmModalOnStatusTab(vmid: number) {
|
||
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
|
||
}
|
||
|
||
function AppCard({
|
||
link,
|
||
openLabel,
|
||
isLightTheme,
|
||
editMode,
|
||
onEditCustom,
|
||
}: {
|
||
link: LaunchLink
|
||
openLabel: string
|
||
isLightTheme: boolean
|
||
editMode: boolean
|
||
onEditCustom: (customId: string) => void
|
||
}) {
|
||
const t = useT()
|
||
// Navigate to the bound guest's modal on the appropriate inner tab:
|
||
// LXC → App tab (where the weblink was registered), VM → Status tab
|
||
// (VMs don't have an App tab). Unbound custom links have no CT ref
|
||
// to click, so this handler is only wired when `link.vmid` exists.
|
||
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (link.vmid == null) return
|
||
if (link.guestType === "qemu") {
|
||
openVmModalOnStatusTab(link.vmid)
|
||
} else {
|
||
openLxcModalOnAppTab(link.vmid)
|
||
}
|
||
}
|
||
|
||
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
if (link.customId) onEditCustom(link.customId)
|
||
}
|
||
|
||
const guestPrefix = link.guestType === "qemu" ? "VM" : "LXC"
|
||
const hasBinding = link.vmid != null
|
||
|
||
return (
|
||
<a
|
||
href={link.weblink}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
aria-label={openLabel.replace("{name}", link.appName)}
|
||
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
|
||
>
|
||
{/* Head: logo + name (+ update icon) */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
|
||
{link.logoUrl ? (
|
||
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
|
||
) : (
|
||
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
|
||
</div>
|
||
{/* Edit mode on a custom card takes over the update-icon slot
|
||
with a proper edit button — custom links never carry the
|
||
update signal, so nothing is displaced. Falls back to the
|
||
update icon in every other case. */}
|
||
{editMode && link.isCustom ? (
|
||
<button
|
||
type="button"
|
||
onClick={goToEditor}
|
||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
|
||
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
|
||
title={t("actions.edit")}
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</button>
|
||
) : link.updateAvailable && (
|
||
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
|
||
)}
|
||
</div>
|
||
|
||
{/* Foot: weblink + CT ref + category chip */}
|
||
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
|
||
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
|
||
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
|
||
<span className="truncate">{link.weblink}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||
{/* Guest ref → click opens that guest's modal (LXC → App
|
||
tab, VM → Status tab). stopPropagation keeps the outer
|
||
anchor from firing at the same time. Unbound custom
|
||
links: in edit mode show the edit button here, otherwise
|
||
show nothing. */}
|
||
{hasBinding && (
|
||
<button
|
||
type="button"
|
||
onClick={goToBoundGuest}
|
||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
|
||
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
|
||
aria-label={t("apps.openGuestAriaLabel", {
|
||
name: link.ctName || String(link.vmid),
|
||
type: guestPrefix,
|
||
id: link.vmid!,
|
||
})}
|
||
>
|
||
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
|
||
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
|
||
</button>
|
||
)}
|
||
{link.category && (
|
||
<span
|
||
style={categoryChipStyle(link.category, isLightTheme)}
|
||
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
|
||
title={getCategoryLabel(t, link.category)}
|
||
>
|
||
{getCategoryLabel(t, link.category)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</a>
|
||
)
|
||
}
|