chore: remove semicolons from TS/TSX code (#2403)

This commit is contained in:
Nico Miguelino
2025-07-16 07:30:31 -07:00
committed by GitHub
parent 719b19f7e6
commit 88a8ab7308
60 changed files with 1393 additions and 1393 deletions

View File

@@ -5,7 +5,7 @@
"options": {
"singleQuote": true,
"jsxSingleQuote": false,
"semi": true,
"semi": false,
"trailingComma": "all"
}
},

View File

@@ -45,13 +45,13 @@ export default [
}
},
rules: {
'semi': ['error', 'always'],
'quotes': ['error', 'single'],
'indent': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'error',
'no-console': 'error',
'no-debugger': 'warn',
'no-unexpected-multiline': 'error',
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
'react/jsx-no-duplicate-props': 'error',

View File

@@ -1,9 +1,9 @@
import { useSelector, useDispatch } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux'
import {
selectActiveAssets,
updateAssetOrder,
fetchAssets,
} from '@/store/assets';
} from '@/store/assets'
import {
DndContext,
closestCenter,
@@ -12,59 +12,59 @@ import {
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableAssetRow } from '@/components/sortable-asset-row';
import { useState, useEffect } from 'react';
import { Asset, ActiveAssetsTableProps, AppDispatch } from '@/types';
} from '@dnd-kit/sortable'
import { SortableAssetRow } from '@/components/sortable-asset-row'
import { useState, useEffect } from 'react'
import { Asset, ActiveAssetsTableProps, AppDispatch } from '@/types'
export const ActiveAssetsTable = ({ onEditAsset }: ActiveAssetsTableProps) => {
const dispatch = useDispatch<AppDispatch>();
const activeAssets = useSelector(selectActiveAssets) as Asset[];
const [items, setItems] = useState<Asset[]>(activeAssets);
const dispatch = useDispatch<AppDispatch>()
const activeAssets = useSelector(selectActiveAssets) as Asset[]
const [items, setItems] = useState<Asset[]>(activeAssets)
useEffect(() => {
setItems(activeAssets);
}, [activeAssets]);
setItems(activeAssets)
}, [activeAssets])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
)
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
const { active, over } = event
if (!over || active.id === over.id) {
return;
return
}
const oldIndex = items.findIndex(
(asset) => asset.asset_id.toString() === active.id,
);
)
const newIndex = items.findIndex(
(asset) => asset.asset_id.toString() === over.id,
);
)
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
const newItems = arrayMove(items, oldIndex, newIndex)
setItems(newItems)
const activeIds = newItems.map((asset: Asset) => asset.asset_id);
const activeIds = newItems.map((asset: Asset) => asset.asset_id)
try {
await dispatch(updateAssetOrder(activeIds.join(','))).unwrap();
dispatch(fetchAssets());
await dispatch(updateAssetOrder(activeIds.join(','))).unwrap()
dispatch(fetchAssets())
} catch {
setItems(activeAssets);
setItems(activeAssets)
}
};
}
return (
<DndContext
@@ -121,5 +121,5 @@ export const ActiveAssetsTable = ({ onEditAsset }: ActiveAssetsTableProps) => {
</tbody>
</table>
</DndContext>
);
};
)
}

View File

@@ -1,16 +1,16 @@
import React from 'react';
import classNames from 'classnames';
import React from 'react'
import classNames from 'classnames'
interface FileUploadTabProps {
fileInputRef: React.RefObject<HTMLInputElement | null>;
dropZoneRef: React.RefObject<HTMLDivElement | null>;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleFileDrop: (e: React.DragEvent<HTMLDivElement>) => void;
handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
handleDragEnter: (e: React.DragEvent<HTMLDivElement>) => void;
handleDragLeave: (e: React.DragEvent<HTMLDivElement>) => void;
isSubmitting: boolean;
uploadProgress: number;
fileInputRef: React.RefObject<HTMLInputElement | null>
dropZoneRef: React.RefObject<HTMLDivElement | null>
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
handleFileDrop: (e: React.DragEvent<HTMLDivElement>) => void
handleDragOver: (e: React.DragEvent<HTMLDivElement>) => void
handleDragEnter: (e: React.DragEvent<HTMLDivElement>) => void
handleDragLeave: (e: React.DragEvent<HTMLDivElement>) => void
isSubmitting: boolean
uploadProgress: number
}
export const FileUploadTab = ({
@@ -81,5 +81,5 @@ export const FileUploadTab = ({
></div>
</div>
</div>
);
};
)
}

View File

@@ -8,26 +8,26 @@
* @returns {string} - The mimetype of the file
*/
export const getMimetype = (filename: string): string => {
const viduris = ['rtsp', 'rtmp'];
const viduris = ['rtsp', 'rtmp']
const mimetypes = [
[['jpe', 'jpg', 'jpeg', 'png', 'pnm', 'gif', 'bmp'], 'image'],
[['avi', 'mkv', 'mov', 'mpg', 'mpeg', 'mp4', 'ts', 'flv'], 'video'],
];
const domains = [[['www.youtube.com', 'youtu.be'], 'youtube_asset']];
]
const domains = [[['www.youtube.com', 'youtu.be'], 'youtube_asset']]
// Check if it's a streaming URL
const scheme = filename.split(':')[0].toLowerCase();
const scheme = filename.split(':')[0].toLowerCase()
if (viduris.includes(scheme)) {
return 'streaming';
return 'streaming'
}
// Check if it's a domain-specific asset
try {
const domain = filename.split('//')[1]?.toLowerCase().split('/')[0];
const domain = filename.split('//')[1]?.toLowerCase().split('/')[0]
if (domain) {
for (const [domainList, type] of domains) {
if (domainList.includes(domain)) {
return type as string;
return type as string
}
}
}
@@ -35,19 +35,19 @@ export const getMimetype = (filename: string): string => {
// Check file extension
try {
const ext = filename.split('.').pop()?.toLowerCase();
const ext = filename.split('.').pop()?.toLowerCase()
if (ext) {
for (const [extList, type] of mimetypes) {
if (extList.includes(ext)) {
return type as string;
return type as string
}
}
}
} catch {}
// Default to webpage
return 'webpage';
};
return 'webpage'
}
/**
* Get the duration for a mimetype
@@ -62,25 +62,25 @@ export const getDurationForMimetype = (
defaultStreamingDuration: number,
): number => {
if (mimetype === 'video') {
return 0;
return 0
} else if (mimetype === 'streaming') {
return defaultStreamingDuration;
return defaultStreamingDuration
} else {
return defaultDuration;
return defaultDuration
}
};
}
/**
* Get default dates for an asset
* @returns {Object} - Object containing start_date and end_date
*/
export const getDefaultDates = (): { start_date: string; end_date: string } => {
const now = new Date();
const endDate = new Date();
endDate.setDate(endDate.getDate() + 30); // 30 days from now
const now = new Date()
const endDate = new Date()
endDate.setDate(endDate.getDate() + 30) // 30 days from now
return {
start_date: now.toISOString(),
end_date: endDate.toISOString(),
};
};
}
}

View File

@@ -1,25 +1,25 @@
import React, { useEffect } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { setActiveTab, selectAssetModalState } from '@/store/assets';
import { Asset, AppDispatch } from '@/types';
import { useFileUpload } from './use-file-upload';
import { useAssetForm } from './use-asset-form';
import { useModalAnimation } from './use-modal-animation';
import { UriTab } from './uri-tab';
import { FileUploadTab } from './file-upload-tab';
import React, { useEffect } from 'react'
import classNames from 'classnames'
import { useDispatch, useSelector } from 'react-redux'
import { setActiveTab, selectAssetModalState } from '@/store/assets'
import { Asset, AppDispatch } from '@/types'
import { useFileUpload } from './use-file-upload'
import { useAssetForm } from './use-asset-form'
import { useModalAnimation } from './use-modal-animation'
import { UriTab } from './uri-tab'
import { FileUploadTab } from './file-upload-tab'
interface AddAssetModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (asset: Asset) => void;
isOpen: boolean
onClose: () => void
onSave: (asset: Asset) => void
initialData?: {
uri?: string;
name?: string;
mimetype?: string;
duration?: number;
skipAssetCheck?: boolean;
};
uri?: string
name?: string
mimetype?: string
duration?: number
skipAssetCheck?: boolean
}
}
export const AddAssetModal = ({
@@ -28,10 +28,10 @@ export const AddAssetModal = ({
onSave,
initialData = {},
}: AddAssetModalProps) => {
const dispatch = useDispatch<AppDispatch>();
const dispatch = useDispatch<AppDispatch>()
const { activeTab, statusMessage, uploadProgress } = useSelector(
selectAssetModalState,
);
)
// Use custom hooks
const {
@@ -42,7 +42,7 @@ export const AddAssetModal = ({
handleDragOver,
handleDragEnter,
handleDragLeave,
} = useFileUpload();
} = useFileUpload()
const {
formData,
@@ -51,21 +51,21 @@ export const AddAssetModal = ({
isSubmitting,
handleInputChange,
handleSubmit,
} = useAssetForm(onSave, onClose);
} = useAssetForm(onSave, onClose)
const { isVisible, modalRef, handleClose } = useModalAnimation(
isOpen,
onClose,
);
)
// Reset form data when modal is opened
useEffect(() => {
if (isOpen) {
// Form reset is handled by the useAssetForm hook
}
}, [isOpen, initialData]);
}, [isOpen, initialData])
if (!isOpen && !isVisible) return null;
if (!isOpen && !isVisible) return null
return (
<div
@@ -199,5 +199,5 @@ export const AddAssetModal = ({
</div>
</div>
</div>
);
};
)
}

View File

@@ -1,13 +1,13 @@
import React from 'react';
import classNames from 'classnames';
import { FormData } from '@/types';
import React from 'react'
import classNames from 'classnames'
import { FormData } from '@/types'
interface UriTabProps {
formData: FormData;
isValid: boolean;
errorMessage: string;
isSubmitting: boolean;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
formData: FormData
isValid: boolean
errorMessage: string
isSubmitting: boolean
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const UriTab = ({
@@ -56,5 +56,5 @@ export const UriTab = ({
</div>
</div>
</div>
);
};
)
}

View File

@@ -1,4 +1,4 @@
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux'
import {
updateFormData,
validateUrl,
@@ -7,20 +7,20 @@ import {
resetForm,
saveAsset,
selectAssetModalState,
} from '@/store/assets';
import { selectSettings } from '@/store/settings/index';
} from '@/store/assets'
import { selectSettings } from '@/store/settings/index'
import {
getMimetype,
getDurationForMimetype,
getDefaultDates,
} from '@/components/add-asset-modal/file-upload-utils';
import { Asset, AppDispatch } from '@/types';
} from '@/components/add-asset-modal/file-upload-utils'
import { Asset, AppDispatch } from '@/types'
export const useAssetForm = (
onSave: (asset: Asset) => void,
onClose: () => void,
) => {
const dispatch = useDispatch<AppDispatch>();
const dispatch = useDispatch<AppDispatch>()
const {
activeTab,
formData,
@@ -28,49 +28,49 @@ export const useAssetForm = (
errorMessage,
statusMessage,
isSubmitting,
} = useSelector(selectAssetModalState);
const settings = useSelector(selectSettings);
} = useSelector(selectAssetModalState)
const settings = useSelector(selectSettings)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
const { name, value, type, checked } = e.target
dispatch(
updateFormData({
[name]: type === 'checkbox' ? checked : value,
}),
);
)
// Validate URL when it changes
if (name === 'uri' && activeTab === 'uri') {
dispatch(validateUrl(value));
dispatch(validateUrl(value))
}
};
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
if (activeTab === 'uri') {
if (!formData.uri) {
dispatch(setErrorMessage('Please enter a URL'));
dispatch(setValid(false));
return;
dispatch(setErrorMessage('Please enter a URL'))
dispatch(setValid(false))
return
}
if (!isValid) {
return;
return
}
// Determine mimetype based on URL
const mimetype = getMimetype(formData.uri);
const mimetype = getMimetype(formData.uri)
// Get duration based on mimetype
const duration = getDurationForMimetype(
mimetype,
settings.defaultDuration,
settings.defaultStreamingDuration,
);
)
// Get default dates
const dates = getDefaultDates();
const dates = getDefaultDates()
// Create asset data
const assetData = {
@@ -85,25 +85,25 @@ export const useAssetForm = (
skip_asset_check: formData.skipAssetCheck ? 1 : 0,
duration,
...dates,
};
}
try {
// Save the asset
const savedAsset = await dispatch(saveAsset({ assetData })).unwrap();
const savedAsset = await dispatch(saveAsset({ assetData })).unwrap()
// Call the onSave callback with the asset data
onSave(savedAsset);
onSave(savedAsset)
// Reset form
dispatch(resetForm());
dispatch(resetForm())
// Close the modal
onClose();
onClose()
} catch {
dispatch(setErrorMessage('Failed to save asset. Please try again.'));
dispatch(setErrorMessage('Failed to save asset. Please try again.'))
}
}
};
}
return {
activeTab,
@@ -114,5 +114,5 @@ export const useAssetForm = (
isSubmitting,
handleInputChange,
handleSubmit,
};
};
}
}

View File

@@ -1,5 +1,5 @@
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRef } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
uploadFile,
saveAsset,
@@ -8,53 +8,53 @@ import {
setUploadProgress,
resetForm,
selectAssetModalState,
} from '@/store/assets';
import { AppDispatch } from '@/types';
} from '@/store/assets'
import { AppDispatch } from '@/types'
export const useFileUpload = () => {
const dispatch = useDispatch<AppDispatch>();
const { formData } = useSelector(selectAssetModalState);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const dispatch = useDispatch<AppDispatch>()
const { formData } = useSelector(selectAssetModalState)
const fileInputRef = useRef<HTMLInputElement>(null)
const dropZoneRef = useRef<HTMLDivElement>(null)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const file = e.target.files?.[0]
if (file) {
handleFileUpload(file);
handleFileUpload(file)
}
};
}
const handleFileDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.preventDefault()
e.stopPropagation()
const file = e.dataTransfer.files[0];
const file = e.dataTransfer.files[0]
if (file) {
handleFileUpload(file);
handleFileUpload(file)
}
};
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
e.preventDefault()
e.stopPropagation()
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
e.preventDefault()
e.stopPropagation()
}
const handleFileUpload = async (file: File) => {
try {
// Upload the file
const result = await dispatch(
uploadFile({ file, skipAssetCheck: formData.skipAssetCheck }),
).unwrap();
).unwrap()
// Create asset data
const assetData = {
@@ -70,37 +70,37 @@ export const useFileUpload = () => {
duration: result.duration,
skip_asset_check: formData.skipAssetCheck ? 1 : 0,
...result.dates,
};
}
// Save the asset
await dispatch(saveAsset({ assetData })).unwrap();
await dispatch(saveAsset({ assetData })).unwrap()
// Reset form and show success message
dispatch(resetForm());
dispatch(setStatusMessage('Upload completed.'));
dispatch(resetForm())
dispatch(setStatusMessage('Upload completed.'))
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = ''
}
// Hide status message after 5 seconds
setTimeout(() => {
dispatch(setStatusMessage(''));
}, 5000);
dispatch(setStatusMessage(''))
}, 5000)
} catch (error) {
dispatch(setErrorMessage(`Upload failed: ${(error as Error).message}`));
dispatch(setUploadProgress(0));
dispatch(setErrorMessage(`Upload failed: ${(error as Error).message}`))
dispatch(setUploadProgress(0))
// Reset the progress bar width directly
const progressBar = document.querySelector(
'.progress .bar',
) as HTMLElement | null;
) as HTMLElement | null
if (progressBar) {
progressBar.style.width = '0%';
progressBar.style.width = '0%'
}
}
};
}
return {
fileInputRef,
@@ -111,5 +111,5 @@ export const useFileUpload = () => {
handleDragEnter,
handleDragLeave,
handleFileUpload,
};
};
}
}

