diff --git a/npm/README.md b/npm/README.md index 7803634be..d7925e154 100644 --- a/npm/README.md +++ b/npm/README.md @@ -19,7 +19,7 @@ Create a Web client middleware for use with express-style server libraries. **Parameters** * `options` - * `options.apiBase` - Base URL to the mount point of the + * `options.apiUrl` - Base URL to the mount point of the [üWave Web API][u-wave-core] to talk to. Defaults to `/api`, but it's recommended to set this explicitly. * `options.emoji` - An object describing the custom emoji that will be @@ -49,7 +49,7 @@ app.listen(6041); app.use('/', createWebClient({ // Use nginx to send this traffic to the API server. - apiBase: 'https://example.com/api', + apiUrl: 'https://example.com/api', recaptcha: { key: 'my ReCaptcha site key' }, })); ``` diff --git a/src/admin/components/SchemaForm/AssetField.js b/src/admin/components/SchemaForm/AssetField.js new file mode 100644 index 000000000..01ed7771c --- /dev/null +++ b/src/admin/components/SchemaForm/AssetField.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useAsyncCallback } from 'react-async-hook'; +import Typography from '@mui/material/Typography'; +import FormHelperText from '@mui/material/FormHelperText'; +import Button from '@mui/material/Button'; +import FileIcon from '@mui/icons-material/FileUpload'; +import TextField from '../../../components/Form/TextField'; + +const { + useRef, +} = React; + +function AssetField({ + className, + schema, + value, + onChange, +}) { + const fileInput = useRef(null); + const handleSelect = useAsyncCallback(async (event) => { + const file = event.target.files[0]; + const body = new FormData(); + body.append('file', file); + + const res = await fetch('/api/server/config/asset/u-wave:base.logo', { + method: 'put', + body, + }); + const json = await res.json(); + const { data } = json; + onChange(data.path); + }, [onChange]); + + return ( +
+ {schema.title && {schema.title}} + +
+ } /> + +
+ {schema.description && {schema.description}} +
+ ); +} + +AssetField.propTypes = { + className: PropTypes.string, + schema: PropTypes.object.isRequired, + value: PropTypes.string, + onChange: PropTypes.func.isRequired, +}; + +export default AssetField; diff --git a/src/admin/components/SchemaForm/Field.js b/src/admin/components/SchemaForm/Field.js index 322469995..dde970610 100644 --- a/src/admin/components/SchemaForm/Field.js +++ b/src/admin/components/SchemaForm/Field.js @@ -17,6 +17,10 @@ function getControlName(schema) { return 'enum'; } + if (schema.type === 'string' && schema.format === 'asset') { + return 'asset'; + } + return schema.type; } diff --git a/src/admin/components/SchemaForm/index.js b/src/admin/components/SchemaForm/index.js index 3146b503d..08c750b4a 100644 --- a/src/admin/components/SchemaForm/index.js +++ b/src/admin/components/SchemaForm/index.js @@ -8,6 +8,7 @@ import NumberField from './NumberField'; import ObjectField, { ObjectProperties } from './ObjectField'; import StringField from './StringField'; import MarkdownField from './MarkdownField'; +import AssetField from './AssetField'; import EnumField from './EnumField'; const controls = new Map([ @@ -18,6 +19,7 @@ const controls = new Map([ ['string', StringField], ['enum', EnumField], ['markdown', MarkdownField], + ['asset', AssetField], ]); function SchemaForm({ diff --git a/src/components/HeaderBar/AppTitle.js b/src/components/HeaderBar/AppTitle.js index ee42ae372..d56467cd4 100644 --- a/src/components/HeaderBar/AppTitle.js +++ b/src/components/HeaderBar/AppTitle.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import IconButton from '@mui/material/IconButton'; import AboutIcon from '@mui/icons-material/ArrowDropDown'; -const logo = new URL('../../../assets/img/logo-white.png', import.meta.url); +const defaultLogo = new URL('../../../assets/img/logo-white.png', import.meta.url); // We use `logo.pathname` to ensure that the prerendered loading screen // does not include a `file://` prefix. This should still work fine in browsers @@ -17,28 +17,32 @@ function pathname(url) { return url.pathname; } -const AppTitle = ({ +function AppTitle({ className, children, + logo = defaultLogo, onClick, -}) => ( -
-

- {children} -

- - - -
-); +}) { + return ( +
+

+ {children} +

+ + + +
+ ); +} AppTitle.propTypes = { className: PropTypes.string, children: PropTypes.string.isRequired, + logo: PropTypes.string, onClick: PropTypes.func.isRequired, }; diff --git a/src/components/HeaderBar/index.js b/src/components/HeaderBar/index.js index 5baeb2156..0a30aae4c 100644 --- a/src/components/HeaderBar/index.js +++ b/src/components/HeaderBar/index.js @@ -11,6 +11,7 @@ import CurrentDJ from './CurrentDJ'; const HeaderBar = ({ className, title, + logo, dj, media, mediaStartTime, @@ -29,6 +30,7 @@ const HeaderBar = ({ > {title} @@ -62,6 +64,7 @@ const HeaderBar = ({ HeaderBar.propTypes = { className: PropTypes.string, title: PropTypes.string, + logo: PropTypes.string, dj: PropTypes.object, media: PropTypes.object, diff --git a/src/containers/HeaderBar.js b/src/containers/HeaderBar.js index 18022d2cd..1e7bea3d1 100644 --- a/src/containers/HeaderBar.js +++ b/src/containers/HeaderBar.js @@ -4,9 +4,12 @@ import { setVolume, mute, unmute } from '../actions/PlaybackActionCreators'; import { toggleRoomHistory, toggleAbout } from '../actions/OverlayActionCreators'; import { djSelector, mediaSelector, startTimeSelector } from '../selectors/boothSelectors'; import { volumeSelector, isMutedSelector } from '../selectors/settingSelectors'; +import { serverNameSelector, serverLogoSelector } from '../selectors/configSelectors'; import HeaderBar from '../components/HeaderBar'; const mapStateToProps = createStructuredSelector({ + title: serverNameSelector, + logo: serverLogoSelector, mediaStartTime: startTimeSelector, media: mediaSelector, dj: djSelector, diff --git a/src/reducers/config.js b/src/reducers/config.js index d2aa268fc..8c5afe87f 100644 --- a/src/reducers/config.js +++ b/src/reducers/config.js @@ -5,14 +5,19 @@ const initialState = {}; export default function reduce(state = initialState, action = {}) { const { type, payload } = action; switch (type) { - case INIT_STATE: + case INIT_STATE: { + const patch = {}; if (payload.roles) { - return { - ...state, - roles: payload.roles, - }; + patch.roles = payload.roles; } - return state; + if (payload.config?.['u-wave:base']) { + Object.assign(patch, payload.config['u-wave:base']); + } + if (Object.keys(patch).length === 0) { + return state; + } + return { ...state, ...patch }; + } default: return state; } diff --git a/src/selectors/configSelectors.js b/src/selectors/configSelectors.js index c4191667f..49a192e4e 100644 --- a/src/selectors/configSelectors.js +++ b/src/selectors/configSelectors.js @@ -3,6 +3,17 @@ import defaultEmoji from '../utils/emojiShortcodes'; export const configSelector = (state) => state.config; +export const serverNameSelector = createSelector(configSelector, (config) => { + return config.name ?? 'üWave'; +}); +export const serverLogoSelector = createSelector(configSelector, (config) => { + if (config.logo) { + const ASSET_BASE_URL = new URL('/assets/', window.location.href); + return new URL(config.logo.replace(/^\//, ''), ASSET_BASE_URL); + } + return undefined; +}); + export const requestOptionsSelector = createSelector( configSelector, (config) => { diff --git a/tasks/serve.mjs b/tasks/serve.mjs index 2e8bdd01d..d1d9775a1 100644 --- a/tasks/serve.mjs +++ b/tasks/serve.mjs @@ -50,6 +50,10 @@ const socketUrl = Object.assign(new URL(serverUrl.href), { protocol: 'ws:' }).hr app.use(apiUrl, createProxyMiddleware({ target: serverUrl.href, })); +app.use('/assets', createProxyMiddleware({ + target: serverUrl.href, +})); + if (watch) { const compiler = webpack(wpConfig);