mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 18:18:06 +08:00 
			
		
		
		
	 1310dc1b80
			
		
	
	1310dc1b80
	
	
	
		
			
			* feat(admin): add horizontal line to sidebar. Closes #4098 * fix: lint css file * fix: tweak css * chore: go mod tidy * fix: another css fix
		
			
				
	
	
		
			384 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			384 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import React, { FC, ReactNode, useContext, useEffect, useState } from 'react';
 | |
| import Link from 'next/link';
 | |
| import Head from 'next/head';
 | |
| import { differenceInSeconds } from 'date-fns';
 | |
| import { useRouter } from 'next/router';
 | |
| import { Layout, Menu, Alert, Button, Space, Tooltip } from 'antd';
 | |
| 
 | |
| import classNames from 'classnames';
 | |
| import dynamic from 'next/dynamic';
 | |
| import { upgradeVersionAvailable } from '../../utils/apis';
 | |
| import { parseSecondsToDurationString } from '../../utils/format';
 | |
| 
 | |
| import { OwncastLogo } from '../common/OwncastLogo/OwncastLogo';
 | |
| import { ServerStatusContext } from '../../utils/server-status-context';
 | |
| import { AlertMessageContext } from '../../utils/alert-message-context';
 | |
| 
 | |
| import { TextFieldWithSubmit } from './TextFieldWithSubmit';
 | |
| import { TEXTFIELD_PROPS_STREAM_TITLE } from '../../utils/config-constants';
 | |
| import { ComposeFederatedPost } from './ComposeFederatedPost';
 | |
| import { UpdateArgs } from '../../types/config-section';
 | |
| import { FatalErrorStateModal } from '../modals/FatalErrorStateModal/FatalErrorStateModal';
 | |
| 
 | |
| // Lazy loaded components
 | |
| 
 | |
| const SettingOutlined = dynamic(() => import('@ant-design/icons/SettingOutlined'), {
 | |
|   ssr: false,
 | |
| }); // Lazy loaded components
 | |
| 
 | |
