Compare commits

...

299 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen de8f9ef587 Merge pull request #1535 from cpvalente/v4
v4
2025-10-07 12:51:09 +02:00
arc-alex 217402050d fix merge issue 2025-10-07 12:44:06 +02:00
Alex Christoffer Rasmussen de75d55849 Merge branch 'master' into v4 2025-10-07 12:39:06 +02:00
Alex Christoffer Rasmussen c4e6215b29 Update test time until (#1809)
* test views

test timeline

* timeline show seconds when less than 2m

* fix: countown typings

* refactor: backstage schedule item

* chore: lint
2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 57315595a5 All views follow offset mode (#1808)
* chore: remove unused

* fix: studio view follow offset mode
2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen fc7ed6bd20 clean turbo config and cache github actions (#1805)
* refactor: turbo scripts

refactor dev and build scripts

remove cleanup

* fix e2e script

* cache playwright

* cache pnpm

* fix docker env in vite

* fixup! refactor: turbo scripts

* chore: add comment
2025-10-07 12:32:12 +02:00
jwetzell 750927dc8d chore: switch workspaces to use pnpm to setup node with useNodeVersion value 2025-10-07 12:32:12 +02:00
Carlos Valente 00d2ae0a7c refactor: clarify settings section name 2025-10-07 12:32:12 +02:00
Carlos Valente 56c4d68bae refactor: use coffee icon for keep awake feature 2025-10-07 12:32:12 +02:00
Carlos Valente a02cdf71c0 fix: maintain offset on roll takeover 2025-10-07 12:32:12 +02:00
Carlos Valente 25030debe4 refactor: error page sends email 2025-10-07 12:32:12 +02:00
Carlos Valente 27243c4432 chore: upgrade electron packages 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 22559c59d4 Expected time for views (#1798)
* refactor: use correct metadata in op view

* refactor: use correct metadata in backstage view

* remove uneeded test

* fix: correct offset colour

* refactor: use correct metadata in timline view

* refactor: schedule item dont pass the whole event

* chore: lint
2025-10-07 12:32:12 +02:00
Carlos Valente 700948bfbb fix: use correct order when creating groups 2025-10-07 12:32:12 +02:00
Carlos Valente 960a39fbf4 refactor: cleanup dragging cursor indicator 2025-10-07 12:32:12 +02:00
Carlos Valente d986f438d9 refactor: improve delay component 2025-10-07 12:32:12 +02:00
Carlos Valente c2012bd9e3 refactor: improve studio clock responsiveness 2025-10-07 12:32:12 +02:00
Carlos Valente 1f0f92371f fix: show secondary timer only if active 2025-10-07 12:32:12 +02:00
Carlos Valente 4b81725e80 chore: upgrade base-ui 2025-10-07 12:32:12 +02:00
Carlos Valente 846eaf569d refactor: remove project CSV export option 2025-10-07 12:32:12 +02:00
Carlos Valente ccf1ddf2fd fix: conflict between swatch picker and swatch elements 2025-10-07 12:32:12 +02:00
Carlos Valente 81e00993f2 fix: prevent rolling into events of 0 duration 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen e22c7698c3 fix: alpha bg (#1799) 2025-10-07 12:32:12 +02:00
arc-alex ef053fc2f7 bump to v4.0.0-beta.5 2025-10-07 12:32:12 +02:00
Alex Christoffer Rasmussen 3b6d3a4631 feat: Keep screen awake (#1788)
* feat keepAwake hook

* reaqure lock after view change

* refactor: move it to a navigation view setting

* save to search params
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 31c3e603ed refactor: better type for api aux action (#1796) 2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 1dfe5c980d refactor: collect common tsconfig (#1794)
* refactor: collect common tsconfig settings

* chore: module and target should be lower case
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 80e4dc0941 Fix: rundown import (#1795)
* fix: patch in the active rundown id and title

* fix: send a ALL refetch on project patch

* fix: load the last used rundown on project patch and not just the first one

* chore: lint

* chore: clean up and let rundown service send the refetch request

* chore: dont destructure rundown
2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen bc0b83aa14 remove private field from package (#1793) 2025-09-26 21:34:01 +02:00
Alex Christoffer Rasmussen 971c6c66f0 Chore: Small todo about coerce boolean (#1789)
* fix: coerce boolean types negative number is false

* strict coerce
2025-09-26 21:34:01 +02:00
Carlos Valente 343697dc93 refactor: remove unused dependencies 2025-09-26 21:34:01 +02:00
Carlos Valente 4058231d6b refactor: remove unused code 2025-09-26 21:34:01 +02:00
Carlos Valente c5ff254267 refactor: prevent upload logo errors from being stuck 2025-09-26 21:34:01 +02:00
Carlos Valente ac14cb9624 refactor: increase file size limits 2025-09-26 21:34:01 +02:00
Carlos Valente bcabc50ad1 fix: check automation usage in all rundowns before allowing delete 2025-09-26 21:34:01 +02:00
Carlos Valente cbd7bce106 bump version to 4.0.0-beta.4 2025-09-26 21:34:01 +02:00
Carlos Valente 00aa8a09fa refactor(roll breaking): make starting with roll backtrace planned times 2025-09-21 07:42:47 +02:00
Carlos Valente d04f958c11 fix: initialise group time 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 5716fc31cf feat: export package to help api consumers (#1624) 2025-09-21 07:42:47 +02:00
Carlos Valente a5d628af0c fix: prevent params drawer from being larger than screen 2025-09-21 07:42:47 +02:00
Carlos Valente 111a1f95ba fix: correct offset direction in expected time calculations 2025-09-21 07:42:47 +02:00
Carlos Valente 4bf74d86b3 refactor: set start metadata when starting from roll 2025-09-21 07:42:47 +02:00
arc-alex d2638d64e4 fix: group end elements should only send timertype data nothing else 2025-09-21 07:42:47 +02:00
arc-alex 02cddeec4d refactor: add a try grourd to the cell data generator to better pinpoint errors 2025-09-21 07:42:47 +02:00
Carlos Valente a8fe8b5f1e refactor: allow filtering schedule 2025-09-21 07:42:47 +02:00
Carlos Valente 8d2db7ffcd Relative mode (#1764)
* feat: add group start time to state

* refactor: calculate expected times when offset mode changes

* feat: show relative data in ui

* fixup! feat: add group start time to state

* fixup! refactor: calculate expected times when offset mode changes

* fixup! feat: show relative data in ui

* try to handle multiday

* show planed/expected rundown end day offset values

* refactor: style tweaks to timers

* refactor: cleanup data use

* day offset

* update tests

* test getExpectedStart multiday

* write start epoch to restore file

* current day in relative mode

* remove todo

* move find day offset to utils

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen 313590905f fix docker ignore (#1780) 2025-09-21 07:42:47 +02:00
Carlos Valente 7bbdab7dd9 fix(cuesheet): infinite render loop on resizing columns 2025-09-21 07:42:47 +02:00
Carlos Valente 304832ecde fix: maintain multiline in cuesheet cells 2025-09-21 07:42:47 +02:00
Carlos Valente 0ab4abfcfe docs: add contribution guidelines 2025-09-21 07:42:47 +02:00
Carlos Valente 1847822f31 fix: skip nested events 2025-09-21 07:42:47 +02:00
Carlos Valente 4499495212 feat: allow finding milestones 2025-09-21 07:42:47 +02:00
Carlos Valente 0035070db8 refactor: improve automated following 2025-09-21 07:42:47 +02:00
Carlos Valente 890a97d071 fix: allow moving a group after another 2025-09-21 07:42:47 +02:00
Carlos Valente 5441e5b07f refactor: improve pin styling 2025-09-21 07:42:47 +02:00
Carlos Valente ab4b94f2d8 fix: prevent layout reflow on different entry types 2025-09-21 07:42:47 +02:00
Carlos Valente fd7ccb4a5b refactor: add default value to secondary source 2025-09-21 07:42:47 +02:00
Carlos Valente 2e6bf234d5 fix: phase style overrides in timer 2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen c934b0ecdd cleanup (#1776)
* chore: cleanup leftover console log

* chore: prevent error in client tsconfig
2025-09-21 07:42:47 +02:00
Alex Christoffer Rasmussen cd6d6f24eb fix: sheet default import map (#1775)
* fix: default import map

* fix: update test
2025-09-21 07:42:47 +02:00
arc-alex e632a08557 chore: bump version 2025-09-21 07:42:47 +02:00
Carlos Valente 0057b4a3f1 refactor: tweaks to countdown styles 2025-09-08 06:23:39 +02:00
Carlos Valente ac8c1e333e bump version to 4.0.0-beta.2 2025-09-08 06:09:31 +02:00
Carlos Valente 321297b685 fix: redirect to preset 2025-09-08 06:09:00 +02:00
Alex Christoffer Rasmussen 67e67d6a5d feat: propper timeuntil calc in countdown view (#1765)
* fix error with is linked to loaded metadata

* pass down metadata

* create expected schedule

* expected start in sub info

* for singel subscribtions

* refactor

* add color to single events

* refactor: clean up subscribtion data

* show schedule and secondray data for single events

* improve subscribtion data display
2025-09-07 21:49:45 +02:00
Carlos Valente 4385e0b9c9 refactor: ensure clocks timer and clock are in sync (#1772)
* refactor: ensure clocks timer and clock are in sync

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-09-07 21:49:45 +02:00
Carlos Valente 02243242f1 fix: redirect to preset 2025-09-07 21:49:45 +02:00
arc-alex 1af42b766d fix: insert group-end where needed 2025-09-07 21:49:45 +02:00
arc-alex 9d6ec3758b chore: clarifying comments 2025-09-07 21:49:45 +02:00
Carlos Valente 9e78ff4d1c refactor: export data for all entry types 2025-09-07 21:49:45 +02:00
Carlos Valente 2037aef080 fix: prevent clipping on bold font sizes 2025-09-07 21:49:45 +02:00
Carlos Valente 48b9cf042f refactor: lax validation for settings
with this changes we allow the client and server to share the same data
shape. even if some of it is useless.
we also get an extra version property in the payload, if we want to save
the transfer cost, we would rather remove it from the sent payload
2025-09-07 21:49:45 +02:00
Carlos Valente 1866e5e78d refactor: simplify restore point 2025-09-07 21:49:45 +02:00
Carlos Valente bc1b3800c8 refactor: op clock uses time format string 2025-09-07 21:49:45 +02:00
Carlos Valente a2505c838b refactor: add none option to optional select 2025-09-07 21:49:45 +02:00
Carlos Valente bffa863fb4 fix: multi select infinite loop 2025-09-07 21:49:45 +02:00
Carlos Valente d00d7677bc fix: merge virtuoso and rundown styles 2025-09-07 21:49:45 +02:00
Carlos Valente 3ae73be562 refactor: prevent emitting errors from user pages 2025-09-07 21:49:45 +02:00
Carlos Valente 763080cd4d fix: generate link for companion 2025-09-07 21:49:45 +02:00
Carlos Valente 6ff06b21a8 fix: hide fade out elements by default 2025-09-07 21:49:45 +02:00
Carlos Valente adebde1b43 chore: upgrade client packages 2025-09-07 21:49:45 +02:00
Carlos Valente 89922fe394 revert: fixed size for editor 2025-09-03 17:48:45 +02:00
Alex Christoffer Rasmussen f219ebb8f8 refactor: settings route (#1753)
* refactor: settings route
2025-09-03 17:48:45 +02:00
Carlos Valente eace97ec7a bump version to 4.0.0-alpha.6 2025-09-03 17:48:45 +02:00
Carlos Valente ec1df8b958 fix: bundle translations 2025-09-03 17:48:45 +02:00
Carlos Valente 8d2d8ef979 feat: add user defined translations (#1756)
* feat: add user defined translations

* add refetch key for translation (#1757)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 16:06:03 +02:00
Carlos Valente ae15f3cdc5 Alpha 5 (#1741)
* refactor: align header columns

* refactor: improve scrollbar visibility

* refactor: center align table elements

* refactor: make param elements stateful

* fix: issue with collapsed elements not loosing value

* fix: prevent search params containing multiple alias references

* fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744)

* refactor: disable group action for elements in groups

* refactor: move context menu items into the event element (#1747)

* feat: sheet import new features for v4 (#1730)

* import milestone

* fixup! import milestone

* test: milestone import

* add entries to group

stop on new group or on group-end type

* fixup! add entries to group

* cleanup

* add event target duration

* link start if undefined

* add skip import type

* extract some to the excel paresing functions

* tweaks to presentation

* move file

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* fix: notify runtimeStore of events bieng groupd

* fix: improve authentication and stage detection in demo

* chore: ship logo with project

* fix: client is referenced by name

* fix: prevent reflow in event editor

* fix: stale render on selected event due to ref mismatch

* Create/Load/Delete multiple rundowns (#1696)

* refactor: restore last loaded rundown

* refactor: initialise rundown in ProjectService

* feat: allow switching rundowns

ensure on coordination between the db object and the working object

server provide list of rundowns

implement switch in the UI

implement delete

implement new rundown button

* fix: render order for floating button

* refactor: appropriate names to service

* refactor: rundown endpoints

* refactor: save last loaded rundown ID

* refactor: rundown management UI

* refactor: emit refetch all on project load

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: recover single event subscription

* fixup! feat: sheet import new features for v4 (#1730)

* fix: prevent dropping a group inside another

* fixup! refactor: move context menu items into the event element (#1747)

* fix: prevent stale references to custom fields

* fix: propagate updates to all rundowns

* refactor: client rundown metadata (#1728)

* generate metadata in the hook

* move test

* ensure there is always a last element

* use for-loop

* update metadata in useEfect

* fully extract metadata generation

* use direct assignment

* cleanup

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: small imporvement and tests for coerce functions (#1752)

* refactor: small imporvement and tests for coerce functions

add test `coerceString`

add test `coerceBoolean`

add test `coerceColour`

* remove old todo

* fix: consistent quick add behaviour

* refactor: create flat rundown with metadata

* fix: show add buttons on top

* feat: allow editing milestones

* refactor: style tweaks to rundown elements

refactor: milestones are full width

refactor: cuesheet header alignment

fix: editor styling in cuesheet

* refactor: virtualise table

* refactor: improve overscan (#1758)

* bump version to 4.0.0-alpha.5

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2025-09-03 15:50:20 +02:00
Carlos Valente 3c41b40c5f refactor: create fast tracks for cookie parsing 2025-08-18 22:00:16 +02:00
arc-alex 3b38756dda fix: use new token with scope 2025-08-18 22:00:16 +02:00
Carlos Valente 5a4494ba85 refactor: improve timers visibility 2025-08-18 22:00:16 +02:00
Carlos Valente 02ad852dd4 fix: external demo connects to hash 2025-08-18 22:00:16 +02:00
Carlos Valente 7ce1097826 fix: prevent reflow between entry editors 2025-08-18 22:00:16 +02:00
Carlos Valente 7ee0de5341 docs: improve development documentation 2025-08-18 22:00:16 +02:00
Carlos Valente 0db249871b bump version to 4.0.0-alpha.4 2025-08-18 22:00:16 +02:00
Carlos Valente 87df656470 docs: demo project is runtime data documentation 2025-08-18 06:16:53 +02:00
Carlos Valente 54f07c4330 refactor: simplify messages 2025-08-18 06:16:53 +02:00
Carlos Valente ae061cb69a bump version 4.0.0-alpha.3 2025-08-18 06:16:53 +02:00
google-labs-jules[bot] ba529def34 fix(client): use fresh state in row observer to prevent stale closures
The IntersectionObserver callback in rowObserver.ts was capturing the `addVisibleRow` and `removeVisibleRow` actions from the Zustand store only once, at the time of the observer's creation.

This created a potential for a stale closure bug, where the observer would call outdated action functions if the store's state or actions were ever re-initialized. This would lead to the application's state not being updated correctly, causing the bug where visible rows would not render their content.

This commit fixes the issue by calling `useVisibleRowsStore.getState()` inside the observer callback. This ensures that the latest, freshest versions of the action functions are always used, preventing the stale state bug.
2025-08-18 06:16:53 +02:00
Carlos Valente 210a85c463 fix: bug with default field url 2025-08-18 06:16:53 +02:00
Carlos Valente 3ad3ad088f ux: improve warning message in partial merge 2025-08-18 06:16:53 +02:00
Carlos Valente 617da68ab5 fix: storage key conflict across stages 2025-08-18 06:16:53 +02:00
Carlos Valente efceb930e5 refactor: improve navigation and settings locks 2025-08-18 06:16:53 +02:00
Carlos Valente 836fa52757 fix: small style tweaks and bug fixes
fix: remove interactive styles from display elements

fix: automation form reset on submit

fix: prevent empty image src
2025-08-18 06:16:53 +02:00
Carlos Valente 902edf4a85 fix: ensure filename sanitation in all routes 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 0e770655dc Fix: still counting to un-flaged event (#1726)
* create failing test

* ensuere all values a reset before calculations
2025-08-18 06:16:53 +02:00
arc-alex db922a67e2 chore: cleanup 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 048a9c96ea Refactor: split runtime state (#1725)
* refactor: runtime types

* refactor: runtimeState functions to use the split type

* refactor: RuntimeService to use the new split data

* refactor: use the split data in the UI

* update comments

* refactor: rename runtime to offset

* fix utils test
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen ed93cf313a fix: move playing event into group (#1723)
* fix: moving paying event into group

* add comment
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 97a0e17192 feat: abort partial load if there is a version missmatch (#1724)
* abort partial load on version missmatch

* reword comment

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 022a58e142 refactor: runtime service (#1722)
* refactor: trim type

* refactor: update runtime service data push logic

* keep using just deep

* reword didDependencyUpdate

* remove dulicate
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen e0149c1cbf Invert overtime (#1715)
* refactor: invert offset calculation

* chore: update tests

* chore: flip offset in UI

* fix seconds display on group target duration

* update comment

* fix rebase
2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 4665f9c2f7 fix: partial load (#1720) 2025-08-18 06:16:53 +02:00
Carlos Valente 9ab6b30aea refactor: small code improvements 2025-08-18 06:16:53 +02:00
Carlos Valente e5d2457717 chore: rename blocks to groups 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 486d89ecf4 refactor: remove finishedAt from runtime data (#1716) 2025-08-18 06:16:53 +02:00
Carlos Valente 94b1f63c7c refactor: remove onAir 2025-08-18 06:16:53 +02:00
Alex Christoffer Rasmussen 0299463f62 fix: ensure the restore point is the first thing loaded (#1713) 2025-08-18 06:16:53 +02:00
Carlos Valente f7ccb6bb51 chore: upgrade packages 2025-08-18 06:16:53 +02:00
Carlos Valente 6c6f5c2c0f feat: cuesheet sharing
feat: locked presets

refactor: simplify locked param

refactor: create share links
2025-08-18 06:16:53 +02:00
Carlos Valente 1695b4dc68 refactor: add presets to refetch list 2025-08-18 06:16:53 +02:00
Carlos Valente 1ed08df198 refactor: extract project info data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente c300fc18e1 refactor: extract timeline data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente bea7e2bb2b refactor: extract studio data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente ad7f658f15 refactor: extract backstage data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente f23adfa45c refactor: extract countdown data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente 20ee391525 refactor: extract timer data to hook 2025-08-18 06:16:53 +02:00
Carlos Valente 78184b23b7 refactor: simplify routing 2025-08-18 06:16:53 +02:00
Carlos Valente e204f671be refactor: small code improvements
refactor: remove unused code

refactor: extract loader component

refactor: prevent keyword collision

refactor: cleanup hook pending states
2025-08-18 06:16:53 +02:00
Carlos Valente 8c90f5043f chore: upgrade react router 2025-08-18 06:16:53 +02:00
Carlos Valente 7a322406da chore: directory restructure 2025-08-18 06:16:53 +02:00
Carlos Valente 307f0ffd34 feat: expose presets in view options 2025-08-18 06:16:53 +02:00
Carlos Valente 5f8a5c4995 refactor: small style tweaks 2025-08-18 06:16:53 +02:00
Carlos Valente 3747dbc1a8 refactor: style and ux tweaks 2025-08-18 06:16:53 +02:00
Carlos Valente 20859abda9 chore: add demo images to github 2025-08-09 07:01:01 +02:00
Alex Christoffer Rasmussen 9ebb3decdb V3 project migration (#1652)
* migrate settings

* migrate viewsettings

* migrate url preset

* migrate project data

* migrate custom fields

* migrate automations

* migrate rundown

* lint

* return custom field translation table

* check regex

* new url type

* refactor

* migrate old OSC subscriptions

* migrate old http subscriptions

* update comments

* migrate the whole db

* remove automation logging unlis there is an error

* return a new object

* refactor: copy currup is only used in one place

* make a copy of original migrated file

* refactor: ensure image flder is part of project service init

* add entrys to groups

* small cleanup

* just drop incorrect custom fields in the default data parser
2025-08-07 15:48:58 +02:00
Alex Christoffer Rasmussen 35347e8d63 Feat: Time until group and flag (#1708)
* refactor: split event data and state data

* refactor: calculate the start time insted of until

* cleanup

* send next flag expected start from server

* refactor: render

* fix test

* use same icon

* refactor: group duration

* Optimize (#1711)

* refactor: consolidate block, flag and end loading and expected times

* work on test

* cal end value

* lint

* fix test

* remove todo
2025-08-06 21:55:28 +02:00
Carlos Valente e37d2ce50f refactor: tweaks to timeline styles 2025-08-06 21:40:19 +02:00
Carlos Valente 49e923026e feat: expose planned start in operator 2025-08-06 21:40:19 +02:00
Carlos Valente f1a8db9649 feat: timer overview shows waiting timer 2025-08-06 21:40:19 +02:00
Carlos Valente dddeefd544 chore: rename projected > expected 2025-08-06 21:40:19 +02:00
Carlos Valente b4aaa8966f fix: insert into group 2025-08-06 21:40:19 +02:00
Carlos Valente ad9db4edd3 chore: add demo project 2025-08-06 21:40:19 +02:00
Carlos Valente fef9ed4fcc chore: update icon assets 2025-08-06 21:40:19 +02:00
Carlos Valente 9ec47eda6e feat: expose block target duration in header 2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen 32edf3404c Fix tag ws api (#1709)
* fix: ws api should also reten tag and not type

* fix: when starting by cue try finding the next first then check before
2025-08-06 21:40:19 +02:00
Alex Christoffer Rasmussen bcf80f9d77 chore: rename offset to offsetAbs and offsetRel (#1706) 2025-08-06 21:40:19 +02:00
Carlos Valente df137402cf bump version to 4.0.0-alpha.2 2025-08-06 21:40:19 +02:00
Carlos Valente daddb7f7b7 fix: open group when selecting from finder 2025-08-06 21:40:19 +02:00
Carlos Valente 82e8b9d66a fix: cleanup behaviour for rundown add buttons 2025-08-06 21:40:19 +02:00
Carlos Valente 765d5d97cf refactor: readonly inputs in cuesheet 2025-08-06 21:40:19 +02:00
Carlos Valente ed4d3ba265 refactor: remove toggle link context menu action 2025-08-06 21:40:19 +02:00
Carlos Valente a358ec0f17 refactor: small style tweaks
refactor: consistent font size on inputs

refactor: style tweaks for block spacing

refactor: improve skipped styles

fix: improve responsiveness in extracted rundown

refactor: style tweaks to overview

refactor: simplify group target duration
2025-08-06 21:40:19 +02:00
Carlos Valente 524721002a fix: prevent nesting groups 2025-08-06 21:40:19 +02:00
Carlos Valente 9ab54edbf5 refactor: small code cleanups
- remove compiler directives
2025-08-06 21:40:19 +02:00
Carlos Valente 8a7f8c8ebc refactor: small style tweaks
- cursor styles on dnd
2025-08-06 21:40:19 +02:00
Carlos Valente 3ce539af2a fix: rendering if countdown in safari 2025-08-06 21:40:19 +02:00
Carlos Valente 3158311aa1 docs: align readme with documentation 2025-07-26 10:51:00 +02:00
Carlos Valente 6b4d96dddd feat: extra info in backstage 2025-07-26 10:51:00 +02:00
Carlos Valente dc3a32d2bc fix: custom project data 2025-07-26 10:51:00 +02:00
Carlos Valente bda01c886e fix: mirroring 2025-07-26 10:51:00 +02:00
Carlos Valente 93916653be refactor: tweaks to styles
- cuesheet action is ghosted
- mute index in cuesheet milestones
- clarify mirrored state in navigation menu
- align overview contents
2025-07-26 10:51:00 +02:00
Carlos Valente 40f8976350 fix: custom fields are last in order 2025-07-26 10:51:00 +02:00
Carlos Valente 78e905a735 fix: flag cannot be the current event 2025-07-26 10:51:00 +02:00
Carlos Valente 126ff61738 refactor: milestone actions in context menu 2025-07-26 10:51:00 +02:00
Carlos Valente 1e63238cac fix: allow empty string as a cue value 2025-07-26 10:51:00 +02:00
Carlos Valente 6ffe34a642 bump version to 4.0.0-alpha.1 2025-07-26 10:51:00 +02:00
Carlos Valente 69f276765b fix: delay indicator styling 2025-07-26 10:51:00 +02:00
Carlos Valente 75554071a7 refactor: improve params performance 2025-07-26 10:51:00 +02:00
Carlos Valente a1fab10bfd feat: improve preset building 2025-07-26 10:51:00 +02:00
Carlos Valente 5bc286145d refactor: extract regex utilities 2025-07-26 10:51:00 +02:00
Carlos Valente 44508ce8b4 refactor: block writing to base fields in run mode 2025-07-26 10:51:00 +02:00
Carlos Valente 90105a199a refactor: empty state for cuesheet 2025-07-26 10:51:00 +02:00
Carlos Valente 9618bec847 refactor: prevent reflow on visible rows 2025-07-26 10:51:00 +02:00
Carlos Valente 66a4e27fdd refactor: remove unused data 2025-07-26 10:51:00 +02:00
Carlos Valente ef906f43d7 docs: improve feature description 2025-07-26 10:51:00 +02:00
Carlos Valente 93b2a1aece refactor: index test alignment 2025-07-26 10:51:00 +02:00
Carlos Valente 050629d281 fix: stale column data 2025-07-26 10:51:00 +02:00
Carlos Valente abe9012f20 fix: keyboard shortcuts in rundown 2025-07-26 10:51:00 +02:00
Carlos Valente ad2ef3b53f refactor: small fixes and style tweaks
fix: prevent reflow on editing images

fix: schedule overview

fix: report on all events

refactor: over-under time colours

fix: prevent edit on delete
2025-07-14 16:08:40 +02:00
Carlos Valente 1e1f547ce1 breaking: convert offset > over-under
change offset direction and relabel to over-under
2025-07-14 11:39:16 +02:00
Carlos Valente 92a34cf135 fix: group expected end 2025-07-14 11:39:16 +02:00
Carlos Valente 1e5081f4a2 fix: parse nested events in rundown 2025-07-14 11:39:16 +02:00
Carlos Valente 6f9e13948d refactor: timeline design review 2025-07-14 11:39:16 +02:00
Carlos Valente 55e34b5f9a fix: guard corrupted parent on delete 2025-07-14 11:39:16 +02:00
Carlos Valente dd47d2b7e7 refactor: always reload on changes 2025-07-14 11:39:16 +02:00
Carlos Valente 445cc9e845 feat: time to next flag 2025-07-14 11:39:16 +02:00
Carlos Valente 637454f73d feat: add flag property 2025-07-14 11:38:27 +02:00
Alex Christoffer Rasmussen e9dda817e5 block end calculation (#1683) 2025-07-14 11:38:27 +02:00
Carlos Valente e811a82be2 refactor: make minimal and clock as presets 2025-07-14 11:38:27 +02:00
Carlos Valente fd9bf0120c refactor: disable react compiler 2025-07-14 11:38:27 +02:00
Carlos Valente e5f8a99bff refactor: extract sentry 2025-07-13 12:33:50 +02:00
Carlos Valente 7305edc398 refactor: review timer styles 2025-07-13 12:33:50 +02:00
Carlos Valente a69c5f354c react 19 type migration 2025-07-13 12:33:50 +02:00
Carlos Valente 0eaec1f88f refactor: timeline style tweaks 2025-07-13 12:33:50 +02:00
google-labs-jules[bot] cdd021e76d Docs: Update DEVELOPMENT.MD with explicit turbo run commands
Updated DEVELOPMENT.MD to use `turbo run <task>` instead of `turbo <task>` for clarity and consistency when referring to Turborepo tasks that don't have a direct pnpm script alias.

Also, added `dependsOn: ["build"]` to `dist-*` tasks in `turbo.json` to ensure correct build order before packaging.
2025-07-12 22:12:17 +02:00
Carlos Valente 05d01e0863 refactor: timer design review 2025-07-12 18:41:17 +02:00
Carlos Valente 2aac4cb06d refactor: remove deprecated views 2025-07-12 18:41:17 +02:00
Carlos Valente 1c6adbd2fd refactor: upgrade react and add compiler 2025-07-12 18:41:17 +02:00
Carlos Valente d24d5c1365 refactor: freeze is view option 2025-07-12 18:41:17 +02:00
Carlos Valente 138c6523d2 feat: implement custom project data image 2025-07-12 18:41:17 +02:00
Carlos Valente d8c1c38123 refactor: fixes to custom field 2025-07-12 18:41:17 +02:00
Carlos Valente 4d419c0877 refactor: style tweaks and bug fixes
- italic and bold for rundown metadata entries
- allow deleting groups
- make groups in order
- correct style for milestones
- open playing groups
- style tweaks
2025-07-12 18:41:17 +02:00
Carlos Valente 50b603f668 create release 4.0.0-alpha.0 2025-07-11 19:46:25 +02:00
Carlos Valente e1e8410ba4 refactor: migrate UI components and remove chakra
migrate pin page
migrate copy tag
migrate pin input
migrate dropdown menus
migrate radio buttons
migrate switch
migrate select
migrate textarea
migrate colour picker
migrate select
migrate context menu
migrate viewparams
remove chakra
2025-07-11 08:43:38 +02:00
Alex Christoffer Rasmussen 4d359445a7 Fix ontime aux actions (#1684)
* switch to base ui

* ensure all action types is part of the valitation

* update test

* atempt to do it in the base-ui way

* fix fixing

* allow undefide visible on set timer message

* fix set time not showing

* correct time data for aux set automation

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2025-07-09 13:14:23 +02:00
Carlos Valente 8d68a4554d refactor: deprecate languages 2025-07-08 20:14:49 +02:00
Carlos Valente 6f34e1006b refactor: migrate project data 2025-07-08 20:14:49 +02:00
Carlos Valente 1fc6f9b3ea refactor: inline quick add 2025-07-08 16:34:23 +02:00
Carlos Valente a1ad1d47a4 refactor: review action menu 2025-07-08 16:34:23 +02:00
Carlos Valente 9281c0b51d refactor: update app menu links 2025-07-08 16:34:23 +02:00
Carlos Valente 4365c19498 refactor: review event entry 2025-07-08 16:34:23 +02:00
Carlos Valente 8728c1450b refactor: rename block > group in UI elements 2025-07-08 16:34:23 +02:00
Carlos Valente a98c14406d refactor: remove isNextDay block property 2025-07-08 16:34:23 +02:00
Alex Christoffer Rasmussen ab8bfbcd59 handle rearange in block (#1682) 2025-07-07 23:15:37 +02:00
Carlos Valente f2913971db feat: milestones 2025-07-07 18:22:10 +02:00
Carlos Valente a7ae8598db feat: add group metadata to overview 2025-07-07 18:22:10 +02:00
Carlos Valente 17a7d035bf refactor: style cleanups and migrations
fix: close modal with button
refactor: small type improvements
refactor: migrate time inputs
refactor: migrate tooltips
refactor: prevent component resizing
2025-07-07 18:22:10 +02:00
Carlos Valente 6facd6a666 refactor: improve editor and cuesheet headers
- consistent behaviour for app mode
- improve navigation
2025-07-07 18:22:10 +02:00
Carlos Valente 79bcc179ba refactor: style tweaks and ux improvements 2025-07-07 18:22:10 +02:00
Carlos Valente 3790681bec fix: reordering out of a block 2025-07-07 18:22:10 +02:00
Carlos Valente c32203c759 fix: search params editor conflicts with alias 2025-07-07 18:22:10 +02:00
Carlos Valente ce449408f0 Chore/remove got dependency (#1667)
* Refactor: Remove GOT dependency, use simplified fetch wrapper

Replaced GOT with a lightweight, functional, fetch-based utility (`appClient.ts`) for handling JSON POST requests in `SheetService.ts`.

- Created `appClient.ts` to encapsulate fetch logic. It mimics GOT's behavior for sending JSON payloads and parsing JSON responses.
- Error handling in `appClient.ts` uses standard `Error` objects with descriptive messages, including status and error body details, instead of custom error types.
- Updated `SheetService.ts` to use the new `appClient.ts`.
- Removed `got` from server dependencies.

* cleanup

* dont throw when we know the error

* rearange logging

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2025-07-07 15:54:26 +02:00
Carlos Valente 5600235ba1 refactor: restructure settings
refactor: migrate react components
2025-07-04 15:32:33 +02:00
Carlos Valente a8ea1080f3 refactor: migrate modal components 2025-07-04 15:24:14 +02:00
Carlos Valente 36223ff49f fix: resizing columns in cuesheet 2025-07-04 08:55:46 +02:00
Carlos Valente 3ce5b42e05 refactor: prepare rundown loading process 2025-07-04 08:55:46 +02:00
Carlos Valente c6c1e4a5d7 feat: share link from cuesheet 2025-07-04 08:55:46 +02:00
Carlos Valente 84b73fc02a refactor: migrate related components 2025-07-04 08:55:46 +02:00
google-labs-jules[bot] cd8e014f08 refactor: optimize cuesheet IntersectionObserver
Decouples the IntersectionObserver from the React component tree to
prevent
unnecessary re-renders of the entire Cuesheet table when row visibility
changes. Restores rootMargin for better UX and memoizes EventRow.

- Introduced `rowObserver.ts` to manage a global IntersectionObserver.
  - Observer callback directly updates `useVisibleRowsStore`.
  - Configured with `rootMargin: '400px 0px'` and `threshold: 0.01`
    to pre-load and retain rows near the viewport, improving UX.
- `EventRow` components use `observeRow`/`unobserveRow` from
`rowObserver.ts`.
- `EventRow` is now wrapped in `React.memo` to prevent re-renders if
props
  haven't changed when its parent re-renders.
- Removed observer creation and prop-drilling from `CuesheetBody.tsx`.

This ensures only individual rows re-render content based on visibility
and optimizes row component rendering, improving performance and UX.
2025-07-03 08:46:21 +02:00
Carlos Valente 2e9ee698e9 fix: rundown shortcuts 2025-07-03 08:46:21 +02:00
Carlos Valente d06b5af538 refactor: upgrade base-ui 2025-07-03 08:46:21 +02:00
Carlos Valente 24a3823d3b refactor: entry actions in cuesheet
fix: insert entry before
fix: avoid double submit on enter
fix: move events in the rundown
fix: clone groups
2025-07-03 08:46:21 +02:00
Carlos Valente 8ad260d28a refactor: cuesheet design review
fix: parsing of custom fields for blocks
refactor: extract cuesheet settings
refactor: cuesheet actions
refactor: improve cuesheet performance on resizing
2025-07-03 08:46:21 +02:00
Carlos Valente 0726c04f20 refactor: align property names between block and event 2025-07-03 08:46:21 +02:00
Alex Christoffer Rasmussen 1025347c08 refactor: add version to session endpoint (#1659) 2025-06-23 20:42:53 +02:00
Carlos Valente 43988ea12b fix: stale ids in cuesheet (#1657) 2025-06-23 19:56:53 +02:00
Alex Christoffer Rasmussen 722e045b20 Revert "Session version" (#1649)
* Revert "refactor: add version to session endpoint"

This reverts commit cd40a6e55e.

* Revert "Remove public event feature (#1645)"

This reverts commit 08d9e24871.

* Revert "Fix: rearrange playing event (#1640)"

This reverts commit 0649678dca.

* Revert "refactor: style tweaks to edit css modal"

This reverts commit a00ec2d02a.

* Revert "refactor: remove usages of framer-motion"

This reverts commit 54a74ccc2a.

* Revert "Upgrade expressjs (#1633)"

This reverts commit 6f3ab274bd.

* Revert "Refactor: require trigger in all events objects (#1636)"

This reverts commit 90870ecfb6.

* Revert "Fix: Correct boundary condition in applyDelay"

This reverts commit b640e0e181.

* Revert "let vite be the proxy to the dev server (#1630)"

This reverts commit c41fe824cf.

* Revert "refactor: migrate custom fields to transactions"

This reverts commit 62c8319d70.

* Revert "refactor: simplify validations"

This reverts commit b1d23467a2.

* Revert "refactor: create transaction system and apply to adding entry (#1620)"

This reverts commit 9a62daf047.

* Revert "Refactor: better rounding (#1594)"

This reverts commit b9ab1c6fd7.

* Revert "refactor: improve reorder logic"

This reverts commit c1054711b0.

* Revert "chore: simplify URLs"

This reverts commit a4d4f29a37.

* Revert "refactor: order is single source of truth"

This reverts commit 2793aadea0.

* Revert "refactor: refetch targets is enum"

This reverts commit e7cfb7d9d9.

* Revert "refactor: small ux improvements"

This reverts commit 256a851c9b.

* Revert "refactor: remove trivially inferred numEvents"

This reverts commit 4ab9c81cb8.

* Revert "feat: duplicate groups"

This reverts commit 2f13d6c89e.

* Revert "feat: create group from entry selection"

This reverts commit f1f7bad25e.

* Revert "feat: create block from rundown empty"

This reverts commit a8b52a48f7.

* Revert "fix: collapsed blocks dont render children"

This reverts commit cd0999b2ab.

* Revert "refactor: type cleanup and test improvements"

This reverts commit b4c60f3f04.

* Revert "feat: allow dissolving a block"

This reverts commit 5cefad3666.

* Revert "fix: uncontrolled prop on controlled component"

This reverts commit 7a6ecd8c34.

* Revert "refactor: improve return of reorder"

This reverts commit ba96ecfd91.

* Revert "refactor: mutations on batch elements must have IDs"

This reverts commit 7bed3757f2.

* Revert "chore: upgrade dependencies"

This reverts commit e2e755b1d2.

* Revert "refactor: extract utility to merge two arrays"

This reverts commit a77d23109d.

* Revert "refactor: change network mode defaults"

This reverts commit 0eb3b8d382.

* Revert "fix: delete nested events"

This reverts commit 0021185288.

* Revert "fix: add event at end of block"

This reverts commit 94c72ff4f6.

* Revert "refactor: make finder available in exported rundown"

This reverts commit e4c08dc9b2.

* Revert "assert non null and update test (#1604)"

This reverts commit ec74af0d62.

* Revert "Fix project renumber (#1597)"

This reverts commit b6d72dd082.

* Revert "Refactor: WebSocket from flush queue to one patch (#1595)"

This reverts commit 31c311daf0.

* Revert "Refactor: ms for api calls (#1593)"

This reverts commit e9b3cc6090.

* Revert "fix test (#1601)"

This reverts commit 543b04a097.

* Revert "fix: rebase master"

This reverts commit d39b85b6e6.

* Revert "chore: correct test path"

This reverts commit bbe107bb2b.

* Revert "refactor: extract rundown parsing"

This reverts commit c616240db1.

* Revert "chore: improve convention entry <> event"

This reverts commit 166be66ce3.

* Revert "refactor: maintain flat orders"

This reverts commit 4180d0a337.

* Revert "refactor: implement operations on nested events"

This reverts commit 78108e316c.

* Revert "refactor: process events in rundown"

This reverts commit 3bb8b70915.

* Revert "chore: improve convention entry <> event"

This reverts commit 1c4f13a0ed.

* Revert "chore: rename currentBlock > parent"

This reverts commit 3ca0abad53.

* Revert "refactor: fix delay positioning in gaps"

This reverts commit 030c8f897f.

* Revert "refactor(e2e): skip flaky test"

This reverts commit 68175cfa3b.

* Revert "refactor: improve project loading"

This reverts commit fd8f757851.

* Revert "refactor: gather group metadata"

This reverts commit 876d111c61.

* Revert "refactor: swap maintains schedule"

This reverts commit 730cb95c04.

* Revert "chore: rename files"

This reverts commit 3c388d4fb5.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 2e23718d73.

* Revert "refactor: clearer relationship on rundown elements"

This reverts commit 89ea8c470b.

* Revert "refactor: use strict typing"

This reverts commit 178640bfc4.

* Revert "refactor: remove stop as a possible end action"

This reverts commit 4ed38340e0.

* Revert "refactor: restructure model to contain an object of rundowns"

This reverts commit 69eb9a5eff.

* Revert "chore: remove IDE files"

This reverts commit 351425127a.

* Revert "refactor: remove unused and legacy code"

This reverts commit 95f2ba37cc.
2025-06-20 12:28:20 +02:00
Carlos Valente ad6804019b bump version to 3.16.0 2025-06-20 07:41:39 +02:00
Carlos Valente cd40a6e55e refactor: add version to session endpoint 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 08d9e24871 Remove public event feature (#1645) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 0649678dca Fix: rearrange playing event (#1640)
* creat failing test

* find and pass index of eventNow in case it moved

* fix cache invalidate edits should also recalculate timer

* use coluor as test indicator

* fixed empty cue guard

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] a00ec2d02a refactor: style tweaks to edit css modal 2025-06-19 18:53:28 +02:00
google-labs-jules[bot] 54a74ccc2a refactor: remove usages of framer-motion 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 6f3ab274bd Upgrade expressjs (#1633)
* upgrade expressjs

* migration

* reenable test

* extend timeout on download test

* fixup! migration

* move empty body test from controller to validator

* enusre not empty

* extract validation function

* fixup! reenable test

* remove thin controllers

* disable e2e test of project file download
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 90870ecfb6 Refactor: require trigger in all events objects (#1636)
* reqire trigger in all events

* use structuredClone when cloning an event
2025-06-19 18:53:28 +02:00
google-labs-jules[bot] b640e0e181 Fix: Correct boundary condition in applyDelay
The `applyDelay` function had a condition that incorrectly used `rundown.order.length` instead of `rundownMetadata.flatEntryOrder.length` to check if a delay entry was the last in the sequence. `rundown.order` only contains top-level entries, while `flatEntryOrder` contains all entries, including those within blocks, which is the relevant list for this check.

This commit corrects the condition to use `rundownMetadata.flatEntryOrder.length`.

Existing tests in `rundown.dao.test.ts` (specifically the test `removes delays in last position without applying changes`) already cover this scenario and pass with the correction, ensuring the fix behaves as expected.
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen c41fe824cf let vite be the proxy to the dev server (#1630) 2025-06-19 18:53:28 +02:00
Carlos Valente 62c8319d70 refactor: migrate custom fields to transactions
refactor: extract functions to api domain

refactor: strict custom field parsing

refactor: remove rundown cache utilities

refactor: directory restructure
2025-06-19 18:53:28 +02:00
Carlos Valente b1d23467a2 refactor: simplify validations 2025-06-19 18:53:28 +02:00
Carlos Valente 9a62daf047 refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry

* refactor: migrate edit mutations to transaction

* refactor: migrate delete mutation to transaction

* refactor: migrate reorder mutation to transaction

* refactor: migrate apply delay to transaction

* refactor: migrate swapEvents to transaction

* refactor: migrate clone to transaction

* refactor: migrate group/ungroup transactions

* refactor: simplify mutations

* refactor: migrate getters

* chore: add tests to processRundown()
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b9ab1c6fd7 Refactor: better rounding (#1594) 2025-06-19 18:53:28 +02:00
Carlos Valente c1054711b0 refactor: improve reorder logic 2025-06-19 18:53:28 +02:00
Carlos Valente a4d4f29a37 chore: simplify URLs 2025-06-19 18:53:28 +02:00
Carlos Valente 2793aadea0 refactor: order is single source of truth 2025-06-19 18:53:28 +02:00
Carlos Valente e7cfb7d9d9 refactor: refetch targets is enum 2025-06-19 18:53:28 +02:00
Carlos Valente 256a851c9b refactor: small ux improvements
- rename dissolve > ungroup
- prevent ondrag when clicking
- add untitled as block title fallback
- move block action to context menu
2025-06-19 18:53:28 +02:00
Carlos Valente 4ab9c81cb8 refactor: remove trivially inferred numEvents 2025-06-19 18:53:28 +02:00
Carlos Valente 2f13d6c89e feat: duplicate groups 2025-06-19 18:53:28 +02:00
Carlos Valente f1f7bad25e feat: create group from entry selection 2025-06-19 18:53:28 +02:00
Carlos Valente a8b52a48f7 feat: create block from rundown empty 2025-06-19 18:53:28 +02:00
Carlos Valente cd0999b2ab fix: collapsed blocks dont render children 2025-06-19 18:53:28 +02:00
Carlos Valente b4c60f3f04 refactor: type cleanup and test improvements 2025-06-19 18:53:28 +02:00
Carlos Valente 5cefad3666 feat: allow dissolving a block 2025-06-19 18:53:28 +02:00
Carlos Valente 7a6ecd8c34 fix: uncontrolled prop on controlled component 2025-06-19 18:53:28 +02:00
Carlos Valente ba96ecfd91 refactor: improve return of reorder 2025-06-19 18:53:28 +02:00
Carlos Valente 7bed3757f2 refactor: mutations on batch elements must have IDs 2025-06-19 18:53:28 +02:00
Carlos Valente e2e755b1d2 chore: upgrade dependencies 2025-06-19 18:53:28 +02:00
Carlos Valente a77d23109d refactor: extract utility to merge two arrays 2025-06-19 18:53:28 +02:00
Carlos Valente 0eb3b8d382 refactor: change network mode defaults 2025-06-19 18:53:28 +02:00
Carlos Valente 0021185288 fix: delete nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 94c72ff4f6 fix: add event at end of block 2025-06-19 18:53:28 +02:00
Carlos Valente e4c08dc9b2 refactor: make finder available in exported rundown 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen ec74af0d62 assert non null and update test (#1604) 2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen b6d72dd082 Fix project renumber (#1597)
* fix: generateUniqueFileName

* loadProject should not generate new names

* update comments

* extract and test getProjectNumber

* spell

* finish jsdoc

* use getProjectNumber

* cleanup loadProject

* create a `incrementProjectNumber` function

* spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 31c311daf0 Refactor: WebSocket from flush queue to one patch (#1595)
* change flush to one patch

* remove unused types

* create batch

* merge patch into eventStore

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen e9b3cc6090 Refactor: ms for api calls (#1593)
* use ms for api calls

* change it in UI

* update comment

* rename variables

* refactor: addtime api logic
2025-06-19 18:53:28 +02:00
Alex Christoffer Rasmussen 543b04a097 fix test (#1601)
* fix test

* add comment
2025-06-19 18:53:28 +02:00
Carlos Valente d39b85b6e6 fix: rebase master 2025-06-19 18:53:28 +02:00
Carlos Valente bbe107bb2b chore: correct test path 2025-06-19 18:53:28 +02:00
Carlos Valente c616240db1 refactor: extract rundown parsing
refactor: implement groups in editor
2025-06-19 18:53:28 +02:00
Carlos Valente 166be66ce3 chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 4180d0a337 refactor: maintain flat orders 2025-06-19 18:53:28 +02:00
Carlos Valente 78108e316c refactor: implement operations on nested events 2025-06-19 18:53:28 +02:00
Carlos Valente 3bb8b70915 refactor: process events in rundown 2025-06-19 18:53:28 +02:00
Carlos Valente 1c4f13a0ed chore: improve convention entry <> event 2025-06-19 18:53:28 +02:00
Carlos Valente 3ca0abad53 chore: rename currentBlock > parent 2025-06-19 18:53:28 +02:00
Carlos Valente 030c8f897f refactor: fix delay positioning in gaps 2025-06-19 18:53:28 +02:00
Carlos Valente 68175cfa3b refactor(e2e): skip flaky test 2025-06-19 18:53:28 +02:00
Carlos Valente fd8f757851 refactor: improve project loading 2025-06-19 18:53:28 +02:00
Carlos Valente 876d111c61 refactor: gather group metadata 2025-06-19 18:53:28 +02:00
Carlos Valente 730cb95c04 refactor: swap maintains schedule 2025-06-19 18:53:28 +02:00
Carlos Valente 3c388d4fb5 chore: rename files 2025-06-19 18:53:28 +02:00
Carlos Valente 2e23718d73 refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 89ea8c470b refactor: clearer relationship on rundown elements 2025-06-19 18:53:28 +02:00
Carlos Valente 178640bfc4 refactor: use strict typing 2025-06-19 18:53:28 +02:00
Carlos Valente 4ed38340e0 refactor: remove stop as a possible end action 2025-06-19 18:53:28 +02:00
Carlos Valente 69eb9a5eff refactor: restructure model to contain an object of rundowns 2025-06-19 18:53:28 +02:00
Carlos Valente 351425127a chore: remove IDE files 2025-06-19 18:53:28 +02:00
Carlos Valente 95f2ba37cc refactor: remove unused and legacy code
- remove legacy migrations
- remove unused server code
- remove unused UI code
2025-06-19 18:53:28 +02:00
740 changed files with 24828 additions and 18573 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
# Ignore build folders
node_modules
dist
**/node_modules
**/dist
# Ignore default volumes created by running docker compose up
ontime-db
+2 -1
View File
@@ -1 +1,2 @@
"ONTIME_VERSION.js"
"ONTIME_VERSION.js"
dist/
Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

+12 -12
View File
@@ -13,20 +13,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -11,15 +11,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -53,15 +58,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -86,15 +85,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Install dependencies
run: pnpm install --frozen-lockfile
+42
View File
@@ -0,0 +1,42 @@
name: Ontime Resolver build
on:
release:
types: [published]
workflow_dispatch:
jobs:
build_resolver:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
run: pnpm build:resolver
- name: Publish to NPM
run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
working-directory: ./apps/resolver
+41 -46
View File
@@ -14,55 +14,35 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
# Run code quality
- name: Run linter
run: pnpm lint
working-directory: ./packages/types
- name: Run TypeScript checks
run: pnpm typecheck
# We choose to run tests separately
- name: React - Run unit tests
- name: Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
run: pnpm test
e2e-test:
runs-on: ubuntu-latest
@@ -70,21 +50,36 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v3
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
version: 10
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build client
run: pnpm build:local
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
restore-keys: |
${{ runner.os }}-playwright-
- run: npx playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Install Playwright Browsers
run: npx playwright install --with-deps
+4
View File
@@ -34,6 +34,10 @@ e2e/tests/fixtures/tmp/*
build/
dist/
# bundled assets
translations.json
override.css
# working stuff
**/TODO.md
+4 -1
View File
@@ -7,4 +7,7 @@ playwright-report
**/*.toml
**/*.yml
**/*.json
**/*.json
!tsconfig.common.json
!turbo.json
!package.json
+24 -17
View File
@@ -20,19 +20,20 @@ development.
Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev`
- __Run dev mode__ by running `pnpm dev` or `pnpm dev:electron` to get the electron window
### Debugging backend
To debug backend code in Node.js:
The previous command will start the development servers for both the client, server and electron applications.
Typically in dev mode we prefer to start these in separate terminals to help with error tracking and debugging.
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
We do that by creating two terminals an running
- __Run the React UI__ by running `pnpm dev --filter=ontime-ui`
- __Run the nodejs server__ by running `pnpm dev --filter=ontime-server`
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect --filter=ontime-server`.
## TESTING
@@ -45,7 +46,7 @@ Generally we have 2 types of tests.
Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root.
You can run unit tests by running `pnpm test:pipeline` from the project root.
This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
@@ -65,7 +66,7 @@ start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e:ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -76,13 +77,13 @@ You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:electron`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
- __Build the UI and server__ by running `pnpm build`
- __Create the package__ by running `pnpm dist-win`, `pnpm dist-mac` or `pnpm dist-linux`
The build distribution assets will be at `.apps/electron/dist`
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
Use the `pnpm dist-mac:local` command to build a MacOS distribution locally and skip the notary process.
## DOCKER
@@ -99,9 +100,15 @@ Other useful commands
- __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>`
## General Info
## CONTRIBUTION GUIDELINES
# APP Building
If you want to propose changes to the codebase, please reach out before opening a Pull Request.
We build the app from app.js for almost all applications. The output file will still be named index.cjs. This is because of Electron.
Building the app from index.ts only applies for applications that don't use electron. index.ts will take over the initialization of the server and UI when electron isn't present.
For new PRs, please follow the following checklist:
* [ ] You have updated and ran unit locally and they are passing. Unit tests are generally created for all utility functions and business logic
* [ ] You have ran code formatting and linting in all your changes
* [ ] The branch is clean and the commits are meaningfully separated and contain descriptive messages
* [ ] The PR body contains description and motivation for the changes
After this checklist is complete, you can request a review from one of the maintainers to get feedback and approval on the changes. \
We will review as soon as possible
+36 -37
View File
@@ -1,37 +1,36 @@
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:22-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
WORKDIR /app/
# Prepare UI
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
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
FROM node:22-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm@10.11.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils --filter=ontime-types install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm run build:docker
FROM node:22-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/data/
WORKDIR /app/
# Prepare UI
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
CMD ["node", "server/docker.cjs"]
# Build and run commands
# 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
+14 -61
View File
@@ -43,12 +43,12 @@ Ontime is made by entertainment and broadcast engineers and used by
- 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)
- **Multiplatform**: Available as a Cloud service and for Windows, macOS, Linux, or self-hosted via Docker.
- **In any device**: Ontime is available to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- **Team Collaboration**: Dedicated views for directors, operators, backstage, and signage.
- **Real-Time Updates**: Manage and communicate runtime delays effortlessly.
- **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- **Flexible 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 ...
@@ -79,77 +79,29 @@ Ontime is made by video engineers and entertainment technicians.
## Using Ontime
Ontime can be started by downloading the latest release for your platform. \
Alternatively you can also use the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
### Getting started
Once installed and running, any device in the network has access to Ontime.
The easiest way to start with Ontime is by leveraging our [Cloud service](https://getontime.no). \
This will give you immediate access to running instances of Ontime which are available to share with anyone with an internet connection.
Ontime provides different screens which allow for different types of interactions with the data. These are called
views. \
Each view in Ontime focuses on empowering a specific role or achieving a particular task.
Alternatively, you can run Ontime locally for free by downloading the latest release for your platform or using the docker image, available at [Docker Hub](https://hub.docker.com/r/getontime/ontime)
You can access the different views by reaching the ontime server, in your browser, at (_default port
4001_) `http://localhost:4001` or `http://192.168.1.3:4001`
```
For the backstage views
-------------------------------------------------------------
IP.ADDRESS:4001/timer > Presenter / Stage timer view
IP.ADDRESS:4001/minimal > Simple timer view
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
```
```
For production views
-------------------------------------------------------------
IP.ADDRESS:4001/editor > the control interface, same as the app
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/op > automated views for operators
```
Once installed and running, any device that shares the same network as Ontime will have access to Ontime.
More information is available [in our docs](https://docs.getontime.no)
## Roadmap
### Continued development
## Continued development
Ontime is under active development. We continue adding and improving 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)
### Issues
## Issues
We use Github's issue tracking for bug reporting and feature requests. \
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
#### Unsigned App
When installing the app you would see warning screens from the Operating System like:
in Windows
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
or in Linux
`Could Not Display "ontime-linux.AppImage`
We currently only sign MacOS releases. \
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
You can circumvent this by allowing the execution of the app manually.
- In Windows: click `more` -> `Run Anyway`
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
If you have tips on how to improve this or would like to sponsor the code signing,
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
## Contributing
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
@@ -161,6 +113,7 @@ Information about the project setup can be found in the [development documentati
## Links
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Video tutorials](https://www.youtube.com/@ontimeapp)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.15.2",
"version": "4.0.0-beta.5",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
-6
View File
@@ -13,12 +13,6 @@
<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>
+27 -34
View File
@@ -1,54 +1,48 @@
{
"name": "ontime-ui",
"version": "3.15.2",
"version": "4.0.0-beta.5",
"private": true,
"type": "module",
"dependencies": {
"@base-ui-components/react": "1.0.0-beta.0",
"@chakra-ui/react": "^2.7.0",
"@base-ui-components/react": "1.0.0-beta.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.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.17.2",
"@sentry/react": "^8.43.0",
"@fontsource/open-sans": "^5.2.6",
"@mantine/hooks": "^8.2.8",
"@sentry/react": "^10.2.0",
"@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.62.7",
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-query": "^5.85.9",
"@tanstack/react-query-devtools": "^5.85.9",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.9.0",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-dom": "^19.1.1",
"react-fast-compare": "^3.2.2",
"react-hook-form": "^7.53.1",
"react-icons": "5.4.0",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"react-hook-form": "^7.62.0",
"react-icons": "5.5.0",
"react-qr-code": "^2.0.18",
"react-router": "^7.8.2",
"react-simple-code-editor": "^0.14.1",
"web-vitals": "^3.1.1",
"zustand": "^5.0.3"
"react-virtuoso": "^4.14.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.8"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
"postinstall": "pnpm addversion",
"dev": "cross-env BROWSER=none vite",
"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:localdocker": "cross-env NODE_ENV=local vite build",
"dev:electron": "pnpm dev",
"lint": "eslint . --quiet",
"typecheck": "tsc --noEmit",
"build": "vite build",
"test": "vitest",
"test:pipeline": "vitest run",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf build",
"analyse": "npx vite-bundle-visualizer"
},
"browserslist": {
@@ -67,8 +61,8 @@
"@sentry/vite-plugin": "^2.16.1",
"@tanstack/eslint-plugin-query": "^5.8.4",
"@types/prismjs": "^1.26.5",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
@@ -78,16 +72,15 @@
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"happy-dom": "^16.7.2",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"prettier": "catalog:",
"sass": "^1.57.1",
"typescript": "catalog:",
"vite": "6.3.1",
"vite-plugin-compression2": "1.4.0",
"vite-plugin-compression2": "2.2.0",
"vite-plugin-svgr": "4.3.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "catalog:"
+10 -9
View File
@@ -1,5 +1,5 @@
import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider } from '@chakra-ui/react';
import { BrowserRouter } from 'react-router';
import { Tooltip } from '@base-ui-components/react/tooltip';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -8,7 +8,7 @@ import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverla
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
import { connectSocket } from './common/utils/socket';
import theme from './theme/theme';
import KeepAwake from './features/keep-awake/KeepAwake';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
import { baseURI } from './externals';
@@ -17,14 +17,15 @@ connectSocket();
function App() {
return (
<ChakraProvider disableGlobalStyle resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider>
<Tooltip.Provider>
<BrowserRouter basename={baseURI}>
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<IdentifyOverlay />
<KeepAwake />
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
@@ -34,9 +35,9 @@ function App() {
<div id='identify-portal' />
</ErrorBoundary>
</BrowserRouter>
</AppContextProvider>
</QueryClientProvider>
</ChakraProvider>
</Tooltip.Provider>
</AppContextProvider>
</QueryClientProvider>
);
}
+161 -109
View File
@@ -1,165 +1,114 @@
import React from 'react';
import {
createRoutesFromChildren,
matchRoutes,
Navigate,
Route,
Routes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { ComponentType, lazy, Suspense, useEffect, useMemo } from 'react';
import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router';
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu';
import { PresetContext } from './common/context/PresetContext';
import { useClientPath } from './common/hooks/useClientPath';
import useUrlPresets from './common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from './common/utils/urlPresets';
import Log from './features/log/Log';
import withPreset from './features/PresetWrapper';
import withData from './features/viewers/ViewWrapper';
import Loader from './views/common/loader/Loader';
import NotFound from './views/common/not-found/NotFound';
import ViewLoader from './views/ViewLoader';
import { ONTIME_VERSION } from './ONTIME_VERSION';
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
import { getIsNavigationLocked, sessionScope } from './externals';
import { initializeSentry } from './sentry.config';
const Editor = React.lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = React.lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
const Timer = lazy(() => import('./views/timer/Timer'));
const Countdown = lazy(() => import('./views/countdown/Countdown'));
const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const TimerView = React.lazy(() => import('./views/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('./views/countdown/Countdown'));
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
const Operator = lazy(() => import('./features/operator/OperatorExport'));
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
const StudioClock = React.lazy(() => import('./views/studio/Studio'));
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
const TimerControl = lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = lazy(() => import('./features/control/message/MessageControlExport'));
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 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'));
const TimerControl = React.lazy(() => import('./features/control/playback/TimerControlExport'));
const MessageControl = React.lazy(() => import('./features/control/message/MessageControlExport'));
Sentry.init({
dsn: sentryDsn,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
tracesSampleRate: 0.3,
release: ONTIME_VERSION,
enabled: import.meta.env.PROD,
ignoreErrors: [...sentryRecommendedIgnore, /Unable to preload CSS/i, /dynamically imported module/i],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
});
const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes);
// Initialize Sentry with our configuration
const SentryRouter = initializeSentry();
export default function AppRouter() {
// handle client path changes
useClientPath();
return (
<React.Suspense fallback={null}>
<SentryRoutes>
<Suspense fallback={<Loader />}>
<SentryRouter>
<Route path='/' element={<Navigate to='/timer' />} />
<Route
path='/timer'
path='timer'
element={
<ViewLoader>
<STimer />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timer />
</ViewLoader>
}
/>
<Route
path='/minimal'
path='countdown'
element={
<ViewLoader>
<SMinimalTimer />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Countdown />
</ViewLoader>
}
/>
<Route
path='/clock'
path='backstage'
element={
<ViewLoader>
<SClock />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Backstage />
</ViewLoader>
}
/>
<Route
path='/countdown'
path='studio'
element={
<ViewLoader>
<SCountdown />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<StudioClock />
</ViewLoader>
}
/>
<Route
path='/backstage'
path='timeline'
element={
<ViewLoader>
<SBackstage />
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} />
<Timeline />
</ViewLoader>
}
/>
<Route
path='/studio'
path='info'
element={
<ViewLoader>
<SStudio />
<ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />
<ProjectInfo />
</ViewLoader>
}
/>
{/*/!* Lower third cannot have a loading screen *!/*/}
<Route path='/lower' element={<SLowerThird />} />
<Route
path='/timeline'
element={
<ViewLoader>
<STimeline />
</ViewLoader>
}
/>
<Route
path='/info'
element={
<ViewLoader>
<SProjectInfo />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route path='editor' element={<Editor />} />
<Route path='cuesheet' element={<Cuesheet />} />
<Route
path='/op'
path='op'
element={
<ViewLoader>
<POperator />
<Operator />
</ViewLoader>
}
/>
{/*/!* Protected Routes - Elements *!/*/}
<Route
path='/rundown'
path='rundown'
element={
<EditorFeatureWrapper>
<RundownPanel />
@@ -167,7 +116,7 @@ export default function AppRouter() {
}
/>
<Route
path='/timercontrol'
path='timercontrol'
element={
<EditorFeatureWrapper>
<TimerControl />
@@ -175,7 +124,7 @@ export default function AppRouter() {
}
/>
<Route
path='/messagecontrol'
path='messagecontrol'
element={
<EditorFeatureWrapper>
<MessageControl />
@@ -183,16 +132,119 @@ export default function AppRouter() {
}
/>
<Route
path='/log'
path='log'
element={
<EditorFeatureWrapper>
<Log />
</EditorFeatureWrapper>
}
/>
{/*/!* Send to default if nothing found *!/*/}
<Route path='*' element={<STimer />} />
</SentryRoutes>
</React.Suspense>
{/**
* If the views are prefixed with the "preset" path, we are in a locked preset
* Locked presets do not expose their parameters
*/}
<Route path='preset/:alias' element={<PresetView />} />
{/**
* If we havent matched any views or presets, we may be in an unlocked preset
* Unlocked presets are unwrapped to expose their target and parameters
*/}
<Route path='*' element={<RedirectPreset />} />
</SentryRouter>
</Suspense>
);
}
const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.Cuesheet]: Cuesheet,
[OntimeView.Operator]: Operator,
[OntimeView.Timer]: Timer,
[OntimeView.Backstage]: Backstage,
[OntimeView.Timeline]: Timeline,
[OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo,
};
/**
* This view will mask a configured canonical route
* and inject the preset search parameters to context
* User are not able to configure the parameters locked presets
*/
function PresetView() {
const { data, status } = useUrlPresets();
const { alias } = useParams();
const preset: URLPreset | undefined = useMemo(() => {
if (status === 'pending' || !alias) return;
return data.find((p) => p.alias === alias && p.enabled);
}, [data, status, alias]);
if (status === 'pending') {
return <Loader />;
}
/**
* We need to check the session scope to determine if the user can navigate
* If the user has a global scope, they can navigate freely
* Otherwise, they are locked to the preset view
*/
const showNav = sessionScope === 'rw';
/**
* If we are in a preset path but cannot find a preset, we will need to show a not found page
* This can happen if the preset was deleted or disabled
*/
if (!preset) {
return (
<>
<ViewNavigationMenu isNavigationLocked={!showNav} suppressSettings />
<NotFound />
</>
);
}
/**
* Locked presets do not allow configuration changes
* Whether the user can navigate is determined by the locked param
*/
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
<PresetContext value={preset}>
{preset.target !== OntimeView.Cuesheet && (
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
)}
{Component ? <Component /> : <NotFound />}
</PresetContext>
);
}
function RedirectPreset() {
const { data, status } = useUrlPresets();
const navigate = useNavigate();
const location = useLocation();
// checks if we are in a preset path and resolves a destination URL
const destination = useMemo(() => {
if (status === 'pending') return null;
return getRouteFromPreset(location, data);
}, [data, location, status]);
// if we have a destination, we will navigate to it
useEffect(() => {
if (destination) {
navigate(`/${destination}`, { replace: true });
}
}, [destination, navigate]);
if (status === 'pending') {
return <Loader />;
}
return (
<>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<NotFound />
</>
);
}
+22 -1
View File
@@ -1,6 +1,9 @@
import axios from 'axios';
import { TranslationObject } from 'ontime-types';
import { apiEntryUrl } from './constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { apiEntryUrl, customTranslationsURL, TRANSLATION } from './constants';
const assetsPath = `${apiEntryUrl}/assets`;
@@ -28,3 +31,21 @@ export async function restoreCSSContents(): Promise<string> {
const res = await axios.post(`${assetsPath}/css/restore`);
return res.data;
}
/**
* HTTP request to get user translation
*/
export async function getUserTranslation(): Promise<TranslationObject> {
const res = await axios.get(customTranslationsURL);
return res.data;
}
/**
* HTTP request to post user translation
*/
export async function postUserTranslation(translation: TranslationObject): Promise<void> {
await axios.post(`${assetsPath}/translations`, {
translation,
});
await ontimeQueryClient.invalidateQueries({ queryKey: TRANSLATION });
}
+4
View File
@@ -8,18 +8,22 @@ export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const PROJECT_RUNDOWNS = ['projectRundowns'];
export const RUNDOWN = ['rundown'];
export const RUNTIME = ['runtimeStore'];
export const URL_PRESETS = ['urlpresets'];
export const VIEW_SETTINGS = ['viewSettings'];
export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
const userAssetsPath = 'user';
const cssOverridePath = 'styles/override.css';
const customTranslationsPath = 'translations/translations.json';
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`;
export const customTranslationsURL = `${serverURL}/${userAssetsPath}/${customTranslationsPath}`;
-24
View File
@@ -1,9 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils';
@@ -33,27 +30,6 @@ export async function downloadProject(fileName: string) {
}
}
/**
* Request download of the current rundown as a CSV file
* @param fileName
*/
export async function downloadCSV(fileName: string = 'rundown') {
try {
const { data, name } = await fileDownload(fileName);
const { project, rundowns, customFields } = data;
const flatRundowns = aggregateRundowns(rundowns);
const sheetData = makeTable(project, flatRundowns, customFields);
const fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
downloadBlob(blob, `${name}.csv`);
} catch (error) {
console.error(error);
}
}
/**
* HTTP request to upload project file
*/
+62 -44
View File
@@ -1,17 +1,11 @@
import axios, { AxiosResponse } from 'axios';
import {
EntryId,
MessageResponse,
OntimeEntry,
OntimeEvent,
ProjectRundownsList,
Rundown,
TransientEventPayload,
} from 'ontime-types';
import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types';
import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`;
const rundownPath = `${apiEntryUrl}/rundowns`;
// #region operations on project rundowns =========================
/**
* HTTP request to fetch a list of existing rundowns
@@ -22,37 +16,64 @@ export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
}
/**
* HTTP request to fetch all events
* HTTP request to fetch all entries in the currently loaded rundown
*/
export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/current`);
return res.data;
}
/**
* HTTP request to switch the currently loaded rundown
*/
export async function loadRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/${id}/load`);
}
/**
* HTTP request to create a new rundown
*/
export async function createRundown(title: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(rundownPath, { title });
}
/**
* HTTP request to delete a rundown
*/
export async function deleteRundown(id: string): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.delete(`${rundownPath}/${id}`);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
/**
* HTTP request to post new entry
*/
export async function postAddEntry(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data);
export async function postAddEntry(
rundownId: string,
data: TransientEventPayload,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(`${rundownPath}/${rundownId}/entry`, data);
}
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data);
export async function putEditEntry(rundownId: string, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
type BatchEditEntry = {
export type BatchEditEntry = {
data: Partial<OntimeEvent>;
ids: string[];
ids: EntryId[];
};
/**
* HTTP request to edit multiple events
*/
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/batch`, data);
export async function putBatchEditEvents(rundownId: string, data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
}
export type ReorderEntry = {
@@ -64,60 +85,57 @@ export type ReorderEntry = {
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/reorder`, data);
export async function patchReorderEntry(rundownId: string, data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/reorder`, data);
}
export type SwapEntry = {
from: string;
to: string;
};
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/swap`, data);
export async function requestEventSwap(rundownId: string, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
export async function requestApplyDelay(rundownId: string, delayId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`);
export async function postCloneEntry(rundownId: string, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a block
* HTTP request for grouping a list of entries into a group
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
export async function requestGroupEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/group`, { ids: entryIds });
}
/**
* HTTP request for grouping a list of entries into a block
* HTTP request for dissolving of a group
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
export async function requestUngroup(rundownId: string, groupId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/ungroup/${groupId}`);
}
/**
* HTTP request to delete entries
* HTTP request to delete entries of a given rundown
*/
export async function deleteEntries(entryIds: EntryId[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: entryIds } });
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/entries`, { data: { ids: entryIds } });
}
/**
* HTTP request to delete all events
* HTTP request to delete all entries of a given rundown
*/
export async function requestDeleteAll(): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/all`);
export async function requestDeleteAll(rundownId: string): Promise<AxiosResponse<Rundown>> {
return axios.delete(`${rundownPath}/${rundownId}/all`);
}
// #endregion operations on rundown entries =======================
+3 -8
View File
@@ -1,5 +1,5 @@
import axios from 'axios';
import { GetInfo } from 'ontime-types';
import { GetInfo, LinkOptions } from 'ontime-types';
import { apiEntryUrl } from './constants';
@@ -16,12 +16,7 @@ export async function getInfo(): Promise<GetInfo> {
/**
* 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 });
export async function generateUrl(options: LinkOptions & { baseUrl: string; path: string }): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, options);
return res.data.url;
}
+18 -4
View File
@@ -6,7 +6,7 @@ import { apiEntryUrl } from './constants';
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
/**
* HTTP request to retrieve aliases
* HTTP request to retrieve all presets
*/
export async function getUrlPresets(): Promise<URLPreset[]> {
const res = await axios.get(urlPresetsPath);
@@ -14,8 +14,22 @@ export async function getUrlPresets(): Promise<URLPreset[]> {
}
/**
* HTTP request to mutate aliases
* HTTP request to add a preset
*/
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
return axios.post(urlPresetsPath, data);
export async function postUrlPreset(data: URLPreset): Promise<URLPreset[]> {
return (await axios.post(urlPresetsPath, data)).data;
}
/**
* HTTP request to edit a preset
*/
export async function putUrlPreset(alias: string, data: URLPreset): Promise<URLPreset[]> {
return (await axios.put(`${urlPresetsPath}/${alias}`, data)).data;
}
/**
* HTTP request to delete a preset
*/
export async function deleteUrlPreset(alias: string): Promise<URLPreset[]> {
return (await axios.delete(`${urlPresetsPath}/${alias}`)).data;
}
+14
View File
@@ -33,6 +33,20 @@ export function maybeAxiosError(error: unknown) {
}
}
/**
* Utility unwrap a an instance of Error
*/
export function unwrapError(error: unknown) {
if (error instanceof Error) {
return error.message;
} else {
if (typeof error !== 'string') {
return JSON.stringify(error);
}
return error;
}
}
/**
* Utility unwraps a potential axios error and sends to logger
* @param prepend
+13 -4
View File
@@ -2,14 +2,23 @@ import axios from 'axios';
import type { ViewSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const viewSettingsPath = apiEntryUrl + '/view-settings';
export async function getViewSettings() {
const viewSettingsPath = `${apiEntryUrl}/view-settings`;
/**
* HTTP request to fetch view settings
* @returns
*/
export async function getViewSettings(): Promise<ViewSettings> {
const res = await axios.get(viewSettingsPath);
return res.data;
}
export async function postViewSettings(data: ViewSettings) {
/**
* HTTP request to update view settings
* needs to update entire objects, not just a patch
*/
export async function postViewSettings(data: ViewSettings): Promise<ViewSettings> {
const res = await axios.post(viewSettingsPath, data);
return res.data as ViewSettings;
return res.data;
}
@@ -1,6 +1,7 @@
.subtle {
background: $gray-1050;
color: $blue-400;
line-height: 1em;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
@@ -79,3 +80,61 @@
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $blue-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $blue-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-white {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
.ghosted-destructive {
background: transparent;
color: $red-500;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $red-500;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,7 +1,8 @@
@use '../../../theme/viewerDefs' as *;
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
@@ -25,6 +26,36 @@
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&.loading {
cursor: wait;
.content {
opacity: 0;
}
}
}
.content {
display: flex;
align-items: center;
gap: inherit;
}
.loadingOverlay {
position: absolute;
display: grid;
place-content: center;
}
.spinner {
animation: spin 1s linear infinite;
stroke-dasharray: 8;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.small {
@@ -1,25 +1,53 @@
import { ButtonHTMLAttributes } from 'react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { IoEllipseOutline } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
fluid?: boolean;
loading?: boolean;
}
export default function Button(props: ButtonProps) {
const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props;
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', fluid, loading, ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([
style.baseButton,
style[variant],
style[size],
fluid && style.fluid,
loading && style.loading,
className,
])}
type='button'
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
<span className={style.content}>{children}</span>
{loading && (
<div className={style.loadingOverlay}>
<IoEllipseOutline className={style.spinner} />
</div>
)}
</button>
);
},
);
return (
<button
className={cx([style.baseButton, style[variant], style[size], fluid && style.fluid, className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
}
Button.displayName = 'Button';
export default Button;
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '@/theme/viewerDefs' as *;
@import './BaseButtonStyles.module.scss';
.baseIconButton {
@@ -7,16 +7,26 @@
place-content: center;
border: 1px solid transparent;
border-radius: 3px;
border-radius: $component-border-radius-md;
cursor: pointer;
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
width: 2rem;
@@ -1,28 +1,37 @@
import { ButtonHTMLAttributes } from 'react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
size?: 'medium' | 'large' | 'xlarge';
variant?:
| 'primary'
| 'subtle'
| 'subtle-white'
| 'destructive'
| 'subtle-destructive'
| 'ghosted'
| 'ghosted-white'
| 'ghosted-destructive';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
export default function IconButton({
className,
children,
variant = 'subtle',
size = 'medium',
...buttonProps
}: IconButtonProps) {
return (
<button
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
}
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ className, children, variant = 'subtle', size = 'medium', ...buttonProps }, ref) => {
return (
<button
ref={ref}
className={cx([style.baseIconButton, style[variant], style[size], className])}
type='button'
{...buttonProps}
>
{children}
</button>
);
},
);
IconButton.displayName = 'IconButton';
export default IconButton;
@@ -1,17 +0,0 @@
import { MouseEvent } from 'react';
import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
interface TooltipActionBtnProps extends IconButtonProps {
clickHandler: (event: MouseEvent) => void | Promise<void>;
tooltip: string;
openDelay?: number;
}
export default function TooltipActionBtn(props: TooltipActionBtnProps) {
const { clickHandler, icon, size = 'xs', tooltip, openDelay = 0, className, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton {...rest} size={size} icon={icon} onClick={clickHandler} className={className} />
</Tooltip>
);
}
@@ -1,22 +1,15 @@
import { useState } from 'react';
import { IoArrowForward } from 'react-icons/io5';
import {
IconButton,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Select,
} from '@chakra-ui/react';
import { navigatorConstants } from '../../../viewerConfig';
import { setClientRemote } from '../../hooks/useSocket';
import useUrlPresets from '../../hooks-query/useUrlPresets';
import Button from '../buttons/Button';
import Info from '../info/Info';
import Input from '../input/input/Input';
import AppLink from '../link/app-link/AppLink';
import Modal from '../modal/Modal';
import Select from '../select/Select';
import style from './RedirectClientModal.module.scss';
@@ -29,8 +22,7 @@ interface RedirectClientModalProps {
onClose: () => void;
}
export function RedirectClientModal(props: RedirectClientModalProps) {
const { id, isOpen, name, currentPath, origin, onClose } = props;
export function RedirectClientModal({ id, isOpen, name, currentPath, origin, onClose }: RedirectClientModalProps) {
const { data } = useUrlPresets();
const [path, setPath] = useState(currentPath);
const [selected, setSelected] = useState('/');
@@ -41,86 +33,88 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
if (newPath === '/' || newPath === currentPath) {
return;
}
setRedirect({ target: id, redirect: newPath });
if (newPath.startsWith('preset-')) {
setRedirect({ target: id, redirect: newPath.slice(7) });
} else {
setRedirect({ target: id, redirect: newPath });
}
onClose();
};
const enabledPresets = data.filter((preset) => preset.enabled);
const viewOptions = [
...navigatorConstants.map((view) => ({
value: `/${view.url}`,
label: view.label,
})),
...enabledPresets.map((preset) => ({
value: `preset-${preset.alias}`,
label: `URL Preset: ${preset.alias}`,
})),
];
return (
<Modal isOpen={isOpen} onClose={onClose} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(480px, 35vw)'>
<ModalHeader>Redirect: {name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Modal
isOpen={isOpen}
onClose={onClose}
showCloseButton
showBackdrop
title={`Redirect: ${name}`}
bodyElements={
<>
<Info>
Remotely redirect the client to a different URL. <br />
Either by selecting a URL Preset or entering a custom path.
Either by entering a custom path or selecting a URL Preset.
<br />
<br />
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
<AppLink search='settings=sharing__presets'>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)}
/>
<Input placeholder='eg. /timer' fluid value={path} onChange={(event) => setPath(event.target.value)} />
</label>
<IconButton
variant='ontime-filled'
size='md'
<Button
variant='primary'
aria-label='Redirect'
isDisabled={path === currentPath || path === ''}
disabled={path === currentPath || path === ''}
className={style.redirect}
icon={<IoArrowForward />}
onClick={() => handleRedirect(path)}
/>
>
Redirect
<IoArrowForward />
</Button>
</div>
</ModalBody>
</ModalContent>
</Modal>
<div>
<span className={style.label}>Select View or URL Preset</span>
<div className={style.inlineEntry}>
<label className={style.textEntry}>
{origin}
<Select
fluid
options={viewOptions}
defaultValue={viewOptions[0].value}
onValueChange={(value) => setSelected(value)}
disabled={enabledPresets.length === 0}
/>
</label>
<Button
variant='primary'
aria-label='Redirect to preset'
className={style.redirect}
disabled={enabledPresets.length === 0 || selected === '/'}
onClick={() => handleRedirect(selected)}
>
Redirect <IoArrowForward />
</Button>
</div>
</div>
</>
}
/>
);
}
@@ -1,8 +0,0 @@
.contextMenuButton {
position: absolute;
}
.contextMenuBackdrop {
position: fixed;
inset: 0;
}
@@ -1,99 +0,0 @@
// logic (with some modifications) culled from:
// 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 { create } from 'zustand';
import { ContextMenuOption } from './ContextMenuOption';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type OptionWithoutGroup = {
label: string;
isDisabled?: boolean;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
};
type OptionWithGroup = {
label: string;
group: Omit<OptionWithoutGroup, 'isGroup'>[];
};
export type Option = OptionWithoutGroup | OptionWithGroup;
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map((option) =>
isOptionWithGroup(option) ? (
<MenuGroup key={option.label} title={option.label}>
{option.group.map((groupOption) => (
<ContextMenuOption key={groupOption.label} {...groupOption} />
))}
</MenuGroup>
) : (
<ContextMenuOption key={option.label} {...option} />
),
)}
</MenuList>
</Menu>
</>
);
};
@@ -1,12 +0,0 @@
import { MenuDivider, MenuItem } from '@chakra-ui/react';
import { OptionWithoutGroup } from './ContextMenu';
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
<>
{withDivider && <MenuDivider />}
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</>
);
@@ -0,0 +1,40 @@
.copytag {
display: flex;
align-items: center;
}
.action {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.label {
border-radius: 3px 0 0 3px;
border: 1px solid $gray-1200;
background-color: $white-3;
padding-inline: 0.5rem 1rem;
line-height: 1em;
display: flex;
align-items: center;
color: $label-gray;
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
font-size: calc(1rem - 2px);
}
.large {
height: 2.5rem;
font-weight: 600;
}
.copy {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -1,47 +1,54 @@
import { PropsWithChildren, useState } from 'react';
import { PropsWithChildren, useRef, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IoCopy } from 'react-icons/io5';
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { Size } from '../../models/Util.type';
import copyToClipboard from '../../utils/copyToClipboard';
import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import style from './CopyTag.module.scss';
interface CopyTagProps {
copyValue: string;
label: string;
size?: Size;
disabled?: boolean;
onClick?: () => void;
size?: 'small' | 'medium' | 'large';
}
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
export default function CopyTag({
copyValue,
disabled,
size = 'medium',
children,
onClick,
}: PropsWithChildren<CopyTagProps>) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleClick = () => {
copyToClipboard(copyValue);
setCopied(true);
// reset copied state
setTimeout(() => setCopied(false), 1000);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<Tooltip label={label} openDelay={tooltipDelayFast}>
<ButtonGroup size={size} isAttached>
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
<div className={style.copytag}>
{onClick !== undefined ? (
<Button className={style.action} size={size} onClick={onClick} disabled={disabled}>
{children}
</Button>
<IconButton
aria-label={label}
icon={copied ? <IoCheckmark /> : <IoCopy />}
variant='ontime-filled'
tabIndex={-1}
onClick={handleClick}
isDisabled={disabled}
/>
</ButtonGroup>
</Tooltip>
) : (
<div className={cx([style.label, style[size]])}>{children}</div>
)}
<IconButton className={style.copy} variant='primary' size={size} onClick={handleClick} disabled={disabled}>
{copied ? <IoCheckmark /> : <IoCopy />}
</IconButton>
</div>
);
}
@@ -1,5 +1,7 @@
.delaySymbol {
svg {
display: inline;
vertical-align: middle;
font-size: 1.5rem;
color: $ontime-delay;
margin: 0 auto;
@@ -1,8 +1,7 @@
import { IoChevronDown, IoChevronUp } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { millisToDelayString } from '../../utils/dateConfig';
import Tooltip from '../tooltip/Tooltip';
import style from './DelayIndicator.module.scss';
@@ -23,8 +22,8 @@ export default function DelayIndicator(props: DelayIndicatorProps) {
: millisToDelayString(delayValue);
return (
<Tooltip openDelay={tooltipDelayFast} label={delayString}>
<span className={style.delaySymbol}>{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}</span>
<Tooltip text={delayString} render={<span />} className={style.delaySymbol}>
{delayValue < 0 ? <IoChevronDown /> : <IoChevronUp />}
</Tooltip>
);
}
@@ -3,7 +3,6 @@
top: 10%;
left: 50%;
z-index: $zindex-dialog;
transform: translateX(-50%);
padding-inline: 1rem;
@@ -13,13 +12,13 @@
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1200;
border: 1px solid $gray-1100;
outline: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
@@ -0,0 +1,70 @@
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
padding-block: 0.25rem;
color: $ui-white;
background-color: $gray-1250;
font-size: calc(1rem - 2px);
border-radius: 3px;
border: 1px solid $gray-1000;
box-shadow: $box-shadow-l1;
outline: none;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.item {
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
svg {
color: $gray-500;
}
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&[data-highlighted] {
z-index: 0;
position: relative;
}
&[data-highlighted]:not([data-disabled])::before {
content: '';
z-index: -1;
position: absolute;
inset-block: 0;
inset-inline: 0.25rem;
border-radius: 3px;
background-color: $gray-1000;
}
}
.separator {
margin: 0.25rem 0.75rem;
height: 1px;
background-color: $white-7;
}
@@ -0,0 +1,82 @@
import { PropsWithChildren } from 'react';
import { IconType } from 'react-icons';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './DropdownMenu.module.scss';
type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = {
type: 'item';
label: string;
icon?: IconType;
disabled?: boolean;
onClick: () => void;
};
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
interface DropdownMenuProps extends BaseMenu.Trigger.Props {
items: DropdownMenuOption[];
}
export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChildren<DropdownMenuProps>) {
return (
<BaseMenu.Root>
<BaseMenu.Trigger {...triggerProps}>{children}</BaseMenu.Trigger>
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
interface PositionedDropdownMenuProps {
items: Array<DropdownMenuItemDivider | DropdownMenuItem>;
isOpen: boolean;
onClose: () => void;
position: { x: number; y: number };
}
export function PositionedDropdownMenu({ items, isOpen, position, onClose }: PositionedDropdownMenuProps) {
return (
<BaseMenu.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<BaseMenu.Trigger style={{ position: 'fixed', left: position.x, top: position.y }} aria-hidden />
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8} alignOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />}
{item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
@@ -33,5 +33,5 @@ interface SeparatorProps extends HTMLAttributes<HTMLDivElement> {
}
export function Separator({ className, orientation = 'vertical', ...elementProps }: SeparatorProps) {
return <div className={cx([style.separator, style[orientation], className])} {...elementProps} />;
return <div className={cx([style.separator, style[orientation], className])} role='separator' {...elementProps} />;
}
@@ -49,15 +49,14 @@ class ErrorBoundary extends React.Component {
return (
<div className={style.errorContainer} data-testid='error-container'>
<div>
<p className={style.error}>:/</p>
<p>Something went wrong</p>
<div
role='button'
<p className={style.error}>: /</p>
<p>Something went wrong.</p>
<a
className={style.report}
onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })}
href={`mailto:mail@getontime.no?subject=Error%20Report&body=${encodeURIComponent(this.reportContent)}`}
>
Report error
</div>
</a>
<div
role='button'
className={style.report}
@@ -3,24 +3,21 @@
height: 100%;
display: grid;
place-content: center;
background-color: #121212;
color: white;
background-color: $ui-black;
color: $ui-white;
}
.error {
color: $error-red;
font-weight: 600;
}
.error {
color: $error-red;
}
.report {
text-decoration: underline $error-red;
cursor: pointer;
}
.report {
color: $blue-500;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
.report:hover {
color: $error-red;
}
.report:active {
color: white;
&:hover {
color: $ontime-color;
}
}
@@ -13,8 +13,13 @@ interface FitTextProps extends HTMLAttributes<HTMLDivElement> {
max?: number; // inclusive
}
export function FitText(props: PropsWithChildren<FitTextProps>) {
const { children, mode = 'multi', min = 16, max = 256, ...elementProps } = props;
export function FitText({
children,
mode = 'multi',
min = 16,
max = 256,
...elementProps
}: PropsWithChildren<FitTextProps>) {
const ref = useRef<HTMLDivElement>(null);
const isOverflown = useCallback(() => {
@@ -0,0 +1,6 @@
import { IconBaseProps } from 'react-icons';
import { IoLink } from 'react-icons/io5';
export default function RotatedLink(linkProps: IconBaseProps) {
return <IoLink style={{ transform: 'rotate(-45deg)' }} {...linkProps} />;
}
@@ -15,7 +15,6 @@
display: grid;
place-content: center;
text-align: center;
z-index: $zindex-modal;
cursor: pointer;
}
@@ -7,15 +7,29 @@
border-radius: 3px;
font-size: $text-body-size;
padding: 1rem;
.content {
color: $gray-200;
}
color: $gray-200;
svg {
min-width: 1.5rem;
align-self: start;
font-size: 1.5rem;
}
}
.info {
svg {
color: $info-blue;
}
}
.warning {
svg {
color: $orange-500;
}
}
.error {
svg {
color: $red-500;
}
}
@@ -1,5 +1,5 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from 'react-icons/io5';
import { IoAlertCircle, IoWarning } from 'react-icons/io5';
import { cx } from '../../utils/styleUtils';
@@ -7,14 +7,15 @@ import style from './Info.module.scss';
interface InfoProps {
className?: string;
type?: 'info' | 'warning' | 'error';
}
export default function Info(props: PropsWithChildren<InfoProps>) {
const { className, children } = props;
export default function Info({ className, type = 'info', children }: PropsWithChildren<InfoProps>) {
return (
<div className={cx([style.infoLabel, className])}>
<IoAlertCircle />
<div className={cx([style.infoLabel, style[type], className])}>
{type === 'info' && <IoAlertCircle />}
{type === 'warning' && <IoWarning />}
{type === 'error' && <IoWarning />}
<div>{children}</div>
</div>
);
@@ -1,28 +0,0 @@
import { RefObject, useEffect } from 'react';
import { Textarea, TextareaProps } from '@chakra-ui/react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
const { value, inputref } = props;
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return (
<Textarea
overflow='hidden'
w='100%'
ref={inputref}
resize='none'
transition='height none'
variant='ontime-transparent'
{...props}
/>
);
};
@@ -0,0 +1,26 @@
import { RefObject, useEffect } from 'react';
// @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize';
import Textarea, { type TextareaProps } from '../textarea/Textarea';
interface AutoTextAreaProps extends TextareaProps {
inputref: RefObject<HTMLTextAreaElement | null>;
}
/**
* A textarea that automatically resizes based on its content
*/
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) {
// when the value changes, we use the ref to reapply autosize
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
return () => {
autosize.destroy(node);
};
}, [inputref, value]);
return <Textarea ref={inputref} value={value} {...textAreaProps} />;
}
@@ -1,3 +1,4 @@
import { MouseEvent } from 'react';
import { IoBan } from 'react-icons/io5';
import { cx } from '../../../utils/styleUtils';
@@ -10,12 +11,13 @@ interface SwatchProps {
isSelected?: boolean;
}
export default function Swatch(props: SwatchProps) {
const { color, isSelected, onClick } = props;
const handleClick = () => {
export default function Swatch({ color, isSelected, onClick }: SwatchProps) {
const handleClick = (event: MouseEvent) => {
onClick?.(color);
event.preventDefault();
event.stopPropagation();
};
const classes = cx([style.swatch, isSelected && style.selected, onClick && style.selectable]);
if (!color) {
@@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { useController, UseControllerProps } from 'react-hook-form';
import { IoEyedrop } from 'react-icons/io5';
import { useDebouncedCallback } from '@mantine/hooks';
import { ViewSettings } from 'ontime-types';
import { debounce } from '../../../utils/debounce';
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
import PopoverPicker from '../popover-picker/PopoverPicker';
@@ -19,12 +18,9 @@ interface SwatchPickerProps {
export default function SwatchPicker(props: SwatchPickerProps) {
const { color, onChange, isSelected, alwaysDisplayColor } = props;
const debouncedOnChange = useCallback(
debounce((newValue: string) => {
onChange(newValue);
}, 500),
[onChange],
);
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
onChange(newValue);
}, 100);
const displayColor = alwaysDisplayColor || isSelected ? color : '';
const { color: iconColor } = getAccessibleColour(displayColor);
@@ -1,12 +1,13 @@
.list {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 0.5rem;
}
.swatch {
width: 2rem;
height: 2rem;
aspect-ratio: 1;
border-radius: 99px;
border: 2px solid $gray-1200;
color: $ui-white;
@@ -0,0 +1,58 @@
.radioGroup {
color: $gray-900;
font-size: calc(1rem - 3px);
color: $label-gray;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2em;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 0.75rem;
height: 0.75rem;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
background-color: $gray-1200;
}
&[data-checked] {
background-color: $gray-700;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 0.5em;
height: 0.5em;
background-color: $ui-white;
}
}
@@ -0,0 +1,35 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import style from './BlockRadio.module.scss';
interface BlockRadioProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
}
export default function BlockRadio<T extends string | number | boolean>({
items,
onValueChange,
...elementProps
}: BlockRadioProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={style.radioGroup}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -1,22 +1,12 @@
$input-font-size: 15px;
.delayInput {
display: flex;
gap: $element-spacing;
align-items: center;
font-size: $text-body-size;
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
}
}
.delayOptions {
display: flex;
flex-direction: column;
.inputField {
text-align: center;
letter-spacing: 1px;
max-width: 7em;
color: $ontime-delay-text
}
@@ -1,8 +1,10 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction';
import Input from '../input/Input';
import BlockRadio from './BlockRadio';
import style from './DelayInput.module.scss';
@@ -18,7 +20,7 @@ export default function DelayInput(props: DelayInputProps) {
const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null);
// avoid wrong submit on cancel
let ignoreChange = false;
const ignoreChangeRef = useRef(false);
// set internal value on duration change
useEffect(() => {
@@ -33,8 +35,8 @@ export default function DelayInput(props: DelayInputProps) {
* @param {string} newValue string to be parsed
*/
const validateAndSubmit = (newValue: string) => {
if (ignoreChange) {
ignoreChange = false;
if (ignoreChangeRef.current) {
ignoreChangeRef.current = false;
return;
}
@@ -76,7 +78,7 @@ export default function DelayInput(props: DelayInputProps) {
} else if (event.key === 'Tab') {
validateAndSubmit((event.target as HTMLInputElement).value);
} else if (event.key === 'Escape') {
ignoreChange = true;
ignoreChangeRef.current = true;
setValue(millisToString(duration));
inputRef.current?.blur();
}
@@ -105,13 +107,10 @@ export default function DelayInput(props: DelayInputProps) {
return (
<div className={style.delayInput}>
<Input
size='sm'
ref={inputRef}
data-testid='delay-input'
className={style.inputField}
type='text'
placeholder='-'
variant='ontime-filled'
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => validateAndSubmit(event.target.value)}
@@ -119,16 +118,14 @@ export default function DelayInput(props: DelayInputProps) {
value={value}
maxLength={9}
/>
<RadioGroup
className={style.delayOptions}
onChange={handleSlipChange}
<BlockRadio
onValueChange={handleSlipChange}
value={checkedOption}
variant='ontime-block'
size='sm'
>
<Radio value='add'>Add time</Radio>
<Radio value='subtract'>Subtract time</Radio>
</RadioGroup>
items={[
{ value: 'add', label: 'Add time' },
{ value: 'subtract', label: 'Subtract time' },
]}
/>
</div>
);
}
@@ -2,7 +2,7 @@
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
font-weight: inherit;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
@@ -10,13 +10,19 @@
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled):not(:read-only) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
outline: 2px solid $blue-500;
outline-offset: 2px;
}
&:disabled {
@@ -24,9 +30,8 @@
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
&:read-only::placeholder {
opacity: 0;
}
}
@@ -1,20 +1,36 @@
// styles from Input.module.scss
.input {
color: $gray-200;
border: 1px solid transparent;
border-radius: 0 0 8px 8px;
background-color: $gray-1200;
padding: 0 0.5rem;
width: 100%;
margin-top: 0.25rem;
box-sizing: border-box;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
border-radius: $component-border-radius-md;
background-color: $gray-1200;
border: 1px solid transparent;
height: 2rem;
padding-inline: 0.5em;
outline: none;
&:hover {
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus {
&:focus:not(:read-only) {
background-color: $gray-1000;
color: $gray-50;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
@@ -1,6 +1,8 @@
import { PropsWithChildren } from 'react';
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
import { Popover } from '@base-ui-components/react/popover';
import PopoverContents from '../../popover/Popover';
import style from './PopoverPicker.module.scss';
@@ -9,15 +11,14 @@ interface PopoverPickerProps {
onChange: (color: string) => void;
}
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
const { color, onChange, children } = props;
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) {
return (
<Popover>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
<Popover.Root>
<Popover.Trigger>{children}</Popover.Trigger>
<PopoverContents>
<HexAlphaColorPicker color={color} onChange={onChange} />
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
</PopoverContent>
</Popover>
</PopoverContents>
</Popover.Root>
);
}
@@ -1,4 +1,4 @@
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getHotkeyHandler, HotkeyItem } from '@mantine/hooks';
interface UseReactiveTextInputReturn {
@@ -11,7 +11,7 @@ interface UseReactiveTextInputReturn {
export default function useReactiveTextInput(
initialText: string,
submitCallback: (newValue: string) => void,
ref: RefObject<HTMLInputElement>,
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>,
options?: {
submitOnEnter?: boolean;
submitOnCtrlEnter?: boolean;
@@ -21,6 +21,8 @@ export default function useReactiveTextInput(
},
): UseReactiveTextInputReturn {
const [text, setText] = useState<string>(initialText);
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
const isKeyboardSubmitting = useRef(false);
useEffect(() => {
if (typeof initialText === 'undefined') {
@@ -99,11 +101,31 @@ export default function useReactiveTextInput(
];
if (options?.submitOnEnter) {
hotKeys.push(['Enter', () => handleSubmit(text)]);
hotKeys.push([
'Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
}
if (options?.submitOnCtrlEnter) {
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
hotKeys.push([
'mod + Enter',
() => {
isKeyboardSubmitting.current = true;
handleSubmit(text);
// clear flag after blur has been processed
setTimeout(() => {
isKeyboardSubmitting.current = false;
}, 0);
},
]);
}
const hotKeyHandler = getHotkeyHandler(hotKeys);
@@ -126,7 +148,11 @@ export default function useReactiveTextInput(
return {
value: text,
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => handleSubmit((event.target as HTMLInputElement).value),
onBlur: (event: ChangeEvent) => {
if (!isKeyboardSubmitting.current) {
handleSubmit((event.target as HTMLInputElement).value);
}
},
onKeyDown: keyHandler,
};
}
@@ -0,0 +1,52 @@
.textarea {
box-sizing: border-box;
min-height: 2rem;
display: block;
font-size: 1rem;
font-weight: inherit;
color: $gray-200;
border-radius: $component-border-radius-md;
border: 1px solid transparent;
outline: none;
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&:read-only::placeholder {
opacity: 0;
}
}
.subtle {
background-color: $gray-1200;
padding-inline: 0.5em;
padding-top: 0.25em;
}
.ghosted {
background-color: transparent;
padding: 0;
padding-top: 0.25em;
}
.fluid {
width: 100%;
}
@@ -0,0 +1,31 @@
import { forwardRef, TextareaHTMLAttributes } from 'react';
import { cx } from '../../../utils/styleUtils';
import style from './Textarea.module.scss';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
variant?: 'subtle' | 'ghosted';
fluid?: boolean;
resize?: 'none' | 'both' | 'horizontal' | 'vertical';
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function TextArea(
{ className, variant = 'subtle', fluid, rows = 5, resize = 'none', style: customStyle, ...textareaProps },
ref,
) {
return (
<textarea
ref={ref}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
rows={rows}
style={{ ...customStyle, resize }}
className={cx([style.textarea, style[variant], fluid && style.fluid, className])}
{...textareaProps}
/>
);
});
export default Textarea;
@@ -9,7 +9,7 @@ import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
submitHandler: (field: T, value: number) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
@@ -18,8 +18,20 @@ interface NullableTimeInputProps<T extends string> {
className?: string;
}
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
/**
* Similar to TimeInput, but allows clearing the time value
*/
export default function NullableTimeInput<T extends string>({
id,
name,
submitHandler,
time,
emptyDisplay,
placeholder,
disabled,
align = 'center',
className,
}: NullableTimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -59,7 +71,7 @@ export default function NullableTimeInput<T extends string>(props: NullableTimeI
return false;
}
submitHandler(name, newValue);
submitHandler(name, valueInMillis);
return true;
},
[name, submitHandler, time],
@@ -1,6 +1,10 @@
.timeInput {
width: 100%;
max-width: 7.5em;
letter-spacing: 1px;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
&.delayed {
border: 1px solid $ontime-delay-text;
}
}
@@ -14,11 +14,21 @@ interface TimeInputProps<T extends string> {
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
delayed?: boolean;
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props;
export default function TimeInput<T extends string>({
id,
name,
submitHandler,
time,
placeholder,
disabled,
align = 'center',
delayed,
className,
}: TimeInputProps<T>) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -122,7 +132,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
className={cx([style.timeInput, delayed && style.delayed, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
@@ -130,7 +140,6 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
@@ -1,10 +0,0 @@
$input-delayed-border-color: $ontime-delay-text;
.timeInput {
border: 1px solid transparent;
color: $label-gray;
&.delayed {
border: 1px solid $input-delayed-border-color;
}
}
@@ -1,37 +0,0 @@
import { PropsWithChildren } from 'react';
import { InputGroup } from '@chakra-ui/react';
import { cx } from '../../../utils/styleUtils';
import TimeInput from './TimeInput';
import style from './TimeInputWithButton.module.scss';
interface TimeInputWithButtonProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
hasDelay?: boolean;
disabled?: boolean;
placeholder: string;
}
export default function TimeInputWithButton<T extends string>(props: PropsWithChildren<TimeInputWithButtonProps<T>>) {
const { name, submitHandler, time, hasDelay, placeholder, disabled, children } = props;
const inputClasses = cx([style.timeInput, hasDelay ? style.delayed : null]);
return (
<InputGroup size='sm' className={inputClasses} width='fit-content'>
<TimeInput<T>
name={name}
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
align='left'
disabled={disabled}
/>
{children}
</InputGroup>
);
}
@@ -1,5 +1,5 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import style from './AppLink.module.scss';
@@ -11,8 +11,7 @@ interface AppLinkProps {
* 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;
export default function AppLink({ search, children }: PropsWithChildren<AppLinkProps>) {
const navigate = useNavigate();
const handleClick = () => navigate({ search });
@@ -7,6 +7,7 @@
transition-property: color;
transition-duration: $transition-time-action;
width: fit-content;
text-decoration: none;
&.inline {
display: inline-flex;
@@ -15,4 +16,9 @@
&:hover {
color: $ontime-color;
}
}
&:focus {
outline: none;
box-shadow: 0 1px 0 0 currentColor;
}
}
@@ -12,9 +12,7 @@ interface ExternalLinkProps {
inline?: boolean;
}
export default function ExternalLink(props: ExternalLinkProps) {
const { href, inline, children } = props;
export default function ExternalLink({ href, inline, children }: ExternalLinkProps) {
const handleClick = (event: MouseEvent) => {
event.preventDefault();
openLink(href);
@@ -0,0 +1,57 @@
.modal {
position: fixed;
top: 10vh;
left: 50%;
transform: translateX(-50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
max-width: min(900px, 90vw);
background-color: $gray-1250;
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1100;
outline: none;
}
.backdrop {
position: fixed;
inset: 0;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.title {
font-size: 1rem;
font-weight: 600;
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
overflow-y: auto;
}
.footer {
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
}
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton';
import style from './Modal.module.scss';
interface ModalProps {
isOpen: boolean;
title?: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
export default function Modal({
isOpen,
title,
showCloseButton,
showBackdrop,
bodyElements,
footerElements,
onClose,
}: ModalProps) {
return (
<BaseDialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.modal}>
<div className={style.title}>
{title}
{showCloseButton && (
<IconButton variant='subtle-white' onClick={onClose}>
<IoClose />
</IconButton>
)}
</div>
<div className={style.body}>{bodyElements}</div>
<div className={style.footer}>{footerElements}</div>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '@/theme/viewerDefs' as *;
$progress-bar-size: 1.5rem;
$progress-bar-br: 3px;
@@ -5,7 +5,6 @@
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
@@ -21,7 +20,6 @@
top: 0;
left: 0;
bottom: 0;
z-index: $zindex-dialog;
width: 22rem;
height: 100vh;
@@ -32,7 +30,7 @@
background-color: $gray-1250;
color: $ui-white;
border-right: 1px solid $gray-1200;
border-right: 1px solid $gray-1100;
&[data-open] {
transform: translateX(0%);
@@ -66,3 +64,8 @@
flex: 1;
overflow-y: auto;
}
.note {
margin-left: auto;
color: $white-20;
}
@@ -1,10 +1,12 @@
import { memo } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
import { useLocation } from 'react-router-dom';
import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router';
import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { navigatorConstants } from '../../../viewerConfig';
import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions';
@@ -31,6 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation();
return (
@@ -57,10 +60,18 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
Toggle Fullscreen
{fullscreen ? <IoContract /> : <IoExpand />}
</NavigationMenuItem>
<NavigationMenuItem active={mirror} onClick={toggleMirror}>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} />
@@ -1,54 +1,54 @@
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import { useDisclosure, useHotkeys } from '@mantine/hooks';
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
import FloatingNavigation from './floating-navigation/FloatingNavigation';
import ViewLockedIcon from './view-locked-icon/ViewLockedIcon';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
interface ViewNavigationMenuProps {
isLockable?: boolean;
supressSettings?: boolean;
/** prevent navigation */
isNavigationLocked?: boolean;
/** prevent showing settings */
suppressSettings?: boolean;
}
export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isLockable, supressSettings }: ViewNavigationMenuProps) {
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure();
const { open: showEditFormDrawer } = useViewParamsEditorStore();
useHotkeys([
[
'Space',
() => {
if (isViewLocked) return;
toggleMenu();
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
[
'mod + ,',
() => {
if (isViewLocked || supressSettings) return;
if (suppressSettings) return;
showEditFormDrawer();
},
{ preventDefault: true },
],
]);
if (isViewLocked) {
if (isNavigationLocked && suppressSettings) {
return <ViewLockedIcon />;
}
return (
<>
<FloatingNavigation
toggleMenu={toggleMenu}
toggleSettings={supressSettings ? undefined : () => showEditFormDrawer()}
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
/>
<NavigationMenu isOpen={isMenuOpen} onClose={onMenuClose} />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
</>
);
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { IoArrowUp } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useElectronEvent } from '../../../hooks/useElectronEvent';
import { handleLinks } from '../../../utils/linkUtils';
@@ -1,14 +1,14 @@
import { IoLockClosedOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useIsSmallDevice } from '../../../hooks/useIsSmallDevice';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
export default function EditorNavigation() {
const navigate = useNavigate();
const isSmallDevide = useIsSmallDevice();
const isSmallDevice = useIsSmallDevice();
if (!isSmallDevide) {
if (!isSmallDevice) {
return (
<NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}>
<IoLockClosedOutline />
@@ -14,7 +14,8 @@
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
padding-top: 0.75rem;
padding-left: 0.5em;
position: fixed;
left: 0;
@@ -8,7 +8,7 @@ import IconButton from '../../buttons/IconButton';
import style from './FloatingNavigation.module.scss';
interface FloatingNavigationProps {
toggleMenu: () => void;
toggleMenu?: () => void;
toggleSettings?: () => void;
}
@@ -20,15 +20,17 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
id='fadeable-navigation'
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
>
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
{toggleMenu && (
<IconButton
variant='subtle-white'
size='xlarge'
onClick={toggleMenu}
aria-label='toggle menu'
data-testid='navigation__toggle-menu'
>
<IoApps />
</IconButton>
)}
{toggleSettings && (
<IconButton
variant='subtle-white'
@@ -25,9 +25,8 @@
}
&:focus {
outline: none;
background-color: $gray-1300;
border: 2px solid $ui-white;
outline: 2px solid $blue-500;
background-color: $gray-1350;
}
&.current {
@@ -5,12 +5,17 @@
}
.interfaces {
padding: 0.5rem 1rem;
padding: 0.5rem 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.interfaceCopy {
display: flex;
align-items: center;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
@@ -30,13 +30,10 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
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 key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
<span className={style.interfaceCopy}>
{nif.address} <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
);
})}
@@ -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,40 +0,0 @@
import { IoPause, IoPlay, IoStop } from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps {
state: Playback;
skipTooltip?: boolean;
className?: string;
}
export default function PlaybackIcon(props: PlaybackIconProps) {
const { state, skipTooltip, className } = props;
// if timer is Pause or Armed
let label = 'Timer Paused';
let Icon = IoPause;
if (state === Playback.Roll) {
label = 'Timer Rolling';
Icon = IoPlay;
} else if (state === Playback.Play) {
label = 'Timer Playing';
Icon = IoPlay;
} else if (state === Playback.Stop) {
label = 'Timer Stopped';
Icon = IoStop;
}
if (skipTooltip) {
return <Icon className={className} />;
}
return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon className={className} />
</Tooltip>
);
}
@@ -0,0 +1,30 @@
.popup {
box-sizing: border-box;
padding: 1rem 1.5rem;
color: $ui-white;
background-color: $gray-1250;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1000;
max-width: 90vw;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
@@ -0,0 +1,28 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover';
import style from './Popover.module.scss';
interface PopoverContentsProps extends Popover.Positioner.Props {
title?: string;
className?: string;
}
export default function PopoverContents({
title,
className,
children,
...popoverProps
}: PropsWithChildren<PopoverContentsProps>) {
return (
<Popover.Portal>
<Popover.Positioner sideOffset={8} {...popoverProps}>
<Popover.Popup className={style.popup}>
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
<Popover.Description className={className} render={<div />}>
{children}
</Popover.Description>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
);
}
@@ -1,4 +1,4 @@
@use '../../../theme/viewerDefs' as *;
@use '@/theme/viewerDefs' as *;
$progress-bar-size: 12px;
$progress-bar-br: 3px;
@@ -0,0 +1,52 @@
.container {
width: 100%;
height: 100%;
padding-top: 25vh;
background: $ui-black;
color: $ontime-color;
font-size: 3rem;
text-align: center;
}
.pin {
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
input {
font-size: 4rem;
height: 4rem;
width: 4em;
text-align: center;
letter-spacing: 0.25em;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
height: 4rem;
width: 4rem;
font-size: 4rem;
}
}
.pinFailed {
input {
animation: redFlash 1.5s ease;
}
}
@keyframes redFlash {
from {
background: $red-500;
}
to {
background: rgba($red-500, 0);
}
}
@@ -1,71 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { PropsWithChildren, useState } from 'react';
import { IoCheckmark } from 'react-icons/io5';
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
import style from './ProtectRoute.module.scss';
import { cx } from '../../utils/styleUtils';
import IconButton from '../buttons/IconButton';
import Input from '../input/input/Input';
import style from './PinPage.module.scss';
interface PinPageProps {
permission: 'editor' | 'operator';
handleValidation: (pin: string) => boolean;
}
export default function PinPage(props: PinPageProps) {
const { permission, handleValidation } = props;
export default function PinPage({ permission, handleValidation }: PropsWithChildren<PinPageProps>) {
const [pin, setPin] = useState('');
const [failed, setFailed] = useState(false);
const validate = useCallback(() => {
const validate = () => {
const isValid = handleValidation(pin);
if (!isValid) {
setFailed(true);
setPin('');
}
}, [handleValidation, pin]);
};
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key === 'Enter') {
validate();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, [validate]);
const handleInputChange = (value: string) => {
setPin(value);
if (failed) setFailed(false);
};
return (
<div className={style.container}>
{`Ontime ${permission}`}
<div className={failed ? style.pin__failed : style.pin}>
<PinInput
type='alphanumeric'
size='lg'
mask
autoFocus
<form
onSubmit={(event) => {
event.preventDefault();
validate();
}}
className={cx([style.pin, failed && style.pinFailed])}
>
<Input
type='password'
maxLength={4}
height='large'
value={pin}
onChange={(value) => {
setFailed(false);
setPin(value);
}}
>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
<IconButton
variant='ontime-filled'
aria-label='Enter'
size='lg'
isRound
icon={<IoCheckmark />}
onClick={validate}
onChange={(e) => handleInputChange(e.target.value)}
/>
</div>
<IconButton type='submit' variant='primary' aria-label='Enter'>
<IoCheckmark />
</IconButton>
</form>
</div>
);
}
@@ -1,49 +0,0 @@
.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;
}
.pin,
.pin__failed {
display: flex;
gap: 0.125em;
padding-block: 0.5em;
input {
border-radius: 99px;
border-color: $gray-500;
&:hover {
border-color: $blue-500;
}
}
button {
margin-left: 1em;
}
}
.pin__failed {
input {
animation: colourFade 1.5s ease;
}
}
@keyframes colourFade {
from {
background: $red-500;
}
to {
background: rgba($red-500, 0);
}
}
@@ -0,0 +1,80 @@
.radioGroup {
display: flex;
color: $gray-900;
font-size: calc(1rem - 2px);
color: $ui-white;
&[data-disabled] {
.item {
opacity: $opacity-disabled;
cursor: not-allowed;
}
}
}
.horizontal {
align-items: center;
flex-direction: row;
gap: 1rem;
width: 100%;
}
.vertical {
align-items: start;
flex-direction: column;
}
.item {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2rem;
&:has([data-checked]) {
color: $ui-white;
}
}
.radio {
box-sizing: border-box;
display: flex;
width: 16px; // use absolute units to avoid subpixel rendering issues
height: 16px;
align-items: center;
justify-content: center;
border-radius: 100%;
outline: 0;
border: none;
&[data-unchecked] {
border: 1px solid $gray-1200;
background-color: $gray-1200;
}
&[data-checked] {
background-color: $blue-700;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.indicator {
display: grid;
place-content: center;
&[data-unchecked] {
display: none;
}
&::before {
content: '';
border-radius: 100%;
width: 6px; // half of the radio size
height: 6px;
aspect-ratio: 1;
background-color: $ui-white;
}
}
@@ -0,0 +1,40 @@
import { Radio } from '@base-ui-components/react/radio';
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
import { cx } from '../../utils/styleUtils';
import style from './RadioGroup.module.scss';
interface RadioGroupProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
items: {
value: T;
label: string;
}[];
onValueChange?: (value: T) => void;
orientation?: 'horizontal' | 'vertical';
}
export default function RadioGroup<T extends string | number | boolean>({
items,
className,
orientation = 'vertical',
onValueChange,
...elementProps
}: RadioGroupProps<T>) {
return (
<BaseRadioGroup
onValueChange={(value) => onValueChange?.(value as T)}
className={cx([style.radioGroup, style[orientation], className])}
{...elementProps}
>
{items.map((item) => (
<label className={style.item} key={item.value.toString()}>
<Radio.Root value={item.value.toString()} className={style.radio}>
<Radio.Indicator className={style.indicator} />
</Radio.Root>
{item.label}
</label>
))}
</BaseRadioGroup>
);
}
@@ -14,14 +14,13 @@
border: 1px solid transparent;
padding-inline: 0.5rem;
font-size: calc(1rem - 2px);
white-space: nowrap;
&:hover:not(:disabled) {
&:hover:not([data-disabled]) {
background-color: $gray-1100;
}
&:active {
&:active:not([data-disabled]) {
background-color: $gray-1000;
}
@@ -29,10 +28,27 @@
background-color: $gray-1000;
}
&:disabled {
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: 2px;
}
}
.medium {
height: 2rem;
}
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
.selectIcon {
@@ -40,14 +56,12 @@
}
.popup {
font-size: calc(1rem - 2px);
font-size: 1rem;
background-color: $gray-1200;
box-sizing: border-box;
padding: 2px;
border-radius: $component-border-radius-md;
color: $ui-white;
overflow-y: auto;
max-height: 20rem;
border: 1px solid $gray-1000;
&[data-side='start'] {
@@ -57,6 +71,16 @@
}
}
.list {
box-sizing: border-box;
position: relative;
padding-block: 0.25rem;
overflow-y: auto;
max-height: 20rem;
max-height: var(--available-height);
scroll-padding-block: 1.5rem;
}
.item {
box-sizing: border-box;
outline: 0;
@@ -2,31 +2,27 @@ import { IoCheckmark } from 'react-icons/io5';
import { LuChevronsUpDown } from 'react-icons/lu';
import { Select as BaseSelect } from '@base-ui-components/react/select';
import { cx } from '../../utils/styleUtils';
import styles from './Select.module.scss';
interface SelectProps<T extends string | null = string> {
defaultValue?: T;
options: {
value: NonNullable<T>;
label: string;
disabled?: boolean; // exposed to allow creating a non-selectable option
}[];
placeholder?: string;
value?: T;
onChange?: (value: NonNullable<T>) => void;
export type SelectOption<T = string> = {
value: T;
label: string;
disabled?: boolean;
};
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
options: SelectOption<T>[];
fluid?: boolean;
size?: 'medium' | 'large';
}
export default function Select<T extends string | null = string>({
defaultValue,
options,
placeholder,
value,
onChange,
}: SelectProps<T>) {
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
return (
<BaseSelect.Root defaultValue={defaultValue} onValueChange={onChange} value={value}>
<BaseSelect.Trigger className={styles.select}>
<BaseSelect.Value placeholder={placeholder} />
<BaseSelect.Root items={options} {...selectRootProps}>
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
<BaseSelect.Value />
<BaseSelect.Icon className={styles.selectIcon}>
<LuChevronsUpDown />
</BaseSelect.Icon>
@@ -35,16 +31,17 @@ export default function Select<T extends string | null = string>({
<BaseSelect.Positioner side='bottom' align='start'>
<BaseSelect.ScrollUpArrow className={styles.scrollArrow} />
<BaseSelect.Popup className={styles.popup}>
{options.map((option) => {
return (
<BaseSelect.Item key={option.value} className={styles.item} value={option.value}>
<BaseSelect.Arrow />
<BaseSelect.List className={styles.list}>
{options.map(({ disabled, label, value }) => (
<BaseSelect.Item key={String(value)} className={styles.item} value={value} disabled={disabled}>
<BaseSelect.ItemIndicator className={styles.itemIndicator}>
<IoCheckmark className={styles.itemIndicatorIcon} />
</BaseSelect.ItemIndicator>
<BaseSelect.ItemText className={styles.itemLabel}>{option.label}</BaseSelect.ItemText>
<BaseSelect.ItemText className={styles.itemLabel}>{label}</BaseSelect.ItemText>
</BaseSelect.Item>
);
})}
))}
</BaseSelect.List>
</BaseSelect.Popup>
<BaseSelect.ScrollDownArrow className={styles.scrollArrow} />
</BaseSelect.Positioner>

Some files were not shown because too many files have changed in this diff Show More