build ok,
This commit is contained in:
44
002_source/cms/src/components/core/analytics.tsx
Normal file
44
002_source/cms/src/components/core/analytics.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { GTMProvider, useGTMDispatch } from '@elgorditosalsero/react-gtm-hook';
|
||||
|
||||
import { config } from '@/config';
|
||||
|
||||
export interface PageViewTrackerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function PageViewTracker({ children }: PageViewTrackerProps): React.JSX.Element {
|
||||
const dispatch = useGTMDispatch();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch({ event: 'page_view', page: pathname });
|
||||
}, [dispatch, pathname, searchParams]);
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
|
||||
export interface AnalyticsProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* This loads GTM and tracks the page views.
|
||||
*
|
||||
* If GTM ID is not configured, this will no track any event.
|
||||
*/
|
||||
export function Analytics({ children }: AnalyticsProps): React.JSX.Element {
|
||||
if (!config.gtm?.id) {
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<GTMProvider state={{ id: config.gtm.id }}>
|
||||
<PageViewTracker>{children}</PageViewTracker>
|
||||
</GTMProvider>
|
||||
);
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export function BreadcrumbsSeparator(): React.JSX.Element {
|
||||
return <Box sx={{ bgcolor: 'var(--mui-palette-neutral-500)', borderRadius: '50%', height: '4px', width: '4px' }} />;
|
||||
}
|
110
002_source/cms/src/components/core/code-highlighter.tsx
Normal file
110
002_source/cms/src/components/core/code-highlighter.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
|
||||
const codeStyle: Record<string, React.CSSProperties> = {
|
||||
'code[class*="language-"]': {
|
||||
color: 'var(--mui-palette-neutral-50)',
|
||||
background: 'none',
|
||||
textShadow: '0 1px rgba(0, 0, 0, 0.3)',
|
||||
fontFamily: "'Roboto Mono', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace",
|
||||
fontSize: 'var(--fontSize-sm)',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal',
|
||||
wordWrap: 'normal',
|
||||
lineHeight: 1.5,
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none',
|
||||
MozHyphens: 'none',
|
||||
msHyphens: 'none',
|
||||
hyphens: 'none',
|
||||
},
|
||||
'pre[class*="language-"]': {
|
||||
color: 'var(--mui-palette-neutral-50)',
|
||||
background: 'var(--mui-palette-neutral-800)',
|
||||
textShadow: '0 1px rgba(0, 0, 0, 0.3)',
|
||||
fontFamily: "'Roboto Mono', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace",
|
||||
fontSize: 'var(--fontSize-sm)',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre',
|
||||
wordSpacing: 'normal',
|
||||
wordBreak: 'normal',
|
||||
wordWrap: 'normal',
|
||||
lineHeight: 1.5,
|
||||
MozTabSize: '4',
|
||||
OTabSize: '4',
|
||||
tabSize: '4',
|
||||
WebkitHyphens: 'none',
|
||||
MozHyphens: 'none',
|
||||
msHyphens: 'none',
|
||||
hyphens: 'none',
|
||||
padding: '1em',
|
||||
margin: '.5em 0',
|
||||
overflow: 'auto',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
':not(pre) > code[class*="language-"]': {
|
||||
background: 'var(--mui-palette-neutral-800)',
|
||||
padding: '.1em',
|
||||
borderRadius: '.3em',
|
||||
whiteSpace: 'normal',
|
||||
},
|
||||
comment: { color: '#6272a4' },
|
||||
prolog: { color: '#6272a4' },
|
||||
doctype: { color: '#6272a4' },
|
||||
cdata: { color: '#6272a4' },
|
||||
punctuation: { color: '#f8f8f2' },
|
||||
'.namespace': { opacity: '.7' },
|
||||
property: { color: '#ff79c6' },
|
||||
tag: { color: '#ff79c6' },
|
||||
constant: { color: '#ff79c6' },
|
||||
symbol: { color: '#ff79c6' },
|
||||
deleted: { color: '#ff79c6' },
|
||||
boolean: { color: '#bd93f9' },
|
||||
number: { color: '#bd93f9' },
|
||||
selector: { color: '#50fa7b' },
|
||||
'attr-name': { color: '#50fa7b' },
|
||||
string: { color: '#50fa7b' },
|
||||
char: { color: '#50fa7b' },
|
||||
builtin: { color: '#50fa7b' },
|
||||
inserted: { color: '#50fa7b' },
|
||||
operator: { color: '#f8f8f2' },
|
||||
entity: { color: '#f8f8f2', cursor: 'help' },
|
||||
url: { color: '#f8f8f2' },
|
||||
'.language-css .token.string': { color: '#f8f8f2' },
|
||||
'.style .token.string': { color: '#f8f8f2' },
|
||||
variable: { color: '#f8f8f2' },
|
||||
atrule: { color: '#f1fa8c' },
|
||||
'attr-value': { color: '#f1fa8c' },
|
||||
function: { color: '#f1fa8c' },
|
||||
'class-name': { color: '#f1fa8c' },
|
||||
keyword: { color: '#8be9fd' },
|
||||
regex: { color: '#ffb86c' },
|
||||
important: { color: '#ffb86c', fontWeight: 'bold' },
|
||||
bold: { fontWeight: 'bold' },
|
||||
italic: { fontStyle: 'italic' },
|
||||
};
|
||||
|
||||
export interface CodeHighlighterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export function CodeHighlighter({ children, className, inline, ...props }: CodeHighlighterProps): React.JSX.Element {
|
||||
const language = (className ?? '').split('language-')[1];
|
||||
const canHighlight = !inline && Boolean(language);
|
||||
|
||||
return canHighlight ? (
|
||||
<SyntaxHighlighter PreTag="div" language={language} style={codeStyle} {...props}>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
147
002_source/cms/src/components/core/data-table.tsx
Normal file
147
002_source/cms/src/components/core/data-table.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Table from '@mui/material/Table';
|
||||
import type { TableProps } from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
export interface ColumnDef<TRowModel> {
|
||||
align?: 'left' | 'right' | 'center';
|
||||
field?: keyof TRowModel;
|
||||
formatter?: (row: TRowModel, index: number) => React.ReactNode;
|
||||
hideName?: boolean;
|
||||
name: string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
type RowId = number | string;
|
||||
|
||||
export interface DataTableProps<TRowModel> extends Omit<TableProps, 'onClick'> {
|
||||
columns: ColumnDef<TRowModel>[];
|
||||
hideHead?: boolean;
|
||||
hover?: boolean;
|
||||
onClick?: (event: React.MouseEvent, row: TRowModel) => void;
|
||||
onDeselectAll?: (event: React.ChangeEvent) => void;
|
||||
onDeselectOne?: (event: React.ChangeEvent, row: TRowModel) => void;
|
||||
onSelectAll?: (event: React.ChangeEvent) => void;
|
||||
onSelectOne?: (event: React.ChangeEvent, row: TRowModel) => void;
|
||||
rows: TRowModel[];
|
||||
selectable?: boolean;
|
||||
selected?: Set<RowId>;
|
||||
uniqueRowId?: (row: TRowModel) => RowId;
|
||||
}
|
||||
|
||||
export function DataTable<TRowModel extends object & { id?: RowId | null }>({
|
||||
columns,
|
||||
hideHead,
|
||||
hover,
|
||||
onClick,
|
||||
onDeselectAll,
|
||||
onDeselectOne,
|
||||
onSelectOne,
|
||||
onSelectAll,
|
||||
rows,
|
||||
selectable,
|
||||
selected,
|
||||
uniqueRowId,
|
||||
...props
|
||||
}: DataTableProps<TRowModel>): React.JSX.Element {
|
||||
const selectedSome = (selected?.size ?? 0) > 0 && (selected?.size ?? 0) < rows.length;
|
||||
const selectedAll = rows.length > 0 && selected?.size === rows.length;
|
||||
|
||||
return (
|
||||
<Table {...props}>
|
||||
<TableHead sx={{ ...(hideHead && { visibility: 'collapse', '--TableCell-borderWidth': 0 }) }}>
|
||||
<TableRow>
|
||||
{selectable ? (
|
||||
<TableCell padding="checkbox" sx={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<Checkbox
|
||||
checked={selectedAll}
|
||||
indeterminate={selectedSome}
|
||||
onChange={(event: React.ChangeEvent) => {
|
||||
if (selectedAll) {
|
||||
onDeselectAll?.(event);
|
||||
} else {
|
||||
onSelectAll?.(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{columns.map(
|
||||
(column): React.JSX.Element => (
|
||||
<TableCell
|
||||
key={column.name}
|
||||
sx={{
|
||||
width: column.width,
|
||||
minWidth: column.width,
|
||||
maxWidth: column.width,
|
||||
...(column.align && { textAlign: column.align }),
|
||||
}}
|
||||
>
|
||||
{column.hideName ? null : column.name}
|
||||
</TableCell>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, index): React.JSX.Element => {
|
||||
const rowId = row.id ? row.id : uniqueRowId?.(row);
|
||||
const rowSelected = rowId ? selected?.has(rowId) : false;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
hover={hover}
|
||||
key={rowId ?? index}
|
||||
selected={rowSelected}
|
||||
{...(onClick && {
|
||||
onClick: (event: React.MouseEvent) => {
|
||||
onClick(event, row);
|
||||
},
|
||||
})}
|
||||
sx={{ ...(onClick && { cursor: 'pointer' }) }}
|
||||
>
|
||||
{selectable ? (
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={rowId ? rowSelected : false}
|
||||
onChange={(event: React.ChangeEvent) => {
|
||||
if (rowSelected) {
|
||||
onDeselectOne?.(event, row);
|
||||
} else {
|
||||
onSelectOne?.(event, row);
|
||||
}
|
||||
}}
|
||||
onClick={(event: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
{columns.map(
|
||||
(column): React.JSX.Element => (
|
||||
<TableCell key={column.name} sx={{ ...(column.align && { textAlign: column.align }) }}>
|
||||
{
|
||||
(column.formatter
|
||||
? column.formatter(row, index)
|
||||
: column.field
|
||||
? row[column.field]
|
||||
: null) as React.ReactNode
|
||||
}
|
||||
</TableCell>
|
||||
)
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function noop(..._: unknown[]): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
export interface ContextValue {
|
||||
anchorEl: HTMLElement | null;
|
||||
onPopoverMouseEnter: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onPopoverMouseLeave: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onPopoverEscapePressed: () => void;
|
||||
onTriggerMouseEnter: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onTriggerMouseLeave: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
onTriggerKeyUp: (event: React.KeyboardEvent<HTMLElement>) => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export const DropdownContext = React.createContext<ContextValue>({
|
||||
anchorEl: null,
|
||||
onPopoverMouseEnter: noop,
|
||||
onPopoverMouseLeave: noop,
|
||||
onPopoverEscapePressed: noop,
|
||||
onTriggerMouseEnter: noop,
|
||||
onTriggerMouseLeave: noop,
|
||||
onTriggerKeyUp: noop,
|
||||
open: false,
|
||||
});
|
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import type { PaperProps } from '@mui/material/Paper';
|
||||
import type { PopoverOrigin } from '@mui/material/Popover';
|
||||
import Popover from '@mui/material/Popover';
|
||||
|
||||
import { DropdownContext } from './dropdown-context';
|
||||
|
||||
export interface DropdownPopoverProps {
|
||||
anchorOrigin?: PopoverOrigin;
|
||||
children?: React.ReactNode;
|
||||
disableScrollLock?: boolean;
|
||||
PaperProps?: PaperProps;
|
||||
transformOrigin?: PopoverOrigin;
|
||||
}
|
||||
|
||||
export function DropdownPopover({ children, PaperProps, ...props }: DropdownPopoverProps): React.JSX.Element {
|
||||
const { anchorEl, onPopoverMouseEnter, onPopoverMouseLeave, onPopoverEscapePressed, open } =
|
||||
React.useContext(DropdownContext);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
onClose={(_, reason) => {
|
||||
if (reason === 'escapeKeyDown') {
|
||||
onPopoverEscapePressed?.();
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
slotProps={{
|
||||
paper: {
|
||||
...PaperProps,
|
||||
onMouseEnter: onPopoverMouseEnter,
|
||||
onMouseLeave: onPopoverMouseLeave,
|
||||
sx: { ...PaperProps?.sx, pointerEvents: 'auto' },
|
||||
},
|
||||
}}
|
||||
sx={{ pointerEvents: 'none' }}
|
||||
transformOrigin={{ horizontal: 'left', vertical: 'top' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DropdownContext } from './dropdown-context';
|
||||
|
||||
export interface DropdownButtonProps {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export function DropdownTrigger({ children }: DropdownButtonProps): React.JSX.Element {
|
||||
const { onTriggerMouseEnter, onTriggerMouseLeave, onTriggerKeyUp } = React.useContext(DropdownContext);
|
||||
|
||||
return React.cloneElement(children, {
|
||||
onKeyUp: (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
(children.props as { onKeyUp?: (event: React.KeyboardEvent<HTMLElement>) => void }).onKeyUp?.(event);
|
||||
onTriggerKeyUp(event);
|
||||
},
|
||||
onMouseEnter: (event: React.MouseEvent<HTMLElement>) => {
|
||||
(children.props as { onMouseEnter?: (event: React.MouseEvent<HTMLElement>) => void }).onMouseEnter?.(event);
|
||||
onTriggerMouseEnter(event);
|
||||
},
|
||||
onMouseLeave: (event: React.MouseEvent<HTMLElement>) => {
|
||||
(children.props as { onMouseLeave?: (event: React.MouseEvent<HTMLElement>) => void }).onMouseLeave?.(event);
|
||||
onTriggerMouseLeave(event);
|
||||
},
|
||||
});
|
||||
}
|
69
002_source/cms/src/components/core/dropdown/dropdown.tsx
Normal file
69
002_source/cms/src/components/core/dropdown/dropdown.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DropdownContext } from './dropdown-context';
|
||||
|
||||
export interface DropdownProps {
|
||||
children: React.ReactNode[];
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function Dropdown({ children, delay = 50 }: DropdownProps): React.JSX.Element {
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
|
||||
const cleanupRef = React.useRef<number>();
|
||||
|
||||
const handleTriggerMouseEnter = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
clearTimeout(cleanupRef.current);
|
||||
setAnchorEl(event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleTriggerMouseLeave = React.useCallback(
|
||||
(_: React.MouseEvent<HTMLElement>) => {
|
||||
cleanupRef.current = setTimeout(() => {
|
||||
setAnchorEl(null);
|
||||
}, delay) as unknown as number;
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
|
||||
const handleTriggerKeyUp = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
setAnchorEl(event.currentTarget as unknown as HTMLElement);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePopoverMouseEnter = React.useCallback((_: React.MouseEvent<HTMLElement>) => {
|
||||
clearTimeout(cleanupRef.current);
|
||||
}, []);
|
||||
|
||||
const handlePopoverMouseLeave = React.useCallback(
|
||||
(_: React.MouseEvent<HTMLElement>) => {
|
||||
cleanupRef.current = setTimeout(() => {
|
||||
setAnchorEl(null);
|
||||
}, delay) as unknown as number;
|
||||
},
|
||||
[delay]
|
||||
);
|
||||
|
||||
const handlePopoverEscapePressed = React.useCallback(() => {
|
||||
setAnchorEl(null);
|
||||
}, []);
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return (
|
||||
<DropdownContext.Provider
|
||||
value={{
|
||||
anchorEl,
|
||||
onPopoverMouseEnter: handlePopoverMouseEnter,
|
||||
onPopoverMouseLeave: handlePopoverMouseLeave,
|
||||
onPopoverEscapePressed: handlePopoverEscapePressed,
|
||||
onTriggerMouseEnter: handleTriggerMouseEnter,
|
||||
onTriggerMouseLeave: handleTriggerMouseLeave,
|
||||
onTriggerKeyUp: handleTriggerKeyUp,
|
||||
open,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DropdownContext.Provider>
|
||||
);
|
||||
}
|
73
002_source/cms/src/components/core/file-dropzone.tsx
Normal file
73
002_source/cms/src/components/core/file-dropzone.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { CloudArrowUp as CloudArrowUpIcon } from '@phosphor-icons/react/dist/ssr/CloudArrowUp';
|
||||
import type { DropzoneOptions, FileWithPath } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
export type File = FileWithPath;
|
||||
|
||||
export interface FileDropzoneProps extends DropzoneOptions {
|
||||
caption?: string;
|
||||
files?: File[];
|
||||
onRemove?: (file: File) => void;
|
||||
onRemoveAll?: () => void;
|
||||
onUpload?: () => void;
|
||||
}
|
||||
|
||||
export function FileDropzone({ caption, ...props }: FileDropzoneProps): React.JSX.Element {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone(props);
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
outline: 'none',
|
||||
p: 6,
|
||||
...(isDragActive && { bgcolor: 'var(--mui-palette-action-selected)', opacity: 0.5 }),
|
||||
'&:hover': { ...(!isDragActive && { bgcolor: 'var(--mui-palette-action-hover)' }) },
|
||||
}}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Avatar
|
||||
sx={{
|
||||
'--Avatar-size': '64px',
|
||||
'--Icon-fontSize': 'var(--icon-fontSize-lg)',
|
||||
bgcolor: 'var(--mui-palette-background-paper)',
|
||||
boxShadow: 'var(--mui-shadows-8)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
<CloudArrowUpIcon fontSize="var(--Icon-fontSize)" />
|
||||
</Avatar>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h6">
|
||||
<Typography component="span" sx={{ textDecoration: 'underline' }} variant="inherit">
|
||||
Click to upload
|
||||
</Typography>{' '}
|
||||
or drag and drop
|
||||
</Typography>
|
||||
{caption ? (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{caption}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
40
002_source/cms/src/components/core/file-icon.tsx
Normal file
40
002_source/cms/src/components/core/file-icon.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
jpeg: '/assets/icon-jpg.svg',
|
||||
jpg: '/assets/icon-jpg.svg',
|
||||
mp4: '/assets/icon-mp4.svg',
|
||||
pdf: '/assets/icon-pdf.svg',
|
||||
png: '/assets/icon-png.svg',
|
||||
svg: '/assets/icon-svg.svg',
|
||||
};
|
||||
|
||||
export interface FileIconProps {
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export function FileIcon({ extension }: FileIconProps): React.JSX.Element {
|
||||
let icon: string;
|
||||
|
||||
if (!extension) {
|
||||
icon = '/assets/icon-other.svg';
|
||||
} else {
|
||||
icon = icons[extension] ?? '/assets/icon-other.svg';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
flex: '0 0 auto',
|
||||
justifyContent: 'center',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
}}
|
||||
>
|
||||
<Box alt="File" component="img" src={icon} sx={{ height: '100%', width: 'auto' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
146
002_source/cms/src/components/core/filter-button.tsx
Normal file
146
002_source/cms/src/components/core/filter-button.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { MinusCircle as MinusCircleIcon } from '@phosphor-icons/react/dist/ssr/MinusCircle';
|
||||
import { PlusCircle as PlusCircleIcon } from '@phosphor-icons/react/dist/ssr/PlusCircle';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
function noop(..._: unknown[]): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
export interface FilterContextValue<T = unknown> {
|
||||
anchorEl: HTMLElement | null;
|
||||
onApply: (value: unknown) => void;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
value?: T;
|
||||
}
|
||||
|
||||
export const FilterContext = React.createContext<FilterContextValue>({
|
||||
anchorEl: null,
|
||||
onApply: noop,
|
||||
onClose: noop,
|
||||
open: false,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
export function useFilterContext(): FilterContextValue {
|
||||
const context = React.useContext(FilterContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFilterContext must be used within a FilterProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export interface FilterButtonProps {
|
||||
displayValue?: string;
|
||||
label: string;
|
||||
onFilterApply?: (value: unknown) => void;
|
||||
onFilterDelete?: () => void;
|
||||
popover: React.ReactNode;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
// Currently, the `value` prop can be string | number | boolean | undefined
|
||||
export function FilterButton({
|
||||
displayValue,
|
||||
label,
|
||||
onFilterApply,
|
||||
onFilterDelete,
|
||||
popover,
|
||||
value,
|
||||
}: FilterButtonProps): React.JSX.Element {
|
||||
const { anchorRef, handleOpen, handleClose, open } = usePopover<HTMLButtonElement>();
|
||||
|
||||
const handleApply = React.useCallback(
|
||||
(newValue: unknown) => {
|
||||
handleClose();
|
||||
onFilterApply?.(newValue);
|
||||
},
|
||||
[handleClose, onFilterApply]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterContext.Provider
|
||||
value={{ anchorEl: anchorRef.current, onApply: handleApply, onClose: handleClose, open, value }}
|
||||
>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={handleOpen}
|
||||
ref={anchorRef}
|
||||
startIcon={
|
||||
value ? (
|
||||
<Box
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onFilterDelete?.();
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
onFilterDelete?.();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
sx={{ display: 'flex' }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<MinusCircleIcon />
|
||||
</Box>
|
||||
) : (
|
||||
<PlusCircleIcon />
|
||||
)
|
||||
}
|
||||
variant="outlined"
|
||||
>
|
||||
<span>
|
||||
{label}
|
||||
{displayValue ? (
|
||||
<React.Fragment>
|
||||
:{' '}
|
||||
<Box component="span" sx={{ color: 'var(--mui-palette-primary-main)' }}>
|
||||
{displayValue}
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
{popover}
|
||||
</FilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface FilterPopoverProps {
|
||||
anchorEl: HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function FilterPopover({ children, title, onClose, anchorEl, open }: FilterPopoverProps): React.JSX.Element {
|
||||
return (
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
onClose={onClose}
|
||||
open={Boolean(anchorEl && open)}
|
||||
sx={{ '& .MuiPopover-paper': { mt: '4px', width: '280px' } }}
|
||||
>
|
||||
<Stack spacing={2} sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle2">{title}</Typography>
|
||||
{children}
|
||||
</Stack>
|
||||
</Popover>
|
||||
);
|
||||
}
|
25
002_source/cms/src/components/core/i18n-provider.tsx
Normal file
25
002_source/cms/src/components/core/i18n-provider.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { logger } from '@/lib/default-logger';
|
||||
|
||||
import '@/lib/i18n';
|
||||
|
||||
export interface I18nProviderProps {
|
||||
children: React.ReactNode;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children, language = 'en' }: I18nProviderProps): React.JSX.Element {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
i18n.changeLanguage(language).catch(() => {
|
||||
logger.error(`Failed to change language to ${language}`);
|
||||
});
|
||||
}, [i18n, language]);
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
13
002_source/cms/src/components/core/localization-provider.tsx
Normal file
13
002_source/cms/src/components/core/localization-provider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||
import { LocalizationProvider as Provider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
|
||||
export interface LocalizationProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LocalizationProvider({ children }: LocalizationProviderProps): React.JSX.Element {
|
||||
return <Provider dateAdapter={AdapterDayjs}>{children}</Provider>;
|
||||
}
|
56
002_source/cms/src/components/core/logo.tsx
Normal file
56
002_source/cms/src/components/core/logo.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
|
||||
import { NoSsr } from '@/components/core/no-ssr';
|
||||
|
||||
const HEIGHT = 60;
|
||||
const WIDTH = 60;
|
||||
|
||||
type Color = 'dark' | 'light';
|
||||
|
||||
export interface LogoProps {
|
||||
color?: Color;
|
||||
emblem?: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function Logo({ color = 'dark', emblem, height = HEIGHT, width = WIDTH }: LogoProps): React.JSX.Element {
|
||||
let url: string;
|
||||
|
||||
if (emblem) {
|
||||
url = color === 'light' ? '/assets/logo-emblem.svg' : '/assets/logo-emblem--dark.svg';
|
||||
} else {
|
||||
url = color === 'light' ? '/assets/logo.svg' : '/assets/logo--dark.svg';
|
||||
}
|
||||
|
||||
return <Box alt="logo" component="img" height={height} src={url} width={width} />;
|
||||
}
|
||||
|
||||
export interface DynamicLogoProps {
|
||||
colorDark?: Color;
|
||||
colorLight?: Color;
|
||||
emblem?: boolean;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function DynamicLogo({
|
||||
colorDark = 'light',
|
||||
colorLight = 'dark',
|
||||
height = HEIGHT,
|
||||
width = WIDTH,
|
||||
...props
|
||||
}: DynamicLogoProps): React.JSX.Element {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const color = colorScheme === 'dark' ? colorDark : colorLight;
|
||||
|
||||
return (
|
||||
<NoSsr fallback={<Box sx={{ height: `${height}px`, width: `${width}px` }} />}>
|
||||
<Logo color={color} height={height} width={width} {...props} />
|
||||
</NoSsr>
|
||||
);
|
||||
}
|
75
002_source/cms/src/components/core/multi-select.tsx
Normal file
75
002_source/cms/src/components/core/multi-select.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
|
||||
// `T` should be `string`, `number` or `boolean`
|
||||
export interface MultiSelectProps<T = string> {
|
||||
label: string;
|
||||
onChange?: (value: T[]) => void;
|
||||
options: readonly { label: string; value: T }[];
|
||||
value: T[];
|
||||
}
|
||||
|
||||
export function MultiSelect<T = string>({
|
||||
label,
|
||||
onChange,
|
||||
options,
|
||||
value = [],
|
||||
}: MultiSelectProps<T>): React.JSX.Element {
|
||||
const popover = usePopover<HTMLButtonElement>();
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(v: T, checked: boolean) => {
|
||||
let updateValue = [...value] as T[];
|
||||
|
||||
if (checked) {
|
||||
updateValue.push(v);
|
||||
} else {
|
||||
updateValue = updateValue.filter((item) => item !== v);
|
||||
}
|
||||
|
||||
onChange?.(updateValue);
|
||||
},
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
color="secondary"
|
||||
endIcon={<CaretDownIcon />}
|
||||
onClick={popover.handleOpen}
|
||||
ref={popover.anchorRef}
|
||||
sx={{ '& .MuiButton-endIcon svg': { fontSize: 'var(--icon-fontSize-sm)' } }}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={popover.anchorRef.current}
|
||||
onClose={popover.handleClose}
|
||||
open={popover.open}
|
||||
slotProps={{ paper: { sx: { width: '250px' } } }}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = value.includes(option.value);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
handleValueChange(option.value, !selected);
|
||||
}}
|
||||
selected={selected}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
31
002_source/cms/src/components/core/no-ssr.tsx
Normal file
31
002_source/cms/src/components/core/no-ssr.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
|
||||
|
||||
export interface NoSsrProps {
|
||||
children?: React.ReactNode;
|
||||
defer?: boolean;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
// https://github.com/mui/material-ui/blob/master/packages/mui-base/src/NoSsr/NoSsr.tsx
|
||||
// without prop-types
|
||||
export function NoSsr(props: NoSsrProps): React.JSX.Element {
|
||||
const { children, defer = false, fallback = null } = props;
|
||||
const [mountedState, setMountedState] = React.useState(false);
|
||||
|
||||
useEnhancedEffect((): void => {
|
||||
if (!defer) {
|
||||
setMountedState(true);
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
React.useEffect((): void => {
|
||||
if (defer) {
|
||||
setMountedState(true);
|
||||
}
|
||||
}, [defer]);
|
||||
|
||||
return <React.Fragment>{mountedState ? children : fallback}</React.Fragment>;
|
||||
}
|
12
002_source/cms/src/components/core/option.tsx
Normal file
12
002_source/cms/src/components/core/option.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
|
||||
export interface OptionProps {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export function Option({ children, ...props }: OptionProps): React.JSX.Element {
|
||||
return <MenuItem {...props}>{children}</MenuItem>;
|
||||
}
|
9
002_source/cms/src/components/core/pdf-viewer.tsx
Normal file
9
002_source/cms/src/components/core/pdf-viewer.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import type ReactPDF from '@react-pdf/renderer';
|
||||
|
||||
export const PDFViewer = dynamic<ReactPDF.PDFViewerProps>(
|
||||
() => import('@react-pdf/renderer').then((module) => module.PDFViewer),
|
||||
{ ssr: false }
|
||||
);
|
37
002_source/cms/src/components/core/presence.tsx
Normal file
37
002_source/cms/src/components/core/presence.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
type Size = 'small' | 'medium' | 'large';
|
||||
|
||||
type Status = 'online' | 'offline' | 'away' | 'busy';
|
||||
|
||||
const sizes = { small: 8, medium: 16, large: 24 };
|
||||
|
||||
export interface PresenceProps {
|
||||
size?: Size;
|
||||
status?: Status;
|
||||
}
|
||||
|
||||
export function Presence({ size = 'medium', status = 'offline' }: PresenceProps): React.JSX.Element {
|
||||
const colors = {
|
||||
offline: 'var(--mui-palette-neutral-100)',
|
||||
away: 'var(--mui-palette-warning-main)',
|
||||
busy: 'var(--mui-palette-error-main)',
|
||||
online: 'var(--mui-palette-success-main)',
|
||||
} as Record<Status, string>;
|
||||
|
||||
const color = colors[status];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: color,
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
flex: '0 0 auto',
|
||||
height: sizes[size],
|
||||
width: sizes[size],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
37
002_source/cms/src/components/core/property-item.tsx
Normal file
37
002_source/cms/src/components/core/property-item.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export interface PropertyItemProps {
|
||||
name: string;
|
||||
value: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export function PropertyItem({ name, value }: PropertyItemProps): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
display: 'grid',
|
||||
gridGap: 'var(--PropertyItem-gap, 8px)',
|
||||
gridTemplateColumns: 'var(--PropertyItem-columns)',
|
||||
p: 'var(--PropertyItem-padding, 8px)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{name}
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
{typeof value === 'string' ? (
|
||||
<Typography color={value ? 'text.primary' : 'text.secondary'} variant="subtitle2">
|
||||
{value || 'None'}
|
||||
</Typography>
|
||||
) : (
|
||||
<React.Fragment>{value}</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
35
002_source/cms/src/components/core/property-list.tsx
Normal file
35
002_source/cms/src/components/core/property-list.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import type { SxProps } from '@mui/system/styleFunctionSx';
|
||||
|
||||
export interface PropertyListProps {
|
||||
children: React.ReactNode;
|
||||
divider?: React.ReactNode;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
stripe?: 'even' | 'odd';
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
export function PropertyList({
|
||||
children,
|
||||
divider,
|
||||
orientation = 'horizontal',
|
||||
stripe,
|
||||
sx,
|
||||
}: PropertyListProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack
|
||||
divider={divider}
|
||||
sx={{
|
||||
'--PropertyItem-columns': orientation === 'horizontal' ? '150px minmax(0, 1fr)' : '1fr',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--PropertyList-gap)',
|
||||
...(stripe && { [`& > *:nth-child(${stripe})`]: { bgcolor: 'var(--mui-palette-background-level1)' } }),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
32
002_source/cms/src/components/core/settings/option.tsx
Normal file
32
002_source/cms/src/components/core/settings/option.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
|
||||
export interface OptionProps {
|
||||
icon?: React.ReactElement;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function Option({ selected, ...props }: OptionProps): React.JSX.Element {
|
||||
return (
|
||||
<Chip
|
||||
{...props}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
borderRadius: 'inherit',
|
||||
bottom: 0,
|
||||
content: '" "',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
...(selected && { boxShadow: '0 0 0 2px var(--mui-palette-primary-main)' }),
|
||||
},
|
||||
}}
|
||||
variant="soft"
|
||||
/>
|
||||
);
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Moon as MoonIcon } from '@phosphor-icons/react/dist/ssr/Moon';
|
||||
import { Sun as SunIcon } from '@phosphor-icons/react/dist/ssr/Sun';
|
||||
|
||||
import type { ColorScheme } from '@/styles/theme/types';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsColorSchemeProps {
|
||||
onChange?: (value: ColorScheme) => void;
|
||||
value?: ColorScheme;
|
||||
}
|
||||
|
||||
export function OptionsColorScheme({ onChange, value }: OptionsColorSchemeProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Color scheme</InputLabel>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Light', value: 'light', icon: <SunIcon /> },
|
||||
{ label: 'Dark', value: 'dark', icon: <MoonIcon /> },
|
||||
] satisfies { label: string; value: string; icon: React.ReactNode }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
icon={option.icon}
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value as ColorScheme);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { TextAlignLeft as TextAlignLeftIcon } from '@phosphor-icons/react/dist/ssr/TextAlignLeft';
|
||||
import { TextAlignRight as TextAlignRightIcon } from '@phosphor-icons/react/dist/ssr/TextAlignRight';
|
||||
|
||||
import type { Direction } from '@/styles/theme/types';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsDirectionProps {
|
||||
onChange?: (value: Direction) => void;
|
||||
value?: Direction;
|
||||
}
|
||||
|
||||
export function OptionsDirection({ onChange, value }: OptionsDirectionProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Orientation</InputLabel>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Left-to-right', value: 'ltr', icon: <TextAlignLeftIcon /> },
|
||||
{ label: 'Right-to-left', value: 'rtl', icon: <TextAlignRightIcon /> },
|
||||
] satisfies { label: string; value: Direction; icon: React.ReactElement }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
icon={option.icon}
|
||||
key={option.label}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
143
002_source/cms/src/components/core/settings/options-layout.tsx
Normal file
143
002_source/cms/src/components/core/settings/options-layout.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Info as InfoIcon } from '@phosphor-icons/react/dist/ssr/Info';
|
||||
|
||||
import type { Layout } from '@/types/settings';
|
||||
|
||||
export interface OptionsLayoutProps {
|
||||
onChange?: (value: Layout) => void;
|
||||
value?: Layout;
|
||||
}
|
||||
|
||||
export function OptionsLayout({ onChange, value }: OptionsLayoutProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<InputLabel>Layout</InputLabel>
|
||||
<Tooltip placement="top" title="Dashboard only">
|
||||
<InfoIcon color="var(--mui-palette-text-secondary)" fontSize="var(--icon-fontSize-md)" weight="fill" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: 'repeat(2, minmax(0, 140px))' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Vertical', value: 'vertical', icon: <VerticalIcon /> },
|
||||
{ label: 'Horizontal', value: 'horizontal', icon: <HorizontalIcon /> },
|
||||
] satisfies { label: string; value: Layout; icon: React.ReactElement }[]
|
||||
).map((option) => (
|
||||
<Stack key={option.value} spacing={1}>
|
||||
<Box
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
role="button"
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
height: '88px',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
borderRadius: 'inherit',
|
||||
bottom: 0,
|
||||
content: '" "',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
...(option.value === value && { boxShadow: '0 0 0 2px var(--mui-palette-primary-main)' }),
|
||||
},
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{option.icon}
|
||||
</Box>
|
||||
<Typography sx={{ textAlign: 'center' }} variant="subtitle2">
|
||||
{option.label}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalIcon(): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 'inherit',
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ borderRight: '1px dashed var(--mui-palette-divider)', px: 1, py: 0.5 }}>
|
||||
<Stack spacing={1}>
|
||||
<Box sx={{ bgcolor: 'var(--mui-palette-primary-main)', borderRadius: '2px', height: '4px', width: '26px' }} />
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '26px' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '26px' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', p: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalIcon(): React.JSX.Element {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 'inherit',
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: 'center', borderBottom: '1px dashed var(--mui-palette-divider)', px: 1, py: '4px' }}
|
||||
>
|
||||
<Box sx={{ bgcolor: 'var(--mui-palette-primary-main)', borderRadius: '2px', height: '4px', width: '16px' }} />
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '16px' }}
|
||||
/>
|
||||
<Box
|
||||
sx={{ bgcolor: 'var(--mui-palette-background-level3)', borderRadius: '2px', height: '4px', width: '16px' }}
|
||||
/>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', flex: '1 1 auto', p: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'var(--mui-palette-background-level1)',
|
||||
border: '1px dashed var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { Info as InfoIcon } from '@phosphor-icons/react/dist/ssr/Info';
|
||||
|
||||
import type { NavColor } from '@/types/settings';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsNavColorProps {
|
||||
onChange?: (value: NavColor) => void;
|
||||
value?: NavColor;
|
||||
}
|
||||
|
||||
export function OptionsNavColor({ onChange, value }: OptionsNavColorProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<InputLabel>Nav color</InputLabel>
|
||||
<Tooltip placement="top" title="Dashboard only">
|
||||
<InfoIcon color="var(--mui-palette-text-secondary)" fontSize="var(--icon-fontSize-md)" weight="fill" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Blend-in', value: 'blend_in' },
|
||||
{ label: 'Discrete', value: 'discrete' },
|
||||
{ label: 'Evident', value: 'evident' },
|
||||
] as { label: string; value: NavColor }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
key={option.label}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
import type { PrimaryColor } from '@/styles/theme/types';
|
||||
|
||||
import { Option } from './option';
|
||||
|
||||
export interface OptionsPrimaryColorProps {
|
||||
onChange?: (value: PrimaryColor) => void;
|
||||
value?: PrimaryColor;
|
||||
}
|
||||
|
||||
export function OptionsPrimaryColor({ onChange, value }: OptionsPrimaryColorProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Primary color</InputLabel>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{(
|
||||
[
|
||||
{ label: 'Chateau Green', value: 'chateauGreen', color: '#16b364' },
|
||||
{ label: 'Neon Blue', value: 'neonBlue', color: '#635bff' },
|
||||
{ label: 'Royal Blue', value: 'royalBlue', color: '#5265ff' },
|
||||
{ label: 'Tomato Orange', value: 'tomatoOrange', color: '#ff6c47' },
|
||||
] satisfies { label: string; value: PrimaryColor; color: string }[]
|
||||
).map((option) => (
|
||||
<Option
|
||||
icon={
|
||||
<Box
|
||||
sx={{ bgcolor: option.color, borderRadius: '50%', flex: '0 0 auto', height: '24px', width: '24px' }}
|
||||
/>
|
||||
}
|
||||
key={option.value}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value);
|
||||
}}
|
||||
selected={option.value === value}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Box from '@mui/material/Box';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { GearSix as GearSixIcon } from '@phosphor-icons/react/dist/ssr/GearSix';
|
||||
|
||||
import type { Settings } from '@/types/settings';
|
||||
import { config } from '@/config';
|
||||
import { setSettings as setPersistedSettings } from '@/lib/settings/set-settings';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
|
||||
import { SettingsDrawer } from './settings-drawer';
|
||||
|
||||
export function SettingsButton(): React.JSX.Element {
|
||||
const { settings } = useSettings();
|
||||
const { setColorScheme } = useColorScheme();
|
||||
const router = useRouter();
|
||||
|
||||
const [openDrawer, setOpenDrawer] = React.useState<boolean>(false);
|
||||
|
||||
const handleUpdate = async (values: Partial<Settings>): Promise<void> => {
|
||||
if (values.colorScheme) {
|
||||
setColorScheme(values.colorScheme);
|
||||
}
|
||||
|
||||
const updatedSettings = { ...settings, ...values } satisfies Settings;
|
||||
|
||||
await setPersistedSettings(updatedSettings);
|
||||
|
||||
// Refresh the router to apply the new settings.
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleReset = async (): Promise<void> => {
|
||||
setColorScheme(config.site.colorScheme);
|
||||
|
||||
await setPersistedSettings({});
|
||||
|
||||
// Refresh the router to apply the new settings.
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Tooltip title="Settings">
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => {
|
||||
setOpenDrawer(true);
|
||||
}}
|
||||
sx={{
|
||||
animation: 'spin 4s linear infinite',
|
||||
background: 'var(--mui-palette-neutral-900)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
bottom: 0,
|
||||
color: 'var(--mui-palette-common-white)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
height: '40px',
|
||||
m: 4,
|
||||
p: '10px',
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
width: '40px',
|
||||
zIndex: 'var(--mui-zIndex-speedDial)',
|
||||
'&:hover': { bgcolor: 'var(--mui-palette-neutral-700)' },
|
||||
'@keyframes spin': { '0%': { rotate: '0' }, '100%': { rotate: '360deg' } },
|
||||
}}
|
||||
>
|
||||
<GearSixIcon fontSize="var(--icon-fontSize-md)" />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<SettingsDrawer
|
||||
onClose={() => {
|
||||
setOpenDrawer(false);
|
||||
}}
|
||||
onReset={handleReset}
|
||||
onUpdate={handleUpdate}
|
||||
open={openDrawer}
|
||||
values={settings}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
105
002_source/cms/src/components/core/settings/settings-drawer.tsx
Normal file
105
002_source/cms/src/components/core/settings/settings-drawer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { ArrowCounterClockwise as ArrowCounterClockwiseIcon } from '@phosphor-icons/react/dist/ssr/ArrowCounterClockwise';
|
||||
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
|
||||
|
||||
import type { Settings } from '@/types/settings';
|
||||
|
||||
import { OptionsColorScheme } from './options-color-scheme';
|
||||
import { OptionsDirection } from './options-direction';
|
||||
import { OptionsLayout } from './options-layout';
|
||||
import { OptionsNavColor } from './options-nav-color';
|
||||
import { OptionsPrimaryColor } from './options-primary-color';
|
||||
|
||||
export interface SettingsDrawerProps {
|
||||
canReset?: boolean;
|
||||
onClose?: () => void;
|
||||
onReset?: () => void;
|
||||
onUpdate?: (settings: Partial<Settings>) => void;
|
||||
open?: boolean;
|
||||
values?: Partial<Settings>;
|
||||
}
|
||||
|
||||
export function SettingsDrawer({
|
||||
canReset = true,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onReset,
|
||||
open,
|
||||
values = {},
|
||||
}: SettingsDrawerProps): React.JSX.Element {
|
||||
const handleChange = React.useCallback(
|
||||
(field: keyof Settings, value: unknown) => {
|
||||
onUpdate?.({ [field]: value });
|
||||
},
|
||||
[onUpdate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
ModalProps={{ BackdropProps: { invisible: true }, sx: { zIndex: 1400 } }}
|
||||
PaperProps={{ elevation: 24, sx: { display: 'flex', flexDirection: 'column', maxWidth: '100%', width: '440px' } }}
|
||||
anchor="right"
|
||||
disableScrollLock
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
>
|
||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, pt: 2 }}>
|
||||
<Typography variant="h6">App settings</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||
<Badge
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
color="error"
|
||||
sx={{ '& .MuiBadge-badge': { top: 6, right: 6, ...(!canReset && { display: 'none' }) } }}
|
||||
variant="dot"
|
||||
>
|
||||
<IconButton onClick={onReset}>
|
||||
<ArrowCounterClockwiseIcon />
|
||||
</IconButton>
|
||||
</Badge>
|
||||
<IconButton onClick={onClose}>
|
||||
<XIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack spacing={5} sx={{ overflowY: 'auto', p: 3 }}>
|
||||
<OptionsPrimaryColor
|
||||
onChange={(value) => {
|
||||
handleChange('primaryColor', value);
|
||||
}}
|
||||
value={values.primaryColor}
|
||||
/>
|
||||
<OptionsColorScheme
|
||||
onChange={(value) => {
|
||||
handleChange('colorScheme', value);
|
||||
}}
|
||||
value={values.colorScheme}
|
||||
/>
|
||||
<OptionsNavColor
|
||||
onChange={(value) => {
|
||||
handleChange('navColor', value);
|
||||
}}
|
||||
value={values.navColor}
|
||||
/>
|
||||
<OptionsLayout
|
||||
onChange={(value) => {
|
||||
handleChange('layout', value);
|
||||
}}
|
||||
value={values.layout}
|
||||
/>
|
||||
<OptionsDirection
|
||||
onChange={(value) => {
|
||||
handleChange('direction', value);
|
||||
}}
|
||||
value={values.direction}
|
||||
/>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import Select from '@mui/material/Select';
|
||||
import type { SelectChangeEvent } from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { Code as CodeIcon } from '@phosphor-icons/react/dist/ssr/Code';
|
||||
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
|
||||
import { LinkBreak as LinkBreakIcon } from '@phosphor-icons/react/dist/ssr/LinkBreak';
|
||||
import { ListDashes as ListDashesIcon } from '@phosphor-icons/react/dist/ssr/ListDashes';
|
||||
import { ListNumbers as ListNumbersIcon } from '@phosphor-icons/react/dist/ssr/ListNumbers';
|
||||
import { TextB as TextBIcon } from '@phosphor-icons/react/dist/ssr/TextB';
|
||||
import { TextItalic as TextItalicIcon } from '@phosphor-icons/react/dist/ssr/TextItalic';
|
||||
import { TextStrikethrough as TextStrikethroughIcon } from '@phosphor-icons/react/dist/ssr/TextStrikethrough';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { usePopover } from '@/hooks/use-popover';
|
||||
import { Option } from '@/components/core/option';
|
||||
|
||||
type HeadlingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export interface TextEditorToolbarProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
export function TextEditorToolbar({ editor }: TextEditorToolbarProps): React.JSX.Element {
|
||||
const linkPopover = usePopover<HTMLButtonElement>();
|
||||
const [link, setLink] = React.useState<string>('');
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack
|
||||
className="tiptap-toolbar"
|
||||
spacing={1}
|
||||
sx={{ borderBottom: '1px solid var(--mui-palette-divider)', p: '8px', minHeight: '57px' }}
|
||||
>
|
||||
{editor ? (
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
onChange={(event: SelectChangeEvent) => {
|
||||
const value = event.target.value;
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'p') {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('h')) {
|
||||
const level = parseInt(value.replace('h', '')) as HeadlingLevel;
|
||||
|
||||
if (!isNaN(level) && level >= 1 && level <= 6) {
|
||||
editor.chain().focus().setHeading({ level }).run();
|
||||
}
|
||||
}
|
||||
}}
|
||||
value={getFontValue(editor)}
|
||||
>
|
||||
<Option disabled={!editor.can().chain().focus().setParagraph().run()} value="p">
|
||||
Paragrah
|
||||
</Option>
|
||||
{([1, 2, 3, 4, 5, 6] as const).map((level) => (
|
||||
<Option
|
||||
disabled={!editor.can().chain().focus().setHeading({ level }).run()}
|
||||
key={level}
|
||||
value={`h${level}`}
|
||||
>
|
||||
Heading {level}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('bold')}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleBold().run();
|
||||
}}
|
||||
>
|
||||
<TextBIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('italic')}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleItalic().run();
|
||||
}}
|
||||
>
|
||||
<TextItalicIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('strike')}
|
||||
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleStrike().run();
|
||||
}}
|
||||
>
|
||||
<TextStrikethroughIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('codeBlock')}
|
||||
disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleCodeBlock();
|
||||
}}
|
||||
>
|
||||
<CodeIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('bulletList')}
|
||||
disabled={!editor.can().chain().focus().toggleBulletList().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleBulletList().run();
|
||||
}}
|
||||
>
|
||||
<ListDashesIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('orderedList')}
|
||||
disabled={!editor.can().chain().focus().toggleOrderedList().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().toggleOrderedList().run();
|
||||
}}
|
||||
>
|
||||
<ListNumbersIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
setLink((editor.getAttributes('link').href as string) ?? '');
|
||||
linkPopover.handleOpen();
|
||||
}}
|
||||
ref={linkPopover.anchorRef}
|
||||
>
|
||||
<LinkIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
active={editor.isActive('link')}
|
||||
disabled={!editor.can().chain().focus().unsetLink().run()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
}}
|
||||
>
|
||||
<LinkBreakIcon />
|
||||
</ToolbarButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Popover
|
||||
anchorEl={linkPopover.anchorRef.current}
|
||||
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
|
||||
onClose={() => {
|
||||
linkPopover.handleClose();
|
||||
setLink('');
|
||||
}}
|
||||
open={linkPopover.open}
|
||||
slotProps={{ paper: { sx: { p: 2 } } }}
|
||||
>
|
||||
<FormControl>
|
||||
<InputLabel>URL</InputLabel>
|
||||
<OutlinedInput
|
||||
name="url"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLink(event.target.value);
|
||||
}}
|
||||
onKeyUp={(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (link === '') {
|
||||
editor?.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
editor?.chain().focus().setLink({ href: link }).run();
|
||||
linkPopover.handleClose();
|
||||
setLink('');
|
||||
}}
|
||||
value={link}
|
||||
/>
|
||||
</FormControl>
|
||||
</Popover>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function getFontValue(editor: Editor): string {
|
||||
return editor.isActive('paragraph')
|
||||
? 'p'
|
||||
: editor.isActive('heading', { level: 1 })
|
||||
? 'h1'
|
||||
: editor.isActive('heading', { level: 2 })
|
||||
? 'h2'
|
||||
: editor.isActive('heading', { level: 3 })
|
||||
? 'h3'
|
||||
: editor.isActive('heading', { level: 4 })
|
||||
? 'h4'
|
||||
: editor.isActive('heading', { level: 5 })
|
||||
? 'h5'
|
||||
: editor.isActive('heading', { level: 6 })
|
||||
? 'h6'
|
||||
: 'p';
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(function ToolbarButton(
|
||||
{ active, children, disabled, onClick },
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<IconButton color={active ? 'primary' : 'secondary'} disabled={disabled} onClick={onClick} ref={ref}>
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
});
|
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Placeholder } from '@tiptap/extension-placeholder';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import type { EditorOptions, Extension } from '@tiptap/react';
|
||||
import { StarterKit } from '@tiptap/starter-kit';
|
||||
|
||||
import { TextEditorToolbar } from './text-editor-toolbar';
|
||||
|
||||
export interface TextEditorProps {
|
||||
content: EditorOptions['content'];
|
||||
editable?: EditorOptions['editable'];
|
||||
hideToolbar?: boolean;
|
||||
onUpdate?: EditorOptions['onUpdate'];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thin wrapper around tiptap.
|
||||
*
|
||||
* How to get the updated content:
|
||||
* ```ts
|
||||
* <TextEditor
|
||||
* onUpdate={({ editor }) => {
|
||||
* console.log(editor.getHTML());
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function TextEditor({
|
||||
content,
|
||||
editable = true,
|
||||
hideToolbar,
|
||||
onUpdate = () => {
|
||||
// noop
|
||||
},
|
||||
placeholder,
|
||||
}: TextEditorProps): React.JSX.Element {
|
||||
const extensions = [
|
||||
StarterKit,
|
||||
Placeholder.configure({ emptyEditorClass: 'is-editor-empty', placeholder }),
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
] as Extension[];
|
||||
|
||||
const editor = useEditor({ extensions, content, editable, onUpdate });
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="tiptap-root"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...(editable && {
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: 1,
|
||||
boxShadow: 'var(--mui-shadows-1)',
|
||||
}),
|
||||
'& .tiptap-container': { display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 },
|
||||
'& .tiptap': {
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
flex: '1 1 auto',
|
||||
overflow: 'auto',
|
||||
p: '8px 16px',
|
||||
'&:focus-visible': { outline: 'none' },
|
||||
'&.resize-cursor': { cursor: 'ew-resize', '& table': { cursor: 'col-resize' } },
|
||||
'& .is-editor-empty:before': {
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
content: 'attr(data-placeholder)',
|
||||
float: 'left',
|
||||
height: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!hideToolbar ? <TextEditorToolbar editor={editor} /> : <div />}
|
||||
<EditorContent className="tiptap-container" editor={editor} />
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useServerInsertedHTML } from 'next/navigation';
|
||||
import createCache from '@emotion/cache';
|
||||
import type { EmotionCache, Options as OptionsOfCreateCache } from '@emotion/cache';
|
||||
import { CacheProvider as DefaultCacheProvider } from '@emotion/react';
|
||||
|
||||
interface Registry {
|
||||
cache: EmotionCache;
|
||||
flush: () => { name: string; isGlobal: boolean }[];
|
||||
}
|
||||
|
||||
export interface NextAppDirEmotionCacheProviderProps {
|
||||
options: Omit<OptionsOfCreateCache, 'insertionPoint'>;
|
||||
CacheProvider?: (props: { value: EmotionCache; children: React.ReactNode }) => React.JSX.Element | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Adapted from https://github.com/garronej/tss-react/blob/main/src/next/appDir.tsx
|
||||
export default function NextAppDirEmotionCacheProvider(props: NextAppDirEmotionCacheProviderProps): React.JSX.Element {
|
||||
const { options, CacheProvider = DefaultCacheProvider, children } = props;
|
||||
|
||||
const [registry] = React.useState<Registry>(() => {
|
||||
const cache = createCache(options);
|
||||
cache.compat = true;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- Expected
|
||||
const prevInsert = cache.insert;
|
||||
let inserted: { name: string; isGlobal: boolean }[] = [];
|
||||
cache.insert = (...args) => {
|
||||
const [selector, serialized] = args;
|
||||
|
||||
if (cache.inserted[serialized.name] === undefined) {
|
||||
inserted.push({ name: serialized.name, isGlobal: !selector });
|
||||
}
|
||||
|
||||
return prevInsert(...args);
|
||||
};
|
||||
const flush = (): { name: string; isGlobal: boolean }[] => {
|
||||
const prevInserted = inserted;
|
||||
inserted = [];
|
||||
return prevInserted;
|
||||
};
|
||||
return { cache, flush };
|
||||
});
|
||||
|
||||
useServerInsertedHTML((): React.JSX.Element | null => {
|
||||
const inserted = registry.flush();
|
||||
|
||||
if (inserted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let styles = '';
|
||||
let dataEmotionAttribute = registry.cache.key;
|
||||
|
||||
const globals: { name: string; style: string }[] = [];
|
||||
|
||||
inserted.forEach(({ name, isGlobal }) => {
|
||||
const style = registry.cache.inserted[name];
|
||||
|
||||
if (typeof style !== 'boolean') {
|
||||
if (isGlobal) {
|
||||
globals.push({ name, style });
|
||||
} else {
|
||||
styles += style;
|
||||
dataEmotionAttribute += ` ${name}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{globals.map(
|
||||
({ name, style }): React.JSX.Element => (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{ __html: style }}
|
||||
data-emotion={`${registry.cache.key}-global ${name}`}
|
||||
key={name}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{styles ? <style dangerouslySetInnerHTML={{ __html: styles }} data-emotion={dataEmotionAttribute} /> : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
return <CacheProvider value={registry.cache}>{children}</CacheProvider>;
|
||||
}
|
30
002_source/cms/src/components/core/theme-provider/rtl.tsx
Normal file
30
002_source/cms/src/components/core/theme-provider/rtl.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import createCache from '@emotion/cache';
|
||||
import type { EmotionCache } from '@emotion/cache';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import stylisRTLPlugin from 'stylis-plugin-rtl';
|
||||
|
||||
import type { Direction } from '@/styles/theme/types';
|
||||
|
||||
function styleCache(): EmotionCache {
|
||||
return createCache({ key: 'rtl', prepend: true, stylisPlugins: [stylisRTLPlugin] });
|
||||
}
|
||||
|
||||
export interface RTLProps {
|
||||
children: React.ReactNode;
|
||||
direction?: Direction;
|
||||
}
|
||||
|
||||
export function Rtl({ children, direction = 'ltr' }: RTLProps): React.JSX.Element {
|
||||
React.useEffect(() => {
|
||||
document.dir = direction;
|
||||
}, [direction]);
|
||||
|
||||
if (direction === 'rtl') {
|
||||
return <CacheProvider value={styleCache()}>{children}</CacheProvider>;
|
||||
}
|
||||
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';
|
||||
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { createTheme } from '@/styles/theme/create-theme';
|
||||
|
||||
import EmotionCache from './emotion-cache';
|
||||
import { Rtl } from './rtl';
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
|
||||
const { settings } = useSettings();
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: settings.primaryColor,
|
||||
colorScheme: settings.colorScheme,
|
||||
direction: settings.direction,
|
||||
});
|
||||
|
||||
return (
|
||||
<EmotionCache options={{ key: 'mui' }}>
|
||||
<CssVarsProvider defaultColorScheme={settings.colorScheme} defaultMode={settings.colorScheme} theme={theme}>
|
||||
<CssBaseline />
|
||||
<Rtl direction={settings.direction}>{children}</Rtl>
|
||||
</CssVarsProvider>
|
||||
</EmotionCache>
|
||||
);
|
||||
}
|
26
002_source/cms/src/components/core/tip.tsx
Normal file
26
002_source/cms/src/components/core/tip.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Lightbulb as LightbulbIcon } from '@phosphor-icons/react/dist/ssr/Lightbulb';
|
||||
|
||||
export interface TipProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function Tip({ message }: TipProps): React.JSX.Element {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{ alignItems: 'center', bgcolor: 'var(--mui-palette-background-level1)', borderRadius: 1, p: 1 }}
|
||||
>
|
||||
<LightbulbIcon />
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
<Typography component="span" sx={{ fontWeight: 700 }} variant="inherit">
|
||||
Tip.
|
||||
</Typography>{' '}
|
||||
{message}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
5
002_source/cms/src/components/core/toaster.tsx
Normal file
5
002_source/cms/src/components/core/toaster.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { toast, Toaster } from 'sonner';
|
||||
|
||||
export { Toaster, toast };
|
Reference in New Issue
Block a user