Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5a34ff03f |
@@ -36,7 +36,7 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
run: pnpm dist-mac --env-mode=loose
|
||||
run: pnpm dist-mac
|
||||
timeout-minutes: 60
|
||||
|
||||
- name: Release
|
||||
|
||||
@@ -28,7 +28,6 @@ test-results
|
||||
playwright-report
|
||||
/playwright/.cache/
|
||||
automated-screenshots
|
||||
e2e/tests/fixtures/tmp/*
|
||||
|
||||
# production
|
||||
build/
|
||||
@@ -40,7 +39,9 @@ dist/
|
||||
# docker utils
|
||||
ontime-db
|
||||
ontime-external/
|
||||
ontime-data/
|
||||
|
||||
# working database
|
||||
apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
@@ -0,0 +1,51 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="CssUnknownProperty" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="myCustomPropertiesEnabled" value="true" />
|
||||
<option name="myIgnoreVendorSpecificProperties" value="false" />
|
||||
<option name="myCustomPropertiesList">
|
||||
<value>
|
||||
<list size="3">
|
||||
<item index="0" class="java.lang.String" itemvalue="scrollbar-color" />
|
||||
<item index="1" class="java.lang.String" itemvalue="scrollbar-width" />
|
||||
<item index="2" class="java.lang.String" itemvalue="app-region" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredUrls">
|
||||
<list>
|
||||
<option value="http://localhost" />
|
||||
<option value="http://127.0.0.1" />
|
||||
<option value="http://0.0.0.0" />
|
||||
<option value="http://www.w3.org/" />
|
||||
<option value="http://json-schema.org/draft" />
|
||||
<option value="http://java.sun.com/" />
|
||||
<option value="http://xmlns.jcp.org/" />
|
||||
<option value="http://javafx.com/javafx/" />
|
||||
<option value="http://javafx.com/fxml" />
|
||||
<option value="http://maven.apache.org/xsd/" />
|
||||
<option value="http://maven.apache.org/POM/" />
|
||||
<option value="http://www.springframework.org/schema/" />
|
||||
<option value="http://www.springframework.org/tags" />
|
||||
<option value="http://www.springframework.org/security/tags" />
|
||||
<option value="http://www.thymeleaf.org" />
|
||||
<option value="http://www.jboss.org/j2ee/schema/" />
|
||||
<option value="http://www.jboss.com/xml/ns/" />
|
||||
<option value="http://www.ibm.com/webservices/xsd" />
|
||||
<option value="http://activemq.apache.org/schema/" />
|
||||
<option value="http://schema.cloudfoundry.org/spring/" />
|
||||
<option value="http://schemas.xmlsoap.org/" />
|
||||
<option value="http://cxf.apache.org/schemas/" />
|
||||
<option value="http://primefaces.org/ui" />
|
||||
<option value="http://tiles.apache.org/" />
|
||||
<option value="http://__IP__" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
@@ -91,7 +91,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime .`
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||
|
||||
Other useful commands
|
||||
|
||||
@@ -23,8 +23,6 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||
COPY --from=builder /app/apps/server/src/html/ ./html/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
@@ -34,4 +32,4 @@ CMD ["node", "server/docker.cjs"]
|
||||
# Build and run commands
|
||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||
# docker buildx build . -t getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](https://www.buymeacoffee.com/cpvalente)
|
||||
|
||||
@@ -13,16 +12,10 @@
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
|
||||
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
|
||||
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
|
||||
|
||||
... or
|
||||
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
|
||||
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
|
||||
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
|
||||
|
||||
## Need help?
|
||||
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
|
||||
|
||||
## Using Ontime?
|
||||
|
||||
Let us know!
|
||||
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
|
||||
|
||||
@@ -42,16 +35,6 @@ Ontime is made by entertainment and broadcast engineers and used by
|
||||
- Theatres and opera houses
|
||||
- Houses of worship
|
||||
|
||||
## Main features
|
||||
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
|
||||
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
|
||||
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
|
||||
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
|
||||
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
|
||||
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
|
||||
|
||||
... and a lot more ...
|
||||
|
||||
### For live environments
|
||||
|
||||
Ontime is designed for use in live environments. \
|
||||
@@ -100,7 +83,6 @@ IP.ADDRESS:4001/clock > Simple clock view
|
||||
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
|
||||
IP.ADDRESS:4001/countdown > Countdown to anything
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
IP.ADDRESS:4001/timeline > Timeline
|
||||
```
|
||||
|
||||
```
|
||||
@@ -118,13 +100,26 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
IP.ADDRESS:4001/operator > automated views for operators
|
||||
```
|
||||
|
||||
More information is available [in our docs](https://docs.getontime.no)
|
||||
More documentation is available [in our docs](https://docs.getontime.no)
|
||||
|
||||
## Main features
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Collaborative
|
||||
- [x] Extendable
|
||||
- [x] Send messages to different screen types
|
||||
- [x] Differentiate between backstage and public data
|
||||
- [x] Workflow for managing delays
|
||||
- [x] Rich protocol integrations for Control and Feedback
|
||||
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Continued development
|
||||
|
||||
Ontime is under active development. We continue adding and improving features in collaboration with users.
|
||||
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
|
||||
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
@@ -166,9 +161,8 @@ If you are a developer and would like to contribute with code, please open an is
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
## Links
|
||||
- [Ontime website](https://getontime.no)
|
||||
- [Documentation](https://docs.getontime.no)
|
||||
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
|
||||
|
||||
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.14.3",
|
||||
"version": "3.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
],
|
||||
"plugins": [
|
||||
"react",
|
||||
"testing-library",
|
||||
"simple-import-sort",
|
||||
"@tanstack/query"
|
||||
],
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#101010" />
|
||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||
<link rel="apple-touch-icon" href="/ontime-logo.png" />
|
||||
<link rel="icon" type="image/png" href="/ontime-logo.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta name='theme-color' content='#121212' />
|
||||
<meta name='ontime' content='ontime - event timer manager' />
|
||||
<link rel='apple-touch-icon' href='/logo192.png' />
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='32x32'
|
||||
href='/favicon-32x32.png'
|
||||
/>
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='16x16'
|
||||
href='/favicon-16x16.png'
|
||||
/>
|
||||
<link rel='manifest' href='/site.webmanifest' />
|
||||
<link rel='manifest' href='/manifest.json' />
|
||||
<title>ontime</title>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
background-color: rgba(0, 0, 0, 0) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id='root'></div>
|
||||
<script type='module' src='/src/index.tsx'></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.14.3",
|
||||
"version": "3.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -8,16 +8,15 @@
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@mantine/hooks": "^7.6.2",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^8.19.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"@tanstack/react-query-devtools": "^5.17.9",
|
||||
"@tanstack/react-table": "^8.11.3",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
@@ -27,12 +26,11 @@
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-icons": "5.4.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"web-vitals": "^3.1.1",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -41,7 +39,7 @@
|
||||
"build": "vite build",
|
||||
"build:local": "cross-env NODE_ENV=local vite build",
|
||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||
"build:docker": "cross-env VITE_IS_DOCKER=true vite build",
|
||||
"build:docker": "vite build",
|
||||
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||
"lint": "eslint . --quiet",
|
||||
"test": "vitest",
|
||||
@@ -64,9 +62,13 @@
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "catalog:",
|
||||
"@typescript-eslint/parser": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@@ -77,16 +79,17 @@
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"happy-dom": "^16.7.2",
|
||||
"eslint-plugin-testing-library": "^5.9.1",
|
||||
"jsdom": "^21.1.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "catalog:",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^1.3.3",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "catalog:"
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 600 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
@@ -4,11 +4,18 @@
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "ontime-logo.png",
|
||||
"type": "image/png"
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"scope": "/",
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB |
@@ -1,10 +1 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{ "src": "/ontime-logo.png", "sizes": "295x295", "type": "image/png" }
|
||||
],
|
||||
"theme_color": "#2B5ABC",
|
||||
"background_color": "#101010",
|
||||
"display": "standalone"
|
||||
}
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -11,7 +11,6 @@ import { connectSocket } from './common/utils/socket';
|
||||
import theme from './theme/theme';
|
||||
import { TranslationProvider } from './translation/TranslationProvider';
|
||||
import AppRouter from './AppRouter';
|
||||
import { baseURI } from './externals';
|
||||
|
||||
connectSocket();
|
||||
|
||||
@@ -20,7 +19,7 @@ function App() {
|
||||
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
|
||||
<QueryClientProvider client={ontimeQueryClient}>
|
||||
<AppContextProvider>
|
||||
<BrowserRouter basename={baseURI}>
|
||||
<BrowserRouter>
|
||||
<div className='App'>
|
||||
<ErrorBoundary>
|
||||
<TranslationProvider>
|
||||
|
||||
@@ -14,38 +14,33 @@ import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
const Editor = React.lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
const Cuesheet = React.lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
|
||||
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
|
||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||
const TimerView = React.lazy(() => import('./features/viewers/timer/Timer'));
|
||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./views/public/Public'));
|
||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
const PCuesheet = withPreset(Cuesheet);
|
||||
const POperator = withPreset(Operator);
|
||||
|
||||
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
|
||||
@@ -80,92 +75,21 @@ export default function AppRouter() {
|
||||
<React.Suspense fallback={null}>
|
||||
<SentryRoutes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route
|
||||
path='/timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/public'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SPublic />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/minimal'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SMinimalTimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/clock'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SCountdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SBackstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SStudio />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
{/*/!* Lower third cannot have a loading screen *!/*/}
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
<Route
|
||||
path='/timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/info'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SProjectInfo />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route path='/timeline' element={<STimeline />} />
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||
<Route
|
||||
path='/op'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<POperator />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route path='/cuesheet' element={<Cuesheet />} />
|
||||
<Route path='/op' element={<Operator />} />
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationOutput,
|
||||
AutomationSettings,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const automationsPath = `${apiEntryUrl}/automations`;
|
||||
|
||||
/**
|
||||
* HTTP request to get the automations settings
|
||||
*/
|
||||
export async function getAutomationSettings(): Promise<AutomationSettings> {
|
||||
const res = await axios.get(automationsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit the automations settings
|
||||
*/
|
||||
export async function editAutomationSettings(
|
||||
automationSettings: Partial<AutomationSettings>,
|
||||
): Promise<AutomationSettings> {
|
||||
const res = await axios.post(automationsPath, automationSettings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation trigger
|
||||
*/
|
||||
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
|
||||
const res = await axios.post(`${automationsPath}/trigger`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update an automation trigger
|
||||
*/
|
||||
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
|
||||
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete an automation trigger
|
||||
*/
|
||||
export function deleteTrigger(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/trigger/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a new automation
|
||||
*/
|
||||
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
|
||||
const res = await axios.post(`${automationsPath}/automation`, automation);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update a automation
|
||||
*/
|
||||
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
|
||||
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to delete a automation
|
||||
*/
|
||||
export function deleteAutomation(id: string): Promise<void> {
|
||||
return axios.delete(`${automationsPath}/automation/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to test automation output
|
||||
* The return is irrelevant as we care for the resolution of the promise
|
||||
*/
|
||||
export function testOutput(output: AutomationOutput): Promise<void> {
|
||||
return axios.post(`${automationsPath}/test`, output);
|
||||
}
|
||||
@@ -1,25 +1,35 @@
|
||||
import { serverURL } from '../../externals';
|
||||
|
||||
// keys in tanstack store
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const APP_VERSION = ['appVersion'];
|
||||
export const AUTOMATION = ['automation'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const PROJECT_LIST = ['projectList'];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
export const RUNTIME = ['runtimeStore'];
|
||||
export const SHEET_STATE = ['sheetState'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
export const CLIENT_LIST = ['clientList'];
|
||||
export const REPORT = ['report'];
|
||||
|
||||
// API URLs
|
||||
// resolve location
|
||||
const location = window.location;
|
||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
|
||||
// resolve port
|
||||
const STATIC_PORT = 4001;
|
||||
export const serverPort = isProduction ? location.port : STATIC_PORT;
|
||||
export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
|
||||
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
|
||||
|
||||
export const apiEntryUrl = `${serverURL}/data`;
|
||||
|
||||
const userAssetsPath = 'user';
|
||||
const cssOverridePath = 'styles/override.css';
|
||||
export const projectDataURL = `${serverURL}/project`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
|
||||
export const stylesPath = 'external/styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
|
||||
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import { createBlob, downloadBlob } from './utils';
|
||||
@@ -13,7 +12,7 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
export function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download`, { filename });
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,12 +39,10 @@ export async function downloadProject(fileName: string) {
|
||||
export async function downloadCSV(fileName: string = 'rundown') {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
const { project, rundowns, customFields } = data;
|
||||
const { project, rundown, customFields } = data;
|
||||
|
||||
const flatRundowns = aggregateRundowns(rundowns);
|
||||
const sheetData = makeTable(project, flatRundowns, customFields);
|
||||
|
||||
const fileContent = makeCSVFromArrayOfArrays(sheetData);
|
||||
const sheetData = makeTable(project, rundown, customFields);
|
||||
const fileContent = makeCSV(sheetData);
|
||||
|
||||
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
|
||||
downloadBlob(blob, `${name}.csv`);
|
||||
@@ -89,14 +86,6 @@ export async function createProject(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to create a project file
|
||||
*/
|
||||
export async function quickProject(data: QuickStartData): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/quick`, data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get the list of available project files
|
||||
*/
|
||||
@@ -115,14 +104,6 @@ export async function loadProject(filename: string): Promise<MessageResponse> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to load the demo project file
|
||||
*/
|
||||
export async function loadDemo(): Promise<MessageResponse> {
|
||||
const res = await axios.post(`${dbPath}/demo`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to duplicate a project file
|
||||
*/
|
||||
@@ -157,6 +138,14 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${dbPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { CustomFields, Rundown } from 'ontime-types';
|
||||
import { CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const excelPath = `${apiEntryUrl}/excel`;
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* upload Excel file to server
|
||||
* @return string - file ID op the uploaded file
|
||||
@@ -29,10 +34,6 @@ export async function getWorksheetNames(): Promise<string[]> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
type PreviewSpreadsheetResponse = {
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
};
|
||||
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
|
||||
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
|
||||
options,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { HttpSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const httpPath = `${apiEntryUrl}/http`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve http settings
|
||||
*/
|
||||
export async function getHTTP(): Promise<HttpSettings> {
|
||||
const res = await axios.get(httpPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate http settings
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
|
||||
return axios.post(httpPath, data);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const oscPath = `${apiEntryUrl}/osc`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve osc settings
|
||||
*/
|
||||
export async function getOSC(): Promise<OSCSettings> {
|
||||
const res = await axios.get(oscPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate osc settings
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
|
||||
return axios.post(oscPath, data);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { ProjectData, ProjectLogoResponse } from 'ontime-types';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
@@ -19,18 +19,3 @@ export async function getProjectData(): Promise<ProjectData> {
|
||||
export async function postProjectData(data: ProjectData): Promise<AxiosResponse<ProjectData>> {
|
||||
return axios.post(projectPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to upload a project logo
|
||||
*/
|
||||
export async function uploadProjectLogo(file: File): Promise<AxiosResponse<ProjectLogoResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await axios.post(`${projectPath}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { OntimeReport } from 'ontime-types';
|
||||
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
|
||||
import { apiEntryUrl, REPORT } from './constants';
|
||||
|
||||
export const reportUrl = `${apiEntryUrl}/report`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all reports
|
||||
*/
|
||||
export async function fetchReport(): Promise<OntimeReport> {
|
||||
const res = await axios.get(`${reportUrl}/`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteReport(id: string) {
|
||||
await axios.delete(`${reportUrl}/${id}`);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
}
|
||||
|
||||
export async function deleteAllReport() {
|
||||
await axios.delete(`${reportUrl}/all`);
|
||||
await ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
}
|
||||
@@ -1,44 +1,29 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
MessageResponse,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
ProjectRundownsList,
|
||||
Rundown,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const rundownPath = `${apiEntryUrl}/rundown`;
|
||||
|
||||
/**
|
||||
* HTTP request to fetch a list of existing rundowns
|
||||
*/
|
||||
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
|
||||
const res = await axios.get(`${rundownPath}/`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to fetch all events
|
||||
*/
|
||||
export async function fetchCurrentRundown(): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/current`);
|
||||
export async function fetchNormalisedRundown(): Promise<RundownCached> {
|
||||
const res = await axios.get(`${rundownPath}/normalised`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to post new event
|
||||
*/
|
||||
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
|
||||
export async function requestPostEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.post(rundownPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to put new event
|
||||
*/
|
||||
export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
|
||||
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.put(rundownPath, data);
|
||||
}
|
||||
|
||||
@@ -63,7 +48,7 @@ export type ReorderEntry = {
|
||||
/**
|
||||
* HTTP request to reorder events
|
||||
*/
|
||||
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
|
||||
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> {
|
||||
return axios.patch(`${rundownPath}/reorder`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to get a pre-authenticated URL
|
||||
*/
|
||||
export async function generateUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
): Promise<string> {
|
||||
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
|
||||
return res.data.url;
|
||||
}
|
||||
@@ -19,10 +19,3 @@ export async function getSettings(): Promise<Settings> {
|
||||
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
|
||||
return axios.post(settingsPath, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows setting the welcome modal dialog state from the clients
|
||||
*/
|
||||
export async function postShowWelcomeDialog(show: boolean) {
|
||||
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types';
|
||||
import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
@@ -54,7 +54,7 @@ export const previewRundown = async (
|
||||
sheetId: string,
|
||||
options: ImportMap,
|
||||
): Promise<{
|
||||
rundown: Rundown;
|
||||
rundown: OntimeRundown;
|
||||
customFields: CustomFields;
|
||||
}> => {
|
||||
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
|
||||
|
||||
@@ -23,8 +23,6 @@ export function maybeAxiosError(error: unknown) {
|
||||
}
|
||||
}
|
||||
return `${statusText}: ${data}`;
|
||||
} else if (error instanceof Error) {
|
||||
return error.message;
|
||||
} else {
|
||||
if (typeof error !== 'string') {
|
||||
return JSON.stringify(error);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@use "../../../theme/mixins" as *;
|
||||
|
||||
.link {
|
||||
@include action-link;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLink(props: AppLinkProps) {
|
||||
const { href, children } = props;
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
|
||||
{children} <IoArrowUp className={style.linkIcon} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
.subtle {
|
||||
aspect-ratio: 1;
|
||||
height: 2rem;
|
||||
height: 2rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-1050;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './IconButton.module.scss';
|
||||
|
||||
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...buttonProps } = props;
|
||||
return (
|
||||
<button className={cx([style.subtle, className])} {...buttonProps}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.buttonSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $element-spacing;
|
||||
margin-top: -$element-spacing;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.textEntry {
|
||||
grid-area: input;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.inlineEntry {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'label label'
|
||||
'input btn';
|
||||
grid-template-columns: 1fr auto;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.redirect {
|
||||
grid-area: btn;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: $label-gray;
|
||||
}
|
||||
@@ -1,125 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import { IoArrowForward } from 'react-icons/io5';
|
||||
import {
|
||||
IconButton,
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Select,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import Info from '../info/Info';
|
||||
import AppLink from '../link/app-link/AppLink';
|
||||
|
||||
import style from './RedirectClientModal.module.scss';
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RedirectClientModalProps {
|
||||
id: string;
|
||||
name: string;
|
||||
currentPath: string;
|
||||
origin: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
const { id, isOpen, name, currentPath, origin, onClose } = props;
|
||||
const { data } = useUrlPresets();
|
||||
const { id, isOpen, name = '', path: currentPath = '', onClose } = props;
|
||||
const [path, setPath] = useState(currentPath);
|
||||
const [selected, setSelected] = useState('/');
|
||||
|
||||
const { setRedirect } = setClientRemote;
|
||||
|
||||
const handleRedirect = (newPath: string) => {
|
||||
if (newPath === '/' || newPath === currentPath) {
|
||||
return;
|
||||
const handleRedirect = () => {
|
||||
if (path !== currentPath && path !== '') {
|
||||
setRedirect({ target: id, redirect: path });
|
||||
}
|
||||
setRedirect({ target: id, redirect: newPath });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const enabledPresets = data.filter((preset) => preset.enabled);
|
||||
const host = window.location.origin;
|
||||
const canSubmit = path !== currentPath && path !== '';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxWidth='max(480px, 35vw)'>
|
||||
<ModalContent>
|
||||
<ModalHeader>Redirect: {name}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<Info>
|
||||
Remotely redirect the client to a different URL. <br />
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
<br />
|
||||
<br />
|
||||
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
<div className={style.textEntry}>
|
||||
<Select
|
||||
size='md'
|
||||
variant='ontime'
|
||||
isDisabled={enabledPresets.length === 0}
|
||||
onChange={(event) => setSelected(event.target.value)}
|
||||
>
|
||||
<option value='/'>Select view or preset</option>
|
||||
{navigatorConstants.map((view) => {
|
||||
return (
|
||||
<option key={view.url} value={`/${view.url}`}>
|
||||
{view.label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{enabledPresets.map((preset) => {
|
||||
return (
|
||||
<option key={preset.pathAndParams} value={preset.pathAndParams}>
|
||||
{`Preset: ${preset.alias}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
aria-label='Redirect to preset'
|
||||
className={style.redirect}
|
||||
icon={<IoArrowForward />}
|
||||
isDisabled={enabledPresets.length === 0 || selected === '/'}
|
||||
onClick={() => handleRedirect(selected)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.inlineEntry}>
|
||||
<span className={style.label}>Enter custom path</span>
|
||||
<label className={style.textEntry}>
|
||||
{origin}
|
||||
<Input
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
placeholder='eg. /minimal?key=0000ffff'
|
||||
value={path}
|
||||
onChange={(event) => setPath(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
size='md'
|
||||
aria-label='Redirect'
|
||||
isDisabled={path === currentPath || path === ''}
|
||||
className={style.redirect}
|
||||
icon={<IoArrowForward />}
|
||||
onClick={() => handleRedirect(path)}
|
||||
/>
|
||||
</div>
|
||||
<InputGroup variant='ontime-filled' size='md'>
|
||||
<InputLeftAddon>{host}</InputLeftAddon>
|
||||
<Input placeholder='minimal?key=0000ffff' value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</InputGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRedirect} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
|
||||
import style from './ClientModal.module.scss';
|
||||
|
||||
interface RenameClientModalProps {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -51,12 +53,14 @@ export function RenameClientModal(props: RenameClientModalProps) {
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
<div className={style.buttonSection}>
|
||||
<Button size='md' variant='ontime-subtle' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size='md' variant='ontime-filled' onClick={handleRename} isDisabled={!canSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
||||
import { IconType } from '@react-icons/all-files';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { ContextMenuOption } from './ContextMenuOption';
|
||||
@@ -23,7 +23,7 @@ export type OptionWithoutGroup = {
|
||||
withDivider?: boolean;
|
||||
};
|
||||
|
||||
type OptionWithGroup = {
|
||||
export type OptionWithGroup = {
|
||||
label: string;
|
||||
group: Omit<OptionWithoutGroup, 'isGroup'>[];
|
||||
};
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IoCopy } from 'react-icons/io5';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
|
||||
interface CopyTagProps {
|
||||
copyValue: string;
|
||||
label: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { label, className, size = 'xs', disabled, children } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
const handleClick = () => copyToClipboard(children as string);
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||
icon={<IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
|
||||
import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { millisToDelayString } from '../../utils/dateConfig';
|
||||
@@ -8,23 +9,32 @@ import style from './DelayIndicator.module.scss';
|
||||
|
||||
interface DelayIndicatorProps {
|
||||
delayValue?: number;
|
||||
tooltipPrefix?: string;
|
||||
}
|
||||
|
||||
export default function DelayIndicator(props: DelayIndicatorProps) {
|
||||
const { delayValue, tooltipPrefix } = props;
|
||||
const { delayValue } = props;
|
||||
|
||||
if (typeof delayValue !== 'number' || delayValue === 0) {
|
||||
return null;
|
||||
if (typeof delayValue === 'number') {
|
||||
if (delayValue < 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronDown />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (delayValue > 0) {
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={millisToDelayString(delayValue)}>
|
||||
<span className={style.delaySymbol}>
|
||||
<IoChevronUp />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const delayString = tooltipPrefix
|
||||
? `${tooltipPrefix} ${millisToDelayString(delayValue)}`
|
||||
: millisToDelayString(delayValue);
|
||||
|
||||
return (
|
||||
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
|
||||
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import React from 'react';
|
||||
// skipcq: JS-C1003 - sentry does not expose itself as an ES Module.
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
import { hasConnected, reconnectAttempts } from '../../../common/utils/socket';
|
||||
import { runtimeStore } from '../../stores/runtime';
|
||||
import { runtimeStore } from '@/common/stores/runtime';
|
||||
import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket';
|
||||
|
||||
import style from './ErrorBoundary.module.scss';
|
||||
|
||||
@@ -26,22 +26,17 @@ class ErrorBoundary extends React.Component {
|
||||
error,
|
||||
errorInfo: info,
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
|
||||
const appState = runtimeStore.getState();
|
||||
if (appState.ping < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras({
|
||||
error,
|
||||
store: appState,
|
||||
hasSocket: { hasConnected, reconnectAttempts },
|
||||
store: runtimeStore.getState(),
|
||||
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
||||
});
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from 'react-icons/io5';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
|
||||
import { openLink } from '../../../utils/linkUtils';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './ExternalLink.module.scss';
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ExternalLinkProps {
|
||||
|
||||
export default function ExternalLink(props: ExternalLinkProps) {
|
||||
const { href, inline, children } = props;
|
||||
const classes = cx([style.link, inline ? style.inline : null]);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -21,14 +22,8 @@ export default function ExternalLink(props: ExternalLinkProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href='#!'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={cx([style.link, inline && style.inline])}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children} <IoOpenOutline style={{ fontSize: '1em' }} />
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
|
||||
{children} <IoOpenOutline />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Copied from
|
||||
* https://github.com/namhong2001/react-textfit/blob/master/index.tsx
|
||||
*/
|
||||
|
||||
import { HTMLAttributes, PropsWithChildren, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { bsearch } from './fitText.utils';
|
||||
|
||||
interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
|
||||
mode?: 'single' | 'multi';
|
||||
min?: number; // inclusive
|
||||
max?: number; // inclusive
|
||||
}
|
||||
|
||||
export function FitText(props: PropsWithChildren<FitTextProps>) {
|
||||
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isOverflown = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return false;
|
||||
return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth;
|
||||
}, []);
|
||||
|
||||
const setFontSize = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const originVisibility = el.style.visibility;
|
||||
|
||||
el.style.visibility = 'hidden';
|
||||
const fontSize = bsearch(min, max + 1, (mid) => {
|
||||
el.style.fontSize = `${mid}px`;
|
||||
return !isOverflown();
|
||||
});
|
||||
el.style.fontSize = `${fontSize}px`;
|
||||
el.style.visibility = originVisibility;
|
||||
}, [isOverflown, min, max]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
setFontSize();
|
||||
const observer = new ResizeObserver(setFontSize);
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [children, mode, setFontSize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
whiteSpace: mode === 'single' ? 'nowrap' : 'normal',
|
||||
}}
|
||||
{...elementProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* @param low inclusive, must be true on predicate function
|
||||
* @param high exclusive,
|
||||
* @param predicate predicate function
|
||||
*/
|
||||
export const bsearch = (low: number, high: number, predicate: (mid: number) => boolean): number => {
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (mid === low) break;
|
||||
|
||||
if (predicate(mid)) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
.infoLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $element-spacing;
|
||||
|
||||
background-color: $gray-1200;
|
||||
border-radius: 3px;
|
||||
font-size: $text-body-size;
|
||||
padding: 1rem;
|
||||
|
||||
.content {
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
svg {
|
||||
min-width: 1.5rem;
|
||||
align-self: start;
|
||||
font-size: 1.5rem;
|
||||
color: $info-blue;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoAlertCircle } from 'react-icons/io5';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Info.module.scss';
|
||||
|
||||
interface InfoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Info(props: PropsWithChildren<InfoProps>) {
|
||||
const { className, children } = props;
|
||||
|
||||
return (
|
||||
<div className={cx([style.infoLabel, className])}>
|
||||
<IoAlertCircle />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IoBan } from 'react-icons/io5';
|
||||
import { IoBan } from '@react-icons/all-files/io5/IoBan';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
@@ -16,11 +16,11 @@ export default function Swatch(props: SwatchProps) {
|
||||
const handleClick = () => {
|
||||
onClick?.(color);
|
||||
};
|
||||
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
|
||||
const classes = cx([style.swatch, isSelected ? style.selected : null, onClick ? style.selectable : null]);
|
||||
|
||||
if (!color) {
|
||||
return (
|
||||
<div className={classes} onClick={handleClick}>
|
||||
<div className={`${classes} ${style.center}`} onClick={handleClick}>
|
||||
<IoBan />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { IoEyedrop } from 'react-icons/io5';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { debounce } from '../../../utils/debounce';
|
||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||
import PopoverPicker from '../popover-picker/PopoverPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface SwatchPickerProps {
|
||||
color: string;
|
||||
isSelected?: boolean;
|
||||
onChange: (name: string) => void;
|
||||
alwaysDisplayColor?: boolean;
|
||||
}
|
||||
|
||||
export default function SwatchPicker(props: SwatchPickerProps) {
|
||||
const { color, onChange, isSelected, alwaysDisplayColor } = props;
|
||||
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((newValue: string) => {
|
||||
onChange(newValue);
|
||||
}, 500),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const displayColor = alwaysDisplayColor || isSelected ? color : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
return (
|
||||
<PopoverPicker color={displayColor} onChange={debouncedOnChange}>
|
||||
<div
|
||||
className={cx([style.swatch, isSelected && style.selected, style.selectable])}
|
||||
style={{ backgroundColor: displayColor }}
|
||||
>
|
||||
<IoEyedrop color={iconColor} />
|
||||
</div>
|
||||
</PopoverPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwatchPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
const displayColor = typeof value === 'string' ? value : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
return (
|
||||
<PopoverPicker color={value as string} onChange={onChange}>
|
||||
<div className={cx([style.swatch, style.selectable])} style={{ backgroundColor: displayColor }}>
|
||||
<IoEyedrop color={iconColor} />
|
||||
</div>
|
||||
</PopoverPicker>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 0.5rem
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 99px;
|
||||
border: 2px solid $gray-1200;
|
||||
color: $ui-white;
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&.selected {
|
||||
border: 2px solid $blue-500;
|
||||
@@ -20,9 +17,11 @@
|
||||
|
||||
&.selectable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
import SwatchPicker from './SwatchPicker';
|
||||
|
||||
import style from './SwatchSelect.module.scss';
|
||||
|
||||
@@ -44,7 +43,6 @@ export default function SwatchSelect(props: ColourInputProps) {
|
||||
{colours.map((colour) => (
|
||||
<Swatch key={colour} color={colour} onClick={setColour} isSelected={value === colour} />
|
||||
))}
|
||||
<SwatchPicker color={value} onChange={setColour} isSelected={!colours.includes(value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
height: 2rem;
|
||||
outline: none;
|
||||
.swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 0 1px $gray-300;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
transition-property: box-shadow;
|
||||
transition-duration: $transition-time-action;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
box-shadow: 0 0 0 1px $blue-300;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||
import { HexAlphaColorPicker } from 'react-colorful';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import style from './PopoverPicker.module.scss';
|
||||
|
||||
export function PopoverPickerRHF(props: UseControllerProps<ViewSettings>) {
|
||||
const { name, control } = props;
|
||||
const {
|
||||
field: { onChange, value },
|
||||
} = useController({ control, name });
|
||||
|
||||
return <PopoverPicker color={value as string} onChange={onChange} />;
|
||||
}
|
||||
|
||||
interface PopoverPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
||||
const { color, onChange, children } = props;
|
||||
export default function PopoverPicker(props: PopoverPickerProps) {
|
||||
const { color, onChange } = props;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
||||
<PopoverTrigger>
|
||||
<div className={style.swatch} style={{ backgroundColor: color }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent style={{ width: 'auto' }}>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -15,9 +15,6 @@ export default function useReactiveTextInput(
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -50,25 +47,16 @@ export default function useReactiveTextInput(
|
||||
const handleSubmit = useCallback(
|
||||
(valueToSubmit: string) => {
|
||||
// No need to update if it hasn't changed
|
||||
if (valueToSubmit === initialText && !options?.allowSubmitSameValue) {
|
||||
options?.onCancelUpdate?.();
|
||||
} else {
|
||||
if (valueToSubmit !== initialText) {
|
||||
const cleanVal = valueToSubmit.trim();
|
||||
submitCallback(cleanVal);
|
||||
if (cleanVal !== valueToSubmit) {
|
||||
setText(cleanVal);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (options?.allowKeyboardNavigation) {
|
||||
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
|
||||
} else {
|
||||
ref.current?.blur();
|
||||
}
|
||||
}); // Immediate timeout to ensure text is set before blurring
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
},
|
||||
[initialText, options, ref, submitCallback],
|
||||
[initialText, ref, submitCallback],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -78,25 +66,11 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
// force the text to be the initial value
|
||||
if (ref.current) {
|
||||
ref.current.value = initialText;
|
||||
}
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||
}, [initialText, options, ref]);
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
}, [initialText, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [
|
||||
[
|
||||
'Escape',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
handleEscape();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
],
|
||||
];
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||
@@ -105,22 +79,7 @@ export default function useReactiveTextInput(
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
|
||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||
|
||||
return (event: KeyboardEvent<HTMLElement>) => {
|
||||
// allow moving in input field with arrow keys
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
hotKeyHandler(event);
|
||||
};
|
||||
return getHotkeyHandler(hotKeys);
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { useEmitLog } from '../../../stores/logger';
|
||||
|
||||
interface TimeInputProps<T extends string> {
|
||||
id?: T;
|
||||
name: T;
|
||||
submitHandler: (field: T, value: string) => void;
|
||||
time?: number;
|
||||
@@ -16,7 +15,7 @@ interface TimeInputProps<T extends string> {
|
||||
}
|
||||
|
||||
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [value, setValue] = useState<string>('');
|
||||
@@ -55,7 +54,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
submitHandler(name, newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
@@ -119,7 +117,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
|
||||
@@ -2,8 +2,6 @@ $input-delayed-border-color: $ontime-delay-text;
|
||||
|
||||
.timeInput {
|
||||
border: 1px solid transparent;
|
||||
color: $label-gray;
|
||||
|
||||
&.delayed {
|
||||
border: 1px solid $input-delayed-border-color;
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.link {
|
||||
color: $label-gray;
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: $blue-400;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component used to navigate to an editor link inside the same window
|
||||
* Handles the path to respect Ontime Clouds base URL
|
||||
*/
|
||||
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||
const { search, children } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={style.link}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
$loader-size: 4rem;
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
background-color: $black-10;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
|
||||
.loader {
|
||||
width: $loader-size;
|
||||
height: $loader-size;
|
||||
background: $blue-500;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
animation: animloader 1s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import style from './LoaderOverlay.module.scss';
|
||||
|
||||
export default function LoaderOverlay() {
|
||||
return (
|
||||
<div className={style.overlay}>
|
||||
<span className={style.loader} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
$progress-bar-size: 1.5rem;
|
||||
$progress-bar-size: 12px;
|
||||
$progress-bar-br: 3px;
|
||||
|
||||
.multiprogress-bar {
|
||||
@@ -53,6 +53,3 @@ $progress-bar-br: 3px;
|
||||
.multiprogress-bar__bg-danger {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.multiprogress-bar__bg-danger--overtime {
|
||||
background: var(--timer-overtime-color-override, $timer-finished-color);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './MultiPartProgressBar.scss';
|
||||
|
||||
@@ -14,7 +13,6 @@ interface MultiPartProgressBar {
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
hideOvertime?: boolean;
|
||||
ignoreCssOverride?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -29,7 +27,6 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
danger,
|
||||
dangerColor,
|
||||
hidden,
|
||||
hideOvertime,
|
||||
ignoreCssOverride,
|
||||
className = '',
|
||||
} = props;
|
||||
@@ -37,17 +34,12 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const percentRemaining = 100 - getProgress(now, complete);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const isOvertime = now !== null && now < 0;
|
||||
const showOvertime = isOvertime && !hideOvertime;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx([
|
||||
'multiprogress-bar',
|
||||
hidden && 'multiprogress-bar--hidden',
|
||||
ignoreCssOverride && 'multiprogress-bar--ignore-css-override',
|
||||
className,
|
||||
])}
|
||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
{now !== null && (
|
||||
<>
|
||||
@@ -58,8 +50,8 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className={cx(['multiprogress-bar__bg-danger', showOvertime && 'multiprogress-bar__bg-danger--overtime'])}
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: showOvertime ? undefined : dangerColor }}
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className='multiprogress-bar__indicator'>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IoApps } from 'react-icons/io5';
|
||||
import { IoSettingsOutline } from 'react-icons/io5';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IoApps } from '@react-icons/all-files/io5/IoApps';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { debounce } from '../../utils/debounce';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -13,10 +13,32 @@ interface FloatingNavigationProps {
|
||||
|
||||
export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
const { toggleMenu, toggleSettings } = props;
|
||||
const isButtonShown = useFadeOutOnInactivity();
|
||||
const [showButton, setShowButton] = useState(false);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setShowButton(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setShowButton(false), 3000);
|
||||
};
|
||||
|
||||
const debouncedShowMenu = debounce(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', debouncedShowMenu);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', debouncedShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
|
||||
<div className={`${style.buttonContainer} ${!showButton ? style.hidden : ''}`}>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
|
||||
@@ -7,38 +7,24 @@ $icon-color: $ui-white;
|
||||
$button-bg: $gray-1050;
|
||||
$button-size: 3rem;
|
||||
|
||||
.fadeable {
|
||||
opacity: 1;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
font-size: 1.5rem;
|
||||
color: $active-indicator;
|
||||
padding: 0.5em;
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 1rem;
|
||||
padding: 0.5em;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
|
||||
opacity: 1;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -61,8 +47,6 @@ $button-size: 3rem;
|
||||
@include action-link;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $menu-hover-bg;
|
||||
@@ -86,33 +70,9 @@ $button-size: 3rem;
|
||||
|
||||
.linkIcon {
|
||||
margin-left: auto;
|
||||
@include rotate-fourty-five;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IoArrowUp, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
@@ -12,18 +11,18 @@ import {
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { useFullscreen } from '@mantine/hooks';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
import { IoContract } from '@react-icons/all-files/io5/IoContract';
|
||||
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
|
||||
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { useElectronEvent } from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { handleLinks, linkToOtherHost, openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import CopyTag from '../copy-tag/CopyTag';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -40,15 +39,18 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
|
||||
const { isOpen: isOpenRename, onOpen: onRenameOpen, onClose: onCloseRename } = useDisclosure();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const location = useLocation();
|
||||
const { toggleMirror } = useViewOptionsStore();
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside(menuRef, () => onClose);
|
||||
|
||||
return createPortal(
|
||||
<div id='navigation-menu-portal'>
|
||||
<div id='navigation-menu-portal' ref={menuRef}>
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxWidth='22rem'>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
@@ -56,7 +58,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<DrawerBody padding={0}>
|
||||
<div className={style.buttonsContainer}>
|
||||
<div
|
||||
className={cx([style.link, fullscreen && style.current])}
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={toggle}
|
||||
@@ -68,7 +70,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</div>
|
||||
<div
|
||||
className={cx([style.link, mirror && style.current])}
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={() => toggleMirror()}
|
||||
@@ -94,27 +96,39 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<hr className={style.separator} />
|
||||
<Link
|
||||
to='/editor'
|
||||
className={`${style.link} ${location.pathname === '/editor' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
className={`${style.link} ${location.pathname === '/editor' && style.current}`}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||
<Link
|
||||
to='/cuesheet'
|
||||
className={`${style.link} ${location.pathname === '/cuesheet' ? style.current : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<Link to='/op' className={`${style.link} ${location.pathname === '/op' ? style.current : ''}`} tabIndex={0}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
</ClientLink>
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||
<Link
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{route.label}
|
||||
</ClientLink>
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
))}
|
||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -123,71 +137,4 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface OtherAddressesProps {
|
||||
currentLocation: string;
|
||||
}
|
||||
|
||||
function OtherAddresses(props: OtherAddressesProps) {
|
||||
const { currentLocation } = props;
|
||||
const { data } = useInfo();
|
||||
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.bottom}>
|
||||
<div className={style.sectionHeader}>Accessible on external networks</div>
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => openLink(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientLinkProps {
|
||||
current: boolean;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
||||
const { current, to, children } = props;
|
||||
const { isElectron } = useElectronEvent();
|
||||
|
||||
const classes = cx([style.link, current && style.current]);
|
||||
|
||||
if (isElectron) {
|
||||
return (
|
||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={`/${to}`} className={classes} tabIndex={0}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NavigationMenu);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
|
||||
interface ProductionNavigationMenuProps {
|
||||
isMenuOpen: boolean;
|
||||
onMenuClose: () => void;
|
||||
}
|
||||
|
||||
function ProductionNavigationMenu(props: ProductionNavigationMenuProps) {
|
||||
const { isMenuOpen, onMenuClose } = props;
|
||||
|
||||
return <NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />;
|
||||
}
|
||||
|
||||
export default memo(ProductionNavigationMenu);
|
||||
@@ -1,16 +0,0 @@
|
||||
import { IoLockClosedOutline } from 'react-icons/io5';
|
||||
|
||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
export default function ViewLockedIcon() {
|
||||
const isLockIconShown = useFadeOutOnInactivity();
|
||||
|
||||
return (
|
||||
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
|
||||
<IoLockClosedOutline />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
import FloatingNavigation from './FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
import useViewEditor from './useViewEditor';
|
||||
import ViewLockedIcon from './ViewLockedIcon';
|
||||
|
||||
interface ViewNavigationMenuProps {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
|
||||
const { isLockable } = props;
|
||||
|
||||
function ViewNavigationMenu() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
|
||||
|
||||
if (isViewLocked) {
|
||||
return <ViewLockedIcon />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
interface EditorVisibilityOptions {
|
||||
isLockable?: boolean;
|
||||
}
|
||||
|
||||
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const showEditFormDrawer = useCallback(() => {
|
||||
searchParams.set('edit', 'true');
|
||||
setSearchParams(searchParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const isViewLocked = useMemo(() => {
|
||||
if (!isLockable) {
|
||||
return false;
|
||||
}
|
||||
return isStringBoolean(searchParams.get('locked'));
|
||||
}, [isLockable, searchParams]);
|
||||
|
||||
return { showEditFormDrawer, isViewLocked };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoPause } from '@react-icons/all-files/io5/IoPause';
|
||||
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
@@ -9,6 +9,10 @@ $progress-bar-br: 3px;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
overflow: clip;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar__indicator {
|
||||
|
||||
@@ -7,15 +7,16 @@ import './ProgressBar.scss';
|
||||
interface ProgressBarProps {
|
||||
current: MaybeNumber;
|
||||
duration: MaybeNumber;
|
||||
hidden?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { current, duration, className } = props;
|
||||
const { current, duration, hidden, className = '' } = props;
|
||||
const progress = getProgress(current, duration);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${className}`}>
|
||||
<div className={`progress-bar__bg ${hidden ? 'progress-bar__bg--hidden' : ''} ${className}`}>
|
||||
<div className='progress-bar__indicator' style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $ui-black;
|
||||
color: $ontime-color;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
@@ -46,4 +42,4 @@
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { PropsWithChildren, useCallback, useContext } from 'react';
|
||||
|
||||
import { isLocalhost } from '../../../externals';
|
||||
import { AppContext } from '../../context/AppContext';
|
||||
|
||||
import PinPage from './PinPage';
|
||||
@@ -10,6 +9,7 @@ interface ProtectRouteProps {
|
||||
}
|
||||
|
||||
export default function ProtectRoute({ permission, children }: PropsWithChildren<ProtectRouteProps>) {
|
||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
const { editorAuth, operatorAuth, validate } = useContext(AppContext);
|
||||
|
||||
const handleValidation = useCallback(
|
||||
@@ -29,7 +29,7 @@ export default function ProtectRoute({ permission, children }: PropsWithChildren
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLocalhost || hasRelevantAuth()) {
|
||||
if (isLocal || hasRelevantAuth()) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- trying to make typescript happy
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.schedule {
|
||||
width: 100%;
|
||||
border-spacing: 50px;
|
||||
|
||||
.entry {
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
|
||||
.entry-colour {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
height: clamp(8px, 0.75vw, 12px);
|
||||
width: clamp(8px, 0.75vw, 12px);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.entry-times {
|
||||
font-family: $viewer-font-family;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.05em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&--past {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
}
|
||||
|
||||
&--now {
|
||||
.entry-title {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&.skip {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.schedule-nav__item {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin-left: 8px;
|
||||
|
||||
&--selected {
|
||||
background-color: var(--color-override, $viewer-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleProps {
|
||||
isProduction?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||
|
||||
// TODO: design a nice placeholder for empty schedules
|
||||
if (paginatedEvents?.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
|
||||
return (
|
||||
<ul className={`schedule ${className}`}>
|
||||
{paginatedEvents.map((event) => {
|
||||
if (scheduleType === 'past' || scheduleType === 'future') {
|
||||
selectedState = scheduleType;
|
||||
} else {
|
||||
if (event.id === selectedEventId) {
|
||||
selectedState = 'now';
|
||||
} else if (selectedState === 'now') {
|
||||
selectedState = 'future';
|
||||
}
|
||||
}
|
||||
|
||||
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
|
||||
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
|
||||
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
selected={selectedState}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
title={event.title}
|
||||
colour={isBackstage ? event.colour : ''}
|
||||
backstageEvent={!event.isPublic}
|
||||
skip={event.skip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
paginatedEvents: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
scheduleType: 'past' | 'now' | 'future';
|
||||
numPages: number;
|
||||
visiblePage: number;
|
||||
isBackstage: boolean;
|
||||
}
|
||||
|
||||
const ScheduleContext = createContext<ScheduleContextState | undefined>(undefined);
|
||||
|
||||
interface ScheduleProviderProps {
|
||||
events: OntimeEvent[];
|
||||
selectedEventId: string | null;
|
||||
isBackstage?: boolean;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
const numEventsPerPage = 8;
|
||||
|
||||
export const ScheduleProvider = ({
|
||||
children,
|
||||
events,
|
||||
selectedEventId,
|
||||
isBackstage = false,
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
const eventsPerPage = Number(searchParams.get('eventsPerPage') ?? numEventsPerPage);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const viewEvents = [...events];
|
||||
if (hidePast) {
|
||||
// we want to show the event after the next
|
||||
viewEvents.splice(0, selectedEventIndex + 2);
|
||||
selectedEventIndex = 0;
|
||||
}
|
||||
|
||||
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||
|
||||
const resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
return 'now';
|
||||
}
|
||||
if (selectedEventIndex > eventEnd) {
|
||||
return 'past';
|
||||
}
|
||||
return 'future';
|
||||
};
|
||||
const scheduleType = resolveScheduleType();
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(0);
|
||||
} else if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
}, time * 1000);
|
||||
|
||||
return (
|
||||
<ScheduleContext.Provider
|
||||
value={{
|
||||
events,
|
||||
paginatedEvents,
|
||||
selectedEventId,
|
||||
scheduleType,
|
||||
numPages,
|
||||
visiblePage,
|
||||
isBackstage,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScheduleContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSchedule = () => {
|
||||
const context = useContext(ScheduleContext);
|
||||
if (!context) {
|
||||
throw new Error('useSchedule() can only be used inside a ScheduleContext');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import SuperscriptTime from '../../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { formatTime } from '../../utils/time';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
const formatOptions = {
|
||||
format12: 'hh:mm a',
|
||||
format24: 'HH:mm',
|
||||
};
|
||||
|
||||
interface ScheduleItemProps {
|
||||
selected: 'past' | 'now' | 'future';
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
backstageEvent: boolean;
|
||||
colour: string;
|
||||
skip: boolean;
|
||||
}
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { selected, timeStart, timeEnd, title, backstageEvent, colour, skip } = props;
|
||||
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
const userColour = colour !== '' ? colour : '';
|
||||
const selectStyle = `entry--${selected}`;
|
||||
|
||||
return (
|
||||
<li className={`entry ${selectStyle} ${skip ? 'skip' : ''}`}>
|
||||
<div className='entry-times'>
|
||||
<span className='entry-colour' style={{ backgroundColor: userColour }} />
|
||||
<div style={{ display: 'flex' }}>
|
||||
<SuperscriptTime time={start} />
|
||||
{' → '}
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent ? '*' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleNavProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ScheduleNav({ className }: ScheduleNavProps) {
|
||||
const { numPages, visiblePage } = useSchedule();
|
||||
|
||||
return (
|
||||
<div className={`schedule-nav ${className}`}>
|
||||
{numPages > 1 &&
|
||||
[...Array(numPages).keys()].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={i === visiblePage ? 'schedule-nav__item schedule-nav__item--selected' : 'schedule-nav__item'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Empty.module.scss';
|
||||
|
||||
interface EmptyProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Empty(props: EmptyProps) {
|
||||
const { text, className, ...rest } = props;
|
||||
const { text, ...rest } = props;
|
||||
return (
|
||||
<div className={cx([style.emptyContainer, className])} {...rest}>
|
||||
<div className={style.emptyContainer} {...rest}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
/* share the same style as a page layout */
|
||||
.page {
|
||||
margin: 0;
|
||||
box-sizing: border-box; /* reset */
|
||||
overflow: hidden;
|
||||
width: 100%; /* restrict the page width to viewport */
|
||||
height: 100vh;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 5rem;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyPage.module.scss';
|
||||
|
||||
interface EmptyPageProps {
|
||||
text?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export default function EmptyPage(props: EmptyPageProps) {
|
||||
const { text, ...rest } = props;
|
||||
|
||||
return (
|
||||
<div className={style.page}>
|
||||
<Empty text={text} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,26 +4,25 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title-card__title,
|
||||
.title-card__placeholder {
|
||||
font-weight: 600;
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.2em;
|
||||
.title-card__secondary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.5rem, 3vw, 3rem);
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.title-card__placeholder {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
font-size: clamp(1rem, 2vw, 2.25rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
@@ -32,8 +31,9 @@
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: $timer-label-size;
|
||||
font-size: clamp(1rem, 1.5vw, 1.5rem);
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--accent {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
interface TitleCardProps {
|
||||
title?: string;
|
||||
title: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
@@ -19,9 +18,9 @@ const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivEl
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className={cx(['title-card', className])} ref={ref}>
|
||||
<div className={`title-card ${className}`} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
|
||||
<span className={accent ? 'title-card__label title-card__label--accent' : 'title-card__label'}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.viewLogo {
|
||||
max-width: 100%;
|
||||
max-height: 75px;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { projectLogoPath } from '../../api/constants';
|
||||
|
||||
import './ViewLogo.scss';
|
||||
|
||||
interface ViewLogoProps {
|
||||
name: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export default function ViewLogo(props: ViewLogoProps) {
|
||||
const { name, className } = props;
|
||||
|
||||
// we wrap the image in a div to help maintain the aspect ratio
|
||||
return (
|
||||
<div className={className}>
|
||||
<img alt='' src={`${projectLogoPath}/${name}`} className='viewLogo' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
height: 2.5rem;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import SwatchPicker from '../input/colour-input/SwatchPicker';
|
||||
|
||||
import style from './InlineColourPicker.module.scss';
|
||||
|
||||
interface InlineColourPickerProps {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ensureHex = (value: string) => {
|
||||
if (!value.startsWith('#')) {
|
||||
return `#${value}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<SwatchPicker color={colour} onChange={setColour} alwaysDisplayColor />
|
||||
<span>{colour}</span>
|
||||
<input type='hidden' name={name} value={colour} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
import { ParamField } from './types';
|
||||
|
||||
interface EditFormInputProps {
|
||||
@@ -25,8 +23,8 @@ interface EditFormInputProps {
|
||||
}
|
||||
|
||||
export default function ParamInput(props: EditFormInputProps) {
|
||||
const { paramField } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const { id, type, defaultValue } = paramField;
|
||||
|
||||
if (type === 'persist') {
|
||||
@@ -83,12 +81,6 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'colour') {
|
||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||
|
||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const { prefix, placeholder } = paramField;
|
||||
|
||||
@@ -109,26 +101,30 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||
{paramField.title}
|
||||
</MenuButton>
|
||||
<MenuList overflow='auto' maxHeight='200px'>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState}
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label, colour } = option;
|
||||
const { value, label } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
||||
<MenuItemOption value={value} key={value}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
|
||||
@@ -8,15 +8,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
.label {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
gap: 2rem;
|
||||
overflow-y: scroll;
|
||||
padding-right: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-bottom: 1rem;
|
||||
.section {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldSet {
|
||||
display: flex;
|
||||
padding: $section-spacing 0;
|
||||
flex-direction: column;
|
||||
gap: $element-spacing;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||