View File

@@ -1,21 +1,21 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef } from 'react'
export const useModalAnimation = (isOpen: boolean, onClose: () => void) => {
const [isVisible, setIsVisible] = useState(false);
const [isClosing] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false)
const [isClosing] = useState(false)
const modalRef = useRef<HTMLDivElement>(null)
// Handle animation when modal opens/closes
useEffect(() => {
if (isOpen) {
// Small delay to ensure the DOM is updated before adding the visible class
setTimeout(() => {
setIsVisible(true);
}, 10);
setIsVisible(true)
}, 10)
} else {
setIsVisible(false);
setIsVisible(false)
}
}, [isOpen]);
}, [isOpen])
// Handle clicks outside the modal
useEffect(() => {
@@ -24,35 +24,35 @@ export const useModalAnimation = (isOpen: boolean, onClose: () => void) => {
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
handleClose()
}
};
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
/**
* Handle modal close
*/
const handleClose = () => {
// Start the closing animation
setIsVisible(false);
setIsVisible(false)
// Wait for animation to complete before calling onClose
setTimeout(() => {
onClose();
}, 300);
};
onClose()
}, 300)
}
return {
isVisible,
isClosing,
modalRef,
handleClose,
};
};
}
}

View File

@@ -1,5 +1,5 @@
interface AlertProps {
message: string;
message: string
}
export const Alert = ({ message }: AlertProps) => {
@@ -14,5 +14,5 @@ export const Alert = ({ message }: AlertProps) => {
</div>
</div>
</div>
);
};
)
}

View File

@@ -1,32 +1,32 @@
import { Routes, Route } from 'react-router';
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { fetchAssets } from '@/store/assets';
import { fetchSettings } from '@/store/settings';
import { connectWebSocket, disconnectWebSocket } from '@/store/websocket';
import { store } from '@/store/index';
import { Routes, Route } from 'react-router'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { fetchAssets } from '@/store/assets'
import { fetchSettings } from '@/store/settings'
import { connectWebSocket, disconnectWebSocket } from '@/store/websocket'
import { store } from '@/store/index'
import { Integrations } from '@/components/integrations';
import { Navbar } from '@/components/navbar';
import { ScheduleOverview } from '@/components/home';
import { Settings } from '@/components/settings';
import { SystemInfo } from '@/components/system-info';
import { Footer } from '@/components/footer';
import Http404 from '@/components/http-404';
import { Integrations } from '@/components/integrations'
import { Navbar } from '@/components/navbar'
import { ScheduleOverview } from '@/components/home'
import { Settings } from '@/components/settings'
import { SystemInfo } from '@/components/system-info'
import { Footer } from '@/components/footer'
import Http404 from '@/components/http-404'
export const App = () => {
const dispatch = useDispatch<typeof store.dispatch>();
const dispatch = useDispatch<typeof store.dispatch>()
useEffect(() => {
dispatch(fetchAssets());
dispatch(fetchSettings());
dispatch(connectWebSocket());
dispatch(fetchAssets())
dispatch(fetchSettings())
dispatch(connectWebSocket())
// Cleanup function to disconnect WebSocket when component unmounts
return () => {
dispatch(disconnectWebSocket());
};
}, [dispatch]);
dispatch(disconnectWebSocket())
}
}, [dispatch])
return (
<>
@@ -42,5 +42,5 @@ export const App = () => {
<Footer />
</>
);
};
)
}

View File

@@ -1,11 +1,11 @@
import { FaDownload, FaPencilAlt, FaTrashAlt } from 'react-icons/fa';
import classNames from 'classnames';
import { FaDownload, FaPencilAlt, FaTrashAlt } from 'react-icons/fa'
import classNames from 'classnames'
interface ActionButtonsProps {
isDisabled: boolean;
handleDownload: (event: React.MouseEvent) => void;
handleEdit: () => void;
handleDelete: () => void;
isDisabled: boolean
handleDownload: (event: React.MouseEvent) => void
handleEdit: () => void
handleDelete: () => void
}
export const ActionButtons = ({
@@ -23,9 +23,9 @@ export const ActionButtons = ({
{
disabled: isDisabled,
},
);
)
const tooltipText = isDisabled ? 'Asset is currently being processed' : '';
const tooltipText = isDisabled ? 'Asset is currently being processed' : ''
return (
<>
@@ -63,5 +63,5 @@ export const ActionButtons = ({
<FaTrashAlt />
</button>
</>
);
};
)
}

View File

@@ -1,65 +1,65 @@
import { GiHamburgerMenu } from 'react-icons/gi';
import classNames from 'classnames';
import { useEffect, forwardRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { GiHamburgerMenu } from 'react-icons/gi'
import classNames from 'classnames'
import { useEffect, forwardRef, useState } from 'react'
import { useDispatch } from 'react-redux'
import { toggleAssetEnabled, fetchAssets } from '@/store/assets';
import { AssetRowProps, AppDispatch } from '@/types';
import { toggleAssetEnabled, fetchAssets } from '@/store/assets'
import { AssetRowProps, AppDispatch } from '@/types'
import {
formatDate,
formatDuration,
handleDelete,
handleDownload,
} from '@/components/asset-row/utils';
import { MimetypeIcon } from '@/components/asset-row/mimetype-icon';
import { ActionButtons } from '@/components/asset-row/action-buttons';
} from '@/components/asset-row/utils'
import { MimetypeIcon } from '@/components/asset-row/mimetype-icon'
import { ActionButtons } from '@/components/asset-row/action-buttons'
export const AssetRow = forwardRef<HTMLTableRowElement, AssetRowProps>(
(props, ref) => {
const defaultDateFormat = 'mm/dd/yyyy';
const dispatch = useDispatch<AppDispatch>();
const [isDisabled, setIsDisabled] = useState(false);
const [dateFormat, setDateFormat] = useState(defaultDateFormat);
const [use24HourClock, setUse24HourClock] = useState(false);
const defaultDateFormat = 'mm/dd/yyyy'
const dispatch = useDispatch<AppDispatch>()
const [isDisabled, setIsDisabled] = useState(false)
const [dateFormat, setDateFormat] = useState(defaultDateFormat)
const [use24HourClock, setUse24HourClock] = useState(false)
useEffect(() => {
const fetchDateFormat = async () => {
try {
const response = await fetch('/api/v2/device_settings');
const data = await response.json();
setDateFormat(data.date_format);
setUse24HourClock(data.use_24_hour_clock);
const response = await fetch('/api/v2/device_settings')
const data = await response.json()
setDateFormat(data.date_format)
setUse24HourClock(data.use_24_hour_clock)
} catch {
setDateFormat(defaultDateFormat);
setUse24HourClock(false);
setDateFormat(defaultDateFormat)
setUse24HourClock(false)
}
};
}
fetchDateFormat();
}, []);
fetchDateFormat()
}, [])
const handleToggle = async () => {
const newValue = !props.isEnabled ? 1 : 0;
setIsDisabled(true);
const newValue = !props.isEnabled ? 1 : 0
setIsDisabled(true)
try {
await dispatch(
toggleAssetEnabled({ assetId: props.assetId, newValue }),
).unwrap();
dispatch(fetchAssets());
).unwrap()
dispatch(fetchAssets())
} catch {
} finally {
setIsDisabled(false);
setIsDisabled(false)
}
};
}
const handleDownloadWrapper = (event: React.MouseEvent) => {
handleDownload(event, props.assetId);
};
handleDownload(event, props.assetId)
}
const handleDeleteWrapper = () => {
handleDelete(props.assetId, setIsDisabled, dispatch, fetchAssets);
};
handleDelete(props.assetId, setIsDisabled, dispatch, fetchAssets)
}
const handleEdit = () => {
if (props.onEditAsset) {
@@ -74,9 +74,9 @@ export const AssetRow = forwardRef<HTMLTableRowElement, AssetRowProps>(
is_enabled: props.isEnabled,
nocache: props.nocache,
skip_asset_check: props.skipAssetCheck,
});
})
}
};
}
return (
<>
@@ -184,6 +184,6 @@ export const AssetRow = forwardRef<HTMLTableRowElement, AssetRowProps>(
</td>
</tr>
</>
);
)
},
);
)

View File

@@ -1,9 +1,9 @@
import { FaImage, FaVideo, FaGlobe } from 'react-icons/fa';
import { FaImage, FaVideo, FaGlobe } from 'react-icons/fa'
interface MimetypeIconProps {
mimetype: string;
className: string;
style: React.CSSProperties;
mimetype: string
className: string
style: React.CSSProperties
}
export const MimetypeIcon = ({
@@ -16,5 +16,5 @@ export const MimetypeIcon = ({
video: <FaVideo className={className} style={style} />,
webpage: <FaGlobe className={className} style={style} />,
default: <FaGlobe className={className} style={style} />,
}[mimetype];
};
}[mimetype]
}

View File

@@ -1,20 +1,20 @@
import Swal from 'sweetalert2';
import { SWEETALERT_TIMER } from '@/constants';
import { AppDispatch } from '@/types';
import { fetchAssets } from '@/store/assets';
import Swal from 'sweetalert2'
import { SWEETALERT_TIMER } from '@/constants'
import { AppDispatch } from '@/types'
import { fetchAssets } from '@/store/assets'
export const formatDate = (
date: string,
dateFormat: string,
use24HourClock = false,
): string => {
if (!date) return '';
if (!date) return ''
// Create a Date object from the input date string
const dateObj = new Date(date);
const dateObj = new Date(date)
// Check if the date is valid
if (isNaN(dateObj.getTime())) return date;
if (isNaN(dateObj.getTime())) return date
// Extract the separator from the format
const separator = dateFormat.includes('/')
@@ -23,10 +23,10 @@ export const formatDate = (
? '-'
: dateFormat.includes('.')
? '.'
: '/';
: '/'
// Extract the format parts from the dateFormat string
const formatParts = dateFormat.split(/[\/\-\.]/);
const formatParts = dateFormat.split(/[\/\-\.]/)
// Set up the date formatting options
const options: Intl.DateTimeFormatOptions = {
@@ -37,73 +37,73 @@ export const formatDate = (
minute: '2-digit',
second: '2-digit',
hour12: !use24HourClock, // Use 12-hour format if use24HourClock is false
};
}
// Create a formatter with the specified options
const formatter = new Intl.DateTimeFormat('en-US', options);
const formatter = new Intl.DateTimeFormat('en-US', options)
// Format the date and get the parts
const formattedParts = formatter.formatToParts(dateObj);
const formattedParts = formatter.formatToParts(dateObj)
// Extract the formatted values with null checks
const month = formattedParts.find((p) => p.type === 'month')?.value || '';
const day = formattedParts.find((p) => p.type === 'day')?.value || '';
const year = formattedParts.find((p) => p.type === 'year')?.value || '';
const hour = formattedParts.find((p) => p.type === 'hour')?.value || '';
const minute = formattedParts.find((p) => p.type === 'minute')?.value || '';
const second = formattedParts.find((p) => p.type === 'second')?.value || '';
const month = formattedParts.find((p) => p.type === 'month')?.value || ''
const day = formattedParts.find((p) => p.type === 'day')?.value || ''
const year = formattedParts.find((p) => p.type === 'year')?.value || ''
const hour = formattedParts.find((p) => p.type === 'hour')?.value || ''
const minute = formattedParts.find((p) => p.type === 'minute')?.value || ''
const second = formattedParts.find((p) => p.type === 'second')?.value || ''
// Get the period (AM/PM) if using 12-hour format
let period = '';
let period = ''
if (!use24HourClock) {
const periodPart = formattedParts.find((p) => p.type === 'dayPeriod');
const periodPart = formattedParts.find((p) => p.type === 'dayPeriod')
if (periodPart) {
period = ` ${periodPart.value}`;
period = ` ${periodPart.value}`
}
}
// Build the date part according to the format
let datePart = '';
let datePart = ''
// Determine the order based on the format
if (formatParts[0].includes('mm')) {
datePart = `${month}${separator}${day}${separator}${year}`;
datePart = `${month}${separator}${day}${separator}${year}`
} else if (formatParts[0].includes('dd')) {
datePart = `${day}${separator}${month}${separator}${year}`;
datePart = `${day}${separator}${month}${separator}${year}`
} else if (formatParts[0].includes('yyyy')) {
datePart = `${year}${separator}${month}${separator}${day}`;
datePart = `${year}${separator}${month}${separator}${day}`
} else {
// Default to mm/dd/yyyy if format is not recognized
datePart = `${month}${separator}${day}${separator}${year}`;
datePart = `${month}${separator}${day}${separator}${year}`
}
// Add the time part with AM/PM if using 12-hour format
const timePart = `${hour}:${minute}:${second}${period}`;
const timePart = `${hour}:${minute}:${second}${period}`
return `${datePart} ${timePart}`;
};
return `${datePart} ${timePart}`
}
export const formatDuration = (seconds: number | string): string => {
let durationString = '';
const secInt = parseInt(seconds.toString());
let durationString = ''
const secInt = parseInt(seconds.toString())
const hours = Math.floor(secInt / 3600);
const hours = Math.floor(secInt / 3600)
if (hours > 0) {
durationString += `${hours} hours `;
durationString += `${hours} hours `
}
const minutes = Math.floor(secInt / 60) % 60;
const minutes = Math.floor(secInt / 60) % 60
if (minutes > 0) {
durationString += `${minutes} min `;
durationString += `${minutes} min `
}
const secs = secInt % 60;
const secs = secInt % 60
if (secs > 0) {
durationString += `${secs} sec`;
durationString += `${secs} sec`
}
return durationString;
};
return durationString
}
export const handleDelete = async (
assetId: string,
@@ -128,21 +128,21 @@ export const handleDelete = async (
cancelButton: 'swal2-cancel',
actions: 'swal2-actions',
},
});
})
if (result.isConfirmed) {
try {
// Disable the row while deleting
setIsDisabled(true);
setIsDisabled(true)
// Make API call to delete the asset
const response = await fetch(`/api/v2/assets/${assetId}`, {
method: 'DELETE',
});
})
if (response.ok) {
// Refresh the assets list after successful deletion
dispatch(fetchAssetsAction());
dispatch(fetchAssetsAction())
// Show success message
Swal.fire({
@@ -156,7 +156,7 @@ export const handleDelete = async (
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
});
})
} else {
// Show error message
Swal.fire({
@@ -169,7 +169,7 @@ export const handleDelete = async (
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
});
})
}
} catch {
Swal.fire({
@@ -182,49 +182,49 @@ export const handleDelete = async (
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
});
})
} finally {
setIsDisabled(false);
setIsDisabled(false)
}
}
};
}
export const handleDownload = async (
event: React.MouseEvent,
assetId: string,
): Promise<void> => {
event.preventDefault();
event.preventDefault()
try {
const response = await fetch(`/api/v2/assets/${assetId}/content`);
const result = await response.json();
const response = await fetch(`/api/v2/assets/${assetId}/content`)
const result = await response.json()
if (result.type === 'url') {
window.open(result.url);
window.open(result.url)
} else if (result.type === 'file') {
// Convert base64 to byte array
const content = atob(result.content);
const bytes = new Uint8Array(content.length);
const content = atob(result.content)
const bytes = new Uint8Array(content.length)
for (let i = 0; i < content.length; i++) {
bytes[i] = content.charCodeAt(i);
bytes[i] = content.charCodeAt(i)
}
const mimetype = result.mimetype;
const filename = result.filename;
const mimetype = result.mimetype
const filename = result.filename
// Create blob and download
const blob = new Blob([bytes], { type: mimetype });
const url = URL.createObjectURL(blob);
const blob = new Blob([bytes], { type: mimetype })
const url = URL.createObjectURL(blob)
const a = document.createElement('a');
document.body.appendChild(a);
a.download = filename;
a.href = url;
a.click();
const a = document.createElement('a')
document.body.appendChild(a)
a.download = filename
a.href = url
a.click()
// Clean up
URL.revokeObjectURL(url);
a.remove();
URL.revokeObjectURL(url)
a.remove()
}
} catch {}
};
}

