From fd9a8b60932653863ab5d3d6fc26336ad768d063 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Tue, 23 Jan 2024 16:25:17 +0100 Subject: [PATCH] feat(server): Add delete and recreate buttons --- model/model.go | 5 +- server/repository/repository.go | 20 + .../20240122084100_enable_cascade_delete.sql | 14 + server/repository/resources/database.sql | 188 ++++---- server/scheduler/scheduler.go | 44 +- server/web/ui/src/App.tsx | 18 +- server/web/ui/src/JobTable.tsx | 415 +++++++++++++++--- server/web/ui/src/Navbar.tsx | 41 +- server/web/ui/src/Theme.tsx | 2 +- server/web/ui/src/themes/_shared.scss | 194 ++++++-- server/web/ui/src/themes/dark.scss | 1 + server/web/ui/src/themes/light.scss | 1 + server/web/web.go | 55 +-- worker/task/pgs.go | 1 - 14 files changed, 721 insertions(+), 278 deletions(-) create mode 100644 server/repository/resources/20240122084100_enable_cascade_delete.sql diff --git a/model/model.go b/model/model.go index 4558c20..ad1ade9 100644 --- a/model/model.go +++ b/model/model.go @@ -34,9 +34,8 @@ const ( CanceledNotificationStatus NotificationStatus = "canceled" FailedNotificationStatus NotificationStatus = "failed" - CancelJob JobAction = "cancel" - EncodeJobType JobType = "encode" - PGSToSrtJobType JobType = "pgstosrt" + EncodeJobType JobType = "encode" + PGSToSrtJobType JobType = "pgstosrt" ) type Identity interface { diff --git a/server/repository/repository.go b/server/repository/repository.go index d9e1836..63844e9 100644 --- a/server/repository/repository.go +++ b/server/repository/repository.go @@ -25,6 +25,7 @@ type Repository interface { PingServerUpdate(ctx context.Context, name string, ip string, queueName string) error GetTimeoutJobs(ctx context.Context, timeout time.Duration) ([]*model.TaskEvent, error) GetJob(ctx context.Context, uuid string) (*model.Video, error) + DeleteJob(ctx context.Context, uuid string) error GetJobs(ctx context.Context, page int, pageSize int) (*[]model.Video, error) GetJobByPath(ctx context.Context, path string) (*model.Video, error) AddNewTaskEvent(ctx context.Context, event *model.TaskEvent) error @@ -134,6 +135,7 @@ func (S *SQLRepository) prepareDatabase(ctx context.Context) (returnError error) if err != nil { return err } + log.Debug("prepare database") _, err = con.ExecContext(ctx, databaseScript) return err }) @@ -182,6 +184,15 @@ func (S *SQLRepository) GetJob(ctx context.Context, uuid string) (video *model.V return video, err } +func (S *SQLRepository) DeleteJob(ctx context.Context, uuid string) error { + db, err := S.getConnection(ctx) + if err != nil { + return err + } + err = S.deleteJob(db, uuid) + return err +} + func (S *SQLRepository) GetJobs(ctx context.Context, page int, pageSize int) (videos *[]model.Video, returnError error) { db, err := S.getConnection(ctx) if err != nil { @@ -235,6 +246,15 @@ func (S *SQLRepository) getJob(ctx context.Context, tx Transaction, uuid string) return &video, nil } +func (S *SQLRepository) deleteJob(tx Transaction, uuid string) error { + sqlResult, err := tx.Exec("DELETE FROM videos WHERE id=$1", uuid) + log.Debugf("query result: +%v", sqlResult) + if err != nil { + return err + } + return nil +} + func (S *SQLRepository) getJobs(ctx context.Context, tx Transaction, page int, pageSize int) (*[]model.Video, error) { offset := (page - 1) * pageSize query := fmt.Sprintf("SELECT id FROM videos LIMIT %d OFFSET %d", pageSize, offset) diff --git a/server/repository/resources/20240122084100_enable_cascade_delete.sql b/server/repository/resources/20240122084100_enable_cascade_delete.sql new file mode 100644 index 0000000..f8e22a9 --- /dev/null +++ b/server/repository/resources/20240122084100_enable_cascade_delete.sql @@ -0,0 +1,14 @@ +DELETE FROM video_events +WHERE video_id NOT IN (SELECT id FROM videos); + +ALTER TABLE video_events +DROP CONSTRAINT video_events_video_id_fkey; + +ALTER TABLE video_events +ADD CONSTRAINT fk_video_events_videos +FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE; + +-- Update video_status table +ALTER TABLE video_status +ADD CONSTRAINT fk_video_status_videos +FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE; diff --git a/server/repository/resources/database.sql b/server/repository/resources/database.sql index 68d2ef6..d7c18b0 100644 --- a/server/repository/resources/database.sql +++ b/server/repository/resources/database.sql @@ -1,99 +1,121 @@ -CREATE TABLE IF NOT EXISTS videos -( - id varchar(255) primary key, - source_path text not null, - destination_path text not null +-- Define videos table +CREATE TABLE IF NOT EXISTS videos ( + id varchar(255) PRIMARY KEY, + source_path text NOT NULL, + destination_path text NOT NULL ); -CREATE TABLE IF NOT EXISTS video_events( - video_id varchar(255) not null, - video_event_id int not null, - worker_name varchar(255) not null, - event_time timestamp not null, - event_type varchar(50) not null, - notification_type varchar(50) not null, - status varchar(20) not null, +-- Define video_events table +CREATE TABLE IF NOT EXISTS video_events ( + video_id varchar(255) NOT NULL, + video_event_id int NOT NULL, + worker_name varchar(255) NOT NULL, + event_time timestamp NOT NULL, + event_type varchar(50) NOT NULL, + notification_type varchar(50) NOT NULL, + status varchar(20) NOT NULL, message text, - primary key (video_id,video_event_id), - foreign KEY (video_id) REFERENCES videos(id) + PRIMARY KEY (video_id, video_event_id), + FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS workers -( - name varchar(100) primary key not null, - ip varchar(100) not null, - queue_name varchar(255) not null, - last_seen timestamp not null +-- Define workers table +CREATE TABLE IF NOT EXISTS workers ( + name varchar(100) PRIMARY KEY NOT NULL, + ip varchar(100) NOT NULL, + queue_name varchar(255) NOT NULL, + last_seen timestamp NOT NULL ); +-- Define video_status table CREATE TABLE IF NOT EXISTS video_status ( - video_id varchar(255) not null, - video_event_id integer not null, - video_path text not null, - worker_name varchar(255) not null, - event_time timestamp not null, - event_type varchar(50) not null, - notification_type varchar(50) not null, - status varchar(20) not null, - message text, - constraint video_status_pkey - primary key (video_id) + video_id varchar(255) NOT NULL, + video_event_id integer NOT NULL, + video_path text NOT NULL, + worker_name varchar(255) NOT NULL, + event_time timestamp NOT NULL, + event_type varchar(50) NOT NULL, + notification_type varchar(50) NOT NULL, + status varchar(20) NOT NULL, + message text, + CONSTRAINT video_status_pkey PRIMARY KEY (video_id), + FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE ); ---Function to insert update on video_status -create or replace function fn_video_status_update(p_video_id varchar, p_video_event_id integer, - p_worker_name varchar, p_event_time timestamp, p_event_type varchar, p_notification_type varchar, p_status varchar, p_message text) returns void - security definer - language plpgsql as $$ -declare +-- Function to insert or update video_status +CREATE OR REPLACE FUNCTION fn_video_status_update( + p_video_id varchar, + p_video_event_id integer, + p_worker_name varchar, + p_event_time timestamp, + p_event_type varchar, + p_notification_type varchar, + p_status varchar, + p_message text +) RETURNS VOID SECURITY DEFINER LANGUAGE plpgsql AS $$ +DECLARE p_video_path varchar; -begin - select v.source_path into p_video_path from videos v where v.id=p_video_id; - insert into video_status(video_id, video_event_id, video_path,worker_name, event_time, event_type, notification_type, status, message) - values (p_video_id, p_video_event_id,p_video_path, p_worker_name, p_event_time, p_event_type, p_notification_type, - p_status, p_message) - on conflict on constraint video_status_pkey - do update set video_event_id=p_video_event_id, video_path=p_video_path,worker_name=p_worker_name, - event_time=p_event_time, event_type=p_event_type, - notification_type=p_notification_type, status=p_status, message=p_message; -end; -$$; +BEGIN + SELECT v.source_path INTO p_video_path + FROM videos v + WHERE v.id = p_video_id; ---trigger function for video_status_update -create or replace function fn_trigger_video_status_update() returns trigger - security definer - language plpgsql -as $$ -begin - perform fn_video_status_update(new.video_id, new.video_event_id, - new.worker_name,new.event_time,new.event_type,new.notification_type, - new.status,new.message); - return new; -end; + INSERT INTO video_status ( + video_id, + video_event_id, + video_path, + worker_name, + event_time, + event_type, + notification_type, + status, + message + ) + VALUES ( + p_video_id, + p_video_event_id, + p_video_path, + p_worker_name, + p_event_time, + p_event_type, + p_notification_type, + p_status, + p_message + ) + ON CONFLICT ON CONSTRAINT video_status_pkey DO UPDATE SET + video_event_id = p_video_event_id, + video_path = p_video_path, + worker_name = p_worker_name, + event_time = p_event_time, + event_type = p_event_type, + notification_type = p_notification_type, + status = p_status, + message = p_message; +END; $$; ---trigger video_events -drop trigger if exists event_insert_video_status_update on video_events; -create trigger event_insert_video_status_update after insert on video_events - for each row -execute procedure fn_trigger_video_status_update(); +-- Trigger function for video_status_update +CREATE OR REPLACE FUNCTION fn_trigger_video_status_update() RETURNS TRIGGER SECURITY DEFINER LANGUAGE plpgsql AS $$ +BEGIN + PERFORM fn_video_status_update( + NEW.video_id, + NEW.video_event_id, + NEW.worker_name, + NEW.event_time, + NEW.event_type, + NEW.notification_type, + NEW.status, + NEW.message + ); + RETURN NEW; +END; +$$; ---To Reload Everything!! ---do language plpgsql $$ --- declare --- e record; --- i integer:=1; --- begin --- for e in (select * from video_events order by event_time asc) loop --- perform fn_video_status_update(e.video_id, e.video_event_id, --- e.worker_name,e.event_time,e.event_type,e.notification_type, --- e.status,e.message); --- i:=i+1; --- IF MOD(i, 200) = 0 THEN --- COMMIT; --- END IF; --- end loop; --- end; ---$$; - +-- Drop existing trigger if it exists +DROP TRIGGER IF EXISTS event_insert_video_status_update ON video_events; +-- Create trigger for video_events +CREATE TRIGGER event_insert_video_status_update +AFTER INSERT ON video_events +FOR EACH ROW +EXECUTE PROCEDURE fn_trigger_video_status_update(); diff --git a/server/scheduler/scheduler.go b/server/scheduler/scheduler.go index fd82d50..783c962 100644 --- a/server/scheduler/scheduler.go +++ b/server/scheduler/scheduler.go @@ -31,11 +31,11 @@ type Scheduler interface { Run(wg *sync.WaitGroup, ctx context.Context) ScheduleJobRequests(ctx context.Context, jobRequest *model.JobRequest) (*ScheduleJobRequestResult, error) GetJob(ctx context.Context, uuid string) (videos *model.Video, err error) + DeleteJob(ctx context.Context, uuid string) error GetJobs(ctx context.Context, page int, pageSize int) (*[]model.Video, error) GetUploadJobWriter(ctx context.Context, uuid string) (*UploadJobStream, error) GetDownloadJobWriter(ctx context.Context, uuid string) (*DownloadJobStream, error) GetChecksum(ctx context.Context, uuid string) (string, error) - CancelJob(ctx context.Context, uuid string) error } type SchedulerConfig struct { @@ -273,9 +273,9 @@ func (R *RuntimeScheduler) scheduleJobRequest(ctx context.Context, jobRequest *m } } - downloadURL, _ := url.Parse(fmt.Sprintf("%s/api/v1/download/%s", R.config.Domain.String(), video.Id.String())) - uploadURL, _ := url.Parse(fmt.Sprintf("%s/api/v1/upload/%s", R.config.Domain.String(), video.Id.String())) - checksumURL, _ := url.Parse(fmt.Sprintf("%s/api/v1/checksum/%s", R.config.Domain.String(), video.Id.String())) + downloadURL, _ := url.Parse(fmt.Sprintf("%s/api/v1/job/%s/download", R.config.Domain.String(), video.Id.String())) + uploadURL, _ := url.Parse(fmt.Sprintf("%s/api/v1/job/%s/upload", R.config.Domain.String(), video.Id.String())) + checksumURL, _ := url.Parse(fmt.Sprintf("%s/api/v1/job/%s/checksum", R.config.Domain.String(), video.Id.String())) task := &model.TaskEncode{ Id: video.Id, DownloadURL: downloadURL.String(), @@ -330,42 +330,16 @@ func (R *RuntimeScheduler) ScheduleJobRequests(ctx context.Context, jobRequest * return result, returnError } -func (R *RuntimeScheduler) GetJob(ctx context.Context, uuid string) (videos *model.Video, err error) { +func (R *RuntimeScheduler) GetJob(ctx context.Context, uuid string) (*model.Video, error) { return R.repo.GetJob(ctx, uuid) } -func (R *RuntimeScheduler) GetJobs(ctx context.Context, page int, pageSize int) (videos *[]model.Video, err error) { - return R.repo.GetJobs(ctx, page, pageSize) +func (R *RuntimeScheduler) DeleteJob(ctx context.Context, uuid string) error { + return R.repo.DeleteJob(ctx, uuid) } -func (R *RuntimeScheduler) CancelJob(ctx context.Context, uuid string) error { - video, err := R.repo.GetJob(ctx, uuid) - if err != nil { - if errors.Is(err, repository.ElementNotFound) { - return ErrorJobNotFound - } - return err - } - lastEvent := video.Events.GetLatestPerNotificationType(model.JobNotification) - status := lastEvent.Status - if status == model.StartedNotificationStatus { - jobAction := &model.JobEvent{ - Id: video.Id, - Action: model.CancelJob, - } - - worker, err := R.repo.GetWorker(ctx, lastEvent.WorkerName) - if err != nil { - if errors.Is(err, repository.ElementNotFound) { - return ErrorJobNotFound - } - return err - } - R.queue.PublishJobEvent(jobAction, worker.QueueName) - } else { - return fmt.Errorf("%w: job in status %s", ErrorInvalidStatus, status) - } - return nil +func (R *RuntimeScheduler) GetJobs(ctx context.Context, page int, pageSize int) (*[]model.Video, error) { + return R.repo.GetJobs(ctx, page, pageSize) } func (R *RuntimeScheduler) isValidStremeableJob(ctx context.Context, uuid string) (*model.Video, error) { diff --git a/server/web/ui/src/App.tsx b/server/web/ui/src/App.tsx index 77fe3c5..28f7321 100644 --- a/server/web/ui/src/App.tsx +++ b/server/web/ui/src/App.tsx @@ -32,7 +32,7 @@ const App: React.FC = () => { }; const Jobs: React.FC = () => ( -
+
{showJobTable && }
); @@ -54,26 +54,26 @@ const App: React.FC = () => { > -
+
{!showJobTable && ( -
+
-
+
-
+
-
+
{showToken ? ( - + ) : ( - + )}
diff --git a/server/web/ui/src/JobTable.tsx b/server/web/ui/src/JobTable.tsx index 1aadb8a..66331a7 100644 --- a/server/web/ui/src/JobTable.tsx +++ b/server/web/ui/src/JobTable.tsx @@ -1,8 +1,35 @@ -// JobTable.tsx import React, { useState, useEffect } from 'react'; import axios from 'axios'; -import { Table, TableBody, TableCell, TableHead, TableRow, CircularProgress, Typography, Button } from '@mui/material'; -import { CalendarMonth, Info, QuestionMark, Task, VideoSettings } from '@mui/icons-material'; +import { + Button, + CircularProgress, + Checkbox, + FormControl, + InputLabel, + Menu, + MenuItem, + ListItemIcon, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { + Cached, + CalendarMonth, + Delete, + Feed, + Info, + MoreVert, + QuestionMark, + Replay, + Search, + Task, + VideoSettings, +} from '@mui/icons-material'; import './JobTable.css'; @@ -35,24 +62,69 @@ const formatDate = (date: Date, options: Intl.DateTimeFormatOptions): string => const formatDateDetailed = (date: Date): string => { const options: Intl.DateTimeFormatOptions = { - timeStyle: "long", + timeStyle: 'long', }; - return formatDate(date, options) -} + return formatDate(date, options); +}; const formatDateShort = (date: Date): string => { const options: Intl.DateTimeFormatOptions = { - dateStyle: "short", + dateStyle: 'short', }; - return formatDate(date, options) + return formatDate(date, options); +}; + +const getDateFromFilterOption = (filterOption: string) => { + const currentDate = new Date(); + + switch (filterOption) { + case 'Last 30 minutes': + return new Date(currentDate.getTime() - 30 * 60 * 1000); + + case 'Last 3 hours': + return new Date(currentDate.getTime() - 3 * 60 * 60 * 1000); + + case 'Last 6 hours': + return new Date(currentDate.getTime() - 6 * 60 * 60 * 1000); + + case 'Last 24 hours': + return new Date(currentDate.getTime() - 24 * 60 * 60 * 1000); + + case 'Last 2 days': + return new Date(currentDate.getTime() - 2 * 24 * 60 * 60 * 1000); + + case 'Last 7 days': + return new Date(currentDate.getTime() - 7 * 24 * 60 * 60 * 1000); + + case 'Last 30 days': + return new Date(currentDate.getTime() - 30 * 24 * 60 * 60 * 1000); + + default: + return new Date(0); + } } const JobTable: React.FC = ({ token, setShowJobTable }) => { const [jobs, setJobs] = useState([]); + const [filteredJobs, setFilteredJobs] = useState([]); + const [forceRender, setForceRender] = useState(false); const [selectedJob, setSelectedJob] = useState(null); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [fetchedDetails, setFetchedDetails] = useState>(new Set()); + const [anchorEl, setAnchorEl] = useState(null); // For menu anchor + const [nameFilter, setNameFilter] = useState(''); // State for name filter + const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedDateFilter, setSelectedDateFilter] = useState(''); + const [detailsMenuAnchor, setDetailsMenuAnchor] = useState(null); + + const reload = () => { + setJobs([]); + setFetchedDetails(new Set()); + setPage(1); + setLoading(true); + setForceRender((a) => !a); + }; useEffect(() => { const fetchJobs = async () => { @@ -65,6 +137,7 @@ const JobTable: React.FC = ({ token, setShowJobTable }) => { }, }); const newJobs: Job[] = response.data; + setJobs((prevJobs) => [...prevJobs, ...newJobs]); } catch (error) { console.error('Error fetching jobs:', error); @@ -75,7 +148,7 @@ const JobTable: React.FC = ({ token, setShowJobTable }) => { }; fetchJobs(); - }, [token, page, setShowJobTable]); + }, [token, page, setShowJobTable, forceRender]); useEffect(() => { const handleScroll = () => { @@ -125,15 +198,114 @@ const JobTable: React.FC = ({ token, setShowJobTable }) => { } }; - // Fetch details for each job when they are rendered in the table jobs.forEach((job) => fetchJobDetails(job.id)); - }, [token, jobs, fetchedDetails]); + const statusFilteredJobs = selectedStatus.length > 0 + ? jobs.filter((job) => selectedStatus.includes(job.status)) + : jobs; + + const dateFilteredJobs = selectedDateFilter ? statusFilteredJobs.filter( + (job) => job.last_update >= getDateFromFilterOption(selectedDateFilter)) + : statusFilteredJobs + + const filteredJobs = nameFilter + ? dateFilteredJobs.filter((job) => job.sourcePath.includes(nameFilter)) + : dateFilteredJobs; + setFilteredJobs(filteredJobs); + }, [token, jobs, fetchedDetails, selectedStatus, selectedDateFilter, nameFilter]); + + const deleteJobDetail = (jobId: string) => { + setFetchedDetails((prevSet) => { + prevSet.delete(jobId); + return new Set(prevSet) + }); + }; + + const deleteJob = async (jobId: string) => { + try { + await axios.delete(`/api/v1/job/${jobId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + deleteJobDetail(jobId); + setJobs((prevJobs) => [...prevJobs.filter(job => job.id !== jobId)]); + } catch (error) { + console.error(`Error deleting job ${jobId}:`, error); + } + }; + + const createJob = async (path: string) => { + try { + + const response = await axios.post(`/api/v1/job/`, + { + SourcePath: path + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const newJobs: Job[] = response.data.scheduled; + console.log(newJobs); + setJobs((prevJobs) => [...prevJobs, ...newJobs]); + } catch (error) { + console.error(`Error creating job with path ${path}:`, error); + } + }; + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleMenuOptionClick = async (job: Job | null, option: string) => { + if (job !== null) { + if (['delete', 'recreate'].includes(option)) { + await deleteJob(job.id); + }; + handleClose(); + if (option === 'recreate') { + await createJob(job.sourcePath); + } + } + }; const handleRowClick = (jobId: string) => { setSelectedJob(jobs.find((job) => job.id === jobId) || null); }; + const handleNameFilterChange = (event: React.ChangeEvent) => { + setNameFilter(event.target.value); + }; + + const handleDetailedViewClick = (event: React.MouseEvent, jobId: string) => { + setSelectedJob(jobs.find((job) => job.id === jobId) || null); + setDetailsMenuAnchor(event.currentTarget); + }; + + const statusFilterOptions = [ + 'started', + 'added', + 'completed', + 'failed', + ]; + + const dateFilterOptions = [ + 'Last update', + 'Last 30 minutes', + 'Last 3 hours', + 'Last 6 hours', + 'Last 24 hours', + 'Last 2 days', + 'Last 7 days', + 'Last 30 days', + ]; + const getStatusColor = (status: string): string => { switch (status) { case 'completed': @@ -146,57 +318,182 @@ const JobTable: React.FC = ({ token, setShowJobTable }) => { }; return ( -
- - - - - - - - - - - - {jobs.map((job) => ( - handleRowClick(job.id)} - className="tableRow" +
+
+
+
+
+ + +
+
+
+
+ + Status + + + + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + - {job.status_message} - {formatDateShort(job.last_update)} - ))} - -
- - {loading && } - - {selectedJob && selectedJob.id && ( -
- - Selected Job Details - - ID: {selectedJob.id} - Source : {selectedJob.sourcePath} - Destination: {selectedJob.destinationPath} - Status: {selectedJob.status} - Message: {selectedJob.status_message} -
- )} + + + {filteredJobs.map((job) => ( + handleRowClick(job.id)} + className="table-row" + > + + {job.sourcePath} + + + {job.destinationPath} + + + + + + {job.status_message} + + + {formatDateShort(job.last_update)} + + + handleDetailedViewClick(event, job.id)}> + + + handleMenuOptionClick(selectedJob, 'delete')}> + + + handleMenuOptionClick(selectedJob, 'recreate')}> + + + + setDetailsMenuAnchor(null)} + > + + + Job Details + + + + ID: {job.id} + + + Source: {job.sourcePath} + + + Destination: {job.destinationPath} + + + Status: {job.status} + + + Message: {job.status_message} + + + + + ))} + + + + {loading && } +
); }; diff --git a/server/web/ui/src/Navbar.tsx b/server/web/ui/src/Navbar.tsx index 9020b92..a128aea 100644 --- a/server/web/ui/src/Navbar.tsx +++ b/server/web/ui/src/Navbar.tsx @@ -11,30 +11,43 @@ import { import { ThemeToggle } from './Theme'; import GitHubIcon from '@mui/icons-material/GitHub'; +interface CollapseNavProps { + isOpen: boolean; + className?: string; + id?: string; +} + +const CollapseNav: React.FC = ({ isOpen, className, id }) => ( + + + +); + const Navigation: React.FC = () => { const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); return ( - + Transcoder - Transcoder + Transcoder - - - + + ); }; diff --git a/server/web/ui/src/Theme.tsx b/server/web/ui/src/Theme.tsx index 20a4ac3..b37f038 100644 --- a/server/web/ui/src/Theme.tsx +++ b/server/web/ui/src/Theme.tsx @@ -21,7 +21,7 @@ export const ThemeToggle: React.FC = () => { const { userPreference, setTheme } = useTheme(); return ( - +