Move Gateway up in nav; render jobs and programs as cards
Reorder the sidebar so Gateway sits right under Overview. Replace the Scheduled and Program tables with card grids matching Services: add JobCard and ProgramCard, convert ScheduledSection to a JobCard grid, and turn the former ProgramTable into a filterable ProgramCard grid (renamed ProgramList; kept the search box, dropped the sortable table). Remove the now-orphaned SortHeader component.
This commit is contained in:
96
app/src/components/JobCard.tsx
Normal file
96
app/src/components/JobCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Clock, Play, RefreshCw, Square, Terminal } from "lucide-react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import type { JobSummary, HealthStatus } from "@/types"
|
||||||
|
import { useServiceAction } from "@/services/api/hooks"
|
||||||
|
import { runnerLabel } from "@/lib/labels"
|
||||||
|
import { StackBadge } from "./StackBadge"
|
||||||
|
|
||||||
|
interface JobCardProps {
|
||||||
|
job: JobSummary
|
||||||
|
health?: HealthStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobCard({ job, health }: JobCardProps) {
|
||||||
|
const { mutate, isPending } = useServiceAction()
|
||||||
|
const isDown = health?.status === "down"
|
||||||
|
|
||||||
|
const doAction = (action: string) => {
|
||||||
|
mutate({ name: job.id, action })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<Link
|
||||||
|
to={`/jobs/${job.id}`}
|
||||||
|
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{job.id}
|
||||||
|
</Link>
|
||||||
|
{health && (
|
||||||
|
<span className={`text-xs ${health.status === "up" ? "text-green-400" : "text-red-400"}`}>
|
||||||
|
{health.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-2">
|
||||||
|
<StackBadge stack={job.stack} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.description && (
|
||||||
|
<p className="text-sm text-[var(--muted)] mb-3">{job.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||||
|
{job.schedule && (
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Clock size={12} />
|
||||||
|
{job.schedule}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{job.runner && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Terminal size={12} />
|
||||||
|
{runnerLabel(job.runner)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.managed && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isDown && (
|
||||||
|
<button
|
||||||
|
onClick={() => doAction("start")}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Start"
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => doAction("restart")}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Restart"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{!isDown && (
|
||||||
|
<button
|
||||||
|
onClick={() => doAction("stop")}
|
||||||
|
disabled={isPending}
|
||||||
|
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
<Square size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ import { useEventStream } from "@/services/api/hooks"
|
|||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: "/", label: "Overview", icon: LayoutDashboard, end: true },
|
{ to: "/", label: "Overview", icon: LayoutDashboard, end: true },
|
||||||
|
{ to: "/gateway", label: "Gateway", icon: Globe, end: false },
|
||||||
{ to: "/services", label: "Services", icon: Server, end: false },
|
{ to: "/services", label: "Services", icon: Server, end: false },
|
||||||
{ to: "/scheduled", label: "Scheduled", icon: Clock, end: false },
|
{ to: "/scheduled", label: "Scheduled", icon: Clock, end: false },
|
||||||
{ to: "/programs", label: "Programs", icon: Package, end: false },
|
{ to: "/programs", label: "Programs", icon: Package, end: false },
|
||||||
{ to: "/gateway", label: "Gateway", icon: Globe, end: false },
|
|
||||||
{ to: "/mesh", label: "Mesh", icon: Share2, end: false },
|
{ to: "/mesh", label: "Mesh", icon: Share2, end: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
42
app/src/components/ProgramCard.tsx
Normal file
42
app/src/components/ProgramCard.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import type { ProgramSummary } from "@/types"
|
||||||
|
import { BehaviorBadge } from "./BehaviorBadge"
|
||||||
|
import { StackBadge } from "./StackBadge"
|
||||||
|
import { ProgramActions } from "./ProgramActions"
|
||||||
|
|
||||||
|
interface ProgramCardProps {
|
||||||
|
program: ProgramSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgramCard({ program }: ProgramCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<Link
|
||||||
|
to={`/programs/${program.id}`}
|
||||||
|
className="text-base font-semibold hover:text-[var(--primary)] transition-colors"
|
||||||
|
>
|
||||||
|
{program.id}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
|
<BehaviorBadge behavior={program.behavior} />
|
||||||
|
<StackBadge stack={program.stack} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{program.description && (
|
||||||
|
<p className="text-sm text-[var(--muted)] mb-3">{program.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgramActions
|
||||||
|
name={program.id}
|
||||||
|
actions={program.actions}
|
||||||
|
active={program.active}
|
||||||
|
behavior={program.behavior}
|
||||||
|
deployedAs={[...program.services, ...program.jobs]}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
app/src/components/ProgramList.tsx
Normal file
45
app/src/components/ProgramList.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import type { ProgramSummary } from "@/types"
|
||||||
|
import { ProgramCard } from "./ProgramCard"
|
||||||
|
|
||||||
|
interface ProgramListProps {
|
||||||
|
programs: ProgramSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgramList({ programs }: ProgramListProps) {
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
if (!search) return base
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return base.filter(
|
||||||
|
(c) =>
|
||||||
|
c.id.toLowerCase().includes(q) ||
|
||||||
|
(c.description?.toLowerCase().includes(q) ?? false),
|
||||||
|
)
|
||||||
|
}, [programs, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Filter programs..."
|
||||||
|
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted)]">No programs match.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filtered.map((program) => (
|
||||||
|
<ProgramCard key={program.id} program={program} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useMemo, useState } from "react"
|
|
||||||
import { Link } from "react-router-dom"
|
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
|
|
||||||
import type { ProgramSummary } from "@/types"
|
|
||||||
import { BehaviorBadge } from "./BehaviorBadge"
|
|
||||||
import { StackBadge } from "./StackBadge"
|
|
||||||
import { ProgramActions } from "./ProgramActions"
|
|
||||||
|
|
||||||
interface ProgramTableProps {
|
|
||||||
programs: ProgramSummary[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortKey = "id" | "stack" | "behavior"
|
|
||||||
type SortDir = "asc" | "desc"
|
|
||||||
|
|
||||||
export function ProgramTable({ programs }: ProgramTableProps) {
|
|
||||||
const [search, setSearch] = useState("")
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("id")
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
|
||||||
|
|
||||||
const toggleSort = (key: SortKey) => {
|
|
||||||
if (sortKey === key) {
|
|
||||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
|
||||||
} else {
|
|
||||||
setSortKey(key)
|
|
||||||
setSortDir("asc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (!search) return programs
|
|
||||||
const q = search.toLowerCase()
|
|
||||||
return programs.filter(
|
|
||||||
(c) =>
|
|
||||||
c.id.toLowerCase().includes(q) ||
|
|
||||||
(c.description?.toLowerCase().includes(q) ?? false),
|
|
||||||
)
|
|
||||||
}, [programs, search])
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
|
||||||
const dir = sortDir === "asc" ? 1 : -1
|
|
||||||
return [...filtered].sort((a, b) => {
|
|
||||||
switch (sortKey) {
|
|
||||||
case "id":
|
|
||||||
return dir * a.id.localeCompare(b.id)
|
|
||||||
case "stack":
|
|
||||||
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
|
|
||||||
case "behavior":
|
|
||||||
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [filtered, sortKey, sortDir])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mb-4">
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Filter programs..."
|
|
||||||
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-[var(--border)] rounded-lg overflow-x-auto">
|
|
||||||
<table className="w-full min-w-[36rem] text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
|
||||||
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
|
||||||
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
|
||||||
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sorted.map((comp) => (
|
|
||||||
<ProgramRow key={comp.id} program={comp} />
|
|
||||||
))}
|
|
||||||
{sorted.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-3 py-6 text-center text-[var(--muted)]">
|
|
||||||
No programs match.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortHeader({
|
|
||||||
label,
|
|
||||||
sortKey,
|
|
||||||
current,
|
|
||||||
dir,
|
|
||||||
onSort,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
sortKey: SortKey
|
|
||||||
current: SortKey
|
|
||||||
dir: SortDir
|
|
||||||
onSort: (key: SortKey) => void
|
|
||||||
}) {
|
|
||||||
const active = current === sortKey
|
|
||||||
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown
|
|
||||||
return (
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">
|
|
||||||
<button
|
|
||||||
onClick={() => onSort(sortKey)}
|
|
||||||
className="flex items-center gap-1 hover:text-[var(--foreground)] transition-colors"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<Icon size={12} className={active ? "text-[var(--foreground)]" : ""} />
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgramRow({ program }: { program: ProgramSummary }) {
|
|
||||||
return (
|
|
||||||
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<Link
|
|
||||||
to={`/programs/${program.id}`}
|
|
||||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{program.id}
|
|
||||||
</Link>
|
|
||||||
{program.description && (
|
|
||||||
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
|
|
||||||
{program.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<StackBadge stack={program.stack} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<BehaviorBadge behavior={program.behavior} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<ProgramActions
|
|
||||||
name={program.id}
|
|
||||||
actions={program.actions}
|
|
||||||
active={program.active}
|
|
||||||
behavior={program.behavior}
|
|
||||||
deployedAs={[...program.services, ...program.jobs]}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Link } from "react-router-dom"
|
import { useMemo } from "react"
|
||||||
import { Play, RefreshCw, Square } from "lucide-react"
|
|
||||||
import type { JobSummary, HealthStatus } from "@/types"
|
import type { JobSummary, HealthStatus } from "@/types"
|
||||||
import { useServiceAction } from "@/services/api/hooks"
|
import { JobCard } from "./JobCard"
|
||||||
import { StackBadge } from "./StackBadge"
|
|
||||||
|
|
||||||
interface ScheduledSectionProps {
|
interface ScheduledSectionProps {
|
||||||
jobs: JobSummary[]
|
jobs: JobSummary[]
|
||||||
@@ -10,94 +8,13 @@ interface ScheduledSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
|
export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
|
||||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-[var(--border)] rounded-lg overflow-x-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<table className="w-full min-w-[36rem] text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Name</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Status</th>
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<ScheduledRow key={job.id} job={job} health={statusMap.get(job.id)} />
|
<JobCard key={job.id} job={job} health={statusMap.get(job.id)} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduledRow({ job, health }: { job: JobSummary; health?: HealthStatus }) {
|
|
||||||
const { mutate, isPending } = useServiceAction()
|
|
||||||
const isDown = health?.status === "down"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<Link
|
|
||||||
to={`/jobs/${job.id}`}
|
|
||||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{job.id}
|
|
||||||
</Link>
|
|
||||||
{job.description && (
|
|
||||||
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
|
|
||||||
{job.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5 font-mono text-xs">{job.schedule}</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<StackBadge stack={job.stack} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
{health ? (
|
|
||||||
<span className={`text-xs ${health.status === "up" ? "text-green-400" : "text-red-400"}`}>
|
|
||||||
{health.status}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{isDown && (
|
|
||||||
<button
|
|
||||||
onClick={() => mutate({ name: job.id, action: "start" })}
|
|
||||||
disabled={isPending}
|
|
||||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
|
||||||
title="Start"
|
|
||||||
>
|
|
||||||
<Play size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => mutate({ name: job.id, action: "restart" })}
|
|
||||||
disabled={isPending}
|
|
||||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
|
||||||
title="Restart"
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
|
||||||
</button>
|
|
||||||
{!isDown && (
|
|
||||||
<button
|
|
||||||
onClick={() => mutate({ name: job.id, action: "stop" })}
|
|
||||||
disabled={isPending}
|
|
||||||
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
|
||||||
title="Stop"
|
|
||||||
>
|
|
||||||
<Square size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
|
|
||||||
|
|
||||||
export type SortDir = "asc" | "desc"
|
|
||||||
|
|
||||||
export function SortHeader<K extends string>({
|
|
||||||
label,
|
|
||||||
sortKey,
|
|
||||||
current,
|
|
||||||
dir,
|
|
||||||
onSort,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
sortKey: K
|
|
||||||
current: K
|
|
||||||
dir: SortDir
|
|
||||||
onSort: (key: K) => void
|
|
||||||
}) {
|
|
||||||
const active = current === sortKey
|
|
||||||
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown
|
|
||||||
return (
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">
|
|
||||||
<button
|
|
||||||
onClick={() => onSort(sortKey)}
|
|
||||||
className="flex items-center gap-1 hover:text-[var(--foreground)] transition-colors"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<Icon size={12} className={active ? "text-[var(--foreground)]" : ""} />
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSort<K extends string>(defaultKey: K, defaultDir: SortDir = "asc") {
|
|
||||||
const [sortKey, setSortKey] = useState<K>(defaultKey)
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>(defaultDir)
|
|
||||||
|
|
||||||
const toggleSort = (key: K) => {
|
|
||||||
if (sortKey === key) {
|
|
||||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"))
|
|
||||||
} else {
|
|
||||||
setSortKey(key)
|
|
||||||
setSortDir("asc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sortKey, sortDir, toggleSort } as const
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { usePrograms } from "@/services/api/hooks"
|
import { usePrograms } from "@/services/api/hooks"
|
||||||
import { ProgramTable } from "@/components/ProgramTable"
|
import { ProgramList } from "@/components/ProgramList"
|
||||||
import { PageHeader } from "@/components/PageHeader"
|
import { PageHeader } from "@/components/PageHeader"
|
||||||
|
|
||||||
export function Programs() {
|
export function Programs() {
|
||||||
@@ -12,7 +12,7 @@ export function Programs() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-[var(--muted)]">Loading...</p>
|
<p className="text-[var(--muted)]">Loading...</p>
|
||||||
) : programs && programs.length > 0 ? (
|
) : programs && programs.length > 0 ? (
|
||||||
<ProgramTable programs={programs} />
|
<ProgramList programs={programs} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[var(--muted)]">No programs yet.</p>
|
<p className="text-[var(--muted)]">No programs yet.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user