View File

@@ -1,8 +1,8 @@
import { EditFormData } from '@/types';
import { EditFormData } from '@/types'
interface AdvancedFieldsProps {
formData: EditFormData;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
formData: EditFormData
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const AdvancedFields = ({
@@ -42,5 +42,5 @@ export const AdvancedFields = ({
</div>
</div>
</div>
);
};
)
}

View File

@@ -1,7 +1,7 @@
import { AssetEditData } from '@/types';
import { AssetEditData } from '@/types'
interface AssetLocationFieldProps {
asset: AssetEditData | null;
asset: AssetEditData | null
}
export const AssetLocationField = ({ asset }: AssetLocationFieldProps) => {
@@ -20,5 +20,5 @@ export const AssetLocationField = ({ asset }: AssetLocationFieldProps) => {
</div>
</div>
</div>
);
};
)
}

View File

@@ -1,8 +1,8 @@
import { EditFormData } from '@/types';
import { EditFormData } from '@/types'
interface AssetTypeFieldProps {
formData: EditFormData;
handleInputChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
formData: EditFormData
handleInputChange: (e: React.ChangeEvent<HTMLSelectElement>) => void
}
export const AssetTypeField = ({
@@ -28,5 +28,5 @@ export const AssetTypeField = ({
</select>
</div>
</div>
);
};
)
}

View File

@@ -1,12 +1,12 @@
interface DateFieldsProps {
startDateDate: string;
startDateTime: string;
endDateDate: string;
endDateTime: string;
startDateDate: string
startDateTime: string
endDateDate: string
endDateTime: string
handleDateChange: (
e: React.ChangeEvent<HTMLInputElement>,
type: string,
) => void;
) => void
}
export const DateFields = ({
@@ -59,5 +59,5 @@ export const DateFields = ({
</div>
</div>
</div>
);
};
)
}

View File

@@ -1,8 +1,8 @@
import { EditFormData } from '@/types';
import { EditFormData } from '@/types'
interface DurationFieldProps {
formData: EditFormData;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
formData: EditFormData
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const DurationField = ({
@@ -24,5 +24,5 @@ export const DurationField = ({
seconds &nbsp;
</div>
</div>
);
};
)
}

View File

@@ -1,22 +1,22 @@
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { AssetEditData, EditFormData } from '@/types';
import React, { useEffect, useState } from 'react'
import classNames from 'classnames'
import { useDispatch } from 'react-redux'
import { AssetEditData, EditFormData } from '@/types'
import { handleSubmit } from '@/components/edit-asset-modal/utils';
import { NameField } from '@/components/edit-asset-modal/name-field';
import { AssetLocationField } from '@/components/edit-asset-modal/asset-location-field';
import { AssetTypeField } from '@/components/edit-asset-modal/asset-type-field';
import { PlayForField } from '@/components/edit-asset-modal/play-for-field';
import { DateFields } from '@/components/edit-asset-modal/date-fields';
import { DurationField } from '@/components/edit-asset-modal/duration-field';
import { ModalFooter } from '@/components/edit-asset-modal/modal-footer';
import { AdvancedFields } from '@/components/edit-asset-modal/advanced';
import { handleSubmit } from '@/components/edit-asset-modal/utils'
import { NameField } from '@/components/edit-asset-modal/name-field'
import { AssetLocationField } from '@/components/edit-asset-modal/asset-location-field'
import { AssetTypeField } from '@/components/edit-asset-modal/asset-type-field'
import { PlayForField } from '@/components/edit-asset-modal/play-for-field'
import { DateFields } from '@/components/edit-asset-modal/date-fields'
import { DurationField } from '@/components/edit-asset-modal/duration-field'
import { ModalFooter } from '@/components/edit-asset-modal/modal-footer'
import { AdvancedFields } from '@/components/edit-asset-modal/advanced'
interface EditAssetModalProps {
isOpen: boolean;
onClose: () => void;
asset: AssetEditData | null;
isOpen: boolean
onClose: () => void
asset: AssetEditData | null
}
export const EditAssetModal = ({
@@ -24,9 +24,9 @@ export const EditAssetModal = ({
onClose,
asset,
}: EditAssetModalProps) => {
const dispatch = useDispatch();
const [isVisible, setIsVisible] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const dispatch = useDispatch()
const [isVisible, setIsVisible] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<EditFormData>({
name: '',
start_date: '',
@@ -35,33 +35,33 @@ export const EditAssetModal = ({
mimetype: 'webpage',
nocache: false,
skip_asset_check: false,
});
const [loopTimes, setLoopTimes] = useState('manual');
const [startDateDate, setStartDateDate] = useState('');
const [startDateTime, setStartDateTime] = useState('');
const [endDateDate, setEndDateDate] = useState('');
const [endDateTime, setEndDateTime] = useState('');
})
const [loopTimes, setLoopTimes] = useState('manual')
const [startDateDate, setStartDateDate] = useState('')
const [startDateTime, setStartDateTime] = useState('')
const [endDateDate, setEndDateDate] = useState('')
const [endDateTime, setEndDateTime] = useState('')
// Initialize form data when asset changes
useEffect(() => {
if (asset) {
// Parse dates from UTC
const startDate = new Date(asset.start_date);
const endDate = new Date(asset.end_date);
const startDate = new Date(asset.start_date)
const endDate = new Date(asset.end_date)
// Format date and time parts in local timezone
const formatDatePart = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const formatTimePart = (date: Date) => {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
setFormData({
name: asset.name || '',
@@ -71,72 +71,72 @@ export const EditAssetModal = ({
mimetype: asset.mimetype || 'webpage',
nocache: asset.nocache || false,
skip_asset_check: asset.skip_asset_check || false,
});
})
setStartDateDate(formatDatePart(startDate));
setStartDateTime(formatTimePart(startDate));
setEndDateDate(formatDatePart(endDate));
setEndDateTime(formatTimePart(endDate));
setStartDateDate(formatDatePart(startDate))
setStartDateTime(formatTimePart(startDate))
setEndDateDate(formatDatePart(endDate))
setEndDateTime(formatTimePart(endDate))
}
}, [asset]);
}, [asset])
// Handle modal visibility
useEffect(() => {
setLoopTimes('manual');
setLoopTimes('manual')
if (isOpen) {
setIsVisible(true);
setIsVisible(true)
} else {
const timer = setTimeout(() => {
setIsVisible(false);
}, 300); // Match the transition duration
return () => clearTimeout(timer);
setIsVisible(false)
}, 300) // Match the transition duration
return () => clearTimeout(timer)
}
}, [isOpen]);
}, [isOpen])
const handleClose = () => {
setIsVisible(false);
setIsVisible(false)
setTimeout(() => {
onClose();
}, 300); // Match the transition duration
};
onClose()
}, 300) // Match the transition duration
}
const handleModalClick = (e: React.MouseEvent<HTMLDivElement>) => {
// Only close if clicking the modal backdrop (outside the modal content)
if (e.target === e.currentTarget) {
handleClose();
handleClose()
}
};
}
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value, type } = e.target;
const { name, value, type } = e.target
const checked =
e.target instanceof HTMLInputElement ? e.target.checked : false;
e.target instanceof HTMLInputElement ? e.target.checked : false
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
});
};
})
}
const handleDateChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: string,
) => {
const { value } = e.target;
const { value } = e.target
if (type === 'startDate') {
setStartDateDate(value);
setStartDateDate(value)
} else if (type === 'startTime') {
setStartDateTime(value);
setStartDateTime(value)
} else if (type === 'endDate') {
setEndDateDate(value);
setEndDateDate(value)
} else if (type === 'endTime') {
setEndDateTime(value);
setEndDateTime(value)
}
};
}
if (!isOpen && !isVisible) return null;
if (!isOpen && !isVisible) return null
return (
<div
@@ -248,5 +248,5 @@ export const EditAssetModal = ({
</div>
</div>
</div>
);
};
)
}

View File

@@ -3,21 +3,21 @@ import {
AssetEditData,
EditFormData,
HandleSubmitParams,
} from '@/types';
} from '@/types'
interface ModalFooterProps {
asset: AssetEditData | null;
formData: EditFormData;
startDateDate: string;
startDateTime: string;
endDateDate: string;
endDateTime: string;
dispatch: AppDispatch;
onClose: () => void;
handleClose: () => void;
isSubmitting: boolean;
handleSubmit: (params: HandleSubmitParams) => void;
setIsSubmitting: (isSubmitting: boolean) => void;
asset: AssetEditData | null
formData: EditFormData
startDateDate: string
startDateTime: string
endDateDate: string
endDateTime: string
dispatch: AppDispatch
onClose: () => void
handleClose: () => void
isSubmitting: boolean
handleSubmit: (params: HandleSubmitParams) => void
setIsSubmitting: (isSubmitting: boolean) => void
}
export const ModalFooter = ({
@@ -71,5 +71,5 @@ export const ModalFooter = ({
Save
</button>
</div>
);
};
)
}

View File

@@ -1,8 +1,8 @@
import { EditFormData } from '@/types';
import { EditFormData } from '@/types'
interface NameFieldProps {
formData: EditFormData;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
formData: EditFormData
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const NameField = ({ formData, handleInputChange }: NameFieldProps) => {
@@ -20,5 +20,5 @@ export const NameField = ({ formData, handleInputChange }: NameFieldProps) => {
/>
</div>
</div>
);
};
)
}

View File

@@ -1,14 +1,14 @@
import { handleLoopTimesChange } from '@/components/edit-asset-modal/utils';
import { EditFormData } from '@/types';
import { handleLoopTimesChange } from '@/components/edit-asset-modal/utils'
import { EditFormData } from '@/types'
interface PlayForFieldProps {
loopTimes: string;
startDateDate: string;
startDateTime: string;
setLoopTimes: (value: string) => void;
setEndDateDate: (value: string) => void;
setEndDateTime: (value: string) => void;
setFormData: (updater: (prev: EditFormData) => EditFormData) => void;
loopTimes: string
startDateDate: string
startDateTime: string
setLoopTimes: (value: string) => void
setEndDateDate: (value: string) => void
setEndDateTime: (value: string) => void
setFormData: (updater: (prev: EditFormData) => EditFormData) => void
}
export const PlayForField = ({
@@ -49,5 +49,5 @@ export const PlayForField = ({
</select>
</div>
</div>
);
};
)
}

View File

@@ -1,14 +1,14 @@
import { fetchAssets, updateAssetOrder } from '@/store/assets';
import { Asset, RootState, EditFormData, HandleSubmitParams } from '@/types';
import { fetchAssets, updateAssetOrder } from '@/store/assets'
import { Asset, RootState, EditFormData, HandleSubmitParams } from '@/types'
interface HandleLoopTimesChangeParams {
e: React.ChangeEvent<HTMLSelectElement>;
startDateDate: string;
startDateTime: string;
setLoopTimes: (value: string) => void;
setEndDateDate: (value: string) => void;
setEndDateTime: (value: string) => void;
setFormData: (updater: (prev: EditFormData) => EditFormData) => void;
e: React.ChangeEvent<HTMLSelectElement>
startDateDate: string
startDateTime: string
setLoopTimes: (value: string) => void
setEndDateDate: (value: string) => void
setEndDateTime: (value: string) => void
setFormData: (updater: (prev: EditFormData) => EditFormData) => void
}
export const handleSubmit = async ({
@@ -23,13 +23,13 @@ export const handleSubmit = async ({
onClose,
setIsSubmitting,
}: HandleSubmitParams) => {
e.preventDefault();
setIsSubmitting(true);
e.preventDefault()
setIsSubmitting(true)
try {
// Combine date and time parts
const startDate = new Date(`${startDateDate}T${startDateTime}`);
const endDate = new Date(`${endDateDate}T${endDateTime}`);
const startDate = new Date(`${startDateDate}T${startDateTime}`)
const endDate = new Date(`${endDateDate}T${endDateTime}`)
// Prepare data for API
const updatedAsset = {
@@ -39,7 +39,7 @@ export const handleSubmit = async ({
asset_id: asset.id,
is_enabled: asset.is_enabled,
play_order: asset.play_order,
};
}
// Make API call to update asset
const response = await fetch(`/api/v2/assets/${asset.id}`, {
@@ -48,36 +48,36 @@ export const handleSubmit = async ({
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedAsset),
});
})
if (!response.ok) {
throw new Error('Failed to update asset');
throw new Error('Failed to update asset')
}
// Get active assets from Redux store and update order
const activeAssetIds = dispatch((_: unknown, getState: () => RootState) => {
const state = getState();
const state = getState()
return state.assets.items
.filter((asset: Asset) => asset.is_active)
.sort((a: Asset, b: Asset) => a.play_order - b.play_order)
.map((asset: Asset) => asset.asset_id)
.join(',');
});
.join(',')
})
// Update the asset order so that active assets won't be reordered
// unexpectedly when the asset is updated and saved.
await dispatch(updateAssetOrder(activeAssetIds));
await dispatch(updateAssetOrder(activeAssetIds))
// Refresh assets list
dispatch(fetchAssets());
dispatch(fetchAssets())
// Close modal
onClose();
onClose()
} catch {
} finally {
setIsSubmitting(false);
setIsSubmitting(false)
}
};
}
export const handleLoopTimesChange = ({
e,
@@ -88,57 +88,57 @@ export const handleLoopTimesChange = ({
setEndDateTime,
setFormData,
}: HandleLoopTimesChangeParams) => {
const playFor = e.target.value;
setLoopTimes(playFor);
const playFor = e.target.value
setLoopTimes(playFor)
if (playFor === 'manual') {
return;
return
}
// Get current start date and time in UTC
const startDate = new Date(`${startDateDate}T${startDateTime}Z`);
let endDate = new Date(startDate);
const startDate = new Date(`${startDateDate}T${startDateTime}Z`)
let endDate = new Date(startDate)
// Add time based on selection
switch (playFor) {
case 'day':
endDate.setUTCDate(endDate.getUTCDate() + 1);
break;
endDate.setUTCDate(endDate.getUTCDate() + 1)
break
case 'week':
endDate.setUTCDate(endDate.getUTCDate() + 7);
break;
endDate.setUTCDate(endDate.getUTCDate() + 7)
break
case 'month':
endDate.setUTCMonth(endDate.getUTCMonth() + 1);
break;
endDate.setUTCMonth(endDate.getUTCMonth() + 1)
break
case 'year':
endDate.setUTCFullYear(endDate.getUTCFullYear() + 1);
break;
endDate.setUTCFullYear(endDate.getUTCFullYear() + 1)
break
case 'forever':
endDate.setUTCFullYear(9999);
break;
endDate.setUTCFullYear(9999)
break
}
// Format the new end date in ISO format with timezone
const formatDatePart = (date: Date) => {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const formatTimePart = (date: Date) => {
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
};
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
// Update end date and time
setEndDateDate(formatDatePart(endDate));
setEndDateTime(formatTimePart(endDate));
setEndDateDate(formatDatePart(endDate))
setEndDateTime(formatTimePart(endDate))
// Update formData with the ISO string
setFormData((prev: EditFormData) => ({
...prev,
end_date: endDate.toISOString(),
}));
};
}))
}

