/**
* Small shared UI primitives for the admin app.
*/
import { __, sprintf } from '@wordpress/i18n';
import { Spinner, Button, Modal, FormToggle } from '@wordpress/components';
import { SVG, Path } from '@wordpress/primitives';
import {
useState,
useEffect,
createContext,
useContext,
} from '@wordpress/element';
import { Link } from './router';
import type { ReactNode, ComponentType } from 'react';
/* ---------- Header utilities (notices bell, command palette, theme toggle, add-on extras) ---------- */
export interface HeaderUtility {
id: string;
order?: number;
Component: ComponentType< Record< string, unknown > >;
}
const headerUtilities: HeaderUtility[] = [];
const utilityListeners = new Set< () => void >();
/**
* Register a small control rendered at the right of every page header.
* @param util
*/
export function registerHeaderUtility( util: HeaderUtility ) {
const i = headerUtilities.findIndex( ( u ) => u.id === util.id );
if ( i >= 0 ) {
headerUtilities[ i ] = util;
} else {
headerUtilities.push( util );
}
utilityListeners.forEach( ( l ) => l() );
}
export function getHeaderUtilities(): HeaderUtility[] {
return [ ...headerUtilities ].sort(
( a, b ) => ( a.order || 50 ) - ( b.order || 50 )
);
}
function HeaderUtilities( { exclude = [] }: { exclude?: string[] } ) {
const [ , bump ] = useState( 0 );
useEffect( () => {
const cb = () => bump( ( n ) => n + 1 );
utilityListeners.add( cb );
return () => {
utilityListeners.delete( cb );
};
}, [] );
const utils = getHeaderUtilities().filter(
( u ) => ! exclude.includes( u.id )
);
if ( ! utils.length ) {
return null;
}
return (
{ utils.map( ( u ) => (
) ) }
);
}
/** Custom white marks for page-header tiles (others use the regular icon in white). */
const W = '#fff';
const GOLD = '#ffd166';
const mark = ( body: ReactNode ) => (
{ body }
);
const HEADER_MARKS: Record< string, ReactNode > = {
broadcast: mark(
<>
>
),
layers: mark(
<>
>
),
calendar: mark(
<>
>
),
inbox: mark(
<>
>
),
player: mark(
<>
>
),
gear: mark(
<>
>
),
lock: mark(
<>
>
),
chart: (
),
};
export function PageHeader( {
title,
description,
icon,
actions,
back,
utility = true,
utilityExclude,
tabs,
children,
}: {
title: ReactNode;
description?: ReactNode;
/** Icon name (see `I`) shown in a tile beside the title — one per screen, e.g. 'radio' for Stations. */
icon?: string;
actions?: ReactNode;
back?: { to: string; label: string };
/** Segmented view switcher rendered in the header between the title and the actions. */
tabs?: ReactNode;
/** Render the registered header utilities (notices, palette, theme). Pass false on full-bleed screens. */
utility?: boolean | ReactNode;
/** Header utility ids to leave out on this screen (e.g. 'palette'). */
utilityExclude?: string[];
children?: ReactNode;
} ) {
return (
{ back ? (
) : null }
{ icon ? (
{ HEADER_MARKS[ icon ] || (
) }
) : null }
{ title }
{ description ?
{ description }
: null }
{ tabs ?
{ tabs }
: null }
{ actions ? (
{ actions }
) : null }
{ utility === true ? (
) : (
utility || null
) }
{ children }
);
}
/** Default icon for Sections rendered inside a settings card (set by the Settings page). */
export const SectionIconContext = createContext< string | undefined >(
undefined
);
/** Section supports `toggle` / `off` (feature switch in the card header). Add-ons check this. */
export const SECTION_TOGGLE = true;
export function Section( {
title,
description,
aside,
icon,
toggle,
off,
tone,
children,
className = '',
}: {
title?: ReactNode;
description?: ReactNode;
aside?: ReactNode;
/** Icon tile before the title (falls back to the surrounding settings card's icon). */
icon?: string;
/** Feature switch in the header: the card's body shows only while it is on. */
toggle?: {
checked: boolean;
onChange: ( v: boolean ) => void;
disabled?: boolean;
label?: string;
};
/** One line shown instead of the body while the switch is off. */
off?: ReactNode;
/** 'danger' for destructive cards. */
tone?: 'danger';
children?: ReactNode;
className?: string;
} ) {
const ctxIcon = useContext( SectionIconContext );
const tile = icon || ctxIcon;
const sw = toggle ? (
{ toggle.checked
? __( 'On', 'streamsera' )
: __( 'Off', 'streamsera' ) }
toggle.onChange( e.target.checked ) }
aria-label={
toggle.label ||
( typeof title === 'string' ? title : undefined )
}
/>
) : null;
const isOff = !! toggle && ! toggle.checked;
return (
{ title || aside ? (
{ title ? (
{ tile ? (
) : null }
{ title }
) : null }
{ description ?
{ description }
: null }
{ aside || sw ? (
{ aside }
{ sw }
) : null }
) : null }
{ isOff ? (
off ? (
) : null
) : (
children
) }
);
}
export function Panel( {
children,
className = '',
padded = true,
}: {
children?: ReactNode;
className?: string;
padded?: boolean;
} ) {
return (
{ children }
);
}
/**
* Choice group: a short list of options as one segmented control, with optional icons.
* @param root0
* @param root0.value
* @param root0.options
* @param root0.onChange
* @param root0.label
*/
export function Choice< T extends string | number >( {
value,
options,
onChange,
label,
}: {
value: T;
options: Array< {
value: T;
label: ReactNode;
icon?: ReactNode;
badge?: ReactNode;
} >;
onChange: ( v: T ) => void;
label?: string;
} ) {
return (
{ options.map( ( o ) => {
const on = o.value === value;
return (
onChange( o.value ) }
>
{ o.icon ? (
{ typeof o.icon === 'string' ? (
) : (
o.icon
) }
) : null }
{ o.label }
{ o.badge ? (
{ o.badge }
) : null }
);
} ) }
);
}
/**
* A settings row: label + help on the left, control on the right.
* @param root0
* @param root0.label
* @param root0.help
* @param root0.children
* @param root0.inline
* @param root0.row
* @param root0.id
* @param root0.className
*/
export function Field( {
label,
help,
children,
inline = false,
row = false,
id,
className = '',
}: {
label?: ReactNode;
help?: ReactNode;
children?: ReactNode;
inline?: boolean;
/** Builder convention: "Title — Control" on one row, description under the title. */
row?: boolean;
id?: string;
className?: string;
} ) {
return (
{ label ?
{ label } : null }
{ help ?
{ help }
: null }
{ children }
);
}
/**
* Summary tile: rounded icon on the left, value + label on the right.
* tone: 'success' | 'error' | 'warning' | 'brand' | 'info' | 'neutral' (legacy 'live' → error, 'ok' → success, 'muted' → neutral).
* delta: { value: number (percent or absolute), label?: string, direction?: 'up' | 'down' | 'flat' } renders a small trend chip.
* @param root0
* @param root0.icon
* @param root0.label
* @param root0.value
* @param root0.hint
* @param root0.tone
* @param root0.delta
* @param root0.href
* @param root0.onClick
* @param root0.className
*/
export function StatCard( {
icon,
label,
value,
hint,
tone,
delta,
href,
onClick,
className = '',
}: {
icon?: string | ReactNode;
label: ReactNode;
value: ReactNode;
hint?: ReactNode;
tone?: string;
delta?: {
value: number;
label?: ReactNode;
direction?: 'up' | 'down' | 'flat';
suffix?: string;
} | null;
href?: string;
onClick?: () => void;
className?: string;
} ) {
const TONES: Record< string, string > = {
live: 'error',
ok: 'success',
muted: 'neutral',
warn: 'warning',
};
const t = tone ? TONES[ tone ] || tone : '';
const dir = delta
? delta.direction ||
( delta.value > 0 ? 'up' : delta.value < 0 ? 'down' : 'flat' )
: null;
const body = (
<>
{ icon ? (
{ typeof icon === 'string' ? (
) : (
icon
) }
) : null }
{ value }
{ delta ? (
{ `${ Math.abs( delta.value ) }${
delta.suffix ?? '%'
}` }
{ delta.label ? (
{ delta.label }
) : null }
) : null }
{ label }
{ hint ? (
{ hint }
) : null }
>
);
const cls = `ss-stat ${ t ? `ss-stat--${ t }` : '' } ${
icon ? 'has-icon' : ''
} ${ href || onClick ? 'is-link' : '' } ${ className }`;
if ( href ) {
return (
{ body }
);
}
if ( onClick ) {
return (
{ body }
);
}
return { body }
;
}
/**
* A StatCard (shorter name, also exposed to add-ons).
* @param props
* @param props.label
* @param props.value
* @param props.tone
* @param props.hint
* @param props.icon
*/
export function Stat( props: {
label: ReactNode;
value: ReactNode;
tone?: string;
hint?: ReactNode;
icon?: string | ReactNode;
} ) {
return ;
}
/**
* Semantic pill tones, with short aliases:
* live|ok → success (green), error → error (red), warn → warning (amber), muted → neutral.
*/
export type PillTone =
| 'success'
| 'error'
| 'warning'
| 'info'
| 'neutral'
| 'brand';
const PILL_TONES: Record< string, PillTone > = {
live: 'success',
ok: 'success',
success: 'success',
error: 'error',
danger: 'error',
offline: 'error',
warn: 'warning',
warning: 'warning',
info: 'info',
neutral: 'neutral',
muted: 'neutral',
brand: 'brand',
};
export function pillTone( tone?: string ): PillTone {
return PILL_TONES[ tone || 'neutral' ] || 'neutral';
}
export function Pill( {
tone = 'neutral',
children,
dot = false,
pulse,
}: {
tone?: string;
children?: ReactNode;
dot?: boolean;
/** Animate the dot (defaults to true for the legacy `live` tone). */
pulse?: boolean;
} ) {
const t = pillTone( tone );
const animate = pulse ?? tone === 'live';
return (
{ dot ? : null }
{ children }
);
}
export function Empty( {
icon = 'radio',
title,
text,
action,
}: {
icon?: string;
title: ReactNode;
text?: ReactNode;
action?: ReactNode;
} ) {
return (
{ title }
{ text ?
{ text }
: null }
{ action }
);
}
export function Loading() {
return (
);
}
/**
* Full-page "no permission" state, shown when the REST API answers 401/403 for the data
* a page needs (e.g. a Contributor opening the Builder).
* @param root0
* @param root0.what
* @param root0.title
*/
export function AccessDenied( {
what,
title,
}: {
/** What the user tried to do, e.g. "edit players". */
what?: string;
title?: ReactNode;
} ) {
return (
{ title ?
: null }
window.history.back() }
>
{ __( 'Go back', 'streamsera' ) }
}
/>
);
}
export function CopyCode( {
code,
onCopied,
}: {
code: string;
onCopied?: () => void;
} ) {
const [ copied, setCopied ] = useState( false );
const copy = async () => {
try {
await navigator.clipboard.writeText( code );
setCopied( true );
onCopied?.();
setTimeout( () => setCopied( false ), 1600 );
} catch ( e ) {
// ignore
}
};
return (
{ code }
{ copied
? __( 'Copied', 'streamsera' )
: __( 'Copy', 'streamsera' ) }
);
}
/**
* Compact click-to-copy chip (shortcodes, IDs).
* @param root0
* @param root0.code
* @param root0.label
* @param root0.title
*/
export function CopyChip( {
code,
label,
title,
}: {
code: string;
label?: ReactNode;
title?: string;
} ) {
const [ copied, setCopied ] = useState( false );
const copy = async ( e: React.MouseEvent ) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText( code );
setCopied( true );
setTimeout( () => setCopied( false ), 1500 );
} catch ( err ) {
// ignore
}
};
return (
{ label || code }
{ copied ? __( 'Copied', 'streamsera' ) : '' }
);
}
/**
* Shared modal shell: icon tile + title, optional description right under the header,
* padded body, right-aligned footer. Every admin dialog goes through this so they match.
* @param root0
* @param root0.title
* @param root0.icon
* @param root0.description
* @param root0.size
* @param root0.onRequestClose
* @param root0.footer
* @param root0.className
* @param root0.children
*/
export function SsModal( {
title,
icon,
description,
size = 'md',
onRequestClose,
footer,
className = '',
children,
...rest
}: {
title: string;
icon?: string;
description?: ReactNode;
size?: 'sm' | 'md' | 'lg';
onRequestClose: () => void;
footer?: ReactNode;
className?: string;
children?: ReactNode;
[ key: string ]: unknown;
} ) {
return (
) : undefined
}
{ ...( rest as object ) }
>
{ description ? (
{ description }
) : null }
{ children }
{ footer ? (
{ footer }
) : null }
);
}
export function ConfirmModal( {
title,
text,
confirmLabel,
onConfirm,
onCancel,
destructive = true,
}: {
title: string;
text: ReactNode;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
} ) {
return (
{ __( 'Cancel', 'streamsera' ) }
{ confirmLabel }
>
}
>
{ text }
);
}
/** Icon set — WordPress icons so the admin matches core and stays crisp at every size. */
import {
Icon,
chartBar,
audio,
cog,
layout,
color,
settings,
pin,
code,
check,
warning,
plus,
external,
starFilled,
undo,
redo,
desktop,
tablet,
mobile,
trash,
copy,
pencil,
update,
cloud,
link,
chevronRight,
page,
download,
keyboard,
close,
formatListBullets,
grid,
megaphone,
help,
brush,
typography,
arrowLeft,
search,
lock,
swatch,
shuffle,
listView,
dragHandle,
moreVertical,
seen,
unseen,
info,
home,
inbox,
media,
button as buttonIcon,
published,
calendar,
people,
bell,
upload,
shield,
key,
tool,
currencyDollar,
store,
lifesaver,
tag,
commentAuthorAvatar,
image,
starEmpty,
moveTo,
textColor,
rotateLeft,
aspectRatio,
// UI-B
trendingUp,
trendingDown,
lineSolid,
postDate,
rss,
connection,
thumbsUp,
receipt,
backup,
starHalf,
postList,
// UI-A (data tables)
chevronLeft,
chevronDown,
chevronUp,
// UI3 (embed modal)
blockDefault,
html,
// Setup wizard
next as skipIcon,
arrowUp,
arrowDown,
globe,
} from '@wordpress/icons';
/** Players: a play triangle inside broadcast waves (outline, matches the Players screens). */
const playerIcon = (
);
const sunIcon = (
);
const moonIcon = (
);
/** Broadcast mark (dashboard header, brand). Stroked. */
const barsIcon = (
);
const layersIcon = (
);
const headphonesIcon = (
);
const shareIcon = (
);
const gripIcon = (
);
const stickyIcon = (
);
const broadcastIcon = (
);
/** Pulse line (setup wizard's "Check stream"). Stroked, so it keeps its weight at any size. */
const pulseIcon = (
);
const ICONS = {
calendar,
people,
back: arrowLeft,
radio: audio,
player: playerIcon,
players: playerIcon,
layout,
palette: color,
toggles: settings,
sliders: settings,
pin,
code,
check,
warn: warning,
plus,
external,
spark: starFilled,
undo,
redo,
desktop,
tablet,
mobile,
sun: sunIcon,
moon: moonIcon,
shield,
key,
tool,
dollar: currencyDollar,
store,
lifesaver,
tag,
bell,
upload,
user: commentAuthorAvatar,
trash,
copy,
edit: pencil,
refresh: update,
gear: cog,
cloud,
link,
chevron: chevronRight,
book: page,
download,
keyboard,
volume: audio,
x: close,
list: formatListBullets,
grid,
megaphone,
help,
brush,
typography,
search,
lock,
swatch,
shuffle,
listView,
drag: dragHandle,
more: moreVertical,
eye: seen,
eyeOff: unseen,
info,
home,
inbox,
chart: chartBar,
button: buttonIcon,
published,
image,
sparkles: starEmpty,
move: moveTo,
font: textColor,
rotate: rotateLeft,
responsive: aspectRatio,
templates: layout,
// UI-B
trendUp: trendingUp,
trendDown: trendingDown,
minus: lineSolid,
clock: postDate,
rss,
signal: connection,
live: rss,
listeners: people,
headphones: audio,
like: thumbsUp,
receipt,
history: backup,
star: starHalf,
postList,
// UI-A (data tables)
chevronLeft,
chevronRight,
chevronDown,
chevronUp,
checkAll: check,
pages: page,
// media
media,
music: audio,
// UI2-B
save: cloud,
radioAlt: audio,
// UI3 (embed modal)
block: blockDefault,
php: html,
// Setup wizard
skip: skipIcon,
pulse: pulseIcon,
broadcast: broadcastIcon,
layers: layersIcon,
headset: headphonesIcon,
share: shareIcon,
grip: gripIcon,
stickyBar: stickyIcon,
bars: barsIcon,
arrowUp,
arrowDown,
globe,
};
export function I( {
name,
size = 20,
className = '',
}: {
name: string;
size?: number;
className?: string;
} ) {
return (
);
}
export function timeAgo( ts?: number ) {
if ( ! ts ) {
return '';
}
const diff = Math.max( 0, Date.now() / 1000 - ts );
if ( diff < 60 ) {
return __( 'just now', 'streamsera' );
}
if ( diff < 3600 ) {
return `${ Math.floor( diff / 60 ) } min ago`;
}
if ( diff < 86400 ) {
return `${ Math.floor( diff / 3600 ) } h ago`;
}
return new Date( ts * 1000 ).toLocaleDateString();
}
/* ---------- Data tables (UI-A) ---------- */
export { default as DataTable, Thumb, Avatar } from './components/DataTable';
export { default as StationPicker } from './components/StationPicker';
export type { StationPickerProps } from './components/StationPicker';
export type {
DataTableColumn,
DataTableBulkAction,
DataTableFilter,
DataTableProps,
} from './components/DataTable';
/**
* Dependent settings: rendered only while their main switch is on (e.g. the listen page's address
* and player appear once "Enable the listen page" is on). Exposed to add-ons as `ui.Reveal`.
* @param root0
* @param root0.when
* @param root0.children
*/
export function Reveal( {
when,
children,
}: {
when: boolean;
children?: any;
} ) {
return when ? <>{ children }> : null;
}