From 568466e28acc68c39a9d7b1414ea38eeba729664 Mon Sep 17 00:00:00 2001 From: Oskar Risberg Date: Wed, 10 Jun 2026 17:48:28 +0200 Subject: [PATCH] Condense the game editor and redesign the sources editor GameInfo: - Edit name / short name inline by clicking them (no separate text fields) - Move the game lookup and theme controls into the header, vertically centred - Show a single image (header thumbnail); the boxart URL is collapsed behind an expander and the picker's own preview is dropped - Label each value (Name / Short name / Identifier) - Split the Sources editor into its own card below the game settings BoxartPicker: - Hidden load probe instead of a visible preview; lookup + collapsible URL only GameSources: - Group fields into Connection / Sections / Custom, each shown only when it has values; one always-visible "Add field" brings back any optional field - Section lists are chip inputs; per-type seeding and helper text - Favicon moved onto each source tab; type shown read-only; first group aligns with the meta column --- src/BoxartPicker.jsx | 102 ++++--- src/GameInfo.jsx | 345 ++++++++++++++--------- src/GameSources.jsx | 649 +++++++++++++++++++++++++++++++------------ 3 files changed, 727 insertions(+), 369 deletions(-) diff --git a/src/BoxartPicker.jsx b/src/BoxartPicker.jsx index e844985..3080aa9 100644 --- a/src/BoxartPicker.jsx +++ b/src/BoxartPicker.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; +import Collapse from '@mui/material/Collapse'; import IconButton from '@mui/material/IconButton'; import InputAdornment from '@mui/material/InputAdornment'; import List from '@mui/material/List'; @@ -10,6 +11,8 @@ import ListItemButton from '@mui/material/ListItemButton'; import ListItemText from '@mui/material/ListItemText'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import SearchIcon from '@mui/icons-material/Search'; import { buildBoxartUrls, extractBoxartQuery } from './boxart.js'; @@ -17,29 +20,6 @@ import { buildBoxartUrls, extractBoxartQuery } from './boxart.js'; const HTTP_SERVICE_UNAVAILABLE = 503; const styles = { - placeholder: { - alignItems: 'center', - bgcolor: 'action.hover', - border: 1, - borderColor: 'divider', - borderRadius: 1, - color: 'text.disabled', - display: 'flex', - flexShrink: 0, - fontSize: 12, - height: 96, - justifyContent: 'center', - textAlign: 'center', - width: 72, - }, - thumb: { - borderRadius: 4, - display: 'block', - flexShrink: 0, - height: 96, - objectFit: 'cover', - width: 72, - }, resultThumb: { borderRadius: 2, flexShrink: 0, @@ -87,6 +67,9 @@ class BoxartPicker extends React.Component { results: [], searching: false, searchMessage: '', + // Reveals the raw Boxart URL field, hidden by default since the + // lookup above usually sets it for you. + urlOpen: false, }; } @@ -254,44 +237,33 @@ class BoxartPicker extends React.Component { } ); } - renderPreview () { + // A hidden probe that loads the current URL purely to report found/missing + // and to advance through candidate URLs on error. The visible thumbnail + // lives in the game header, so the picker shows no image of its own. + renderProbe () { if ( !this.props.value ) { - return ( - - { 'No image' } - - ); + return null; } return ( { ); } - renderStatus () { - if ( !this.props.value || this.state.status === 'found' ) { - return 'Search by name, or enter a Twitch game id.'; - } - - if ( this.state.status === 'missing' ) { - return 'No Twitch image at this value — search by name, try the numeric id, or paste a URL.'; - } - - return ' '; - } - renderResults () { if ( this.state.searchMessage ) { return ( @@ -343,24 +316,25 @@ class BoxartPicker extends React.Component { return ( - { this.renderPreview() } + { this.renderProbe() } - { this.renderResults() } + { + this.setState( ( previousState ) => { + return { + urlOpen: !previousState.urlOpen, + }; + } ); + } } + size = { 'small' } + title = { 'Edit boxart URL' } + > + { this.state.urlOpen + ? + : } + + + { this.renderResults() } + - + ); } diff --git a/src/GameInfo.jsx b/src/GameInfo.jsx index 7b55f98..cb24a63 100644 --- a/src/GameInfo.jsx +++ b/src/GameInfo.jsx @@ -21,20 +21,6 @@ import api from './api.js'; // in the Advanced raw-JSON editor and is preserved untouched on save. const KNOWN_CONFIG_KEYS = [ 'boxart', 'live', 'defaultTheme', 'sources' ]; -// Editable top-level game columns. `identifier` is the key (shown read-only) -// and `id`/`config`/`hostname` are handled separately (hostname is no longer -// edited here — every game uses developertracker.com). -const TEXT_FIELDS = [ - { - key: 'name', - label: 'Name', - }, - { - key: 'shortName', - label: 'Short name', - }, -]; - const styles = { boxartThumb: { borderRadius: 4, @@ -88,6 +74,8 @@ class GameInfo extends React.Component { boxart: config.boxart || '', defaultTheme: config.defaultTheme || '', dirty: false, + // Which inline field (name / shortName) is currently being edited. + editingField: false, // Absence of `live` means the game is live; only an explicit 0/false // marks it offline (matches site/build.js and rest-api consumers). live: !( config.live === 0 || config.live === false ), @@ -217,118 +205,176 @@ class GameInfo extends React.Component { } ); } - renderTextField ( field ) { + // Small uppercase caption that names a value (so it's clear what an inline + // field or read-only value represents). + renderFieldLabel ( text ) { return ( - { - this.handleFieldChange( field.key, event.target.value ); + + > + { text } + ); } - render () { - const fieldGridSx = { - display: 'grid', - gap: 2, - gridTemplateColumns: { - md: 'repeat(4, 1fr)', - sm: '1fr 1fr', - xs: '1fr', - }, - }; + // Inline-editable text: renders a label, then the value as clickable text + // that swaps to a field while editing. Enter / Escape / blur ends editing; + // the value is kept live in state as it's typed (which marks the form dirty). + renderEditableField ( field, options ) { + const settings = options || {}; + const value = this.state[ field ]; + + const inner = this.state.editingField === field + ? ( + { + this.setState( { + editingField: false, + } ); + } } + onChange = { ( event ) => { + this.handleFieldChange( field, event.target.value ); + } } + onKeyDown = { ( event ) => { + if ( event.key === 'Enter' || event.key === 'Escape' ) { + event.target.blur(); + } + } } + placeholder = { settings.placeholder } + size = { 'small' } + value = { value } + variant = { 'standard' } + /> + ) + : ( + { + this.setState( { + editingField: field, + } ); + } } + sx = { { + borderRadius: 1, + cursor: 'text', + mx: -0.5, + px: 0.5, + '&:hover': { + bgcolor: 'action.hover', + }, + } } + title = { 'Click to edit' } + variant = { settings.variant } + > + { value || settings.placeholder } + + ); + + return ( + + { settings.label && this.renderFieldLabel( settings.label ) } + { inner } + + ); + } + render () { return ( - - - { this.state.boxart && - - } - - { this.state.name || this.props.identifier } - - - { this.props.identifier } - - - } - label = { this.state.live ? 'Indexing' : 'Disabled' } - labelPlacement = { 'start' } - /> - - - - { this.renderTextField( TEXT_FIELDS[ 0 ] ) } - { this.renderTextField( TEXT_FIELDS[ 1 ] ) } - + { this.renderEditableField( 'name', { + label: 'Name', + placeholder: this.props.identifier, + variant: 'h6', + } ) } + + { this.renderEditableField( 'shortName', { + color: 'text.secondary', + label: 'Short name', + placeholder: '—', + variant: 'body2', + } ) } + + { this.renderFieldLabel( 'Identifier' ) } + + { this.props.identifier } + + + + + { + this.handleFieldChange( 'boxart', url ); + } } + value = { this.state.boxart } /> @@ -342,57 +388,80 @@ class GameInfo extends React.Component { { 'Light' } + + } + label = { this.state.live ? 'Indexing' : 'Disabled' } + labelPlacement = { 'start' } + sx = { { + flexShrink: 0, + ml: 0, + } } + /> - { - this.handleFieldChange( 'boxart', url ); + + > + + + + + + + + + - - - - - - - + - - + { text } + + ); } - renderBooleanField ( service, key, value ) { + // The left column: source identity (favicon, name, routing type) and the + // source-level controls (enable / remove). Settings live in the right column. + // Small favicon avatar shown on each source tab; falls back to the source's + // initial when no icon resolves. + renderSourceIcon ( service ) { + const serviceValue = this.props.sources[ service ] || {}; + const icon = faviconUrl( faviconDomain( service, serviceValue ) ); + + return ( + + { String( service ).charAt( 0 ).toUpperCase() } + + ); + } + + // The left column: routing type, enable toggle and remove. The source's + // favicon + name live on its tab, not here. + renderIdentity ( service, serviceValue ) { + const enabled = !serviceValue.disabled; + + // `type` is only a routing override; when it's absent both pipelines route + // by the source name (the object key). So a source named after a known + // type (e.g. "Steam") resolves to that same reader — surface the resolved + // type, flagged "(by name)" when it's name-derived rather than explicit. + const effectiveType = serviceValue.type + || ( KNOWN_SOURCE_TYPES.includes( service ) + ? service + : null ); + const typeText = effectiveType + ? `Type: ${ effectiveType }${ serviceValue.type ? '' : ' (by name)' }` + : 'Routes by source name'; + return ( + + { typeText } + { - this.updateField( service, key, checked ); + this.toggleEnabled( service, checked ); } } - size = { 'small' } /> } - label = { key } + label = { 'Enabled' } /> - { - this.removeField( service, key ); + this.removeService( service ); } } size = { 'small' } + startIcon = { } > - - + { 'Remove' } + ); } - renderTypeField ( service, key, value ) { + renderScalarField ( service, key, value, options ) { + const settings = options || {}; + return ( { this.updateField( service, key, event.target.value ); } } - select size = { 'small' } value = { value === null || value === undefined ? '' : String( value ) } variant = { 'outlined' } - > - { KNOWN_SOURCE_TYPES.map( ( typeName ) => { - return ( - - { typeName } - - ); - } ) } - + /> { this.removeField( service, key ); } } size = { 'small' } + sx = { { + mt: 0.5, + } } > + { + this.setSectionList( service, key, newValue ); + } } + options = { [] } + renderInput = { ( params ) => { + return ( + + ); + } } + value = { Array.isArray( values ) ? values : [] } + /> + + ); + } + + renderBooleanField ( service, key, value ) { return ( - { + this.updateField( service, key, checked ); + } } + size = { 'small' } + /> + } label = { key } - onChange = { ( event ) => { - this.updateField( service, key, event.target.value ); - } } - size = { 'small' } - value = { value === null || value === undefined ? '' : String( value ) } - variant = { 'outlined' } /> { @@ -396,13 +592,12 @@ class GameSources extends React.Component { ); } + // Generic editor for custom (non-structured) keys, dispatched by value type. renderField ( service, key, value ) { - if ( key === 'type' ) { - return this.renderTypeField( service, key, value ); - } - if ( Array.isArray( value ) ) { - return this.renderArrayField( service, key, value ); + return this.renderChipField( service, key, value, { + label: key, + } ); } if ( typeof value === 'boolean' ) { @@ -412,13 +607,66 @@ class GameSources extends React.Component { return this.renderScalarField( service, key, value ); } - // The option fields not yet present on this source — the menu of things you - // can add. freeSolo, so an unlisted key can still be typed in. + // A titled group of field nodes. `first` aligns it to the top of the column. + renderGroup ( title, nodes, first ) { + return ( + + { this.renderGroupHeader( title, first ) } + { nodes } + + ); + } + + connectionFields ( service, serviceValue ) { + const fields = []; + + if ( serviceValue.endpoint !== undefined ) { + fields.push( this.renderScalarField( service, 'endpoint', serviceValue.endpoint, { + helperText: endpointHelp( serviceValue.type ), + label: 'Endpoint', + } ) ); + } + + if ( serviceValue.label !== undefined ) { + fields.push( this.renderScalarField( service, 'label', serviceValue.label, { + helperText: 'Display name shown on the site (optional)', + label: 'Label', + } ) ); + } + + return fields; + } + + sectionFields ( service, serviceValue ) { + const fields = []; + + if ( serviceValue.allowedSections !== undefined ) { + fields.push( this.renderChipField( service, 'allowedSections', serviceValue.allowedSections, { + helperText: allowedSectionsHelp( serviceValue.type ), + label: 'Allowed sections', + } ) ); + } + + if ( serviceValue.disallowedSections !== undefined ) { + fields.push( this.renderChipField( service, 'disallowedSections', serviceValue.disallowedSections, { + helperText: 'Skip posts from these sections.', + label: 'Disallowed sections', + } ) ); + } + + return fields; + } + + // The known fields not yet present and not already shown elsewhere — the menu + // of things you can add. freeSolo, so an unlisted custom key can be typed in. renderAddField ( service ) { const serviceValue = this.props.sources[ service ] || {}; const available = KNOWN_SOURCE_FIELDS .filter( ( field ) => { - return !Reflect.apply( {}.hasOwnProperty, serviceValue, [ field.key ] ); + return !ALWAYS_SHOWN_KEYS.includes( field.key ) + && !Reflect.apply( {}.hasOwnProperty, serviceValue, [ field.key ] ); } ) .map( ( field ) => { return field.key; @@ -473,36 +721,81 @@ class GameSources extends React.Component { ); } + // Any non-structured keys on the source, as field nodes (empty when none). + customFieldNodes ( service, serviceValue ) { + return customKeysOf( serviceValue ).map( ( key ) => { + return this.renderField( service, key, serviceValue[ key ] ); + } ); + } + renderPanel ( service ) { const serviceValue = this.props.sources[ service ] || {}; + // Only groups that actually have fields are shown; the first one drops its + // top margin so it lines up with the meta column beside it. + const groups = [ + [ 'Connection', this.connectionFields( service, serviceValue ) ], + [ 'Sections', this.sectionFields( service, serviceValue ) ], + [ 'Custom', this.customFieldNodes( service, serviceValue ) ], + ].filter( ( group ) => { + return group[ 1 ].length > 0; + } ); + return ( + { this.renderIdentity( service, serviceValue ) } - + { groups.map( ( group, index ) => { + return this.renderGroup( group[ 0 ], group[ 1 ], index === 0 ); + } ) } + { this.renderAddField( service ) } - { Object.keys( serviceValue ).map( ( key ) => { - return this.renderField( service, key, serviceValue[ key ] ); - } ) } - { this.renderAddField( service ) } + + ); + } + + renderEmptyState () { + return ( + + + + { 'No sources yet — add one to start indexing this game.' } + ); } @@ -559,8 +852,17 @@ class GameSources extends React.Component { { services.map( ( service ) => { return ( ); @@ -619,16 +921,7 @@ class GameSources extends React.Component { { services.length > 0 ? this.renderPanel( currentService ) - : - { 'No sources yet.' } - - } + : this.renderEmptyState() } ); }