View File

@@ -1,5 +1,5 @@
interface EmptyAssetMessageProps {
onAddAssetClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
onAddAssetClick: (e: React.MouseEvent<HTMLAnchorElement>) => void
}
export const EmptyAssetMessage = ({
@@ -13,5 +13,5 @@ export const EmptyAssetMessage = ({
</a>{' '}
now.
</div>
);
};
)
}

View File

@@ -67,16 +67,16 @@ export const Footer = () => {
src={(() => {
const url = new URL(
'https://img.shields.io/github/stars/Screenly/Anthias',
);
)
const params = new URLSearchParams({
style: 'for-the-badge',
labelColor: '#EBF0F4',
color: '#FFE11A',
logo: 'github',
logoColor: 'black',
});
url.search = params.toString();
return url.toString();
})
url.search = params.toString()
return url.toString()
})()}
/>
</a>
@@ -90,5 +90,5 @@ export const Footer = () => {
</div>
</div>
</footer>
);
};
)
}

View File

@@ -1,121 +1,121 @@
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import $ from 'jquery';
import 'bootstrap/js/dist/tooltip';
import classNames from 'classnames'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import $ from 'jquery'
import 'bootstrap/js/dist/tooltip'
import {
fetchAssets,
selectActiveAssets,
selectInactiveAssets,
} from '@/store/assets';
import { AssetEditData, AppDispatch } from '@/types';
} from '@/store/assets'
import { AssetEditData, AppDispatch } from '@/types'
import { EmptyAssetMessage } from '@/components/empty-asset-message';
import { InactiveAssetsTable } from '@/components/inactive-assets';
import { ActiveAssetsTable } from '@/components/active-assets';
import { AddAssetModal } from '@/components/add-asset-modal';
import { EditAssetModal } from '@/components/edit-asset-modal';
import { EmptyAssetMessage } from '@/components/empty-asset-message'
import { InactiveAssetsTable } from '@/components/inactive-assets'
import { ActiveAssetsTable } from '@/components/active-assets'
import { AddAssetModal } from '@/components/add-asset-modal'
import { EditAssetModal } from '@/components/edit-asset-modal'
interface JQueryWithTooltip extends JQuery<HTMLElement> {
tooltip(options?: object | string): JQueryWithTooltip;
tooltip(options?: object | string): JQueryWithTooltip
}
export const ScheduleOverview = () => {
const dispatch = useDispatch<AppDispatch>();
const activeAssets = useSelector(selectActiveAssets);
const inactiveAssets = useSelector(selectInactiveAssets);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [assetToEdit, setAssetToEdit] = useState<AssetEditData | null>(null);
const [playerName, setPlayerName] = useState('');
const dispatch = useDispatch<AppDispatch>()
const activeAssets = useSelector(selectActiveAssets)
const inactiveAssets = useSelector(selectInactiveAssets)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [assetToEdit, setAssetToEdit] = useState<AssetEditData | null>(null)
const [playerName, setPlayerName] = useState('')
const fetchPlayerName = async () => {
try {
const response = await fetch('/api/v2/device_settings');
const data = await response.json();
setPlayerName(data.player_name || '');
const response = await fetch('/api/v2/device_settings')
const data = await response.json()
setPlayerName(data.player_name || '')
} catch {}
};
}
useEffect(() => {
const title = playerName
? `${playerName} · Schedule Overview`
: 'Schedule Overview';
document.title = title;
dispatch(fetchAssets());
fetchPlayerName();
}, [dispatch, playerName]);
: 'Schedule Overview'
document.title = title
dispatch(fetchAssets())
fetchPlayerName()
}, [dispatch, playerName])
// Initialize tooltips
useEffect(() => {
const initializeTooltips = () => {
($('[data-toggle="tooltip"]') as JQueryWithTooltip).tooltip({
;($('[data-toggle="tooltip"]') as JQueryWithTooltip).tooltip({
placement: 'top',
trigger: 'hover',
html: true,
delay: { show: 0, hide: 0 },
animation: true,
});
};
})
}
// Initial tooltip initialization
initializeTooltips();
initializeTooltips()
// Reinitialize tooltips when assets change
const observer = new MutationObserver(() => {
initializeTooltips();
});
initializeTooltips()
})
// Observe changes in both active and inactive sections
const activeSection = document.getElementById('active-assets-section');
const inactiveSection = document.getElementById('inactive-assets-section');
const activeSection = document.getElementById('active-assets-section')
const inactiveSection = document.getElementById('inactive-assets-section')
if (activeSection) {
observer.observe(activeSection, { childList: true, subtree: true });
observer.observe(activeSection, { childList: true, subtree: true })
}
if (inactiveSection) {
observer.observe(inactiveSection, { childList: true, subtree: true });
observer.observe(inactiveSection, { childList: true, subtree: true })
}
return () => {
observer.disconnect();
($('[data-toggle="tooltip"]') as JQueryWithTooltip).tooltip('dispose');
};
}, [activeAssets, inactiveAssets]);
observer.disconnect()
;($('[data-toggle="tooltip"]') as JQueryWithTooltip).tooltip('dispose')
}
}, [activeAssets, inactiveAssets])
const handleAddAsset = (event: React.MouseEvent) => {
event.preventDefault();
setIsModalOpen(true);
setAssetToEdit(null);
};
event.preventDefault()
setIsModalOpen(true)
setAssetToEdit(null)
}
const handlePreviousAsset = async (event: React.MouseEvent) => {
event.preventDefault();
await fetch('/api/v2/assets/control/previous');
};
event.preventDefault()
await fetch('/api/v2/assets/control/previous')
}
const handleNextAsset = async (event: React.MouseEvent) => {
event.preventDefault();
await fetch('/api/v2/assets/control/next');
};
event.preventDefault()
await fetch('/api/v2/assets/control/next')
}
const handleCloseModal = () => {
setIsModalOpen(false);
};
setIsModalOpen(false)
}
const handleSaveAsset = () => {
setIsModalOpen(false);
};
setIsModalOpen(false)
}
const handleEditAsset = (asset: AssetEditData) => {
setAssetToEdit(asset);
setIsEditModalOpen(true);
};
setAssetToEdit(asset)
setIsEditModalOpen(true)
}
const handleCloseEditModal = () => {
setIsEditModalOpen(false);
setAssetToEdit(null);
};
setIsEditModalOpen(false)
setAssetToEdit(null)
}
return (
<>
@@ -236,5 +236,5 @@ export const ScheduleOverview = () => {
asset={assetToEdit}
/>
</>
);
};
)
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import classNames from 'classnames';
import React from 'react'
import classNames from 'classnames'
const Http404: React.FC = () => {
return (
@@ -35,7 +35,7 @@ const Http404: React.FC = () => {
</div>
</div>
</div>
);
};
)
}
export default Http404;
export default Http404

View File

@@ -1,12 +1,12 @@
import { useSelector } from 'react-redux';
import { selectInactiveAssets } from '@/store/assets';
import { AssetRow } from '@/components/asset-row';
import { Asset, InactiveAssetsTableProps } from '@/types';
import { useSelector } from 'react-redux'
import { selectInactiveAssets } from '@/store/assets'
import { AssetRow } from '@/components/asset-row'
import { Asset, InactiveAssetsTableProps } from '@/types'
export const InactiveAssetsTable = ({
onEditAsset,
}: InactiveAssetsTableProps) => {
const inactiveAssets = useSelector(selectInactiveAssets) as Asset[];
const inactiveAssets = useSelector(selectInactiveAssets) as Asset[]
return (
<table className="InactiveAssets table">
@@ -68,5 +68,5 @@ export const InactiveAssetsTable = ({
))}
</tbody>
</table>
);
};
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react'
export const Integrations = () => {
const [data, setData] = useState({
@@ -9,8 +9,8 @@ export const Integrations = () => {
balena_supervisor_version: '',
balena_host_os_version: '',
balena_device_name_at_init: '',
});
const [playerName, setPlayerName] = useState('');
})
const [playerName, setPlayerName] = useState('')
useEffect(() => {
const fetchData = async () => {
@@ -18,25 +18,25 @@ export const Integrations = () => {
const [integrationsResponse, settingsResponse] = await Promise.all([
fetch('/api/v2/integrations'),
fetch('/api/v2/device_settings'),
]);
])
const [integrationsData, settingsData] = await Promise.all([
integrationsResponse.json(),
settingsResponse.json(),
]);
])
setData(integrationsData);
setPlayerName(settingsData.player_name ?? '');
setData(integrationsData)
setPlayerName(settingsData.player_name ?? '')
} catch {}
};
}
fetchData();
}, []);
fetchData()
}, [])
useEffect(() => {
const title = playerName ? `${playerName} · Integrations` : 'Integrations';
document.title = title;
}, [playerName]);
const title = playerName ? `${playerName} · Integrations` : 'Integrations'
document.title = title
}, [playerName])
return (
<div className="container">
@@ -120,5 +120,5 @@ export const Integrations = () => {
)}
</div>
</div>
);
};
)
}

View File

@@ -1,17 +1,17 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react'
import {
FaArrowCircleDown,
FaRegClock,
FaCog,
FaPlusSquare,
FaTasks,
} from 'react-icons/fa';
import { Link, NavLink } from 'react-router';
} from 'react-icons/fa'
import { Link, NavLink } from 'react-router'
export const Navbar = () => {
const [upToDate, setUpToDate] = useState<boolean | null>(null);
const [isBalena, setIsBalena] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [upToDate, setUpToDate] = useState<boolean | null>(null)
const [isBalena, setIsBalena] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
@@ -19,25 +19,25 @@ export const Navbar = () => {
const [integrationsResponse, infoResponse] = await Promise.all([
fetch('/api/v2/integrations'),
fetch('/api/v2/info'),
]);
])
const [integrationsData, infoData] = await Promise.all([
integrationsResponse.json(),
infoResponse.json(),
]);
])
setIsBalena(integrationsData.is_balena);
setUpToDate(infoData.up_to_date);
setIsBalena(integrationsData.is_balena)
setUpToDate(infoData.up_to_date)
} catch {
setIsBalena(false);
setUpToDate(false);
setIsBalena(false)
setUpToDate(false)
} finally {
setIsLoading(false);
setIsLoading(false)
}
};
}
fetchData();
}, []);
fetchData()
}, [])
return (
<>
@@ -99,5 +99,5 @@ export const Navbar = () => {
</div>
</div>
</>
);
};
)
}

View File

@@ -1,13 +1,13 @@
import { RootState } from '@/types';
import { RootState } from '@/types'
export const AudioOutput = ({
settings,
handleInputChange,
deviceModel,
}: {
settings: RootState['settings']['settings'];
handleInputChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
deviceModel: string;
settings: RootState['settings']['settings']
handleInputChange: (e: React.ChangeEvent<HTMLSelectElement>) => void
deviceModel: string
}) => {
return (
<div className="form-group">
@@ -26,5 +26,5 @@ export const AudioOutput = ({
)}
</select>
</div>
);
};
)
}

View File

@@ -1,27 +1,27 @@
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/types';
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/types'
import { updateSetting } from '@/store/settings';
import { updateSetting } from '@/store/settings'
export const Authentication = () => {
const dispatch = useDispatch();
const dispatch = useDispatch()
const { settings, prevAuthBackend, hasSavedBasicAuth } = useSelector(
(state: RootState) => state.settings,
);
)
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value, type } = e.target;
const { name, value, type } = e.target
const checked =
e.target instanceof HTMLInputElement ? e.target.checked : false;
e.target instanceof HTMLInputElement ? e.target.checked : false
dispatch(
updateSetting({
name: name as keyof RootState['settings']['settings'],
value: type === 'checkbox' ? checked : value,
}),
);
};
)
}
const showCurrentPassword = () => {
// Show current password if:
@@ -31,8 +31,8 @@ export const Authentication = () => {
hasSavedBasicAuth &&
(settings.authBackend === 'auth_basic' ||
prevAuthBackend === 'auth_basic')
);
};
)
}
return (
<>
@@ -114,5 +114,5 @@ export const Authentication = () => {
</>
)}
</>
);
};
)
}

View File

