build ok,

This commit is contained in:
louiscklaw
2025-04-14 09:26:24 +08:00
commit 6c931c1fe8
770 changed files with 63959 additions and 0 deletions

View 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>
);
}

View File

@@ -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' }} />;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,
});

View File

@@ -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>
);
}

View File

@@ -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);
},
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>;
}

View 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 }
);

View 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],
}}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
});

View File

@@ -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>
);
}

View File

@@ -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>;
}

View 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>;
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
'use client';
import { toast, Toaster } from 'sonner';
export { Toaster, toast };