diff --git a/backend/api/rundown/get_rundown.py b/backend/api/rundown/get_rundown.py index 4dc4ec20..b0692ed3 100644 --- a/backend/api/rundown/get_rundown.py +++ b/backend/api/rundown/get_rundown.py @@ -172,6 +172,7 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel: id_asset=id_asset, id_bin=id_bin, id_event=id_event, + id_folder=asset["id_folder"] if asset else None, duration=duration, status=istatus, transfer_progress=transfer_progress, diff --git a/backend/api/rundown/models.py b/backend/api/rundown/models.py index 4f54f06b..27a1533b 100644 --- a/backend/api/rundown/models.py +++ b/backend/api/rundown/models.py @@ -26,6 +26,7 @@ class RundownRow(ResponseModel): title: str | None = Field(None) subtitle: str | None = Field(None) id_asset: int | None = Field(None) + id_folder: int | None = Field(None) asset_mtime: float | None = Field(None) status: ObjectStatus | None = Field(None) transfer_progress: int | None = Field(None) diff --git a/backend/schema/meta-aliases-cs.json b/backend/schema/meta-aliases-cs.json index e1e4a8da..bc2f13f1 100644 --- a/backend/schema/meta-aliases-cs.json +++ b/backend/schema/meta-aliases-cs.json @@ -118,7 +118,7 @@ ["atmosphere" , "Atmosféra" , null , ""], ["video/index" , "Index video stopy" , "Index video" , ""], ["video/height" , "Výška" , null , "Výška obrazu v pixelech"], - ["mark_out" , "Mark out" , "Out" , ""], + ["mark_out" , "Mark out" , "Mark out" , ""], ["description/original" , "Původní popis" , "Pův. popis" , ""], ["language" , "Jazyk" , null , ""], ["audio/r128/lra/t" , "LRA T" , null , ""], @@ -127,7 +127,7 @@ ["audio/r128/lra/l" , "LRA L" , null , ""], ["id/main" , "IDEC" , null , ""], ["subtitle/original" , "Původní podtitul" , "Pův. podtitul" , "Podtitul díla v původním jazyce"], - ["mark_in" , "Mark in" , "In" , ""], + ["mark_in" , "Mark in" , "Mark out" , ""], ["commercial/client" , "Klient" , null , "Zadavatel reklamy"], ["title/original" , "Původní název" , "Pův. název" , "Název díla v původním jazyce"], ["id_storage" , "Úložiště" , null , ""], diff --git a/backend/schema/meta-aliases-en.json b/backend/schema/meta-aliases-en.json index 6fd389a1..b11097d3 100644 --- a/backend/schema/meta-aliases-en.json +++ b/backend/schema/meta-aliases-en.json @@ -118,7 +118,7 @@ ["atmosphere" , "Atmosphere" , null , "Feeling summarising the atmosphere"], ["video/index" , "Video track index" , "Video index" , ""], ["video/height" , "Height" , null , ""], - ["mark_out" , "Mark out" , null , "The point in time the content proposed for editorial use starts"], + ["mark_out" , "Mark out" , "Mark out" , "The point in time the content proposed for editorial use starts"], ["description/original" , "Original description" , "Orig. description" , ""], ["language" , "Language" , null , "Language version of the asset"], ["audio/r128/lra/t" , "LRA T" , null , ""], @@ -127,7 +127,7 @@ ["audio/r128/lra/l" , "LRA L" , null , ""], ["id/main" , "IDEC" , null , "An unambiguous reference to the resource within a given context"], ["subtitle/original" , "Original subtitle" , "Orig. subtitle" , ""], - ["mark_in" , "Mark in" , "In" , "The point in time the content proposed for editorial use ends"], + ["mark_in" , "Mark in" , "Mark in" , "The point in time the content proposed for editorial use ends"], ["commercial/client" , "Client" , null , ""], ["title/original" , "Original title" , "Orig. title" , ""], ["id_storage" , "Storage" , null , ""], diff --git a/backend/setup/defaults/meta_types.py b/backend/setup/defaults/meta_types.py index 9c85aaed..b21569e5 100644 --- a/backend/setup/defaults/meta_types.py +++ b/backend/setup/defaults/meta_types.py @@ -90,11 +90,13 @@ "broadcast_time": { "ns": "v", "type": T.DATETIME, + "mode": "time", "format": "%H:%M:%S", }, "scheduled_time": { "ns": "v", "type": T.DATETIME, + "mode": "time", "format": "%H:%M:%S", }, "rundown_difference": { diff --git a/frontend/package.json b/frontend/package.json index 62eeea9a..43f478b7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@devbookhq/splitter": "^1.4.0", + "@dnd-kit/core": "^6.0.8", "@reduxjs/toolkit": "^1.8.5", "@wfoxall/timeframe": "^1.2.0", "axios": "^1.6.7", diff --git a/frontend/src/app.jsx b/frontend/src/app.jsx index f9162366..0a1f66d5 100644 --- a/frontend/src/app.jsx +++ b/frontend/src/app.jsx @@ -32,6 +32,7 @@ const App = () => { .then((response) => { setInitData(response.data) nebula.settings = response.data.settings + nebula.experimental = response.data.experimental || false nebula.plugins = response.data.frontend_plugins || [] nebula.scopedEndpoints = response.data.scoped_endpoints || [] nebula.user = response.data.user || {} diff --git a/frontend/src/components/ContextMenu.jsx b/frontend/src/components/ContextMenu.jsx index 18c06767..34794e4d 100644 --- a/frontend/src/components/ContextMenu.jsx +++ b/frontend/src/components/ContextMenu.jsx @@ -120,7 +120,7 @@ const ContextMenu = ({ target, options }) => { icon={option.icon} onClick={() => { setContextData({ ...contextData, visible: false }) - option.onClick && option.onClick() + option.onClick && option.onClick(contextData) }} /> diff --git a/frontend/src/components/Dialog.jsx b/frontend/src/components/Dialog.jsx index ae0014e7..a2610961 100644 --- a/frontend/src/components/Dialog.jsx +++ b/frontend/src/components/Dialog.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import styled from 'styled-components' +import clsx from 'clsx' const StyledDialog = styled.dialog` color: var(--color-text); @@ -15,13 +16,28 @@ const StyledDialog = styled.dialog` max-height: 80%; border: none; + // Animated parameters + transition: all 0.3s ease; + opacity: 0; + transform: scale(0.9); &::backdrop { - background-color: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(2px); + background-color: rgba(0, 0, 0, 0); + backdrop-filter: none; } + &[open] { + opacity: 1; + transform: scale(1); + &::backdrop { + background-color: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + } + } + + // Guts + header, footer { padding: 12px 6px; @@ -97,7 +113,7 @@ const Dialog = ({ return ( span:first-child { - color: ${(props) => props.theme.colors.textDim}; + font-size: 1rem; + color: ${(props) => props.theme.colors.textDim}; + > span:last-child { + color: ${(props) => props.theme.colors.text}; } ` @@ -39,7 +40,7 @@ const Timestamp = ({ timestamp, mode, ...props }) => { return ( - {localDate} + {!(mode === 'time') && {localDate}} {!(mode === 'date') && {localTime}} ) diff --git a/frontend/src/components/Icon.jsx b/frontend/src/components/Icon.jsx new file mode 100644 index 00000000..35735209 --- /dev/null +++ b/frontend/src/components/Icon.jsx @@ -0,0 +1,16 @@ +import styled from 'styled-components' + +const StyledIcon = styled.span` + user-select: none !important; + user-drag: none !important; +` + +const Icon = ({ icon, style }) => { + return ( + + {icon} + + ) +} + +export default Icon diff --git a/frontend/src/components/InputColor.jsx b/frontend/src/components/InputColor.jsx new file mode 100644 index 00000000..5d68a6dc --- /dev/null +++ b/frontend/src/components/InputColor.jsx @@ -0,0 +1,62 @@ +import { useMemo } from 'react' +import styled from 'styled-components' +import defaultTheme from './theme' +import BaseInput from './BaseInput' + +const BaseColorInput = styled(BaseInput)` + width: 30px; +` + +const COLOR_PRESETS = [ + '#dc8a78', + '#dd7878', + '#ea76cb', + '#8839ef', + '#d20f39', + '#e64553', + '#fe640b', + '#df8e1d', + '#40a02b', + '#179299', + '#04a5e5', + '#209fb5', + '#1e66f5', + '#7287fd', + '#4c4f69', +] + +const InputColor = ({ value, onChange, tooltip, ...props }) => { + /* + Nebula stores color as integer so we need to convert it to hex + */ + + const hexValue = useMemo(() => { + if (!value) return '#7287fd' + return `#${value.toString(16).padStart(6, '0')}` + }, [value]) + + const setColor = (hex) => { + if (!hex) return null + onChange(parseInt(hex.slice(1), 16)) + } + + return ( + <> + setColor(e.target.value)} + {...props} + /> + + {COLOR_PRESETS.map((color) => ( + + + ) +} + +export default InputColor diff --git a/frontend/src/components/RangeSlider.jsx b/frontend/src/components/RangeSlider.jsx new file mode 100644 index 00000000..50ae9d43 --- /dev/null +++ b/frontend/src/components/RangeSlider.jsx @@ -0,0 +1,44 @@ +import { forwardRef } from 'react' +import styled from 'styled-components' +import defaultTheme from './theme' + +const StyledRange = styled.input` + border: 0; + border-radius: ${(props) => props.theme.inputBorderRadius}; + background: ${(props) => props.theme.inputBackground}; + + -webkit-appearance: none; + appearance: none; + background: transparent; + + cursor: pointer; + width: 150px; + outline: none; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: ${(props) => props.theme.colors.surface08}; + } + + &::-webkit-slider-runnable-track { + width: 100%; + cursor: pointer; + background: ${(props) => props.theme.colors.surface04}; + border-radius: 8px; + } +` + +StyledRange.defaultProps = { + theme: defaultTheme, + type: 'range', +} + +const RangeSlider = forwardRef((props, ref) => { + return +}) + +export default RangeSlider diff --git a/frontend/src/components/index.jsx b/frontend/src/components/index.jsx index ab6f096a..0c55d717 100644 --- a/frontend/src/components/index.jsx +++ b/frontend/src/components/index.jsx @@ -1,23 +1,26 @@ -export { default as Table } from './table' +export { default as Button } from './Button' +export { default as Canvas } from './Canvas' +export { default as DatePicker } from './DatePicker' export { default as Dialog } from './Dialog' export { default as Dropdown } from './Dropdown' export { default as ErrorBanner } from './ErrorBanner' -export { default as Loader } from './Loader' -export { default as Progress } from './Progress' -export { default as Select } from './Select' -export { default as SelectDialog } from './SelectDialog' +export { default as Icon } from './Icon' +export { default as InputColor } from './InputColor' export { default as InputDatetime } from './InputDatetime' export { default as InputInteger } from './InputInteger' export { default as InputNumber } from './InputNumber' export { default as InputPassword } from './InputPassword' +export { default as InputSwitch } from './InputSwitch' export { default as InputText } from './InputText' export { default as InputTimecode } from './InputTimecode' -export { default as InputSwitch } from './InputSwitch' -export { default as TextArea } from './TextArea' -export { default as Button } from './Button' -export { default as DatePicker } from './DatePicker' -export { default as Canvas } from './Canvas' +export { default as Loader } from './Loader' +export { default as Progress } from './Progress' export { default as RadioButton } from './RadioButton' +export { default as RangeSlider } from './RangeSlider' +export { default as Select } from './Select' +export { default as SelectDialog } from './SelectDialog' +export { default as Table } from './table' +export { default as TextArea } from './TextArea' export { DateTime, Timestamp } from './Fields' export { Form, FormRow } from './Form' diff --git a/frontend/src/components/table/cells.jsx b/frontend/src/components/table/cells.jsx index d26fdbda..9dac1f09 100644 --- a/frontend/src/components/table/cells.jsx +++ b/frontend/src/components/table/cells.jsx @@ -1,4 +1,6 @@ import { useMemo } from 'react' +import { useDraggable } from '@dnd-kit/core' +import clsx from 'clsx' const HeaderCell = ({ name, width, title, sortDirection, onSort }) => { let sortArrowElement = null @@ -47,8 +49,21 @@ const DataRow = ({ onRowClick, rowHighlightColor, rowHighlightStyle, + rowClass, selected = false, }) => { + const { attributes, listeners, setNodeRef, transform, isDragging } = + useDraggable({ + id: rowData.id, + data: { + id: rowData.id, + type: 'asset', + duration: rowData.duration, + title: rowData.title, + subtitle: rowData.subtitle, + }, + }) + const handleClick = (event) => { if (event.type === 'contextmenu' || event.button === 2) { // if we're right-clicking, and the row is already selected, @@ -58,13 +73,19 @@ const DataRow = ({ if (onRowClick) onRowClick(rowData, event) } - const rowStyle = {} + + const rowStyle = { + opacity: isDragging ? 0.5 : 1, + } + + let rowClassName = '' // Left-border highlight color let highlightColor = null let highlightStyle = null if (rowHighlightColor) highlightColor = rowHighlightColor(rowData) if (rowHighlightStyle) highlightStyle = rowHighlightStyle(rowData) + if (rowClass) rowClassName = rowClass(rowData) if (highlightColor) rowStyle['borderLeftColor'] = highlightColor if (highlightStyle) rowStyle['borderLeftStyle'] = highlightStyle @@ -95,9 +116,12 @@ const DataRow = ({ return ( {rowContent} diff --git a/frontend/src/components/table/container.jsx b/frontend/src/components/table/container.jsx index 71abb08b..ebfc29bf 100644 --- a/frontend/src/components/table/container.jsx +++ b/frontend/src/components/table/container.jsx @@ -69,7 +69,7 @@ const TableWrapper = styled.div` display: block; z-index: 0; width: 0; - opacity: 0; + opacity: 0; content: ""; } } diff --git a/frontend/src/components/table/index.jsx b/frontend/src/components/table/index.jsx index a8876af3..ede6e9d5 100644 --- a/frontend/src/components/table/index.jsx +++ b/frontend/src/components/table/index.jsx @@ -16,6 +16,7 @@ const Table = ({ selection, rowHighlightColor, rowHighlightStyle, + rowClass, sortBy, sortDirection, onSort, @@ -57,6 +58,7 @@ const Table = ({ onRowClick={onRowClick} rowHighlightColor={rowHighlightColor} rowHighlightStyle={rowHighlightStyle} + rowClass={rowClass} selected={selection && selection.includes(rowData[keyField])} key={keyField ? rowData[keyField] : idx} /> diff --git a/frontend/src/containers/Browser/Browser.jsx b/frontend/src/containers/Browser/Browser.jsx index f65123a1..4e4c212b 100644 --- a/frontend/src/containers/Browser/Browser.jsx +++ b/frontend/src/containers/Browser/Browser.jsx @@ -3,6 +3,7 @@ import { useEffect, useState, useRef } from 'react' import { useSelector, useDispatch } from 'react-redux' import { toast } from 'react-toastify' import { debounce } from 'lodash' +import clsx from 'clsx' import { Table } from '/src/components' import Pagination from '/src/containers/Pagination' @@ -21,11 +22,11 @@ import { getFormatter, formatRowHighlightColor, formatRowHighlightStyle, -} from './Formatting.jsx' +} from '/src/tableFormatting.jsx' const ROWS_PER_PAGE = 200 -const BrowserTable = () => { +const BrowserTable = ({ isDragging }) => { const currentView = useSelector((state) => state.context.currentView?.id) const searchQuery = useSelector((state) => state.context.searchQuery) const selectedAssets = useSelector((state) => state.context.selectedAssets) @@ -108,13 +109,15 @@ const BrowserTable = () => { setSortDirection(response.data.order_dir) let cols = [] - for (const colName of response.data.columns) + for (const colName of response.data.columns) { + if (colName == 'subtitle') continue // added automatically cols.push({ name: colName, title: nebula.metaHeader(colName), formatter: getFormatter(colName), width: getColumnWidth(colName), }) + } setColumns(cols) setHasMore(hasMore) }) @@ -263,13 +266,15 @@ const BrowserTable = () => { }, ] + const tableClass = clsx('contained', isDragging && 'no-scroll') + return ( <>
{ ) } -const Browser = () => { +const Browser = ({ isDragging }) => { return ( <> - + ) } diff --git a/frontend/src/containers/Calendar/Calendar.jsx b/frontend/src/containers/Calendar/Calendar.jsx new file mode 100644 index 00000000..32d569d9 --- /dev/null +++ b/frontend/src/containers/Calendar/Calendar.jsx @@ -0,0 +1,437 @@ +import styled from 'styled-components' +import { useRef, useMemo, useEffect, useState, useCallback } from 'react' +import CalendarWrapper from './CalendarWrapper' +import ZoomControl from './ZoomControl' +import ContextMenu from '/src/components/ContextMenu' +import drawMarks from './drawMarks' +import drawEvents from './drawEvents' +import { useLocalStorage } from '/src/hooks' + +const CalendarCanvas = styled.canvas` + background-color: #24202e; +` + +const CLOCK_WIDTH = 40 +const DRAG_THRESHOLD = 10 + +const Calendar = ({ + startTime, + draggedAsset, + events, + setEvent, + contextMenu, +}) => { + const calendarRef = useRef(null) + const dayRef = useRef(null) + const wrapperRef = useRef(null) + const cursorTime = useRef(null) + + const [scrollbarWidth, setScrollbarWidth] = useState(0) + const [zoom, setZoom] = useLocalStorage(1) + const [scrollPosition, setScrollPosition] = useLocalStorage(0) + const [mousePos, setMousePos] = useState(null) + + // Reference to events + + useEffect(() => { + eventsRef.current = events + }, [events]) + + // Dragging support + + const initialMousePos = useRef(null) + const lastClickedEvent = useRef(null) + const draggedEvent = useRef(null) + + // Drawing parameters + + const drawParams = useRef({}) + const eventsRef = useRef([]) + + // Time functions + + const dayStartOffsetSeconds = useMemo(() => { + const midnight = new Date(startTime) + midnight.setHours(0, 0, 0, 0) + const offset = (startTime.getTime() - midnight.getTime()) / 1000 + return offset + }, [startTime]) + + const pos2time = (x, y) => { + if (x < CLOCK_WIDTH) return null + //if (y < 0) return null + const _y = Math.max(y, 1) + const _x = x - CLOCK_WIDTH + const { dayWidth, hourHeight } = drawParams.current + if (!dayRef.current || !calendarRef.current) return null + let offsetSeconds = Math.floor((_y / hourHeight) * 60 * 60) // vertical offset + offsetSeconds += Math.floor(_x / dayWidth) * 24 * 60 * 60 // day offset + offsetSeconds = Math.round(offsetSeconds / 300) * 300 // round to 5 minutes + const resultDate = new Date(startTime.getTime() + offsetSeconds * 1000) + return resultDate + } + + const time2pos = (time) => { + const { dayWidth, hourHeight } = drawParams.current + const offsetSeconds = (time - startTime) / 1000 + const dayOffset = Math.floor(offsetSeconds / (24 * 60 * 60)) + const x = CLOCK_WIDTH + dayOffset * dayWidth + const y = ((offsetSeconds % (24 * 60 * 60)) / (60 * 60)) * hourHeight + return { x, y } + } + + const eventAtPos = () => { + if (!cursorTime.current) return null + const currentTs = cursorTime.current.getTime() / 1000 + + // Get the current day midnight (for comparing dates) + const currentMidnight = new Date((currentTs - dayStartOffsetSeconds) * 1000) + currentMidnight.setHours(0, 0, 0, 0) + + // When the next day starts (unix timestamp) + const nextDayStartTs = currentTs - dayStartOffsetSeconds + 24 * 60 * 60 + + let i = 0 + for (const event of events) { + i += 1 + // start and nextStart are unix timestamps + const { start } = event + + // Get the next event start time + const nextEvent = events[i] + let nextStart = nextEvent?.start + + // if nextStart is not defined or is in the next day, use the end of the day + if (!nextStart || nextStart > nextDayStartTs) nextStart = nextDayStartTs + + if (currentTs >= start && currentTs < nextStart) { + // skip events that are not on the current day + // (empty space at the beginning of the day) + const edate = new Date((start - dayStartOffsetSeconds) * 1000).getDate() + if (edate !== currentMidnight.getDate()) continue + + // event fits, returning + return event + } + } + // no valid event under the cursor + return null + } + + // Update drawParams reference + + useEffect(() => { + if (!dayRef.current) return + if (!startTime) return + drawParams.current.hourHeight = + (calendarRef.current?.clientHeight || 0) / 24 + drawParams.current.dayWidth = dayRef.current.clientWidth + drawParams.current.pos2time = pos2time + drawParams.current.time2pos = time2pos + drawParams.current.startTime = startTime + }, [drawParams.current, dayRef.current, zoom, startTime]) + + // + // Draw calendar + // + + const drawCalendar = () => { + if (!dayRef.current || !calendarRef.current) return + const canvas = calendarRef.current + const ctx = canvas.getContext('2d') + const { dayWidth, hourHeight } = drawParams.current + ctx.clearRect(0, 0, canvas.width, canvas.height) + + drawMarks(ctx, drawParams) + if (eventsRef.current) { + drawEvents(ctx, drawParams, eventsRef.current, draggedEvent.current) + } + + if (cursorTime.current && mousePos) { + const { x, y } = mousePos + + if (draggedAsset) { + ctx.fillStyle = '#fff' + ctx.fillText( + `${Math.round(x)}:${Math.round( + y + )} ${cursorTime.current.toLocaleString()}: ${draggedAsset.title}`, + x + 10, + y + 50 + ) + + const timePos = time2pos(cursorTime.current) + ctx.beginPath() + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)' + ctx.rect( + timePos.x + 10, + timePos.y, + dayWidth - 10, + hourHeight * (draggedAsset.duration / 3600) + ) + ctx.fill() + } else if (draggedEvent.current) { + ctx.fillStyle = '#cff' + ctx.fillText( + `${cursorTime.current.toLocaleString()}: ${ + draggedEvent.current.title + }`, + x + 20, + y + 30 + ) + + const timePos = time2pos(cursorTime.current) + ctx.beginPath() + ctx.fillStyle = 'rgba(255, 255, 255, 0.5)' + ctx.rect( + timePos.x + 10, + timePos.y, + dayWidth - 10, + hourHeight * ((draggedEvent.current.duration || 300) / 3600) + ) + ctx.fill() + } + } + } + + // When to draw? + + useEffect(() => { + drawCalendar() + }, [cursorTime.current, events]) + + // Event handlers + + const onMouseMove = (e) => { + if (!calendarRef?.current) return + + // get pos2time from drawParams to avoid stale closure + const { pos2time, hourHeight } = drawParams.current + + // Calculate mouse position + const rect = calendarRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + // TODO align yoffset based on y position (subtract on top, add on bottom) + const yoffset = draggedEvent.current + ? hourHeight * (Math.max(draggedEvent.current?.duration || 1200) / 7200) + : 0 + + let newTime = pos2time(x, y - yoffset) + setMousePos({ x, y }) + + // Update current time + + if (newTime !== cursorTime.current) { + cursorTime.current = newTime + } + + // Start dragging after reaching some distance + + if (initialMousePos.current && !draggedEvent.current) { + const distance = Math.sqrt( + Math.pow(x - initialMousePos.current.x, 2) + + Math.pow(y - initialMousePos.current.y, 2) + ) + if (distance > DRAG_THRESHOLD) { + draggedEvent.current = lastClickedEvent.current + } else { + draggedEvent.current = null + } + } // start dragging + } // onMouseMove + + // + // Clicking + // + + const onMouseUp = (e) => { + if (!calendarRef?.current) return + if (draggedAsset && cursorTime.current) { + console.log('Dropped asset', draggedAsset, cursorTime.current) + setEvent({ + id_asset: draggedAsset.id, + start: Math.floor(cursorTime.current.getTime() / 1000), + }) + } else if (draggedEvent.current && cursorTime.current) { + console.log('Dropped event', draggedEvent.current, cursorTime.current) + + setEvent({ + id: draggedEvent.current.id, + start: Math.floor(cursorTime.current.getTime() / 1000), + }) + } + + draggedEvent.current = null + lastClickedEvent.current = null + initialMousePos.current = null + } + + // Keep track where the mouse is + // and what event was clicked last + + const onMouseDown = (evt) => { + const pos = { x: evt.clientX, y: evt.clientY } + const rect = calendarRef.current.getBoundingClientRect() + const x = evt.clientX - rect.left + const y = evt.clientY - rect.top + initialMousePos.current = { x, y } + const event = eventAtPos(pos.x, pos.y) + if (event) lastClickedEvent.current = event + } + + useEffect(() => { + if (!calendarRef.current) return + calendarRef.current.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + return () => { + if (!calendarRef.current) return + calendarRef.current.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + }, [calendarRef.current, startTime]) + + // + // Handle calendar resizing + // + + const resizeCanvas = () => { + const canvas = calendarRef.current + if (!canvas?.parentElement) return + canvas.width = canvas.parentElement.clientWidth + canvas.height = canvas.parentElement.clientHeight * zoom + + const bodyWrapper = calendarRef.current.parentElement + const scrollbarWidth = bodyWrapper.offsetWidth - bodyWrapper.clientWidth + setScrollbarWidth(scrollbarWidth) + + drawParams.current.dayWidth = dayRef.current.clientWidth + drawParams.current.hourHeight = calendarRef.current.clientHeight / 24 + + drawCalendar() + } + + useEffect(() => { + resizeCanvas() + }, [zoom]) + + useEffect(() => { + if (!wrapperRef.current) return + const resizeObserver = new ResizeObserver(() => resizeCanvas()) + resizeObserver.observe(wrapperRef.current) + return () => { + if (wrapperRef.current) { + resizeObserver.unobserve(wrapperRef.current) + } + } + }, [wrapperRef.current]) + + const contextMenuItems = useCallback(() => { + const result = [] + for (const item of contextMenu) { + result.push({ + label: item.label, + icon: item.icon, + onClick: (e) => { + const event = eventAtPos(e.posX, e.posY) + if (!event) return + item.onClick(event) + }, + }) + } + return result + }, [contextMenu, eventAtPos]) + + const onScroll = (e) => { + setScrollPosition(e.target.scrollTop) + } + + useEffect(() => { + if (!wrapperRef.current) return + if (wrapperRef.current.scrollTop !== scrollPosition) { + wrapperRef.current.scrollTop = scrollPosition + } + }, [scrollPosition]) + + // + // Render + // + + const dstyles = useMemo(() => { + const weekStartTs = startTime.getTime() / 1000 + const todayStartTs = + new Date().setHours(0, 0, 0, 0) / 1000 + dayStartOffsetSeconds + + const dayStyles = [] + for (let i = 0; i < 7; i++) { + const dayStartTs = weekStartTs + i * 24 * 3600 + + if (todayStartTs === dayStartTs) { + dayStyles.push({ + borderBottom: '1px solid var(--color-text)', + fontWeight: 'bold', + }) + continue + } + + const color = + todayStartTs > dayStartTs ? 'var(--color-red)' : 'var(--color-green)' + const style = { + borderBottom: `1px solid ${color}`, + } + dayStyles.push(style) + } + return dayStyles + }, [startTime]) + + return ( + +
+
+ Monday +
+
+ Tuesday +
+
+ Wednesday +
+
+ Thursday +
+
+ Friday +
+
+ Saturday +
+
+ Sunday +
+
+
+
+ +
+
+
+ +
+ {contextMenuItems && ( + + )} +
+ ) +} + +export default Calendar diff --git a/frontend/src/containers/Calendar/CalendarWrapper.jsx b/frontend/src/containers/Calendar/CalendarWrapper.jsx new file mode 100644 index 00000000..736aeec3 --- /dev/null +++ b/frontend/src/containers/Calendar/CalendarWrapper.jsx @@ -0,0 +1,45 @@ +import styled from 'styled-components' + +const CalendarWrapper = styled.div` + display: flex; + flex-grow: 1; + flex-direction: column; + font-family: Arial; + gap: 6px; + + .calendar-header { + user-select: none; + user-drag: none; + display: flex; + margin-right: ${(props) => + props.scrollbarWidth}px; /* Dynamic padding to account for scrollbar */ + margin-left: ${(props) => + props.clockWidth}px; /* Dynamic padding to account for scrollbar */ + + .calendar-day { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 6px 0; + color: #c0c0c0; + } + } + + .calendar-body { + display: flex; + flex-grow: 1; + position: relative; + + .calendar-body-wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow-x: hidden; + overflow-y: scroll; + } + } +` +export default CalendarWrapper diff --git a/frontend/src/containers/Calendar/ZoomControl.jsx b/frontend/src/containers/Calendar/ZoomControl.jsx new file mode 100644 index 00000000..1e1a21a1 --- /dev/null +++ b/frontend/src/containers/Calendar/ZoomControl.jsx @@ -0,0 +1,32 @@ +import { RangeSlider, Icon } from '/src/components' + +const ZoomControl = ({ zoom, setZoom }) => { + const divStyle = { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: '5px', + width: 200, + } + + const iconStyle = { + fontSize: '1rem', + } + + return ( +
+ + setZoom(e.target.value)} + value={zoom} + /> + +
+ ) +} + +export default ZoomControl diff --git a/frontend/src/containers/Calendar/drawEvents.js b/frontend/src/containers/Calendar/drawEvents.js new file mode 100644 index 00000000..376d91ee --- /dev/null +++ b/frontend/src/containers/Calendar/drawEvents.js @@ -0,0 +1,63 @@ +import { drawTruncatedText } from './drawUtils' + +const drawEvents = (ctx, drawParams, events, draggedEvent) => { + const { dayWidth, hourHeight, time2pos } = drawParams.current + const maxY = ctx.canvas.height + + let i = 0 + for (const event of events) { + const { start, title, duration } = event + const nextEvent = events[i + 1] + const nextStart = nextEvent?.start + + i += 1 + if (draggedEvent?.id === event?.id) continue + + const startPos = time2pos(start * 1000) + const endPos = nextStart ? time2pos(nextStart * 1000) : null + const eventDuration = nextStart ? (nextStart - start) * 1000 : null + + // Compute the event rectangle height + + let eventHeight + if (!endPos) eventHeight = maxY - startPos.y + else if (endPos.x > startPos.x) eventHeight = maxY - startPos.y + else eventHeight = (eventDuration / (3600 * 1000)) * hourHeight + + // Set the fill style to the gradient + + const gradientEnd = startPos.y + eventHeight + const gradient = ctx.createLinearGradient(0, startPos.y, 0, gradientEnd) + const eventColor = event.color + ? `#${event.color.toString(16).padStart(6, '0')}` + : '#7287fd' + + gradient.addColorStop(0, eventColor) + gradient.addColorStop(1, 'transparent') + + ctx.fillStyle = gradient + ctx.fillRect(startPos.x + 10, startPos.y, dayWidth - 10, eventHeight - 2) + + // draw actual duration + + const usedHeight = hourHeight * (duration / 3600) + ctx.fillStyle = duration - 30 > nextStart - start ? '#ff2404' : '#5fff5f' + ctx.fillRect(startPos.x + 10, startPos.y, 3, usedHeight) + + // event title + + ctx.font = '12px Noto Sans' + ctx.fillStyle = '#fff' + drawTruncatedText( + ctx, + startPos.x + 15, + startPos.y + 15, + dayWidth - 20, + title + ) + + //ctx.fillText(title, startPos.x + 15, startPos.y + 15) + } +} + +export default drawEvents diff --git a/frontend/src/containers/Calendar/drawMarks.js b/frontend/src/containers/Calendar/drawMarks.js new file mode 100644 index 00000000..edcc2f22 --- /dev/null +++ b/frontend/src/containers/Calendar/drawMarks.js @@ -0,0 +1,47 @@ +const drawMarks = (ctx, drawParams) => { + const { dayWidth, hourHeight, startTime, pos2time, time2pos } = + drawParams.current + + const startHour = startTime?.getHours() || 0 + const startMinute = startTime?.getMinutes() || 0 + + const firstTime = new Date(startTime) + firstTime.setMinutes(startMinute + ((15 - (startMinute % 15)) % 15)) + firstTime.setHours( + startHour + (startMinute + ((15 - (startMinute % 15)) % 15)) / 60 + ) + + for (let rday = 0; rday < 7; rday++) { + for (let rtime = 0; rtime < 24 * 4; rtime++) { + const timeMarker = new Date(firstTime.getTime() + rtime * 15 * 60000) + const minutes = timeMarker.getMinutes() + const { x, y } = time2pos(timeMarker) + + if (hourHeight < 50 && minutes !== 0) { + continue + } + + const x1 = x + rday * dayWidth + 10 + const x2 = x1 + dayWidth - 10 + + ctx.beginPath() + ctx.strokeStyle = '#444' + if (minutes === 0) { + ctx.setLineDash([1, 0]) + } else { + ctx.setLineDash([5, 5]) + } + ctx.moveTo(x1, y) + ctx.lineTo(x2, y) + ctx.stroke() + ctx.setLineDash([]) // Reset to solid lines for other drawings + if (rday === 0 && minutes === 0) { + ctx.font = '12px Arial' + ctx.fillStyle = '#fff' + ctx.fillText(timeMarker.toTimeString().slice(0, 5), 10, y + 4) + } + } + } +} + +export default drawMarks diff --git a/frontend/src/containers/Calendar/drawUtils.js b/frontend/src/containers/Calendar/drawUtils.js new file mode 100644 index 00000000..04aa9326 --- /dev/null +++ b/frontend/src/containers/Calendar/drawUtils.js @@ -0,0 +1,43 @@ +function drawTruncatedText(ctx, x, y, width, text) { + function removeLastVowel(str) { + return str.replace(/([aeiouAEIOU])(?!.*[aeiouAEIOU\s])/g, '') + } + const deterministicRm = (str) => { + const words = str.split(' ') + const totalLength = str.length + const wordIndex = totalLength % words.length + const wordToModify = words[wordIndex] + const charIndex = Math.max(totalLength % wordToModify.length, 1) + const newWord = + wordToModify.slice(0, charIndex) + wordToModify.slice(charIndex + 1) + return words + .map((word, index) => (index === wordIndex ? newWord : word)) + .join(' ') + } + + let truncatedText = text + + let i = 0 + while (ctx.measureText(truncatedText).width > width) { + let newText = removeLastVowel(truncatedText) + if (newText.length == truncatedText.length) { + newText = deterministicRm(truncatedText) + if (newText.length == truncatedText.length) { + newText = truncatedText.slice(0, -1) + } + } + + if (newText.length === 0) { + truncatedText = + text.slice(0, Math.floor(width / ctx.measureText('W').width)) + '...' + break + } + truncatedText = newText + i++ + if (i > 40) break + } + + ctx.fillText(truncatedText, x, y) +} + +export { drawTruncatedText } diff --git a/frontend/src/containers/Calendar/index.jsx b/frontend/src/containers/Calendar/index.jsx new file mode 100644 index 00000000..4bf6abe9 --- /dev/null +++ b/frontend/src/containers/Calendar/index.jsx @@ -0,0 +1,2 @@ +import Calendar from './Calendar' +export default Calendar diff --git a/frontend/src/pages/AssetEditor/EditorForm.jsx b/frontend/src/containers/MetadataEditor.jsx similarity index 91% rename from frontend/src/pages/AssetEditor/EditorForm.jsx rename to frontend/src/containers/MetadataEditor.jsx index ccce92bc..9e41ddde 100644 --- a/frontend/src/pages/AssetEditor/EditorForm.jsx +++ b/frontend/src/containers/MetadataEditor.jsx @@ -4,11 +4,12 @@ import { useMemo } from 'react' import { Form, FormRow, Select } from '/src/components' import { - InputText, - InputInteger, - TextArea, + InputColor, InputDatetime, + InputInteger, InputSwitch, + InputText, + TextArea, } from '/src/components' const EditorField = ({ @@ -47,6 +48,8 @@ const EditorField = ({ return 0 case 'boolean': return false + case 'color': + return 0 default: return undefined } @@ -120,6 +123,11 @@ const EditorField = ({ ) break + case 'color': + editor = ( + + ) + break default: editor = } @@ -137,20 +145,21 @@ const EditorField = ({ ) } -const EditorForm = ({ +const MetadataEditor = ({ originalData, - assetData, - setAssetData, + objectData, + setObjectData, fields, onSave, disabled, }) => { const onFieldChanged = (key, value) => - setAssetData((o) => { + setObjectData((o) => { return { ...o, [key]: value } }) function handleKeyDown(event) { + if (!onSave) return if (event.ctrlKey && event.key === 's') { event.preventDefault() // prevent default browser behavior (saving the page) onSave() @@ -163,7 +172,7 @@ const EditorForm = ({ {
Assets - {false && nebula.settings.system.ui_asset_preview && ( - Preview + {nebula.experimental && ( + <> + Scheduler + Rundown + )} Jobs {!nebula.user.is_limited && ( diff --git a/frontend/src/containers/SendTo.jsx b/frontend/src/containers/SendTo.jsx index 50e63785..5537101b 100644 --- a/frontend/src/containers/SendTo.jsx +++ b/frontend/src/containers/SendTo.jsx @@ -9,9 +9,14 @@ const SendToDialogBody = ({ selectedAssets, onHide }) => { const [sendToOptions, setSendToOptions] = useState(null) const loadOptions = () => { - nebula.request('actions', { ids: selectedAssets }).then((response) => { - setSendToOptions(response.data.actions) - }) + nebula + .request('actions', { ids: selectedAssets }) + .then((response) => { + setSendToOptions(response.data.actions) + }) + .catch((error) => { + sendToOptions([]) + }) } useEffect(() => { @@ -64,6 +69,10 @@ const SendToDialogBody = ({ selectedAssets, onHide }) => { ? 'the asset' : `${selectedAssets.length} assets` + if (!sendToOptions) { + return + } + return (
)} - diff --git a/frontend/src/pages/MAMPage.jsx b/frontend/src/pages/MAMPage.jsx index 9b80e4bd..8b009338 100644 --- a/frontend/src/pages/MAMPage.jsx +++ b/frontend/src/pages/MAMPage.jsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect } from 'react' +import { useMemo, useEffect, useState } from 'react' import { useSelector, useDispatch } from 'react-redux' import { useParams, useSearchParams } from 'react-router-dom' import Splitter, { SplitDirection } from '@devbookhq/splitter' @@ -6,9 +6,18 @@ import styled from 'styled-components' import { useLocalStorage } from '/src/hooks' import { setFocusedAsset, setSelectedAssets } from '/src/actions' + import Browser from '/src/containers/Browser' import AssetEditor from '/src/pages/AssetEditor' -// import AssetPreview from '/src/pages/AssetPreview' +import Scheduler from '/src/pages/Scheduler' +import Rundown from './Rundown' +import { + DndContext, + DragOverlay, + MouseSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' import SendToDialog from '/src/containers/SendTo' const MAMContainer = styled.div` @@ -21,7 +30,7 @@ const MAMContainer = styled.div` .__dbk__child-wrapper { display: flex; flex-direction: column; - gap: 8px; + gap: var(--section-gap); min-width: 400px; } @@ -44,11 +53,58 @@ const MAMPage = () => { null ) + // Drag and drop from the browser + + // const [activeId, setActiveId] = useState(null) + const [draggedAsset, setDraggedAsset] = useState(null) + const [isDragging, setIsDragging] = useState(false) + + const mouseSensor = useSensor(MouseSensor, { + // Require the mouse to move by 10 pixels before activating + activationConstraint: { + distance: 10, + }, + }) + + const sensors = useSensors(mouseSensor) + + const setBodyCursor = (cursor) => { + document.body.style.setProperty('cursor', cursor, 'important') + } + + const onDragStart = (event) => { + setIsDragging(true) + // setActiveId(event.active.id) + setDraggedAsset(event.active.data.current) + setBodyCursor('grabbing') + + if (event.active.id === focusedAsset) return + dispatch(setFocusedAsset(event.active.id)) + dispatch(setSelectedAssets([event.active.id])) + } + + const onDragEnd = (event) => { + setIsDragging(false) + // setActiveId(null) + setDraggedAsset(null) + const { active, over } = event + setBodyCursor('auto') + } + + const onDragCancel = () => { + setIsDragging(false) + // setActiveId(null) + setDraggedAsset(null) + } + + // + // URL handling + // + useEffect(() => { if (searchParams.get('asset')) { const assetId = parseInt(searchParams.get('asset')) if (assetId === focusedAsset) return - dispatch(setFocusedAsset(assetId)) dispatch(setSelectedAssets([assetId])) } @@ -57,36 +113,60 @@ const MAMPage = () => { useEffect(() => { if (focusedAsset === searchParams.get('asset')) return if (focusedAsset === null) { - setSearchParams({}) + setSearchParams((o) => { + o.delete('asset') + return o + }) return } - setSearchParams({ asset: focusedAsset }) + console.log('set asset') + setSearchParams((o) => { + o.set('asset', focusedAsset) + return o + }) }, [focusedAsset]) - const componentProps = {} + // + // MAM Module + // + + const componentProps = { + draggedAsset, + } const moduleComponent = useMemo(() => { if (module == 'editor') return - // if (module == 'preview') return + if (module == 'scheduler') return + if (module == 'rundown') return return 'Not implemented' - }, [module]) + }, [module, draggedAsset]) // eslint-disable-next-line no-unused-vars const onResize = (gutter, size) => { setSplitterSizes(size) } + // + // Render + // + return ( - - - {moduleComponent} - + + + {moduleComponent} + + ) diff --git a/frontend/src/pages/Rundown/Rundown.jsx b/frontend/src/pages/Rundown/Rundown.jsx new file mode 100644 index 00000000..6f3f17bd --- /dev/null +++ b/frontend/src/pages/Rundown/Rundown.jsx @@ -0,0 +1,36 @@ +import { useState, useEffect, useMemo } from 'react' +import nebula from '/src/nebula' + +import RundownNav from './RundownNav' +import RundownTable from './RundownTable' + +const Rundown = () => { + const [startTime, setStartTime] = useState(null) + const [rundown, setRundown] = useState(null) + + const onResponse = (response) => { + setRundown(response.data.rows) + } + + const onError = (error) => { + console.log(error.response) + } + + useEffect(() => { + if (!startTime) return + const requestParams = { + date: startTime.toISOString().split('T')[0], + id_channel: 1, + } + nebula.request('rundown', requestParams).then(onResponse).catch(onError) + }, [startTime]) + + return ( +
+ + +
+ ) +} + +export default Rundown diff --git a/frontend/src/pages/Rundown/RundownNav.jsx b/frontend/src/pages/Rundown/RundownNav.jsx new file mode 100644 index 00000000..7a6fe295 --- /dev/null +++ b/frontend/src/pages/Rundown/RundownNav.jsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import { NavLink, useSearchParams } from 'react-router-dom' + +import { Navbar, InputText, Button, Spacer } from '/src/components' +import { setPageTitle } from '/src/actions' + +const DAY = 24 * 60 * 60 * 1000 + +const RundownNav = ({ startTime, setStartTime }) => { + const [searchParams, setSearchParams] = useSearchParams() + const dispatch = useDispatch() + + useEffect(() => { + const dateParam = searchParams.get('date') + if (dateParam) { + const newStartTime = new Date(dateParam) + newStartTime.setHours(7, 30, 0, 0) + if (newStartTime.getTime() !== startTime?.getTime()) { + setStartTime(newStartTime) + } + } else { + const defaultDate = new Date() + defaultDate.setHours(7, 30, 0, 0) + setStartTime(defaultDate) + } + }, [searchParams]) + + useEffect(() => { + if (!startTime) return + const dateParam = searchParams.get('date') + const formattedDate = startTime.toISOString().split('T')[0] + if (dateParam !== formattedDate) { + setSearchParams((o) => { + o.set('date', formattedDate) + return o + }) + } + dispatch(setPageTitle({ title: `Rundown ${formattedDate}` })) + }, [startTime, setSearchParams]) + + const prevDay = () => setStartTime(new Date(startTime.getTime() - DAY)) + const nextDay = () => setStartTime(new Date(startTime.getTime() + DAY)) + + return ( + +
+ + ) +} + +export default RundownTable diff --git a/frontend/src/pages/Rundown/index.jsx b/frontend/src/pages/Rundown/index.jsx new file mode 100644 index 00000000..5226502c --- /dev/null +++ b/frontend/src/pages/Rundown/index.jsx @@ -0,0 +1,2 @@ +import Rundown from './Rundown' +export default Rundown diff --git a/frontend/src/pages/Scheduler/EventDialog.jsx b/frontend/src/pages/Scheduler/EventDialog.jsx new file mode 100644 index 00000000..554a31be --- /dev/null +++ b/frontend/src/pages/Scheduler/EventDialog.jsx @@ -0,0 +1,44 @@ +import React, { useMemo } from 'react' +import { Button, Dialog } from '/src/components' +import MetadataEditor from '/src/containers/MetadataEditor' +import nebula from '/src/nebula' + +const EventDialog = ({ data, setData, onHide, onSave }) => { + const header = useMemo(() => { + if (data.title) return data.title + if (!data.id) return 'New Event' + return '???' + }, [data]) + + const originalData = useMemo(() => { + return data + }, []) + + const fields = useMemo(() => { + return nebula.settings.playout_channels[0].fields + }, []) + + const footer = ( + <> + ) @@ -102,18 +117,40 @@ const getDefaultFormatter = (key) => { } const getFormatter = (key) => { - if (['title', 'subtitle', 'description'].includes(key)) + if (['subtitle', 'description'].includes(key)) // eslint-disable-next-line return (rowData, key) => switch (key) { + case 'title': + return (rowData, key) => { + const title = rowData[key] + const subtitle = rowData.subtitle + return ( + + ) + } + case 'qc/state': // eslint-disable-next-line - return (rowData, key) => ( - - ) + return (rowData, key) => { + const qcState = QC_STATES[rowData[key]] + return ( + + ) + } case 'id_folder': // eslint-disable-next-line @@ -128,9 +165,26 @@ const getFormatter = (key) => { // eslint-disable-next-line return (rowData, key) => { const fps = rowData['video/fps_f'] || 25 - const duration = rowData[key] || 0 + let duration = rowData[key] || 0 + if (rowData.mark_out) duration = rowData.mark_out + if (rowData.mark_in) duration -= rowData.mark_in + const trimmed = duration < rowData.duration const timecode = new Timecode(duration * fps, fps) - return + const title = + trimmed && + `Original duration ${new Timecode(rowData.duration * fps, fps)}` + return ( + + ) + } + + case 'status': + return (rowData, key) => { + const status = STATUSES[rowData[key]] + return } case 'created_by': diff --git a/frontend/src/tableFormatting.scss b/frontend/src/tableFormatting.scss new file mode 100644 index 00000000..5c22649a --- /dev/null +++ b/frontend/src/tableFormatting.scss @@ -0,0 +1,69 @@ +// Coloring stuff + +.qc-state { + div { + display: inline-block; + &::before { + content: '⚑'; + } + } + + &.new { + color: var(--color-text); + } + &.rejected { + color: var(--color-red); + } + &.accepted { + color: var(--color-green); + } +} + +.qc-state-3 { + color: var(--color-red); +} + +.qc-state-4 { + color: var(--color-green); +} + +.status { + text-transform: uppercase; + &.offline { + color: var(--color-red) !important; + } + &.online { + color: var(--color-green) !important; + } + &.creating { + color: var(--color-yellow) !important; + } + &.trashed { + color: var(--color-text-dim) !important; + } + &.archived { + color: var(--color-blue) !important; + } + &.reset { + color: var(--color-yellow) !important; + } + &.corrupted { + color: var(--color-red) !important; + } + &.remote { + color: var(--color-yellow) !important; + } + &.unknown { + color: var(--color-yellow) !important; + } + &.aired { + color: var(--color-text-dim) !important; + } + &.onair { + background-color: var(--color-red) !important; + color: white !important; + } + &.retrieving { + color: var(--color-yellow) !important; + } +}
- {' '} + {rowData[key]} + {title} + {subtitle && ( + <> + + {nebula.settings.system.subtitle_separator} + {subtitle} + + + )} + - - +
+
{timecode.toString().substring(0, 11)} + {timecode.toString().substring(0, 11)} + {trimmed && '*'} + {status}