@@ -1,40 +1,40 @@
import { useDispatch, useSelector } from 'react-redux';
import Swal from 'sweetalert2';
import { RootState, AppDispatch } from '@/types';
import { useDispatch, useSelector } from 'react-redux'
import Swal from 'sweetalert2'
import { RootState, AppDispatch } from '@/types'
import { SWEETALERT_TIMER } from '@/constants';
import { SWEETALERT_TIMER } from '@/constants'
import {
createBackup,
uploadBackup,
resetUploadState,
fetchSettings,
} from '@/store/settings';
} from '@/store/settings'
export const Backup = () => {
const dispatch = useDispatch<AppDispatch>();
const dispatch = useDispatch<AppDispatch>()
const { isUploading, uploadProgress } = useSelector(
(state: RootState) => state.settings,
);
)
const handleBackup = async () => {
const backupButton = document.getElementById(
'btn-backup',
) as HTMLButtonElement | null;
) as HTMLButtonElement | null
const uploadButton = document.getElementById(
'btn-upload',
) as HTMLButtonElement | null;
) as HTMLButtonElement | null
if (!backupButton || !uploadButton) return;
if (!backupButton || !uploadButton) return
const originalText = backupButton.textContent;
backupButton.textContent = 'Preparing archive...';
backupButton.disabled = true;
uploadButton.disabled = true;
const originalText = backupButton.textContent
backupButton.textContent = 'Preparing archive...'
backupButton.disabled = true
uploadButton.disabled = true
try {
const result = await dispatch(createBackup()).unwrap();
const result = await dispatch(createBackup()).unwrap()
if (result) {
window.location.href = `/static_with_mime/${result}?mime=application/x-tgz`;
window.location.href = `/static_with_mime/${result}?mime=application/x-tgz`
}
} catch (err) {
await Swal.fire({
@@ -49,49 +49,49 @@ export const Backup = () => {
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
});
})
} finally {
if (backupButton) {
backupButton.textContent = originalText;
backupButton.disabled = false;
backupButton.textContent = originalText
backupButton.disabled = false
}
if (uploadButton) {
uploadButton.disabled = false;
uploadButton.disabled = false
}
}
};
}
const handleUpload = (e: React.MouseEvent) => {
e.preventDefault();
e.preventDefault()
const fileInput = document.querySelector(
'[name="backup_upload"]',
) as HTMLInputElement | null;
) as HTMLInputElement | null
if (fileInput) {
fileInput.value = ''; // Reset the file input
fileInput.click();
fileInput.value = '' // Reset the file input
fileInput.click()
}
};
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const file = e.target.files?.[0]
if (!file) return
const uploadButton = document.getElementById(
'btn-upload',
) as HTMLElement | null;
) as HTMLElement | null
const backupButton = document.getElementById(
'btn-backup',
) as HTMLElement | null;
) as HTMLElement | null
const progressElement = document.querySelector(
'.progress',
) as HTMLElement | null;
) as HTMLElement | null
if (uploadButton) uploadButton.style.display = 'none';
if (backupButton) backupButton.style.display = 'none';
if (progressElement) progressElement.style.display = 'block';
if (uploadButton) uploadButton.style.display = 'none'
if (backupButton) backupButton.style.display = 'none'
if (progressElement) progressElement.style.display = 'block'
try {
const result = await dispatch(uploadBackup(file)).unwrap();
const result = await dispatch(uploadBackup(file)).unwrap()
if (result) {
await Swal.fire({
@@ -108,10 +108,10 @@ export const Backup = () => {
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
});
})
// Fetch updated settings after successful recovery
dispatch(fetchSettings());
dispatch(fetchSettings())
}
} catch (err) {
await Swal.fire({
@@ -126,16 +126,16 @@ export const Backup = () => {
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
});
})
} finally {
dispatch(resetUploadState());
if (progressElement) progressElement.style.display = 'none';
if (uploadButton) uploadButton.style.display = 'inline-block';
if (backupButton) backupButton.style.display = 'inline-block';
dispatch(resetUploadState())
if (progressElement) progressElement.style.display = 'none'
if (uploadButton) uploadButton.style.display = 'inline-block'
if (backupButton) backupButton.style.display = 'inline-block'
// Reset the file input
e.target.value = '';
e.target.value = ''
}
};
}
return (
<>
@@ -182,5 +182,5 @@ export const Backup = () => {
</div>
</div>
</>
);
};
)
}

View File

@@ -1,11 +1,11 @@
import { RootState } from '@/types';
import { RootState } from '@/types'
export const DateFormat = ({
settings,
handleInputChange,
}: {
settings: RootState['settings']['settings'];
handleInputChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
settings: RootState['settings']['settings']
handleInputChange: (e: React.ChangeEvent<HTMLSelectElement>) => void
}) => {
return (
<div className="form-group">
@@ -29,5 +29,5 @@ export const DateFormat = ({
<option value="yyyy.mm.dd">year.month.day</option>
</select>
</div>
);
};
)
}

View File

@@ -1,11 +1,11 @@
import { RootState } from '@/types';
import { RootState } from '@/types'
export const DefaultDurations = ({
settings,
handleInputChange,
}: {
settings: RootState['settings']['settings'];
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
settings: RootState['settings']['settings']
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) => {
return (
<div className="row">
@@ -34,5 +34,5 @@ export const DefaultDurations = ({
/>
</div>
</div>
);
};
)
}

View File

@@ -1,78 +1,78 @@
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Swal from 'sweetalert2';
import { RootState, AppDispatch } from '@/types';
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import Swal from 'sweetalert2'
import { RootState, AppDispatch } from '@/types'
import { SWEETALERT_TIMER } from '@/constants';
import { SWEETALERT_TIMER } from '@/constants'
import {
fetchSettings,
fetchDeviceModel,
updateSettings,
updateSetting,
} from '@/store/settings';
import { SystemControls } from '@/components/settings/system-controls';
import { Backup } from '@/components/settings/backup';
import { Authentication } from '@/components/settings/authentication';
import { PlayerName } from '@/components/settings/player-name';
import { DefaultDurations } from '@/components/settings/default-durations';
import { AudioOutput } from '@/components/settings/audio-output';
import { DateFormat } from '@/components/settings/date-format';
import { ToggleableSetting } from '@/components/settings/toggleable-setting';
import { Update } from '@/components/settings/update';
} from '@/store/settings'
import { SystemControls } from '@/components/settings/system-controls'
import { Backup } from '@/components/settings/backup'
import { Authentication } from '@/components/settings/authentication'
import { PlayerName } from '@/components/settings/player-name'
import { DefaultDurations } from '@/components/settings/default-durations'
import { AudioOutput } from '@/components/settings/audio-output'
import { DateFormat } from '@/components/settings/date-format'
import { ToggleableSetting } from '@/components/settings/toggleable-setting'
import { Update } from '@/components/settings/update'
export const Settings = () => {
const dispatch = useDispatch<AppDispatch>();
const dispatch = useDispatch<AppDispatch>()
const { settings, deviceModel, isLoading } = useSelector(
(state: RootState) => state.settings,
);
const [upToDate, setUpToDate] = useState<boolean>(true);
const [isBalena, setIsBalena] = useState<boolean>(false);
)
const [upToDate, setUpToDate] = useState<boolean>(true)
const [isBalena, setIsBalena] = useState<boolean>(false)
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchDeviceModel());
}, [dispatch]);
dispatch(fetchSettings())
dispatch(fetchDeviceModel())
}, [dispatch])
useEffect(() => {
fetch('/api/v2/info')
.then((res) => res.json())
.then((data) => {
setUpToDate(data.up_to_date);
});
setUpToDate(data.up_to_date)
})
fetch('/api/v2/integrations')
.then((res) => res.json())
.then((data) => {
setIsBalena(data.is_balena);
});
}, []);
setIsBalena(data.is_balena)
})
}, [])
useEffect(() => {
const title = settings.playerName
? `${settings.playerName} · Settings`
: 'Settings';
document.title = title;
}, [settings.playerName]);
: 'Settings'
document.title = title
}, [settings.playerName])
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value, type } = e.target;
const { name, value, type } = e.target
const checked =
e.target instanceof HTMLInputElement ? e.target.checked : false;
e.target instanceof HTMLInputElement ? e.target.checked : false
dispatch(
updateSetting({
name: name as keyof RootState['settings']['settings'],
value: type === 'checkbox' ? checked : value,
}),
);
};
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
try {
await dispatch(updateSettings(settings)).unwrap();
await dispatch(updateSettings(settings)).unwrap()
await Swal.fire({
title: 'Success!',
@@ -85,9 +85,9 @@ export const Settings = () => {
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
});
})
dispatch(fetchSettings());
dispatch(fetchSettings())
} catch (err) {
await Swal.fire({
title: 'Error!',
@@ -99,9 +99,9 @@ export const Settings = () => {
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
});
})
}
};
}
return (
<div className="container">
@@ -206,5 +206,5 @@ export const Settings = () => {
<Backup />
<SystemControls />
</div>
);
};
)
}

View File

@@ -1,11 +1,11 @@
import { RootState } from '@/types';
import { RootState } from '@/types'
export const PlayerName = ({
settings,
handleInputChange,
}: {
settings: RootState['settings']['settings'];
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
settings: RootState['settings']['settings']
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) => {
return (
<div className="form-group">
@@ -20,5 +20,5 @@ export const PlayerName = ({
onChange={handleInputChange}
/>
</div>
);
};
)
}

View File

@@ -1,14 +1,14 @@
import { useDispatch } from 'react-redux';
import Swal from 'sweetalert2';
import { OperationConfig, AppDispatch } from '@/types';
import { useDispatch } from 'react-redux'
import Swal from 'sweetalert2'
import { OperationConfig, AppDispatch } from '@/types'
import { SWEETALERT_TIMER } from '@/constants';
import { systemOperation } from '@/store/settings';
import { SWEETALERT_TIMER } from '@/constants'
import { systemOperation } from '@/store/settings'
type SystemOperationType = 'reboot' | 'shutdown';
type SystemOperationType = 'reboot' | 'shutdown'
export const SystemControls = () => {
const dispatch = useDispatch<AppDispatch>();
const dispatch = useDispatch<AppDispatch>()
const handleSystemOperation = async (operation: SystemOperationType) => {
const config: Record<SystemOperationType, OperationConfig> = {
@@ -29,10 +29,10 @@ export const SystemControls = () => {
'Device shutdown has started successfully.\nSoon you will be able to unplug the power from your Raspberry Pi.',
errorMessage: 'Failed to shutdown device',
},
};
}
const { title, text, confirmButtonText, endpoint, successMessage } =
config[operation];
config[operation]
const result = await Swal.fire({
title,
@@ -51,13 +51,13 @@ export const SystemControls = () => {
cancelButton: 'swal2-cancel',
actions: 'swal2-actions',
},
});
})
if (result.isConfirmed) {
try {
await dispatch(
systemOperation({ operation, endpoint, successMessage }),
).unwrap();
).unwrap()
await Swal.fire({
title: 'Success!',
@@ -70,7 +70,7 @@ export const SystemControls = () => {
title: 'swal2-title',
htmlContainer: 'swal2-html-container',
},
});
})
} catch (err) {
await Swal.fire({
title: 'Error!',
@@ -84,13 +84,13 @@ export const SystemControls = () => {
htmlContainer: 'swal2-html-container',
confirmButton: 'swal2-confirm',
},
});
})
}
}
};
}
const handleReboot = () => handleSystemOperation('reboot');
const handleShutdown = () => handleSystemOperation('shutdown');
const handleReboot = () => handleSystemOperation('reboot')
const handleShutdown = () => handleSystemOperation('shutdown')
return (
<>
@@ -122,5 +122,5 @@ export const SystemControls = () => {
</div>
</div>
</>
);
};
)
}

View File

@@ -1,4 +1,4 @@
import { RootState } from '@/types';
import { RootState } from '@/types'
export const ToggleableSetting = ({
settings,
@@ -6,10 +6,10 @@ export const ToggleableSetting = ({
label,
name,
}: {
settings: RootState['settings']['settings'];
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
label: string;
name: string;
settings: RootState['settings']['settings']
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
label: string
name: string
}) => {
return (
<div className="form-inline mt-4">
@@ -34,5 +34,5 @@ export const ToggleableSetting = ({
</label>
</div>
</div>
);
};
)
}

View File

@@ -1,20 +1,20 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react'
export const Update = () => {
const [ipAddresses, setIpAddresses] = useState<string[]>([]);
const [hostUser, setHostUser] = useState<string>('<USER>');
const [ipAddresses, setIpAddresses] = useState<string[]>([])
const [hostUser, setHostUser] = useState<string>('<USER>')
useEffect(() => {
fetch('/api/v2/info')
.then((res) => res.json())
.then((data) => {
setIpAddresses(data.ip_addresses);
setIpAddresses(data.ip_addresses)
if (data.host_user) {
setHostUser(data.host_user);
setHostUser(data.host_user)
}
});
}, []);
})
}, [])
return (
<>
@@ -70,5 +70,5 @@ export const Update = () => {
</div>
</div>
</>
);
};
)
}

View File

@@ -1,10 +1,10 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { AssetRow } from './asset-row';
import { AssetRowProps } from '@/types';
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { AssetRow } from './asset-row'
import { AssetRowProps } from '@/types'
interface SortableAssetRowProps extends AssetRowProps {
id: string;
id: string
}
export const SortableAssetRow = (props: SortableAssetRowProps) => {
@@ -15,7 +15,7 @@ export const SortableAssetRow = (props: SortableAssetRowProps) => {
transform,
transition,
isDragging,
} = useSortable({ id: props.id });
} = useSortable({ id: props.id })
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
@@ -24,7 +24,7 @@ export const SortableAssetRow = (props: SortableAssetRowProps) => {
zIndex: isDragging ? 99999 : 'auto',
position: isDragging ? 'relative' : 'static',
backgroundColor: isDragging ? 'rgba(255, 255, 255, 0.1)' : 'transparent',
};
}
return (
<AssetRow
@@ -34,5 +34,5 @@ export const SortableAssetRow = (props: SortableAssetRowProps) => {
dragHandleProps={{ ...attributes, ...listeners }}
isDragging={isDragging}
/>
);
};
)
}

View File

@@ -1,50 +1,50 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react'
import {
AnthiasVersionValueProps,
SkeletonProps,
MemoryInfo,
UptimeInfo,
} from '@/types';
} from '@/types'
const ANTHIAS_REPO_URL = 'https://github.com/Screenly/Anthias';
const ANTHIAS_REPO_URL = 'https://github.com/Screenly/Anthias'
const AnthiasVersionValue = ({ version }: AnthiasVersionValueProps) => {
const [commitLink, setCommitLink] = useState('');
const [commitLink, setCommitLink] = useState('')
useEffect(() => {
if (!version) {
return;
return
}
const [gitBranch, gitCommit] = version ? version.split('@') : ['', ''];
const [gitBranch, gitCommit] = version ? version.split('@') : ['', '']
if (gitBranch === 'master') {
setCommitLink(`${ANTHIAS_REPO_URL}/commit/${gitCommit}`);
setCommitLink(`${ANTHIAS_REPO_URL}/commit/${gitCommit}`)
}
});
})
if (commitLink) {
return (
<a href={commitLink} rel="noopener" target="_blank" className="text-dark">
{version}
</a>
);
)
}
return <>{version}</>;
};
return <>{version}</>
}
const Skeleton = ({ children, isLoading }: SkeletonProps) => {
return isLoading ? (
<span className="placeholder placeholder-wave"></span>
) : (
children
);
};
)
}
export const SystemInfo = () => {
const [loadAverage, setLoadAverage] = useState('');
const [freeSpace, setFreeSpace] = useState('');
const [loadAverage, setLoadAverage] = useState('')
const [freeSpace, setFreeSpace] = useState('')
const [memory, setMemory] = useState<MemoryInfo>({
total: 0,
used: 0,
@@ -52,20 +52,20 @@ export const SystemInfo = () => {
shared: 0,
buff: 0,
available: 0,
});
})
const [uptime, setUptime] = useState<UptimeInfo>({
days: 0,
hours: 0,
});
const [displayPower, setDisplayPower] = useState<string | null>(null);
const [deviceModel, setDeviceModel] = useState('');
const [anthiasVersion, setAnthiasVersion] = useState('');
const [macAddress, setMacAddress] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [playerName, setPlayerName] = useState('');
})
const [displayPower, setDisplayPower] = useState<string | null>(null)
const [deviceModel, setDeviceModel] = useState('')
const [anthiasVersion, setAnthiasVersion] = useState('')
const [macAddress, setMacAddress] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [playerName, setPlayerName] = useState('')
const initializeSystemInfo = async () => {
setIsLoading(true);
setIsLoading(true)
try {
const [infoResponse, settingsResponse] = await Promise.all([
@@ -75,40 +75,40 @@ export const SystemInfo = () => {
},
}),
fetch('/api/v2/device_settings'),
]);
])
if (!infoResponse.ok) {
throw new Error('Failed to fetch system info');
throw new Error('Failed to fetch system info')
}
const [systemInfo, settingsData] = await Promise.all([
infoResponse.json(),
settingsResponse.json(),
]);
])
setLoadAverage(systemInfo.loadavg);
setFreeSpace(systemInfo.free_space);
setMemory(systemInfo.memory);
setUptime(systemInfo.uptime);
setDisplayPower(systemInfo.display_power);
setDeviceModel(systemInfo.device_model);
setAnthiasVersion(systemInfo.anthias_version);
setMacAddress(systemInfo.mac_address);
setPlayerName(settingsData.player_name ?? '');
setLoadAverage(systemInfo.loadavg)
setFreeSpace(systemInfo.free_space)
setMemory(systemInfo.memory)
setUptime(systemInfo.uptime)
setDisplayPower(systemInfo.display_power)
setDeviceModel(systemInfo.device_model)
setAnthiasVersion(systemInfo.anthias_version)
setMacAddress(systemInfo.mac_address)
setPlayerName(settingsData.player_name ?? '')
} catch {
} finally {
setIsLoading(false);
setIsLoading(false)
}
};
}
useEffect(() => {
initializeSystemInfo();
}, []);
initializeSystemInfo()
}, [])
useEffect(() => {
const title = playerName ? `${playerName} · System Info` : 'System Info';
document.title = title;
}, [playerName]);
const title = playerName ? `${playerName} · System Info` : 'System Info'
document.title = title
}, [playerName])
return (
<div className="container">
@@ -215,5 +215,5 @@ export const SystemInfo = () => {
</div>
</div>
</div>
);
};
)
}