| const HomeOutlined = dynamic(() => import('@ant-design/icons/HomeOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const LineChartOutlined = dynamic(() => import('@ant-design/icons/LineChartOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const ToolOutlined = dynamic(() => import('@ant-design/icons/ToolOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const PlayCircleFilled = dynamic(() => import('@ant-design/icons/PlayCircleFilled'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const MinusSquareFilled = dynamic(() => import('@ant-design/icons/MinusSquareFilled'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const QuestionCircleOutlined = dynamic(() => import('@ant-design/icons/QuestionCircleOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const MessageOutlined = dynamic(() => import('@ant-design/icons/MessageOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const ExperimentOutlined = dynamic(() => import('@ant-design/icons/ExperimentOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const EditOutlined = dynamic(() => import('@ant-design/icons/EditOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const DownloadOutlined = dynamic(() => import('@ant-design/icons/DownloadOutlined'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| const FediverseOutlined = dynamic(() => import('../../assets/images/icons/fediverse.svg'), {
 | |
|   ssr: false,
 | |
| });
 | |
| 
 | |
| export type MainLayoutProps = {
 | |
|   children: ReactNode;
 | |
| };
 | |
| 
 | |
| export const MainLayout: FC<MainLayoutProps> = ({ children }) => {
 | |
|   const context = useContext(ServerStatusContext);
 | |
|   const { serverConfig, online, broadcaster, versionNumber, error: serverError } = context || {};
 | |
|   const { instanceDetails, chatDisabled, federation } = serverConfig;
 | |
|   const { enabled: federationEnabled } = federation;
 | |
| 
 | |
|   const [currentStreamTitle, setCurrentStreamTitle] = useState('');
 | |
|   const [postModalDisplayed, setPostModalDisplayed] = useState(false);
 | |
| 
 | |
|   const alertMessage = useContext(AlertMessageContext);
 | |
| 
 | |
|   const router = useRouter();
 | |
|   const { route } = router || {};
 | |
| 
 | |
|   const { Header, Footer, Content, Sider } = Layout;
 | |
| 
 | |
|   const [upgradeVersion, setUpgradeVersion] = useState('');
 | |
|   const checkForUpgrade = async () => {
 | |
|     if (versionNumber === '0.0.0') {
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       const result = await upgradeVersionAvailable(versionNumber);
 | |
|       setUpgradeVersion(result);
 | |
|     } catch (error) {
 | |
|       console.log('==== error', error);
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   useEffect(() => {
 | |
|     checkForUpgrade();
 | |
|   }, [versionNumber]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     setCurrentStreamTitle(instanceDetails.streamTitle);
 | |
|   }, [instanceDetails]);
 | |
| 
 | |
|   const handleStreamTitleChanged = ({ value }: UpdateArgs) => {
 | |
|     setCurrentStreamTitle(value);
 | |
|   };
 | |
| 
 | |
|   const handleCreatePostButtonPressed = () => {
 | |
|     setPostModalDisplayed(true);
 | |
|   };
 | |
| 
 | |
|   const appClass = classNames({
 | |
|     'app-container': true,
 | |
|     online,
 | |
|   });
 | |
| 
 | |
|   const upgradeVersionString = `${upgradeVersion}` || '';
 | |
|   const upgradeMessage = `Upgrade to v${upgradeVersionString}`;
 | |
|   const openMenuItems = upgradeVersion ? ['utilities-menu'] : [];
 | |
| 
 | |
|   const clearAlertMessage = () => {
 | |
|     alertMessage.setMessage(null);
 | |
|   };
 | |
| 
 | |
|   const headerAlertMessage = alertMessage.message ? (
 | |
|     <Alert message={alertMessage.message} afterClose={clearAlertMessage} banner closable />
 | |
|   ) : null;
 | |
| 
 | |
|   // status indicator items
 | |
|   const streamDurationString = broadcaster
 | |
|     ? parseSecondsToDurationString(differenceInSeconds(new Date(), new Date(broadcaster.time)))
 | |
|     : '';
 | |
| 
 | |
|   const statusIcon = online ? <PlayCircleFilled /> : <MinusSquareFilled />;
 | |
|   const statusMessage = online ? `Online ${streamDurationString}` : 'Offline';
 | |
| 
 | |
|   const statusIndicator = (
 | |
|     <div className="online-status-indicator">
 | |
|       <span className="status-label">{statusMessage}</span>
 | |
|       <span className="status-icon">{statusIcon}</span>
 | |
|     </div>
 | |
|   );
 | |
| 
 | |
|   const integrationsMenu = [
 | |
|     {
 | |
|       label: <Link href="/admin/webhooks">Webhooks</Link>,
 | |
|       key: '/admin/webhooks',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/access-tokens">Access Tokens</Link>,
 | |
|       key: '/admin/access-tokens',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/actions">External Actions</Link>,
 | |
|       key: '/admin/actions',
 | |
|     },
 | |
|   ];
 | |
| 
 | |
|   const chatMenu = [
 | |
|     {
 | |
|       label: <Link href="/admin/chat/messages">Messages</Link>,
 | |
|       key: '/admin/chat/messages',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/chat/users">Users</Link>,
 | |
|       key: '/admin/chat/users',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/chat/emojis">Emojis</Link>,
 | |
|       key: '/admin/chat/emojis',
 | |
|     },
 | |
|   ];
 | |
| 
 | |
|   const utilitiesMenu = [
 | |
|     {
 | |
|       label: <Link href="/admin/hardware-info">Hardware</Link>,
 | |
|       key: '/admin/hardware-info',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/stream-health">Stream Health</Link>,
 | |
|       key: '/admin/stream-health',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/logs">Logs</Link>,
 | |
|       key: '/admin/logs',
 | |
|     },
 | |
|     federationEnabled && {
 | |
|       label: <Link href="/admin/federation/actions">Social Actions</Link>,
 | |
|       key: '/admin/federation/actions',
 | |
|     },
 | |
|   ];
 | |
| 
 | |
|   const configurationMenu = [
 | |
|     {
 | |
|       label: <Link href="/admin/config/general">General</Link>,
 | |
|       key: '/admin/config/general',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/config/server">Server Setup</Link>,
 | |
|       key: '/admin/config/server',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/config-video">Video</Link>,
 | |
|       key: '/admin/config-video',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/config-chat">Chat</Link>,
 | |
|       key: '/admin/config-chat',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/config-federation">Social</Link>,
 | |
|       key: '/admin/config-federation',
 | |
|     },
 | |
|     {
 | |
|       label: <Link href="/admin/config-notify">Notifications</Link>,
 | |
|       key: '/admin/config-notify',
 | |
|     },
 | |
|   ];
 | |
| 
 | |
|   const menuItems = [
 | |
|     { label: <Link href="/admin">Home</Link>, icon: <HomeOutlined />, key: '/admin' },
 | |
|     {
 | |
|       label: <Link href="/admin/viewer-info">Viewers</Link>,
 | |
|       icon: <LineChartOutlined />,
 | |
|       key: '/admin/viewer-info',
 | |
|     },
 | |
|     !chatDisabled && {
 | |
|       label: <span>Chat & Users</span>,
 | |
|       icon: <MessageOutlined />,
 | |
|       children: chatMenu,
 | |
|       key: 'chat-and-users',
 | |
|     },
 | |
|     federationEnabled && {
 | |
|       key: '/admin/federation/followers',
 | |
|       label: <Link href="/admin/federation/followers">Followers</Link>,
 | |
|       icon: (
 | |
|         <span
 | |
|           role="img"
 | |
|           aria-label="message"
 | |
|           className="anticon anticon-message ant-menu-item-icon"
 | |
|         >
 | |
|           {/* Wrapping the icon in span for consistency with other icons used
 | |
|             directly from antd */}
 | |
|           <FediverseOutlined />
 | |
|         </span>
 | |
|       ),
 | |
|     },
 | |
|     {
 | |
|       key: 'configuration',
 | |
|       label: 'Configuration',
 | |
|       icon: <SettingOutlined />,
 | |
|       children: configurationMenu,
 | |
|     },
 | |
|     {
 | |
|       key: 'utilities',
 | |
|       label: 'Utilities',
 | |
|       icon: <ToolOutlined />,
 | |
|       children: utilitiesMenu,
 | |
|     },
 | |
|     {
 | |
|       key: 'integrations',
 | |
|       label: 'Integrations',
 | |
|       icon: <ExperimentOutlined />,
 | |
|       children: integrationsMenu,
 | |
|     },
 | |
|     upgradeVersion && {
 | |
|       type: 'divider',
 | |
|       key: 'upgrade-divider',
 | |
|     },
 | |
|     upgradeVersion && {
 | |
|       key: '/admin/upgrade',
 | |
|       label: (
 | |
|         <Link href="/admin/upgrade">
 | |
|           <strong>{upgradeMessage}</strong>
 | |
|         </Link>
 | |
|       ),
 | |
|       icon: <DownloadOutlined />,
 | |
|     },
 | |
|     {
 | |
|       key: '/admin/help',
 | |
|       label: <Link href="/admin/help">Help</Link>,
 | |
|       icon: <QuestionCircleOutlined />,
 | |
|     },
 | |
|   ];
 | |
| 
 | |
|   const [openKeys, setOpenKeys] = useState(openMenuItems);
 | |
| 
 | |
|   const onOpenChange = (keys: string[]) => {
 | |
|     setOpenKeys(keys);
 | |
|   };
 | |
| 
 | |
|   useEffect(() => {
 | |
|     menuItems.forEach(item =>
 | |
|       item?.children?.forEach(child => {
 | |
|         if (child?.key === route) setOpenKeys([...openMenuItems, item.key]);
 | |
|       }),
 | |
|     );
 | |
|   }, []);
 | |
| 
 | |
|   return (
 | |
|     <Layout id="admin-page" className={appClass}>
 | |
|       <Head>
 | |
|         <title>Owncast Admin</title>
 | |
|         <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />
 | |
|       </Head>
 | |
| 
 | |
|       {serverError?.type === 'OWNCAST_SERVICE_UNREACHABLE' && (
 | |
|         <FatalErrorStateModal title="Server Unreachable" message={serverError.msg} />
 | |
|       )}
 | |
| 
 | |
|       <Sider width={240} className="side-nav">
 | |
|         <h1 className="owncast-title">
 | |
|           <span className="logo-container">
 | |
|             <OwncastLogo variant="simple" />
 | |
|           </span>
 | |
|           <span className="title-label">Owncast Admin</span>
 | |
|         </h1>
 | |
|         <Menu
 | |
|           mode="inline"
 | |
|           className="menu-container"
 | |
|           items={menuItems}
 | |
|           selectedKeys={[route || '/admin']}
 | |
|           openKeys={openKeys}
 | |
|           onOpenChange={onOpenChange}
 | |
|         />
 | |
|       </Sider>
 | |
| 
 | |
|       <Layout className="layout-main">
 | |
|         <Header className="layout-header">
 | |
|           <Space direction="horizontal">
 | |
|             <Tooltip title="Compose post to your social followers">
 | |
|               <Button
 | |
|                 type="link"
 | |
|                 icon={<EditOutlined />}
 | |
|                 size="small"
 | |
|                 onClick={handleCreatePostButtonPressed}
 | |
|                 style={{ display: federationEnabled ? 'block' : 'none', margin: '10px' }}
 | |
|               >
 | |
|                 Compose Post
 | |
|               </Button>
 | |
|             </Tooltip>
 | |
|           </Space>
 | |
|           <div className="global-stream-title-container">
 | |
|             <TextFieldWithSubmit
 | |
|               fieldName="streamTitle"
 | |
|               {...TEXTFIELD_PROPS_STREAM_TITLE}
 | |
|               placeholder="What are you streaming now? (Stream title)"
 | |
|               value={currentStreamTitle}
 | |
|               initialValue={instanceDetails.streamTitle}
 | |
|               onChange={handleStreamTitleChanged}
 | |
|             />
 | |
|           </div>
 | |
|           <Space direction="horizontal">{statusIndicator}</Space>
 | |
|         </Header>
 | |
| 
 | |
|         {headerAlertMessage}
 | |
| 
 | |
|         <Content className="main-content-container">{children}</Content>
 | |
| 
 | |
|         <Footer className="footer-container">
 | |
|           <a href="https://owncast.online/?source=admin" target="_blank" rel="noopener noreferrer">
 | |
|             About Owncast v{versionNumber}
 | |
|           </a>
 | |
|         </Footer>
 | |
|       </Layout>
 | |
| 
 | |
|       <ComposeFederatedPost
 | |
|         open={postModalDisplayed}
 | |
|         handleClose={() => setPostModalDisplayed(false)}
 | |
|       />
 | |
|     </Layout>
 | |
|   );
 | |
| };
 |