/** * 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 ? ( ) : 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 ? (
{ 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 ( ); } ) }
); } /** * 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 ? : 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 ? ( ) : 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 ( ); } 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 }
); } /** * 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 ( ); } /** * 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 ( } >

{ 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; }