View File

@@ -1 +1 @@
export const SWEETALERT_TIMER = 3500;
export const SWEETALERT_TIMER = 3500

View File

@@ -1,17 +1,17 @@
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from './store';
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { store } from './store'
import '@/sass/anthias.scss';
import { App } from '@/components/app';
import '@/sass/anthias.scss'
import { App } from '@/components/app'
const appElement = document.getElementById('app');
const appElement = document.getElementById('app')
if (!appElement) {
throw new Error('App element not found');
throw new Error('App element not found')
}
const root = ReactDOM.createRoot(appElement);
const root = ReactDOM.createRoot(appElement)
root.render(
<BrowserRouter basename="/">
@@ -19,4 +19,4 @@ root.render(
<App />
</Provider>
</BrowserRouter>,
);
)

View File

@@ -1,13 +1,13 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { addAsset } from './assets-list-slice';
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { addAsset } from './assets-list-slice'
import {
UploadFileParams,
SaveAssetParams,
RootState,
FileData,
FormData,
} from '@/types';
import { getMimetype } from '@/components/add-asset-modal/file-upload-utils';
} from '@/types'
import { getMimetype } from '@/components/add-asset-modal/file-upload-utils'
// Async thunks for API operations
export const uploadFile = createAsyncThunk(
@@ -17,59 +17,59 @@ export const uploadFile = createAsyncThunk(
{ dispatch, getState, rejectWithValue },
) => {
try {
const formData = new FormData();
formData.append('file_upload', file);
const formData = new FormData()
formData.append('file_upload', file)
// Create XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
const xhr = new XMLHttpRequest()
const uploadPromise = new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
dispatch(setUploadProgress(progress));
const progress = Math.round((e.loaded / e.total) * 100)
dispatch(setUploadProgress(progress))
}
});
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
const response = JSON.parse(xhr.responseText)
resolve(response)
} catch {
reject(new Error('Invalid JSON response'));
reject(new Error('Invalid JSON response'))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
reject(new Error(`Upload failed with status ${xhr.status}`))
}
});
})
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
reject(new Error('Network error during upload'))
})
xhr.addEventListener('abort', () => {
reject(new Error('Upload aborted'));
});
});
reject(new Error('Upload aborted'))
})
})
// Start the upload
xhr.open('POST', '/api/v2/file_asset');
xhr.send(formData);
xhr.open('POST', '/api/v2/file_asset')
xhr.send(formData)
// Wait for upload to complete
const response = await uploadPromise;
const response = await uploadPromise
// Get mimetype and duration
const mimetype = getMimetype(file.name);
const mimetypeString = Array.isArray(mimetype) ? mimetype[0] : mimetype;
const state = getState() as RootState;
const mimetype = getMimetype(file.name)
const mimetypeString = Array.isArray(mimetype) ? mimetype[0] : mimetype
const state = getState() as RootState
const duration = getDurationForMimetype(
mimetypeString,
state.settings.settings.defaultDuration,
state.settings.settings.defaultStreamingDuration,
);
const dates = getDefaultDates();
)
const dates = getDefaultDates()
return {
fileData: response as FileData,
@@ -78,12 +78,12 @@ export const uploadFile = createAsyncThunk(
mimetype: mimetypeString,
duration,
dates,
};
}
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
export const saveAsset = createAsyncThunk(
'assetModal/saveAsset',
@@ -95,30 +95,30 @@ export const saveAsset = createAsyncThunk(
'Content-Type': 'application/json',
},
body: JSON.stringify(assetData),
});
})
if (!response.ok) {
return rejectWithValue('Failed to save asset');
return rejectWithValue('Failed to save asset')
}
const data = await response.json();
const data = await response.json()
// Create the complete asset object with the response data
const completeAsset = {
...assetData,
asset_id: data.asset_id,
...data,
};
}
// Dispatch the addAsset action to update the assets list
dispatch(addAsset(completeAsset));
dispatch(addAsset(completeAsset))
return completeAsset;
return completeAsset
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
const getDurationForMimetype = (
mimetype: string,
@@ -126,24 +126,24 @@ const getDurationForMimetype = (
defaultStreamingDuration: number,
) => {
if (mimetype === 'video') {
return 0;
return 0
} else if (mimetype === 'streaming') {
return defaultStreamingDuration;
return defaultStreamingDuration
} else {
return defaultDuration;
return defaultDuration
}
};
}
const getDefaultDates = () => {
const now = new Date();
const endDate = new Date();
endDate.setDate(endDate.getDate() + 30); // 30 days from now
const now = new Date()
const endDate = new Date()
endDate.setDate(endDate.getDate() + 30) // 30 days from now
return {
start_date: now.toISOString(),
end_date: endDate.toISOString(),
};
};
}
}
// Slice definition
const assetModalSlice = createSlice({
@@ -162,57 +162,57 @@ const assetModalSlice = createSlice({
},
reducers: {
setActiveTab: (state, action) => {
state.activeTab = action.payload;
state.activeTab = action.payload
},
updateFormData: (state, action) => {
state.formData = { ...state.formData, ...action.payload };
state.formData = { ...state.formData, ...action.payload }
},
setValid: (state, action) => {
state.isValid = action.payload;
state.isValid = action.payload
},
setErrorMessage: (state, action) => {
state.errorMessage = action.payload;
state.errorMessage = action.payload
},
setStatusMessage: (state, action) => {
state.statusMessage = action.payload;
state.statusMessage = action.payload
},
setUploadProgress: (state, action) => {
state.uploadProgress = action.payload;
state.uploadProgress = action.payload
},
resetForm: (state) => {
state.formData = {
uri: '',
skipAssetCheck: false,
};
state.isValid = true;
state.errorMessage = '';
state.statusMessage = '';
state.isSubmitting = false;
state.uploadProgress = 0;
}
state.isValid = true
state.errorMessage = ''
state.statusMessage = ''
state.isSubmitting = false
state.uploadProgress = 0
},
validateUrl: (state, action) => {
const url = action.payload;
const url = action.payload
if (!url) {
state.isValid = true;
state.errorMessage = '';
return;
state.isValid = true
state.errorMessage = ''
return
}
const urlPattern =
/(http|https|rtsp|rtmp):\/\/[\w-]+(\.?[\w-]+)+([\w.,@?^=%&amp;:\/~+#-]*[\w@?^=%&amp;\/~+#-])?/;
const isValidUrl = urlPattern.test(url);
/(http|https|rtsp|rtmp):\/\/[\w-]+(\.?[\w-]+)+([\w.,@?^=%&amp;:\/~+#-]*[\w@?^=%&amp;\/~+#-])?/
const isValidUrl = urlPattern.test(url)
state.isValid = isValidUrl;
state.errorMessage = isValidUrl ? '' : 'Please enter a valid URL';
state.isValid = isValidUrl
state.errorMessage = isValidUrl ? '' : 'Please enter a valid URL'
},
},
extraReducers: (builder) => {
builder
// Upload file
.addCase(uploadFile.pending, (state) => {
state.isSubmitting = true;
state.statusMessage = '';
state.uploadProgress = 0;
state.isSubmitting = true
state.statusMessage = ''
state.uploadProgress = 0
})
.addCase(uploadFile.fulfilled, (state, action) => {
const {
@@ -222,7 +222,7 @@ const assetModalSlice = createSlice({
mimetype,
duration,
dates,
} = action.payload;
} = action.payload
// Update form data with file name and other details
state.formData = {
@@ -233,31 +233,31 @@ const assetModalSlice = createSlice({
mimetype,
duration,
dates,
};
}
state.statusMessage = 'Upload completed.';
state.isSubmitting = false;
state.uploadProgress = 0;
state.statusMessage = 'Upload completed.'
state.isSubmitting = false
state.uploadProgress = 0
})
.addCase(uploadFile.rejected, (state, action) => {
state.errorMessage = `Upload failed: ${action.payload}`;
state.isSubmitting = false;
state.uploadProgress = 0;
state.errorMessage = `Upload failed: ${action.payload}`
state.isSubmitting = false
state.uploadProgress = 0
})
// Save asset
.addCase(saveAsset.pending, (state) => {
state.isSubmitting = true;
state.isSubmitting = true
})
.addCase(saveAsset.fulfilled, (state) => {
state.isSubmitting = false;
state.statusMessage = 'Asset saved successfully.';
state.isSubmitting = false
state.statusMessage = 'Asset saved successfully.'
})
.addCase(saveAsset.rejected, (state, action) => {
state.errorMessage = `Failed to save asset: ${action.payload}`;
state.isSubmitting = false;
});
state.errorMessage = `Failed to save asset: ${action.payload}`
state.isSubmitting = false
})
},
});
})
// Export actions
export const {
@@ -269,10 +269,10 @@ export const {
setUploadProgress,
resetForm,
validateUrl,
} = assetModalSlice.actions;
} = assetModalSlice.actions
// Export selectors
export const selectAssetModalState = (state: RootState) => state.assetModal;
export const selectAssetModalState = (state: RootState) => state.assetModal
// Export reducer
export default assetModalSlice.reducer;
export default assetModalSlice.reducer

View File

@@ -1,52 +1,52 @@
import { createSlice } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit'
import {
fetchAssets,
updateAssetOrder,
toggleAssetEnabled,
} from '@/store/assets/assets-thunks';
import { Asset, RootState } from '@/types';
} from '@/store/assets/assets-thunks'
import { Asset, RootState } from '@/types'
const initialState: RootState['assets'] = {
items: [] as Asset[],
status: 'idle' as const,
error: null as string | null,
};
}
const assetsSlice = createSlice({
name: 'assets',
initialState,
reducers: {
addAsset: (state, action) => {
state.items.push(action.payload);
state.items.push(action.payload)
},
},
extraReducers: (builder) => {
builder
.addCase(fetchAssets.pending, (state) => {
state.status = 'loading';
state.status = 'loading'
})
.addCase(fetchAssets.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
state.status = 'succeeded'
state.items = action.payload
})
.addCase(fetchAssets.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message || 'Failed to fetch assets';
state.status = 'failed'
state.error = action.error.message || 'Failed to fetch assets'
})
.addCase(updateAssetOrder.fulfilled, (state) => {
state.status = 'succeeded';
state.status = 'succeeded'
})
.addCase(toggleAssetEnabled.fulfilled, (state, action) => {
const { assetId, newValue, playOrder } = action.payload;
const asset = state.items.find((item) => item.asset_id === assetId);
const { assetId, newValue, playOrder } = action.payload
const asset = state.items.find((item) => item.asset_id === assetId)
if (asset) {
asset.is_enabled = newValue;
asset.play_order = playOrder;
asset.is_enabled = newValue
asset.play_order = playOrder
}
});
})
},
});
})
export const { addAsset } = assetsSlice.actions;
export const { addAsset } = assetsSlice.actions
export default assetsSlice.reducer;
export default assetsSlice.reducer

View File

@@ -1,9 +1,9 @@
import { RootState } from '@/types';
import { RootState } from '@/types'
export const selectActiveAssets = (state: RootState) =>
state.assets.items
.filter((asset) => asset.is_active)
.sort((a, b) => a.play_order - b.play_order);
.sort((a, b) => a.play_order - b.play_order)
export const selectInactiveAssets = (state: RootState) =>
state.assets.items.filter((asset) => !asset.is_active);
state.assets.items.filter((asset) => !asset.is_active)

View File

@@ -1,11 +1,11 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { Asset, ToggleAssetParams, RootState } from '@/types';
import { createAsyncThunk } from '@reduxjs/toolkit'
import { Asset, ToggleAssetParams, RootState } from '@/types'
export const fetchAssets = createAsyncThunk('assets/fetchAssets', async () => {
const response = await fetch('/api/v2/assets');
const data = await response.json();
return data;
});
const response = await fetch('/api/v2/assets')
const data = await response.json()
return data
})
export const updateAssetOrder = createAsyncThunk(
'assets/updateOrder',
@@ -16,27 +16,27 @@ export const updateAssetOrder = createAsyncThunk(
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: orderedIds }),
});
})
if (!response.ok) {
throw new Error('Failed to update order');
throw new Error('Failed to update order')
}
return orderedIds;
return orderedIds
},
);
)
export const toggleAssetEnabled = createAsyncThunk(
'assets/toggleEnabled',
async ({ assetId, newValue }: ToggleAssetParams, { dispatch, getState }) => {
// First, fetch the current assets to determine the next play_order
const response = await fetch('/api/v2/assets');
const assets = await response.json();
const response = await fetch('/api/v2/assets')
const assets = await response.json()
// Get the current active assets to determine the next play_order
const activeAssets = assets.filter((asset: Asset) => asset.is_active);
const activeAssets = assets.filter((asset: Asset) => asset.is_active)
// If enabling the asset, set play_order to the next available position
// If disabling the asset, set play_order to 0
const playOrder = newValue === 1 ? activeAssets.length : 0;
const playOrder = newValue === 1 ? activeAssets.length : 0
const updateResponse = await fetch(`/api/v2/assets/${assetId}`, {
method: 'PATCH',
@@ -47,22 +47,22 @@ export const toggleAssetEnabled = createAsyncThunk(
is_enabled: newValue,
play_order: playOrder,
}),
});
})
const state = getState() as RootState;
const state = getState() as RootState
const activeAssetIds = state.assets.items
.filter((asset) => asset.is_active)
.sort((a, b) => a.play_order - b.play_order)
.map((asset) => asset.asset_id)
.concat(assetId);
.concat(assetId)
await dispatch(updateAssetOrder(activeAssetIds.join(',')));
await dispatch(updateAssetOrder(activeAssetIds.join(',')))
if (!updateResponse.ok) {
throw new Error('Failed to update asset');
throw new Error('Failed to update asset')
}
// Return both the assetId and newValue for the reducer
return { assetId, newValue, playOrder };
return { assetId, newValue, playOrder }
},
);
)

