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 );
+
+ >
+
+
+ : }
+ >
+ { 'Advanced (raw JSON)' }
+
+
+
+
+
+
+
+
-
-
- : }
- >
- { 'Advanced (raw JSON)' }
-
-
-
-
-
-
+
-
+
);
}
}
diff --git a/src/GameSources.jsx b/src/GameSources.jsx
index e59051a..23e9d18 100644
--- a/src/GameSources.jsx
+++ b/src/GameSources.jsx
@@ -2,8 +2,10 @@ import React from 'react';
import PropTypes from 'prop-types';
import Autocomplete from '@mui/material/Autocomplete';
+import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
+import Divider from '@mui/material/Divider';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
@@ -14,26 +16,22 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
+import LanguageIcon from '@mui/icons-material/Language';
// A type-aware editor for the per-game `sources` object:
// { ServiceName: { allowedSections: [...], type, label, endpoint, disabled, ... } }
-// Each service gets a tab; the selected service's fields render by value type —
-// arrays as a tag input (chips + type-to-add), booleans as switches, scalars as
-// text inputs. Every existing key is preserved and editable. The component is
-// controlled: it never mutates props.sources, it emits a fresh object via
-// onChange.
+// Each source gets a tab; the selected source's fields are grouped into an
+// identity header (name + routing type + enabled toggle), a Connection group
+// (endpoint / label), a Sections group (allowed / disallowed sections as chip
+// inputs), and an Advanced group (any custom keys + "Add field"). Every existing
+// key is preserved and editable. The component is controlled: it never mutates
+// props.sources, it emits a fresh object via onChange.
//
-// The option fields available to add to any source (the same set across all
-// custom sources on all games). `kind` drives both the seeded default and the
-// input rendered for it. A custom (generic-reader) source is identified by
-// `type`; e.g. a Strapi news source sets `type: "Strapi"` plus `endpoint` /
-// `articleUrl` and, optionally, which attribute holds the title/date/body.
-// The recognised source types — `type` is a dropdown constrained to these so a
-// custom-named source routes to a reader that actually exists. `type` is the
-// routing override read by BOTH pipelines: the legacy indexer
-// (`modules/indexers/index.js`, matched by exact spelling minus spaces) and the
-// new grunt/peon pipeline (queue-users lowercases/dashes it). The list is the
-// union of both registries; values use each registry's canonical spelling.
+// `type` is the routing override read by BOTH pipelines: the legacy indexer
+// (`indexer/modules/indexers/*`, matched by exact spelling minus spaces) and the
+// new grunt/peon pipeline (queue-users lowercases/dashes it). The dropdown is
+// constrained to this list — the union of both registries, using each registry's
+// canonical spelling — so a custom-named source routes to a reader that exists.
const KNOWN_SOURCE_TYPES = [
'BattleNet',
'Bungie.net',
@@ -60,6 +58,52 @@ const KNOWN_SOURCE_FIELDS = [
{ key: 'disabled', kind: 'boolean' },
];
+// Keys the editor models with a dedicated control somewhere in the layout
+// (header / Connection / Sections). Anything NOT in this set is a custom key and
+// falls through to the Advanced group's generic value-kind editor.
+const STRUCTURED_KEYS = [ 'type', 'label', 'endpoint', 'allowedSections', 'disallowedSections', 'disabled' ];
+
+// Keys with a dedicated control in the identity header, so they're never offered
+// in the "Add field" menu. Everything else (endpoint, label, the section lists)
+// is shown only when present and can be added on demand.
+const ALWAYS_SHOWN_KEYS = [ 'type', 'disabled' ];
+
+// The fields each source type actually needs to function, so adding a source
+// seeds a usable starting point instead of either an empty shell or every
+// possible field. Derived from the reader code in both pipelines (the legacy
+// `indexer/modules/indexers/*` and the new `grunt/indexers/*`):
+// - endpoint: the readers that fetch from a URL bail out without it
+// (CommLink, Discourse, InvisionPowerBoard, RSS, SimpleMachinesForum,
+// Strapi, XenForo).
+// - allowedSections: Steam derives its app ID from allowedSections[0], so the
+// feed yields nothing until it's set.
+// - account-driven sources (Reddit, Twitter, Instagram, rsi, Bungie.net,
+// BattleNet) read no source config beyond `type` — the account identifier
+// drives them — so they seed nothing extra (e.g. Reddit gets no endpoint).
+// Everything optional (label, section filters, Strapi field mappings, …) is
+// left off and added on demand via "Add field".
+const REQUIRED_FIELDS_BY_TYPE = {
+ 'BattleNet': [],
+ 'Bungie.net': [],
+ 'CommLink': [ 'endpoint' ],
+ 'Discourse': [ 'endpoint' ],
+ 'Instagram': [],
+ 'InvisionPowerBoard': [ 'endpoint' ],
+ 'Reddit': [],
+ 'RSS': [ 'endpoint' ],
+ 'rsi': [],
+ 'SimpleMachinesForum': [ 'endpoint' ],
+ 'Steam': [ 'allowedSections' ],
+ 'Strapi': [ 'endpoint' ],
+ 'Twitter': [],
+ 'XenForo': [ 'endpoint' ],
+};
+
+// Field rows hold a single value (an endpoint URL, a label, a section name), so
+// cap them at a comfortable reading width instead of letting them stretch the
+// full panel on wide desktop screens.
+const FIELD_MAX_WIDTH = 480;
+
const defaultForKind = function defaultForKind ( kind ) {
if ( kind === 'list' ) {
return [];
@@ -76,6 +120,89 @@ const defaultForKind = function defaultForKind ( kind ) {
return '';
};
+// What an endpoint means depends on the source type, so tailor the hint.
+const endpointHelp = function endpointHelp ( type ) {
+ if ( type === 'RSS' || type === 'CommLink' ) {
+ return 'Feed URL';
+ }
+
+ if (
+ type === 'Discourse'
+ || type === 'XenForo'
+ || type === 'InvisionPowerBoard'
+ || type === 'SimpleMachinesForum'
+ ) {
+ return 'Forum base URL';
+ }
+
+ if ( type === 'Strapi' ) {
+ return 'API base URL';
+ }
+
+ return 'Source URL';
+};
+
+const allowedSectionsHelp = function allowedSectionsHelp ( type ) {
+ if ( type === 'Steam' ) {
+ return 'Steam app ID(s).';
+ }
+
+ return 'Only index posts from these sections. Leave empty for all.';
+};
+
+// Canonical domains for the account-driven brands that have no `endpoint` to
+// derive a favicon from. Forum/feed types (Discourse, XenForo, RSS, …) instead
+// use their configured endpoint's host, so they're intentionally absent here.
+const BRAND_DOMAINS = {
+ 'BattleNet': 'battle.net',
+ 'Bungie.net': 'bungie.net',
+ 'CommLink': 'robertsspaceindustries.com',
+ 'Instagram': 'instagram.com',
+ 'Reddit': 'reddit.com',
+ 'rsi': 'robertsspaceindustries.com',
+ 'Steam': 'steampowered.com',
+ 'Twitch': 'twitch.tv',
+ 'Twitter': 'x.com',
+ 'YouTube': 'youtube.com',
+};
+
+// The domain whose favicon best represents a source: its endpoint host when set
+// (a forum/feed), else the brand domain for its type or name. False when nothing
+// sensible maps (the avatar then falls back to the source's initial).
+const faviconDomain = function faviconDomain ( service, serviceValue ) {
+ if ( serviceValue.endpoint ) {
+ try {
+ const raw = String( serviceValue.endpoint );
+ const url = new URL( raw.includes( '://' ) ? raw : `https://${ raw }` );
+
+ if ( url.hostname ) {
+ return url.hostname;
+ }
+ } catch {
+ // Not a parseable URL yet (mid-typing) — fall back to the brand map.
+ }
+ }
+
+ return BRAND_DOMAINS[ serviceValue.type ] || BRAND_DOMAINS[ service ] || false;
+};
+
+// Google's favicon service resolves a domain to its site icon; the Avatar shows
+// the source's initial if the image 404s or the domain is unknown.
+const faviconUrl = function faviconUrl ( domain ) {
+ if ( !domain ) {
+ return false;
+ }
+
+ return `https://www.google.com/s2/favicons?domain=${ encodeURIComponent( domain ) }&sz=64`;
+};
+
+// The non-structured keys on a source — what the Advanced group edits.
+const customKeysOf = function customKeysOf ( serviceValue ) {
+ return Object.keys( serviceValue || {} ).filter( ( key ) => {
+ return !STRUCTURED_KEYS.includes( key );
+ } );
+};
+
class GameSources extends React.Component {
constructor ( props ) {
super( props );
@@ -84,9 +211,9 @@ class GameSources extends React.Component {
this.handleTabChange = this.handleTabChange.bind( this );
this.state = {
- // currently selected service tab (by name)
+ // currently selected source tab (by name)
activeService: Object.keys( props.sources )[ 0 ] || false,
- // in-progress "add field" selection for the active service
+ // in-progress "add field" selection for the active source
newField: '',
newService: '',
};
@@ -114,28 +241,6 @@ class GameSources extends React.Component {
} ) );
}
- updateArrayItem ( service, key, index, value ) {
- const next = ( this.props.sources[ service ][ key ] || [] ).slice();
-
- next[ index ] = value;
-
- this.updateField( service, key, next );
- }
-
- addArrayItem ( service, key ) {
- const current = this.props.sources[ service ][ key ] || [];
-
- this.updateField( service, key, [ ...current, '' ] );
- }
-
- removeArrayItem ( service, key, index ) {
- const current = this.props.sources[ service ][ key ] || [];
-
- this.updateField( service, key, current.filter( ( item, itemIndex ) => {
- return itemIndex !== index;
- } ) );
- }
-
removeField ( service, key ) {
const next = Object.assign( {}, this.props.sources[ service ] );
@@ -156,6 +261,39 @@ class GameSources extends React.Component {
} );
}
+ // Enabled is the inverse of the stored `disabled` flag. Turning a source back
+ // on drops the key entirely rather than storing `disabled: false`, to keep
+ // the saved config clean.
+ toggleEnabled ( service, enabled ) {
+ const next = Object.assign( {}, this.props.sources[ service ] );
+
+ if ( enabled ) {
+ delete next.disabled;
+ } else {
+ next.disabled = true;
+ }
+
+ this.updateService( service, next );
+ }
+
+ // Section chip inputs emit the whole list; trim/drop blanks, and remove the
+ // key entirely when emptied so untouched sources don't accrue empty arrays.
+ setSectionList ( service, key, list ) {
+ const cleaned = list
+ .map( ( item ) => {
+ return String( item ).trim();
+ } )
+ .filter( Boolean );
+
+ if ( cleaned.length === 0 ) {
+ this.removeField( service, key );
+
+ return;
+ }
+
+ this.updateField( service, key, cleaned );
+ }
+
handleTabChange ( event, value ) {
this.setState( {
activeService: value,
@@ -170,14 +308,20 @@ class GameSources extends React.Component {
return;
}
- // Seed every known field with its type-appropriate default so a new
- // source shows the full form up front. The source is keyed by its
- // type, so seed `type` to the chosen value. Unwanted fields can be
- // removed, custom ones still added via "Add field".
- const seeded = {};
+ // Seed `type` (the routing field, set to the chosen source name) plus
+ // only the fields this source type actually needs — see
+ // REQUIRED_FIELDS_BY_TYPE. Optional fields are added on demand via
+ // "Add field".
+ const seeded = {
+ type: name,
+ };
+
+ for ( const fieldKey of REQUIRED_FIELDS_BY_TYPE[ name ] || [] ) {
+ const known = KNOWN_SOURCE_FIELDS.find( ( field ) => {
+ return field.key === fieldKey;
+ } );
- for ( const field of KNOWN_SOURCE_FIELDS ) {
- seeded[ field.key ] = field.key === 'type' ? name : defaultForKind( field.kind );
+ seeded[ fieldKey ] = defaultForKind( known ? known.kind : 'text' );
}
this.updateService( name, seeded );
@@ -208,150 +352,163 @@ class GameSources extends React.Component {
} );
}
- renderArrayField ( service, key, values ) {
+ // `first` drops the top margin so the first group in the settings column
+ // lines up with the top of the meta column beside it.
+ renderGroupHeader ( text, first ) {
return (
- { key }
-
-
- { values.map( ( value, index ) => {
- return (
-
- {
- this.updateArrayItem( service, key, index, event.target.value );
- } }
- size = { 'small' }
- value = { value === null || value === undefined ? '' : String( value ) }
- variant = { 'outlined' }
- />
- {
- this.removeArrayItem( service, key, index );
- } }
- size = { 'small' }
- >
-
-
-
- );
- } ) }
-
-
-
-
+ { 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 (
-
- );
- } ) }
-
+ />
{
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() }
);
}