mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 15:09:10 +00:00
many hotfixes (#465)
* style: disable menu bar in run mode * fix: external devices in dev mode * fix: add missing elements to translations * ux: improve translation strings * refactor: improve typing * style: blink on event change * refactor: param options is object --------- Co-authored-by: Hannes Rüger <hannesrueger@gmx.de>
This commit is contained in:
@@ -12,9 +12,12 @@ export const APP_SETTINGS = ['appSettings'];
|
|||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const RUNTIME = ['runtimeStore'];
|
export const RUNTIME = ['runtimeStore'];
|
||||||
|
|
||||||
export const serverPort = import.meta.env.DEV ? STATIC_PORT : window.location.port;
|
const location = window.location;
|
||||||
export const serverURL = import.meta.env.DEV ? `http://localhost:${serverPort}` : window.location.origin;
|
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
export const websocketUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${serverPort}/ws`;
|
|
||||||
|
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
|
||||||
|
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin;
|
||||||
|
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||||
|
|
||||||
export const eventURL = `${serverURL}/eventdata`;
|
export const eventURL = `${serverURL}/eventdata`;
|
||||||
export const rundownURL = `${serverURL}/events`;
|
export const rundownURL = `${serverURL}/events`;
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
|
|||||||
|
|
||||||
if (type === 'option') {
|
if (type === 'option') {
|
||||||
const optionFromParams = searchParams.get(id);
|
const optionFromParams = searchParams.get(id);
|
||||||
const defaultOptionValue = paramField.values.find((value) => value === optionFromParams);
|
const defaultOptionValue = optionFromParams || undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
|
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
|
||||||
{paramField.values.map((value) => (
|
{Object.entries(paramField.values).map(([key, value]) => (
|
||||||
<option key={value} value={value}>
|
<option key={key} value={key}>
|
||||||
{value}
|
{value}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const TIME_FORMAT_OPTION: ParamField = {
|
|||||||
title: '12 / 24 hour timer',
|
title: '12 / 24 hour timer',
|
||||||
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: ['12', '24'],
|
values: { '12': '12 hour AM/PM', '24': '24 hour' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CLOCK_OPTIONS: ParamField[] = [
|
export const CLOCK_OPTIONS: ParamField[] = [
|
||||||
@@ -45,7 +45,7 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
|||||||
title: 'Align Horizontal',
|
title: 'Align Horizontal',
|
||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: ['start', 'center', 'end'],
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsetx',
|
id: 'offsetx',
|
||||||
@@ -58,7 +58,7 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
|||||||
title: 'Align Vertical',
|
title: 'Align Vertical',
|
||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: ['start', 'center', 'end'],
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsety',
|
id: 'offsety',
|
||||||
@@ -106,7 +106,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
title: 'Align Horizontal',
|
title: 'Align Horizontal',
|
||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: ['start', 'center', 'end'],
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsetx',
|
id: 'offsetx',
|
||||||
@@ -119,7 +119,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
title: 'Align Vertical',
|
title: 'Align Vertical',
|
||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: ['start', 'center', 'end'],
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsety',
|
id: 'offsety',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type BaseField = {
|
|||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OptionsField = { type: 'option'; values: string[] };
|
type OptionsField = { type: 'option'; values: Record<string, string> };
|
||||||
type StringField = { type: 'string' };
|
type StringField = { type: 'string' };
|
||||||
type BooleanField = { type: 'boolean' };
|
type BooleanField = { type: 'boolean' };
|
||||||
type NumberField = { type: 'number' };
|
type NumberField = { type: 'number' };
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
<div className={style.gap} />
|
<div className={style.gap} />
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
icon={<IoColorWand />}
|
icon={<IoColorWand />}
|
||||||
className={isQuickStartOpen ? style.open : ''}
|
className={isQuickStartOpen ? style.open : ''}
|
||||||
clickHandler={onQuickStartOpen}
|
clickHandler={onQuickStartOpen}
|
||||||
@@ -115,6 +116,7 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
icon={<IoPushOutline />}
|
icon={<IoPushOutline />}
|
||||||
className={isUploadOpen ? style.open : ''}
|
className={isUploadOpen ? style.open : ''}
|
||||||
clickHandler={onUploadOpen}
|
clickHandler={onUploadOpen}
|
||||||
@@ -125,6 +127,7 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
icon={<IoSaveOutline />}
|
icon={<IoSaveOutline />}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
clickHandler={downloadRundown}
|
clickHandler={downloadRundown}
|
||||||
tooltip='Export project file'
|
tooltip='Export project file'
|
||||||
aria-label='Export project file'
|
aria-label='Export project file'
|
||||||
@@ -154,6 +157,7 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
<div className={style.gap} />
|
<div className={style.gap} />
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
|
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
|
||||||
className={isIntegrationOpen ? style.open : ''}
|
className={isIntegrationOpen ? style.open : ''}
|
||||||
clickHandler={onIntegrationOpen}
|
clickHandler={onIntegrationOpen}
|
||||||
@@ -163,6 +167,7 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
/>
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
icon={<IoSettingsOutline />}
|
icon={<IoSettingsOutline />}
|
||||||
className={isSettingsOpen ? style.open : ''}
|
className={isSettingsOpen ? style.open : ''}
|
||||||
clickHandler={onSettingsOpen}
|
clickHandler={onSettingsOpen}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@use '../../../theme/viewerDefs' as *;
|
@use '../../../theme/viewerDefs' as *;
|
||||||
|
@use '../../../theme/v2Styles' as *;
|
||||||
|
|
||||||
.backstage {
|
.backstage {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -86,6 +87,10 @@
|
|||||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&.blink {
|
||||||
|
animation: blink 0.5s ease-in-out 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer-group {
|
.timer-group {
|
||||||
@@ -119,7 +124,7 @@
|
|||||||
grid-area: schedule;
|
grid-area: schedule;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-left: clamp(16px, 5vw, 64px);;
|
margin-left: clamp(16px, 5vw, 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-nav-container {
|
.schedule-nav-container {
|
||||||
@@ -143,9 +148,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qr {
|
.qr {
|
||||||
margin-left: clamp(16px, 5vw, 64px);;
|
margin-left: clamp(16px, 5vw, 64px);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =================== AMIMATION ===================*/
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0% {
|
||||||
|
background-color: var(--card-background-color-blink-override, $playback-start);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
|
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
|
||||||
@@ -43,12 +43,24 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
|
const { isMirrored, publ, title, time, backstageEvents, selectedId, general, viewSettings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
|
|
||||||
// Set window title
|
// Set window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'ontime - Backstage Screen';
|
document.title = 'ontime - Backstage Screen';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// blink on change
|
||||||
|
useEffect(() => {
|
||||||
|
setBlinkClass(false);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setBlinkClass(true);
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
// defer rendering until we load stylesheets
|
// defer rendering until we load stylesheets
|
||||||
if (!shouldRender) {
|
if (!shouldRender) {
|
||||||
return null;
|
return null;
|
||||||
@@ -57,7 +69,9 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
const clock = formatTime(time.clock, formatOptions);
|
const clock = formatTime(time.clock, formatOptions);
|
||||||
const startedAt = formatTime(time.startedAt, formatOptions);
|
const startedAt = formatTime(time.startedAt, formatOptions);
|
||||||
const isNegative = (time.current ?? 0) < 0;
|
const isNegative = (time.current ?? 0) < 0;
|
||||||
const expectedFinish = isNegative ? 'In overtime' : formatTime(time.expectedFinish, formatOptions);
|
const expectedFinish = isNegative
|
||||||
|
? getLocalizedString('countdown.overtime')
|
||||||
|
: formatTime(time.expectedFinish, formatOptions);
|
||||||
|
|
||||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||||
const filteredEvents = getEventsWithDelay(backstageEvents);
|
const filteredEvents = getEventsWithDelay(backstageEvents);
|
||||||
@@ -99,7 +113,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{title.showNow && (
|
{title.showNow && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className='event now'
|
className={`event now ${blinkClass ? 'blink' : ''}`}
|
||||||
key='now'
|
key='now'
|
||||||
variants={titleVariants}
|
variants={titleVariants}
|
||||||
initial='hidden'
|
initial='hidden'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from '../Translation.types';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langDe: TranslationObject = {
|
export const langDe: TranslationObject = {
|
||||||
'common.end_time': 'Endzeit',
|
'common.end_time': 'Endzeit',
|
||||||
@@ -15,4 +15,5 @@ export const langDe: TranslationObject = {
|
|||||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||||
'countdown.to_start': 'Zeit bis zum Start',
|
'countdown.to_start': 'Zeit bis zum Start',
|
||||||
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
|
||||||
|
'countdown.overtime': 'überfällig',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,4 +13,7 @@ export const langEn = {
|
|||||||
'countdown.select_event': 'Select an event to follow',
|
'countdown.select_event': 'Select an event to follow',
|
||||||
'countdown.to_start': 'Time to start',
|
'countdown.to_start': 'Time to start',
|
||||||
'countdown.waiting': 'Waiting for event start',
|
'countdown.waiting': 'Waiting for event start',
|
||||||
|
'countdown.overtime': 'in overtime',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TranslationObject = Record<keyof typeof langEn, string>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from '../Translation.types';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langEs: TranslationObject = {
|
export const langEs: TranslationObject = {
|
||||||
'common.end_time': 'Hora de finalización',
|
'common.end_time': 'Hora de finalización',
|
||||||
@@ -9,10 +9,11 @@ export const langEs: TranslationObject = {
|
|||||||
'common.start_time': 'Hora de inicio',
|
'common.start_time': 'Hora de inicio',
|
||||||
'common.stage_timer': 'Temporizador de presentador',
|
'common.stage_timer': 'Temporizador de presentador',
|
||||||
'common.started_at': 'Iniciado en',
|
'common.started_at': 'Iniciado en',
|
||||||
'common.time_now': 'Hora actual',
|
'common.time_now': 'Ahora',
|
||||||
'countdown.ended': 'Evento finalizado a las',
|
'countdown.ended': 'Evento finalizado a las',
|
||||||
'countdown.running': 'Evento en curso',
|
'countdown.running': 'Evento en curso',
|
||||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||||
'countdown.to_start': 'Tiempo para comenzar',
|
'countdown.to_start': 'Tiempo para comenzar',
|
||||||
'countdown.waiting': 'Esperando el inicio del evento',
|
'countdown.waiting': 'Esperando el inicio del evento',
|
||||||
|
'countdown.overtime': 'en tiempo extra',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from '../Translation.types';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langNo: TranslationObject = {
|
export const langNo: TranslationObject = {
|
||||||
'common.end_time': 'Sluttid',
|
'common.end_time': 'Sluttid',
|
||||||
@@ -9,10 +9,11 @@ export const langNo: TranslationObject = {
|
|||||||
'common.start_time': 'Starttid',
|
'common.start_time': 'Starttid',
|
||||||
'common.stage_timer': 'Scenetimer',
|
'common.stage_timer': 'Scenetimer',
|
||||||
'common.started_at': 'Startet',
|
'common.started_at': 'Startet',
|
||||||
'common.time_now': 'Tid nå',
|
'common.time_now': 'Klokken nå',
|
||||||
'countdown.ended': 'Hendelse avsluttet',
|
'countdown.ended': 'Hendelse avsluttet',
|
||||||
'countdown.running': 'Hendelse pågår',
|
'countdown.running': 'Hendelse pågår',
|
||||||
'countdown.select_event': 'Velg en hendelse å følge',
|
'countdown.select_event': 'Velg en hendelse å følge',
|
||||||
'countdown.to_start': 'Tid til start',
|
'countdown.to_start': 'Tid til start',
|
||||||
'countdown.waiting': 'Venter på start',
|
'countdown.waiting': 'Venter på start',
|
||||||
|
'countdown.overtime': 'i overtiden',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from '../Translation.types';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langPt: TranslationObject = {
|
export const langPt: TranslationObject = {
|
||||||
'common.end_time': 'Hora de término',
|
'common.end_time': 'Hora de término',
|
||||||
@@ -15,4 +15,5 @@ export const langPt: TranslationObject = {
|
|||||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||||
'countdown.to_start': 'Tempo para iniciar',
|
'countdown.to_start': 'Tempo para iniciar',
|
||||||
'countdown.waiting': 'Aguardando o início do evento',
|
'countdown.waiting': 'Aguardando o início do evento',
|
||||||
|
'countdown.overtime': 'em tempo extra',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TranslationObject } from '../Translation.types';
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
export const langSv: TranslationObject = {
|
export const langSv: TranslationObject = {
|
||||||
'common.end_time': 'Sluttid',
|
'common.end_time': 'Sluttid',
|
||||||
@@ -9,10 +9,11 @@ export const langSv: TranslationObject = {
|
|||||||
'common.start_time': 'Starttid',
|
'common.start_time': 'Starttid',
|
||||||
'common.stage_timer': 'Timer för scenen',
|
'common.stage_timer': 'Timer för scenen',
|
||||||
'common.started_at': 'Började vid',
|
'common.started_at': 'Började vid',
|
||||||
'common.time_now': 'Tid nu',
|
'common.time_now': 'Klockan nu',
|
||||||
'countdown.ended': 'Evenemanget avslutades vid',
|
'countdown.ended': 'Evenemanget avslutades vid',
|
||||||
'countdown.running': 'Evenemang pågår',
|
'countdown.running': 'Evenemang pågår',
|
||||||
'countdown.select_event': 'Välj ett evenemang att följa',
|
'countdown.select_event': 'Välj ett evenemang att följa',
|
||||||
'countdown.to_start': 'Tid till start',
|
'countdown.to_start': 'Tid till start',
|
||||||
'countdown.waiting': '"Väntar på att evenemanget ska starta',
|
'countdown.waiting': '"Väntar på att evenemanget ska starta',
|
||||||
|
'countdown.overtime': 'i övertid',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
--label-color-override: #6c6c6c;
|
--label-color-override: #6c6c6c;
|
||||||
--timer-color-override: #202020;
|
--timer-color-override: #202020;
|
||||||
--card-background-color-override: #fff;
|
--card-background-color-override: #fff;
|
||||||
|
--card-background-color-blink-override: #339e4e;
|
||||||
--font-family-override: "Open Sans";
|
--font-family-override: "Open Sans";
|
||||||
--font-family-bold-override: "Arial Black";
|
--font-family-bold-override: "Arial Black";
|
||||||
--timer-progress-bg-override: #fff;
|
--timer-progress-bg-override: #fff;
|
||||||
|
|||||||
Reference in New Issue
Block a user