View File

@@ -1,15 +1,15 @@
import assetsReducer from '@/store/assets/assets-list-slice';
import { addAsset } from '@/store/assets/assets-list-slice';
import assetsReducer from '@/store/assets/assets-list-slice'
import { addAsset } from '@/store/assets/assets-list-slice'
import {
fetchAssets,
updateAssetOrder,
toggleAssetEnabled,
} from '@/store/assets/assets-thunks';
} from '@/store/assets/assets-thunks'
import {
selectActiveAssets,
selectInactiveAssets,
} from '@/store/assets/assets-selectors';
import assetModalReducer from './asset-modal-slice';
} from '@/store/assets/assets-selectors'
import assetModalReducer from './asset-modal-slice'
import {
uploadFile,
saveAsset,
@@ -22,7 +22,7 @@ import {
resetForm,
validateUrl,
selectAssetModalState,
} from './asset-modal-slice';
} from './asset-modal-slice'
export {
assetsReducer,
@@ -45,4 +45,4 @@ export {
resetForm,
validateUrl,
selectAssetModalState,
};
}

View File

@@ -1,9 +1,9 @@
import { configureStore } from '@reduxjs/toolkit';
import { assetsReducer, assetModalReducer } from '@/store/assets';
import settingsReducer from '@/store/settings';
import websocketReducer from '@/store/websocket';
import { configureStore } from '@reduxjs/toolkit'
import { assetsReducer, assetModalReducer } from '@/store/assets'
import settingsReducer from '@/store/settings'
import websocketReducer from '@/store/websocket'
const environment = process.env.ENVIRONMENT || 'production';
const environment = process.env.ENVIRONMENT || 'production'
export const store = configureStore({
reducer: {
@@ -13,7 +13,7 @@ export const store = configureStore({
websocket: websocketReducer,
},
devTools: environment === 'development',
});
})
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

View File

@@ -1,27 +1,27 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { SettingsData, SystemOperationParams, RootState } from '@/types';
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { SettingsData, SystemOperationParams, RootState } from '@/types'
type SettingsState = RootState['settings']['settings'];
type SettingsState = RootState['settings']['settings']
type UpdateSettingPayload = {
name: keyof SettingsState;
value: SettingsState[keyof SettingsState];
};
name: keyof SettingsState
value: SettingsState[keyof SettingsState]
}
export const fetchSettings = createAsyncThunk(
'settings/fetchSettings',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/v2/device_settings');
const response = await fetch('/api/v2/device_settings')
if (response?.url?.endsWith('/login/')) {
window.location.href = response.url;
window.location.href = response.url
}
if (!response.ok) {
return rejectWithValue('Failed to fetch device settings');
return rejectWithValue('Failed to fetch device settings')
}
const data = await response.json();
const data = await response.json()
return {
playerName: data.player_name || '',
defaultDuration: data.default_duration || 0,
@@ -35,21 +35,21 @@ export const fetchSettings = createAsyncThunk(
shufflePlaylist: data.shuffle_playlist || false,
use24HourClock: data.use_24_hour_clock || false,
debugLogging: data.debug_logging || false,
};
}
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
export const fetchDeviceModel = createAsyncThunk(
'settings/fetchDeviceModel',
async () => {
const response = await fetch('/api/v2/info');
const data = await response.json();
return data.device_model || '';
const response = await fetch('/api/v2/info')
const data = await response.json()
return data.device_model || ''
},
);
)
export const updateSettings = createAsyncThunk(
'settings/updateSettings',
@@ -77,20 +77,20 @@ export const updateSettings = createAsyncThunk(
use_24_hour_clock: settings.use24HourClock,
debug_logging: settings.debugLogging,
}),
});
})
const data = await response.json();
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to save settings');
throw new Error(data.error || 'Failed to save settings')
}
return data;
return data
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
export const createBackup = createAsyncThunk(
'settings/createBackup',
@@ -98,44 +98,44 @@ export const createBackup = createAsyncThunk(
try {
const response = await fetch('/api/v2/backup', {
method: 'POST',
});
})
if (!response.ok) {
throw new Error('Failed to create backup');
throw new Error('Failed to create backup')
}
const data = await response.json();
return data;
const data = await response.json()
return data
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
export const uploadBackup = createAsyncThunk(
'settings/uploadBackup',
async (file: File, { rejectWithValue }) => {
try {
const formData = new FormData();
formData.append('backup_upload', file);
const formData = new FormData()
formData.append('backup_upload', file)
const response = await fetch('/api/v2/recover', {
method: 'POST',
body: formData,
});
})
const data = await response.json();
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to upload backup');
throw new Error(data.error || 'Failed to upload backup')
}
return data;
return data
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
export const systemOperation = createAsyncThunk(
'settings/systemOperation',
@@ -146,18 +146,18 @@ export const systemOperation = createAsyncThunk(
try {
const response = await fetch(endpoint, {
method: 'POST',
});
})
if (!response.ok) {
throw new Error(`Failed to ${operation} device`);
throw new Error(`Failed to ${operation} device`)
}
return { operation, successMessage };
return { operation, successMessage }
} catch (error) {
return rejectWithValue((error as Error).message);
return rejectWithValue((error as Error).message)
}
},
);
)
const initialState = {
settings: {
@@ -184,102 +184,102 @@ const initialState = {
isUploading: false,
uploadProgress: 0,
error: null as string | null,
};
}
const settingsSlice = createSlice({
name: 'settings',
initialState,
reducers: {
updateSetting: (state, action: { payload: UpdateSettingPayload }) => {
const { name, value } = action.payload;
const { name, value } = action.payload
if (name === 'authBackend') {
state.prevAuthBackend = state.settings.authBackend;
state.prevAuthBackend = state.settings.authBackend
}
(
;(
state.settings as Record<
keyof SettingsState,
SettingsState[keyof SettingsState]
>
)[name] = value;
)[name] = value
},
setUploadProgress: (state, action) => {
state.uploadProgress = action.payload;
state.uploadProgress = action.payload
},
resetUploadState: (state) => {
state.isUploading = false;
state.uploadProgress = 0;
state.error = null;
state.isUploading = false
state.uploadProgress = 0
state.error = null
},
clearError: (state) => {
state.error = null;
state.error = null
},
},
extraReducers: (builder) => {
builder
// Fetch Settings
.addCase(fetchSettings.pending, (state) => {
state.isLoading = true;
state.error = null;
state.isLoading = true
state.error = null
})
.addCase(fetchSettings.fulfilled, (state, action) => {
state.settings = { ...state.settings, ...action.payload };
state.prevAuthBackend = action.payload.authBackend;
state.hasSavedBasicAuth = action.payload.authBackend === 'auth_basic';
state.isLoading = false;
state.settings = { ...state.settings, ...action.payload }
state.prevAuthBackend = action.payload.authBackend
state.hasSavedBasicAuth = action.payload.authBackend === 'auth_basic'
state.isLoading = false
})
.addCase(fetchSettings.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string | null;
state.isLoading = false
state.error = action.payload as string | null
})
// Fetch Device Model
.addCase(fetchDeviceModel.fulfilled, (state, action) => {
state.deviceModel = action.payload;
state.deviceModel = action.payload
})
// Update Settings
.addCase(updateSettings.pending, (state) => {
state.isLoading = true;
state.error = null;
state.isLoading = true
state.error = null
})
.addCase(updateSettings.fulfilled, (state) => {
state.isLoading = false;
state.settings.currentPassword = '';
state.hasSavedBasicAuth = state.settings.authBackend === 'auth_basic';
state.isLoading = false
state.settings.currentPassword = ''
state.hasSavedBasicAuth = state.settings.authBackend === 'auth_basic'
})
.addCase(updateSettings.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string | null;
state.isLoading = false
state.error = action.payload as string | null
})
// Create Backup
.addCase(createBackup.rejected, (state, action) => {
state.error = action.payload as string | null;
state.error = action.payload as string | null
})
// Upload Backup
.addCase(uploadBackup.pending, (state) => {
state.isUploading = true;
state.error = null;
state.isUploading = true
state.error = null
})
.addCase(uploadBackup.fulfilled, (state) => {
state.isUploading = false;
state.isUploading = false
})
.addCase(uploadBackup.rejected, (state, action) => {
state.isUploading = false;
state.error = action.payload as string | null;
state.isUploading = false
state.error = action.payload as string | null
})
// System Operation
.addCase(systemOperation.rejected, (state, action) => {
state.error = action.payload as string | null;
});
state.error = action.payload as string | null
})
},
});
})
export const {
updateSetting,
setUploadProgress,
resetUploadState,
clearError,
} = settingsSlice.actions;
} = settingsSlice.actions
// Selectors
export const selectSettings = (state: RootState) => state.settings.settings;
export const selectSettings = (state: RootState) => state.settings.settings
export default settingsSlice.reducer;
export default settingsSlice.reducer

View File

