Move panel explanations into tooltips
The node, flow, widget and dashboard panels explained themselves in paragraphs under every control. They now carry a caption and an info tooltip, so a panel reads as a list of settings. InfoTip, PanelSection and FieldLabel live beside PANEL_SECTION in SidePanel.tsx, which the panels already share. InfoTip holds its own open state because a Radix tooltip ignores a touch pointer, and below md the whole panel is a full-screen sheet — hover-only help would leave a phone with the caption and nothing else. Dashboard/panels.tsx drops from 30 helper paragraphs to 5; the ones left are empty states and status, not explanation. Em-dashes are out of the user-facing strings, except where one separates the halves of a select item.
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
useSavePanels,
|
||||
useUnpairPanel,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { InfoTip } from "@/components/Flow/SidePanel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
@@ -98,9 +99,7 @@ export function PanelsDialog() {
|
||||
<DialogTitle>Panels</DialogTitle>
|
||||
<DialogDescription>
|
||||
A panel is one screen and the dashboards it shows. Point the device at
|
||||
a link and it asks for a code you enter here — this instance's own
|
||||
address for a screen on your network, or the portal's for one hanging
|
||||
where this machine is not reachable.
|
||||
a link and it asks for a code you enter here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -232,9 +231,7 @@ function PanelRow({
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCode("")
|
||||
showSuccessToast(
|
||||
"Paired — the screen switches over within a few seconds.",
|
||||
)
|
||||
showSuccessToast("Paired. The screen switches over within a few seconds.")
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
@@ -254,7 +251,7 @@ function PanelRow({
|
||||
// The trash icon carries no text of its own, so the confirm step says what
|
||||
// the next press does through the label a reader or a hover gets.
|
||||
const removeLabel = confirmRemove
|
||||
? `Confirm — remove ${panel.id}`
|
||||
? `Confirm: remove ${panel.id}`
|
||||
: `Remove ${panel.id}`
|
||||
|
||||
return (
|
||||
@@ -405,7 +402,7 @@ function PanelRow({
|
||||
unpair.mutate(undefined, {
|
||||
onSuccess: () =>
|
||||
showSuccessToast(
|
||||
"Unpaired — the screen asks for a new code.",
|
||||
"Unpaired. The screen asks for a new code.",
|
||||
),
|
||||
onError: (error) =>
|
||||
handleError.call(showErrorToast, error as ApiError),
|
||||
@@ -413,16 +410,16 @@ function PanelRow({
|
||||
}}
|
||||
>
|
||||
{confirmUnpair
|
||||
? "Confirm — sign this screen out"
|
||||
? "Confirm: sign this screen out"
|
||||
: "Unpair screen"}
|
||||
</Button>
|
||||
<p className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
Sends the screen hanging here back to a pairing code and keeps
|
||||
the panel, its dashboards and their arrangement — unlike
|
||||
removing the panel, which throws all three away. A screen paired
|
||||
through the portal holds a credential this does not reach; that
|
||||
one is revoked at the portal.
|
||||
</p>
|
||||
<InfoTip label="Unpair screen">
|
||||
Sends the screen hanging here back to a pairing code, keeping
|
||||
the panel, its dashboards and their arrangement. Removing the
|
||||
panel throws all three away instead. A screen paired through the
|
||||
portal holds a credential this does not reach; revoke that one
|
||||
there.
|
||||
</InfoTip>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useState } from "react"
|
||||
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
|
||||
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
|
||||
import {
|
||||
FieldLabel,
|
||||
InfoTip,
|
||||
PANEL_SECTION,
|
||||
PanelSection,
|
||||
PanelTitle,
|
||||
SidePanel,
|
||||
} from "@/components/Flow/SidePanel"
|
||||
@@ -544,16 +547,16 @@ export function WidgetPanel({
|
||||
<Label className="text-sm font-normal" htmlFor="embed-url">
|
||||
Address
|
||||
</Label>
|
||||
<InfoTip label="Address">
|
||||
Any page that allows being framed. A site that refuses shows its
|
||||
own refusal in the tile.
|
||||
</InfoTip>
|
||||
<Input
|
||||
id="embed-url"
|
||||
value={str(cfg.url)}
|
||||
placeholder="https://embed.ventusky.com/?p=49.41;8.93;9"
|
||||
onChange={(event) => set({ url: event.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Any page that allows being framed. Sites that refuse show their
|
||||
own refusal in the tile.
|
||||
</p>
|
||||
</div>
|
||||
) : widget.type === "markdown" ? (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -827,7 +830,9 @@ export function WidgetPanel({
|
||||
|
||||
{widget.type === "chart" && !querying ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Points kept</Label>
|
||||
<FieldLabel help="How much past the engine keeps for these messages.">
|
||||
Points kept
|
||||
</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={str(
|
||||
@@ -837,16 +842,17 @@ export function WidgetPanel({
|
||||
set({ history: { points: Number(event.target.value) || 0 } })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How much past the engine keeps for these messages.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "chart" && querying ? (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Refresh, seconds</Label>
|
||||
<FieldLabel
|
||||
help={`Leave empty to follow the window: ${paced} seconds at this range, since nothing changes until the bucket closes. A slower value is kept; a faster one only asks for the same picture twice.`}
|
||||
>
|
||||
Refresh, seconds
|
||||
</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={paced}
|
||||
@@ -861,14 +867,11 @@ export function WidgetPanel({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Empty follows the window — {paced} seconds at this range, since
|
||||
nothing changes until the bucket closes. A slower one is kept, a
|
||||
faster one only asks for the same picture twice.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Range shown first</Label>
|
||||
<FieldLabel help="A viewer can pick another on the widget itself.">
|
||||
Range shown first
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={str(cfg.range_s ?? DEFAULT_RANGE.hours * 3600)}
|
||||
onValueChange={(value) => set({ range_s: Number(value) })}
|
||||
@@ -887,9 +890,6 @@ export function WidgetPanel({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A viewer can pick another on the widget itself.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -956,7 +956,9 @@ export function WidgetPanel({
|
||||
{widget.type === "chart" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Smoothing
|
||||
<FieldLabel help="Curves the lines between readings. It draws them softer; it does not change what was measured.">
|
||||
Smoothing
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
checked={Boolean(cfg.smooth)}
|
||||
aria-label="Smoothing"
|
||||
@@ -964,10 +966,6 @@ export function WidgetPanel({
|
||||
onCheckedChange={(smooth) => set({ smooth })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Curves the lines between readings. It draws them softer; it does
|
||||
not change what was measured.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1029,7 +1027,9 @@ export function WidgetPanel({
|
||||
|
||||
{widget.type === "slider" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Drawn as</Label>
|
||||
<FieldLabel help="A fader takes the tile's height, so give it a tall one. Either way the scale is dropped, since a column has no room for the labels.">
|
||||
Drawn as
|
||||
</FieldLabel>
|
||||
<Segmented
|
||||
value={str(cfg.orientation) || "horizontal"}
|
||||
options={[
|
||||
@@ -1040,10 +1040,6 @@ export function WidgetPanel({
|
||||
testId="widget-orientation"
|
||||
onChange={(orientation) => set({ orientation })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A fader takes the tile's height, so give it a tall one. Its scale
|
||||
is dropped either way: a column has no room for the labels.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1126,6 +1122,11 @@ export function WidgetPanel({
|
||||
{widget.type === "icon" ? (
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Mapping</span>
|
||||
<InfoTip label="Mapping">
|
||||
Rows are checked top to bottom and the first match wins. A number
|
||||
also matches anything above it, so a descending ladder reads as
|
||||
thresholds.
|
||||
</InfoTip>
|
||||
{rules.map((rule, index) => (
|
||||
<div
|
||||
// Position is the only identity a rule row has.
|
||||
@@ -1219,11 +1220,6 @@ export function WidgetPanel({
|
||||
<Plus />
|
||||
Add value
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rows are checked top to bottom and the first match wins. A number
|
||||
also matches anything above it, so a descending ladder reads as
|
||||
thresholds.
|
||||
</p>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Otherwise</Label>
|
||||
<IconPicker
|
||||
@@ -1239,7 +1235,18 @@ export function WidgetPanel({
|
||||
|
||||
{widget.type === "color" ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">Sends</Label>
|
||||
<FieldLabel
|
||||
help={
|
||||
<>
|
||||
HSV is{" "}
|
||||
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>
|
||||
, RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
|
||||
<span className="font-mono">"#rrggbb"</span>.
|
||||
</>
|
||||
}
|
||||
>
|
||||
Sends
|
||||
</FieldLabel>
|
||||
<Segmented
|
||||
value={colorFormatOf(widget)}
|
||||
options={COLOR_FORMATS}
|
||||
@@ -1258,12 +1265,6 @@ export function WidgetPanel({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
HSV is{" "}
|
||||
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>,
|
||||
RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
|
||||
<span className="font-mono">"#rrggbb"</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1283,23 +1284,23 @@ export function WidgetPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal" htmlFor="media-stream">
|
||||
<FieldLabel
|
||||
htmlFor="media-stream"
|
||||
help="A camera's own stream, played straight from it. Messages carry one frame at a time, which suits a glance every few seconds rather than live video."
|
||||
>
|
||||
Live stream
|
||||
</Label>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="media-stream"
|
||||
value={str(cfg.stream_url)}
|
||||
placeholder="https://camera.local/stream.mjpg"
|
||||
onChange={(event) => set({ stream_url: event.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A camera's own stream, played straight from it. Messages carry a
|
||||
frame at a time, which suits a glance every few seconds rather
|
||||
than live video.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Play as it arrives
|
||||
<FieldLabel help="A browser only plays sound by itself once someone has touched the page, so a screen nobody has tapped stays silent.">
|
||||
Play as it arrives
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
checked={Boolean(cfg.autoplay)}
|
||||
aria-label="Play as it arrives"
|
||||
@@ -1307,10 +1308,6 @@ export function WidgetPanel({
|
||||
onCheckedChange={(autoplay) => set({ autoplay })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A browser only plays sound by itself once someone has touched the
|
||||
page, so a screen nobody has tapped stays silent.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1408,7 +1405,7 @@ function SettingBinding({
|
||||
value={str(setting.message)}
|
||||
label="Driven by"
|
||||
testId={`dashboard-${name}-message`}
|
||||
placeholder={`Nothing — always ${valueLabel(name, setting.value)}`}
|
||||
placeholder={`Not bound, always ${valueLabel(name, setting.value)}`}
|
||||
filter={(message) => message.dtype === want}
|
||||
onPick={(message, dtype) =>
|
||||
onChange({ ...setting, message, dtype })
|
||||
@@ -1473,7 +1470,7 @@ function PalettePicker({
|
||||
/>
|
||||
{palette.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No palette — this dashboard keeps the app's own colours.
|
||||
No palette. This dashboard keeps the app's own colours.
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
@@ -1600,8 +1597,10 @@ export function DashboardPanel({
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-4">
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Rail icon</span>
|
||||
<PanelSection
|
||||
title="Rail icon"
|
||||
help="Drawn on the rail when a panel carries more than one dashboard."
|
||||
>
|
||||
<IconPicker
|
||||
value={str(dashboard.icon)}
|
||||
placeholder="Two letters of the title"
|
||||
@@ -1610,13 +1609,12 @@ export function DashboardPanel({
|
||||
className="w-full"
|
||||
onChange={(icon) => onChange({ icon })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drawn on the rail when a panel carries more than one dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Grid</span>
|
||||
<PanelSection
|
||||
title="Grid"
|
||||
help="How many columns wide the layout is. Match it to the panel this will hang on."
|
||||
>
|
||||
<Select
|
||||
value={String(columnsOf(dashboard))}
|
||||
onValueChange={(value) => onChange({ columns: Number(value) })}
|
||||
@@ -1632,14 +1630,12 @@ export function DashboardPanel({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How many columns wide this dashboard is laid out, so it can be
|
||||
matched to the panel it will hang on.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Canvas</span>
|
||||
<PanelSection
|
||||
title="Canvas"
|
||||
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere."
|
||||
>
|
||||
<Select
|
||||
value={
|
||||
CANVAS_PRESETS.some(
|
||||
@@ -1690,15 +1686,12 @@ export function DashboardPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The panel this is drawn for, in pixels. Editing and viewing both
|
||||
scale that surface to fit, so the arrangement is the same
|
||||
everywhere.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Look</span>
|
||||
<PanelSection
|
||||
title="Look"
|
||||
help="How the dashboard is drawn. Fluksio is the app's own design; Material lays flat tonal cards on a plain ground; Glass floats translucent tiles over a moving one. A look changes how widgets look, never what they do."
|
||||
>
|
||||
<Segmented
|
||||
value={lookOf(look.value)}
|
||||
options={LOOK_CHOICES}
|
||||
@@ -1711,18 +1704,12 @@ export function DashboardPanel({
|
||||
setting={look}
|
||||
onChange={(setting) => setSetting("look", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How this dashboard is drawn. Fluksio is the app's own design, and
|
||||
what a dashboard wears until you choose otherwise; Material lays
|
||||
flat tonal cards on a plain ground; Glass floats translucent tiles
|
||||
over a soft moving one. The widgets are the same whichever you
|
||||
pick — a look changes what they look like and nothing about what
|
||||
they do.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Theme</span>
|
||||
<PanelSection
|
||||
title="Theme"
|
||||
help="Light or dark wherever this is shown. System follows the device, which a wall screen has nobody to set. Bind a message to let a flow drive it on a schedule; this choice stays the fallback. A palette overrides it, since its first colour is the ground."
|
||||
>
|
||||
<Segmented
|
||||
value={str(theme.value) || "system"}
|
||||
options={THEME_CHOICES}
|
||||
@@ -1735,19 +1722,12 @@ export function DashboardPanel({
|
||||
setting={theme}
|
||||
onChange={(setting) => setSetting("theme", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
What this dashboard wears wherever it is shown — a screen on a
|
||||
wall has nobody to set the device preference System otherwise
|
||||
follows. Bind a message and a flow drives it instead: a node
|
||||
publishing on a cron is what a schedule looks like here, and the
|
||||
choice above stays the fallback. A palette settles this for
|
||||
itself: its first colour is the ground, so a dashboard that names
|
||||
one is already light or dark and this is left idle.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Palette</span>
|
||||
<PanelSection
|
||||
title="Palette"
|
||||
help="Colours in order: ground, widget surface, primary, accent, text. Leave the later ones off and they are derived, so three is enough for a whole dashboard. A sixth and beyond are chart line colours. Name none to keep the app's own."
|
||||
>
|
||||
<PalettePicker
|
||||
palette={palette}
|
||||
onChange={(value: string[]) =>
|
||||
@@ -1759,18 +1739,12 @@ export function DashboardPanel({
|
||||
setting={paletteSetting}
|
||||
onChange={(setting) => setSetting("palette", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The colours this dashboard is drawn in, in order: the ground, the
|
||||
surface a widget is, the primary, the accent, and the text. Leave
|
||||
the later ones off and they are worked out from the ones you gave
|
||||
— three colours are a whole dashboard. Anything past the five is
|
||||
another colour for a chart to draw a line in. Name none and the
|
||||
dashboard keeps the app's own.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Background</span>
|
||||
<PanelSection
|
||||
title="Background"
|
||||
help="An image drawn under the widgets, covering the canvas. It replaces the ground the Glass look brings. Bind a message to let a flow pick the picture."
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
@@ -1789,16 +1763,12 @@ export function DashboardPanel({
|
||||
setting={background}
|
||||
onChange={(setting) => setSetting("background", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
An image drawn under the widgets, covering the canvas. It takes
|
||||
the place of the ground the Glass look brings with it. Bind a
|
||||
message and a flow decides the picture — one per season, or one
|
||||
per time of day.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Touch</span>
|
||||
<PanelSection
|
||||
title="Touch"
|
||||
help="Bigger controls, and nothing that only happens on hover, for a panel that is touched rather than pointed at. A phone gets this anyway; a wall panel cannot say so for itself."
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Touch friendly
|
||||
<Switch
|
||||
@@ -1815,15 +1785,12 @@ export function DashboardPanel({
|
||||
setting={touch}
|
||||
onChange={(setting) => setSetting("touch", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bigger controls, and nothing that only happens on hover — for a
|
||||
panel that is touched rather than pointed at. A phone gets this
|
||||
anyway; a wall panel has no way to say so for itself.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Lock</span>
|
||||
<PanelSection
|
||||
title="Lock"
|
||||
help="Locked, the controls are still shown but stop publishing, and the surface says so. This is a read-only surface, not a permission: what a paired screen may reach is decided by its own credential."
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
Read-only
|
||||
<Switch
|
||||
@@ -1840,23 +1807,19 @@ export function DashboardPanel({
|
||||
setting={locked}
|
||||
onChange={(setting) => setSetting("locked", setting)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Locked, the controls on this dashboard are shown but stop
|
||||
publishing, and the surface says so. It is a read-only surface
|
||||
rather than a permission: what a paired screen may reach is still
|
||||
decided by its own credential.
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Contents</span>
|
||||
<PanelSection
|
||||
title="Contents"
|
||||
help="What this dashboard is called in the API, in a panel's list and in a link to it."
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-mono">{dashboard.name}</span> —{" "}
|
||||
<span className="font-mono">{dashboard.name}</span>,{" "}
|
||||
{widgetCount === 0
|
||||
? "nothing on it yet."
|
||||
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}.`}
|
||||
? "nothing on it yet"
|
||||
: `${widgetCount} widget${widgetCount === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</div>
|
||||
</SidePanel>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user