@@ -3,14 +3,14 @@ import {
createSlice,
createAsyncThunk,
ThunkDispatch,
} from '@reduxjs/toolkit';
import { handleWebSocketMessage } from './message-handler';
} from '@reduxjs/toolkit'
import { handleWebSocketMessage } from './message-handler'
import {
RootState,
WebSocketMessage,
WebSocketState,
ExtendedWindow,
} from '@/types';
} from '@/types'
const initialState: WebSocketState = {
isConnected: false,
@@ -18,143 +18,143 @@ const initialState: WebSocketState = {
error: null,
lastMessage: null,
reconnectAttempts: 0,
};
}
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAY = 3000;
const MAX_RECONNECT_ATTEMPTS = 5
const RECONNECT_DELAY = 3000
export const connectWebSocket = createAsyncThunk(
'websocket/connect',
async (_, { dispatch, getState }) => {
return new Promise<void>((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws`
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
dispatch(setConnected(true));
dispatch(setConnecting(false));
dispatch(setReconnectAttempts(0));
resolve();
};
dispatch(setConnected(true))
dispatch(setConnecting(false))
dispatch(setReconnectAttempts(0))
resolve()
}
ws.onmessage = async (event) => {
let messageData: string;
let messageData: string
// Handle different message types (Blob, ArrayBuffer, or string)
if (event.data instanceof Blob) {
messageData = await event.data.text();
messageData = await event.data.text()
} else if (event.data instanceof ArrayBuffer) {
messageData = new TextDecoder().decode(event.data);
messageData = new TextDecoder().decode(event.data)
} else {
messageData = event.data;
messageData = event.data
}
try {
const message = JSON.parse(messageData) as WebSocketMessage;
dispatch(setLastMessage(message));
const message = JSON.parse(messageData) as WebSocketMessage
dispatch(setLastMessage(message))
handleWebSocketMessage(
message,
dispatch as ThunkDispatch<RootState, unknown, UnknownAction>,
);
)
} catch {
// If it's not JSON, treat it as a string message
dispatch(setLastMessage(messageData));
dispatch(setLastMessage(messageData))
handleWebSocketMessage(
messageData,
dispatch as ThunkDispatch<RootState, unknown, UnknownAction>,
);
)
}
};
}
ws.onerror = () => {
dispatch(setError('WebSocket connection error'));
dispatch(setConnecting(false));
reject(new Error('WebSocket connection failed'));
};
dispatch(setError('WebSocket connection error'))
dispatch(setConnecting(false))
reject(new Error('WebSocket connection failed'))
}
ws.onclose = (event) => {
dispatch(setConnected(false));
dispatch(setConnecting(false));
dispatch(setConnected(false))
dispatch(setConnecting(false))
// Attempt to reconnect if not a normal closure
if (event.code !== 1000) {
const state = getState() as RootState;
const currentAttempts = state.websocket.reconnectAttempts;
const state = getState() as RootState
const currentAttempts = state.websocket.reconnectAttempts
if (currentAttempts < MAX_RECONNECT_ATTEMPTS) {
setTimeout(() => {
dispatch(setReconnectAttempts(currentAttempts + 1));
dispatch(connectWebSocket());
}, RECONNECT_DELAY);
dispatch(setReconnectAttempts(currentAttempts + 1))
dispatch(connectWebSocket())
}, RECONNECT_DELAY)
}
}
};
}
// Store the WebSocket instance for later use
(window as ExtendedWindow).anthiasWebSocket = ws;
});
;(window as ExtendedWindow).anthiasWebSocket = ws
})
},
);
)
export const disconnectWebSocket = createAsyncThunk(
'websocket/disconnect',
async () => {
const ws = (window as ExtendedWindow).anthiasWebSocket;
const ws = (window as ExtendedWindow).anthiasWebSocket
if (ws) {
ws.close(1000); // Normal closure
(window as ExtendedWindow).anthiasWebSocket = undefined;
ws.close(1000) // Normal closure
;(window as ExtendedWindow).anthiasWebSocket = undefined
}
},
);
)
const websocketSlice = createSlice({
name: 'websocket',
initialState,
reducers: {
setConnected: (state, action: { payload: boolean }) => {
state.isConnected = action.payload;
state.isConnected = action.payload
},
setConnecting: (state, action: { payload: boolean }) => {
state.isConnecting = action.payload;
state.isConnecting = action.payload
},
setError: (state, action: { payload: string }) => {
state.error = action.payload;
state.error = action.payload
},
setLastMessage: (
state,
action: { payload: WebSocketMessage | string | null },
) => {
state.lastMessage = action.payload;
state.lastMessage = action.payload
},
setReconnectAttempts: (state, action: { payload: number }) => {
state.reconnectAttempts = action.payload;
state.reconnectAttempts = action.payload
},
clearError: (state) => {
state.error = null;
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(connectWebSocket.pending, (state) => {
state.isConnecting = true;
state.error = null;
state.isConnecting = true
state.error = null
})
.addCase(connectWebSocket.fulfilled, (state) => {
state.isConnecting = false;
state.error = null;
state.isConnecting = false
state.error = null
})
.addCase(connectWebSocket.rejected, (state, action) => {
state.isConnecting = false;
state.error = action.error.message || 'Failed to connect to WebSocket';
state.isConnecting = false
state.error = action.error.message || 'Failed to connect to WebSocket'
})
.addCase(disconnectWebSocket.fulfilled, (state) => {
state.isConnected = false;
state.isConnecting = false;
});
state.isConnected = false
state.isConnecting = false
})
},
});
})
export const {
setConnected,
@@ -163,6 +163,6 @@ export const {
setLastMessage,
setReconnectAttempts,
clearError,
} = websocketSlice.actions;
} = websocketSlice.actions
export default websocketSlice.reducer;
export default websocketSlice.reducer

View File

@@ -1,6 +1,6 @@
import { UnknownAction, ThunkDispatch } from '@reduxjs/toolkit';
import { fetchAssets } from '@/store/assets';
import { RootState, WebSocketMessage } from '@/types';
import { UnknownAction, ThunkDispatch } from '@reduxjs/toolkit'
import { fetchAssets } from '@/store/assets'
import { RootState, WebSocketMessage } from '@/types'
export const handleWebSocketMessage = (
message: WebSocketMessage | string,
@@ -8,6 +8,6 @@ export const handleWebSocketMessage = (
) => {
// Only refresh assets if the message is a string resembling an asset ID (32-char hex)
if (typeof message === 'string' && /^[a-fA-F0-9]{32}$/.test(message)) {
dispatch(fetchAssets());
dispatch(fetchAssets())
}
};
}

View File

@@ -1,28 +1,28 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Alert } from '@/components/alert';
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Alert } from '@/components/alert'
describe('Alert', () => {
it('renders the alert message', () => {
const testMessage = 'This is a test alert message';
render(<Alert message={testMessage} />);
const testMessage = 'This is a test alert message'
render(<Alert message={testMessage} />)
expect(screen.getByText(testMessage)).toBeTruthy();
});
expect(screen.getByText(testMessage)).toBeTruthy()
})
it('renders the close button', () => {
render(<Alert message="Test message" />);
render(<Alert message="Test message" />)
const closeButton = screen.getByRole('button');
expect(closeButton).toBeTruthy();
expect(closeButton.textContent).toBe('×');
});
const closeButton = screen.getByRole('button')
expect(closeButton).toBeTruthy()
expect(closeButton.textContent).toBe('×')
})
it('renders with correct structure', () => {
render(<Alert message="Test message" />);
render(<Alert message="Test message" />)
const messageElement = screen.getByText('Test message');
expect(messageElement).toBeTruthy();
expect(messageElement.tagName).toBe('SPAN');
});
});
const messageElement = screen.getByText('Test message')
expect(messageElement).toBeTruthy()
expect(messageElement.tagName).toBe('SPAN')
})
})

View File

@@ -1,12 +1,12 @@
import { render, screen, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ScheduleOverview } from '@/components/home';
import { RootState } from '@/types';
import { assetsReducer, assetModalReducer } from '@/store/assets';
import settingsReducer from '@/store/settings';
import websocketReducer from '@/store/websocket';
import { render, screen, act } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import { ScheduleOverview } from '@/components/home'
import { RootState } from '@/types'
import { assetsReducer, assetModalReducer } from '@/store/assets'
import settingsReducer from '@/store/settings'
import websocketReducer from '@/store/websocket'
const initialState: RootState = {
assets: {
@@ -105,7 +105,7 @@ const initialState: RootState = {
lastMessage: null,
reconnectAttempts: 0,
},
};
}
const createTestStore = (preloadedState = {}) => {
return configureStore({
@@ -116,30 +116,30 @@ const createTestStore = (preloadedState = {}) => {
websocket: websocketReducer,
},
preloadedState,
});
};
})
}
const renderWithRedux = (
component: React.ReactElement,
state: RootState = initialState,
) => {
const store = createTestStore(state);
const store = createTestStore(state)
return {
...render(<Provider store={store}>{component}</Provider>),
store,
};
};
}
}
describe('ScheduleOverview', () => {
it('renders the home page', async () => {
await act(async () => {
renderWithRedux(<ScheduleOverview />);
});
renderWithRedux(<ScheduleOverview />)
})
expect(screen.getByText('Schedule Overview')).toBeInTheDocument();
expect(screen.getByText('Schedule Overview')).toBeInTheDocument()
expect(screen.getByText('https://react.dev/')).toBeInTheDocument();
expect(screen.getByText('https://angular.dev/')).toBeInTheDocument();
expect(screen.getByText('https://vuejs.org/')).toBeInTheDocument();
});
});
expect(screen.getByText('https://react.dev/')).toBeInTheDocument()
expect(screen.getByText('https://angular.dev/')).toBeInTheDocument()
expect(screen.getByText('https://vuejs.org/')).toBeInTheDocument()
})
})

View File

@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { SystemInfo } from '@/components/system-info';
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { SystemInfo } from '@/components/system-info'
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
http.get('/api/v2/info', () => {
@@ -26,33 +26,33 @@ const server = setupServer(
device_model: 'Generic x86_64 Device',
anthias_version: 'master@3a4747f',
mac_address: 'Unable to retrieve MAC address.',
});
})
}),
http.get('/api/v2/device_settings', () => {
return HttpResponse.json({
player_name: 'Test Player',
});
})
}),
);
)
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('SystemInfo', () => {
it('renders the system info', async () => {
render(<SystemInfo />);
render(<SystemInfo />)
expect(screen.getByRole('heading')).toHaveTextContent('System Info');
expect(screen.getByRole('heading')).toHaveTextContent('System Info')
expect(screen.getByText('Load Average')).toBeInTheDocument();
expect(screen.getByText('Free Space')).toBeInTheDocument();
expect(screen.getByText('Memory')).toBeInTheDocument();
expect(screen.getByText('Uptime')).toBeInTheDocument();
expect(screen.getByText('Display Power (CEC)')).toBeInTheDocument();
expect(screen.getByText('Device Model')).toBeInTheDocument();
expect(screen.getByText('Anthias Version')).toBeInTheDocument();
expect(screen.getByText('MAC Address')).toBeInTheDocument();
expect(screen.getByText('Load Average')).toBeInTheDocument()
expect(screen.getByText('Free Space')).toBeInTheDocument()
expect(screen.getByText('Memory')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
expect(screen.getByText('Display Power (CEC)')).toBeInTheDocument()
expect(screen.getByText('Device Model')).toBeInTheDocument()
expect(screen.getByText('Anthias Version')).toBeInTheDocument()
expect(screen.getByText('MAC Address')).toBeInTheDocument()
const expectedValues = [
'1.58',
@@ -62,11 +62,11 @@ describe('SystemInfo', () => {
'Generic x86_64 Device',
'master@3a4747f',
'Unable to retrieve MAC address.',
];
]
for (const value of expectedValues) {
const element = await screen.findByText(value);
expect(element).toBeInTheDocument();
const element = await screen.findByText(value)
expect(element).toBeInTheDocument()
}
});
});
})
})

View File

@@ -1,271 +1,271 @@
// Centralized type definitions for the Anthias application
import { store } from '@/store/index';
import { store } from '@/store/index'
// Asset-related types
export interface Asset {
asset_id: string;
name: string;
start_date: string;
end_date: string;
duration: number;
uri: string;
mimetype: string;
is_enabled: number;
nocache: boolean;
skip_asset_check: boolean;
is_active: boolean;
play_order: number;
is_processing: boolean;
asset_id: string
name: string
start_date: string
end_date: string
duration: number
uri: string
mimetype: string
is_enabled: number
nocache: boolean
skip_asset_check: boolean
is_active: boolean
play_order: number
is_processing: boolean
}
export interface AssetEditData {
id: string;
name: string;
start_date: string;
end_date: string;
duration: number;
uri: string;
mimetype: string;
is_enabled: boolean;
nocache: boolean;
skip_asset_check: boolean;
play_order?: number;
id: string
name: string
start_date: string
end_date: string
duration: number
uri: string
mimetype: string
is_enabled: boolean
nocache: boolean
skip_asset_check: boolean
play_order?: number
}
export interface EditFormData {
name: string;
start_date: string;
end_date: string;
duration: string;
mimetype: string;
nocache: boolean;
skip_asset_check: boolean;
name: string
start_date: string
end_date: string
duration: string
mimetype: string
nocache: boolean
skip_asset_check: boolean
}
export interface HandleSubmitParams {
e: React.FormEvent;
asset: AssetEditData;
formData: EditFormData;
startDateDate: string;
startDateTime: string;
endDateDate: string;
endDateTime: string;
dispatch: AppDispatch;
onClose: () => void;
setIsSubmitting: (isSubmitting: boolean) => void;
e: React.FormEvent
asset: AssetEditData
formData: EditFormData
startDateDate: string
startDateTime: string
endDateDate: string
endDateTime: string
dispatch: AppDispatch
onClose: () => void
setIsSubmitting: (isSubmitting: boolean) => void
}
// WebSocket-related types
export interface WebSocketMessage {
type?: string;
data?: unknown;
asset_id?: string;
type?: string
data?: unknown
asset_id?: string
}
export interface WebSocketState {
isConnected: boolean;
isConnecting: boolean;
error: string | null;
lastMessage: WebSocketMessage | string | null;
reconnectAttempts: number;
isConnected: boolean
isConnecting: boolean
error: string | null
lastMessage: WebSocketMessage | string | null
reconnectAttempts: number
}
export interface ExtendedWindow extends Window {
anthiasWebSocket?: WebSocket;
anthiasWebSocket?: WebSocket
}
// Redux store types
export interface RootState {
assets: {
items: Asset[];
status: 'idle' | 'loading' | 'succeeded' | 'failed';
error: string | null;
};
items: Asset[]
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
assetModal: {
activeTab: string;
activeTab: string
formData: {
uri: string;
skipAssetCheck: boolean;
name?: string;
mimetype?: string;
duration?: number;
uri: string
skipAssetCheck: boolean
name?: string
mimetype?: string
duration?: number
dates?: {
start_date: string;
end_date: string;
};
};
isValid: boolean;
errorMessage: string;
statusMessage: string;
uploadProgress: number;
isSubmitting: boolean;
};
start_date: string
end_date: string
}
}
isValid: boolean
errorMessage: string
statusMessage: string
uploadProgress: number
isSubmitting: boolean
}
settings: {
settings: {
playerName: string;
defaultDuration: number;
defaultStreamingDuration: number;
audioOutput: string;
dateFormat: string;
authBackend: string;
currentPassword: string;
user: string;
password: string;
confirmPassword: string;
showSplash: boolean;
defaultAssets: boolean;
shufflePlaylist: boolean;
use24HourClock: boolean;
debugLogging: boolean;
};
deviceModel: string;
prevAuthBackend: string;
hasSavedBasicAuth: boolean;
isLoading: boolean;
isUploading: boolean;
uploadProgress: number;
error: string | null;
};
websocket: WebSocketState;
playerName: string
defaultDuration: number
defaultStreamingDuration: number
audioOutput: string
dateFormat: string
authBackend: string
currentPassword: string
user: string
password: string
confirmPassword: string
showSplash: boolean
defaultAssets: boolean
shufflePlaylist: boolean
use24HourClock: boolean
debugLogging: boolean
}
deviceModel: string
prevAuthBackend: string
hasSavedBasicAuth: boolean
isLoading: boolean
isUploading: boolean
uploadProgress: number
error: string | null
}
websocket: WebSocketState
}
// Component prop types
export interface ActiveAssetsTableProps {
onEditAsset: (asset: AssetEditData) => void;
onEditAsset: (asset: AssetEditData) => void
}
export interface InactiveAssetsTableProps {
onEditAsset: (asset: AssetEditData) => void;
onEditAsset: (asset: AssetEditData) => void
}
export interface AssetRowProps {
assetId: string;
name: string;
startDate: string;
endDate: string;
duration: number;
uri: string;
mimetype: string;
isEnabled: boolean;
nocache: boolean;
skipAssetCheck: boolean;
isProcessing?: number;
style?: React.CSSProperties;
showDragHandle?: boolean;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
isDragging?: boolean;
onEditAsset?: (asset: AssetEditData) => void;
assetId: string
name: string
startDate: string
endDate: string
duration: number
uri: string
mimetype: string
isEnabled: boolean
nocache: boolean
skipAssetCheck: boolean
isProcessing?: number
style?: React.CSSProperties
showDragHandle?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
isDragging?: boolean
onEditAsset?: (asset: AssetEditData) => void
}
// Settings-related types
export interface SettingsData {
playerName: string;
defaultDuration: number;
defaultStreamingDuration: number;
audioOutput: string;
dateFormat: string;
authBackend: string;
currentPassword: string;
user: string;
password: string;
confirmPassword: string;
showSplash: boolean;
defaultAssets: boolean;
shufflePlaylist: boolean;
use24HourClock: boolean;
debugLogging: boolean;
playerName: string
defaultDuration: number
defaultStreamingDuration: number
audioOutput: string
dateFormat: string
authBackend: string
currentPassword: string
user: string
password: string
confirmPassword: string
showSplash: boolean
defaultAssets: boolean
shufflePlaylist: boolean
use24HourClock: boolean
debugLogging: boolean
}
export interface SystemOperationParams {
operation: string;
endpoint: string;
successMessage: string;
operation: string
endpoint: string
successMessage: string
}
export interface OperationConfig {
operation?: string;
endpoint: string;
successMessage: string;
confirmMessage?: string;
title?: string;
text?: string;
confirmButtonText?: string;
errorMessage?: string;
operation?: string
endpoint: string
successMessage: string
confirmMessage?: string
title?: string
text?: string
confirmButtonText?: string
errorMessage?: string
}
// Asset modal types
export interface UploadFileParams {
file: File;
skipAssetCheck: boolean;
file: File
skipAssetCheck: boolean
}
export interface SaveAssetParams {
assetData: {
duration: number;
end_date: string;
is_active: number;
is_enabled: number;
is_processing: number;
mimetype: string;
name: string;
nocache: number;
play_order: number;
skip_asset_check: number;
start_date: string;
uri: string;
};
duration: number
end_date: string
is_active: number
is_enabled: number
is_processing: number
mimetype: string
name: string
nocache: number
play_order: number
skip_asset_check: number
start_date: string
uri: string
}
}
export interface FileData {
uri: string;
ext: string;
uri: string
ext: string
}
export interface FormData {
uri: string;
skipAssetCheck: boolean;
name?: string;
mimetype?: string;
duration?: number;
uri: string
skipAssetCheck: boolean
name?: string
mimetype?: string
duration?: number
dates?: {
start_date: string;
end_date: string;
};
start_date: string
end_date: string
}
}
// Redux Toolkit types
export type AppDispatch = typeof store.dispatch;
export type AsyncThunkAction = ReturnType<typeof store.dispatch>;
export type AppDispatch = typeof store.dispatch
export type AsyncThunkAction = ReturnType<typeof store.dispatch>
// Asset thunk types
export interface ToggleAssetParams {
assetId: string;
newValue: number;
assetId: string
newValue: number
}
// System info types
export interface AnthiasVersionValueProps {
version: string;
version: string
}
export interface SkeletonProps {
children: React.ReactNode;
isLoading: boolean;
children: React.ReactNode
isLoading: boolean
}
export interface MemoryInfo {
total: number;
used: number;
free: number;
shared: number;
buff: number;
available: number;
percentage?: number;
total: number
used: number
free: number
shared: number
buff: number
available: number
percentage?: number
}
export interface UptimeInfo {
days: number;
hours: number;
minutes?: number;
seconds?: number;
days: number
hours: number
minutes?: number
seconds?: number
}