first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
import React, {Fragment, useState} from 'react';
import clsx from 'clsx';
import {AnimatePresence, m} from 'framer-motion';
import {Divider} from '@common/text-editor/menubar/divider';
import {FontStyleButtons} from '@common/text-editor/menubar/font-style-buttons';
import {ListButtons} from '@common/text-editor/menubar/list-buttons';
import {LinkButton} from '@common/text-editor/menubar/link-button';
import {ImageButton} from '@common/text-editor/menubar/image-button';
import {ClearFormatButton} from '@common/text-editor/menubar/clear-format-button';
import {InsertMenuTrigger} from '@common/text-editor/menubar/insert-menu-trigger';
import {FormatMenuTrigger} from '@common/text-editor/menubar/format-menu-trigger';
import {ColorButtons} from '@common/text-editor/menubar/color-buttons';
import {AlignButtons} from '@common/text-editor/menubar/align-buttons';
import {IndentButtons} from '@common/text-editor/menubar/indent-buttons';
import {CodeBlockMenuTrigger} from '@common/text-editor/menubar/code-block-menu-trigger';
import {MenubarButtonProps} from '@common/text-editor/menubar/menubar-button-props';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {IconButton} from '@common/ui/buttons/icon-button';
import {UnfoldMoreIcon} from '@common/icons/material/UnfoldMore';
import {UnfoldLessIcon} from '@common/icons/material/UnfoldLess';
const MenubarRowClassName =
'flex items-center px-4 h-42 text-muted border-b overflow-hidden';
interface Props extends MenubarButtonProps {
justify?: string;
hideInsertButton?: boolean;
imageDiskPrefix?: string;
}
export function ArticleBodyEditorMenubar({
editor,
size = 'md',
justify = 'justify-center',
hideInsertButton = false,
imageDiskPrefix,
}: Props) {
const isMobile = useIsMobileMediaQuery();
const [extendedVisible, setExtendedVisible] = useState(false);
return (
<div className={clsx(extendedVisible ? 'h-84' : 'h-42')}>
<div className={clsx(MenubarRowClassName, justify, 'relative z-20')}>
<FormatMenuTrigger editor={editor} size={size} />
<Divider />
<FontStyleButtons editor={editor} size={size} />
<Divider />
<AlignButtons editor={editor} size={size} />
<IndentButtons editor={editor} size={size} />
<Divider />
{isMobile ? (
<IconButton
className="flex-shrink-0"
color={extendedVisible ? 'primary' : null}
size={size}
onClick={() => {
setExtendedVisible(!extendedVisible);
}}
>
{extendedVisible ? <UnfoldLessIcon /> : <UnfoldMoreIcon />}
</IconButton>
) : (
<ExtendedButtons
editor={editor}
size={size}
hideInsertButton={hideInsertButton}
imageDiskPrefix={imageDiskPrefix}
/>
)}
</div>
<AnimatePresence>
{extendedVisible && (
<m.div
className={clsx(
MenubarRowClassName,
justify,
'absolute flex h-full w-full',
)}
initial={{y: '-100%'}}
animate={{y: 0}}
exit={{y: '-100%'}}
>
<ExtendedButtons
editor={editor}
size={size}
imageDiskPrefix={imageDiskPrefix}
/>
</m.div>
)}
</AnimatePresence>
</div>
);
}
function ExtendedButtons({
editor,
size = 'md',
hideInsertButton,
imageDiskPrefix,
}: Props) {
return (
<Fragment>
<ListButtons editor={editor} size={size} />
<Divider />
<LinkButton editor={editor} size={size} />
<ImageButton editor={editor} size={size} diskPrefix={imageDiskPrefix} />
{!hideInsertButton && <InsertMenuTrigger editor={editor} size={size} />}
<Divider />
<ColorButtons editor={editor} size={size} />
<Divider />
<CodeBlockMenuTrigger editor={editor} size={size} />
<ClearFormatButton editor={editor} size={size} />
</Fragment>
);
}

View File

@@ -0,0 +1,113 @@
import {Editor, EditorContent, FocusPosition, useEditor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {Underline} from '@tiptap/extension-underline';
import {Link as LinkExtension} from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import {ReactElement, useEffect, useRef} from 'react';
import {Superscript} from '@tiptap/extension-superscript';
import {Subscript} from '@tiptap/extension-subscript';
import {Color} from '@tiptap/extension-color';
import {TextStyle} from '@tiptap/extension-text-style';
import {TextAlign} from '@tiptap/extension-text-align';
import {CodeBlockLowlight} from '@tiptap/extension-code-block-lowlight';
import {BackgroundColor} from '@common/text-editor/extensions/background-color';
import {Indent} from '@common/text-editor/extensions/indent';
import {Embed} from '@common/text-editor/extensions/embed';
import {lowlight} from '@common/text-editor/highlight/lowlight';
import {InfoBlock} from '@common/text-editor/extensions/info-block';
import {useCallbackRef} from '@common/utils/hooks/use-callback-ref';
import {Extension} from '@tiptap/core';
interface Props {
initialContent?: string;
onLoad?: (editor: Editor) => void;
children: (content: ReactElement, editor: Editor) => ReactElement;
minHeight?: string;
onCtrlEnter?: () => void;
autoFocus?: FocusPosition;
}
export default function ArticleBodyEditor({
initialContent = '',
children,
onLoad: _onLoad,
onCtrlEnter,
minHeight = 'min-h-320',
autoFocus,
}: Props) {
const onLoad = useCallbackRef(_onLoad);
const calledOnLoad = useRef(false);
const extensions = [
StarterKit.configure({
codeBlock: false,
}),
Underline,
LinkExtension.extend({
inclusive: false,
validate: {
// only linkify links that start with a protocol
url: (value: string) => /^https?:\/\//.test(value),
},
}),
Image,
Superscript,
Subscript,
TextStyle,
Color,
BackgroundColor,
Indent,
CodeBlockLowlight.configure({
lowlight,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
InfoBlock,
Embed,
];
if (onCtrlEnter) {
extensions.push(
Extension.create({
addKeyboardShortcuts: () => ({
'Cmd-Enter'() {
onCtrlEnter();
return true;
},
'Ctrl-Enter'() {
onCtrlEnter();
return true;
},
}),
}),
);
}
const editor = useEditor({
extensions,
onFocus: () => {},
autofocus: autoFocus,
content: initialContent,
});
// destroy editor
useEffect(() => {
if (editor) {
return () => editor.destroy();
}
}, [editor]);
if (!editor) {
return null;
}
if (editor && onLoad && !calledOnLoad.current) {
onLoad(editor);
calledOnLoad.current = true;
}
return children(
<EditorContent className={minHeight} editor={editor} />,
editor,
);
}

View File

@@ -0,0 +1,148 @@
import {SlugEditor, SlugEditorProps} from '@common/ui/slug-editor';
import {useController, useFormContext} from 'react-hook-form';
import React, {Fragment, ReactNode, useEffect, useRef} from 'react';
import clsx from 'clsx';
import {useStickySentinel} from '@common/utils/hooks/sticky-sentinel';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {Button} from '@common/ui/buttons/button';
import {Link} from 'react-router-dom';
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
import {Trans} from '@common/i18n/trans';
import {HistoryButtons} from '@common/text-editor/menubar/history-buttons';
import {ModeButton} from '@common/text-editor/menubar/mode-button';
import {ArticleBodyEditorMenubar} from './article-body-editor-menubar';
import {Editor} from '@tiptap/react';
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
interface StickyHeaderProps {
editor: Editor;
allowSlugEditing?: boolean;
onSave?: (editorContent: string) => void;
saveButton?: ReactNode;
backLink: string;
isLoading?: boolean;
slugPrefix?: string;
imageDiskPrefix?: string;
}
export function ArticleEditorStickyHeader({
editor,
allowSlugEditing = true,
onSave,
saveButton,
isLoading = false,
backLink,
slugPrefix = 'pages',
imageDiskPrefix,
}: StickyHeaderProps) {
const {isSticky, sentinelRef} = useStickySentinel();
const isMobile = useIsMobileMediaQuery();
return (
<Fragment>
<div ref={sentinelRef} />
<div className={clsx('sticky top-0 z-10 mb-20 bg', isSticky && 'shadow')}>
<div className="flex items-center justify-between gap-20 border-b px-20 py-10 text-muted sm:justify-start">
{!isMobile && (
<Fragment>
<Button
variant="text"
size="sm"
elementType={Link}
to={backLink}
relative="path"
startIcon={<ArrowBackIcon />}
>
<Trans message="Back" />
</Button>
<div className="mr-auto">
{allowSlugEditing && (
<FormSlugEditor
name="slug"
showLinkIcon={false}
prefix={slugPrefix}
/>
)}
</div>
</Fragment>
)}
{editor && <HistoryButtons editor={editor} />}
{!isMobile && <ModeButton editor={editor} />}
{onSave && (
<SaveButton
onSave={() => {
onSave(editor.getHTML());
}}
isLoading={isLoading}
/>
)}
{saveButton}
</div>
<ArticleBodyEditorMenubar
editor={editor}
size="sm"
imageDiskPrefix={imageDiskPrefix}
/>
</div>
</Fragment>
);
}
interface SaveButtonProps {
onSave: () => void;
isLoading: boolean;
}
function SaveButton({onSave, isLoading}: SaveButtonProps) {
const form = useFormContext();
const title = form.watch('title');
return (
<Button
variant="flat"
size="sm"
color="primary"
className="min-w-90"
disabled={isLoading || !title}
onClick={() => onSave()}
>
<Trans message="Save" />
</Button>
);
}
interface FormSlugEditorProps extends SlugEditorProps {
name: string;
}
function FormSlugEditor({name, ...other}: FormSlugEditorProps) {
const {
field: {onChange, onBlur, value = '', ref},
} = useController({
name,
});
const manuallyChanged = useRef(false);
const {watch, setValue} = useFormContext<CreateCustomPagePayload>();
useEffect(() => {
const subscription = watch((formVal, {name: fieldName}) => {
// if user has not changed slug manually, set it based on page title field changes
if (fieldName === 'title' && !manuallyChanged.current) {
setValue('slug', formVal.title);
}
});
return () => subscription.unsubscribe();
}, [watch, setValue]);
return (
<SlugEditor
className={clsx(!value && 'invisible')}
onChange={e => {
manuallyChanged.current = true;
onChange(e);
}}
onInputBlur={onBlur}
value={value}
inputRef={ref}
{...other}
/>
);
}

View File

@@ -0,0 +1,49 @@
import React, {useState} from 'react';
import {useTrans} from '@common/i18n/use-trans';
import {useFormContext} from 'react-hook-form';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import clsx from 'clsx';
import {EditIcon} from '@common/icons/material/Edit';
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
export function ArticleEditorTitle() {
const [editingTitle, setEditingTitle] = useState(false);
const {trans} = useTrans();
const form = useFormContext<CreateCustomPagePayload>();
const watchedTitle = form.watch('title');
const titlePlaceholder = trans({message: 'Title'});
if (editingTitle) {
return (
<FormTextField
placeholder={titlePlaceholder}
autoFocus
className="mb-30"
onBlur={() => {
setEditingTitle(false);
}}
name="title"
required
/>
);
}
return (
<h1
tabIndex={0}
onClick={() => {
setEditingTitle(true);
}}
onFocus={() => {
setEditingTitle(true);
}}
className={clsx(
'hover:bg-primary/focus rounded cursor-pointer',
!watchedTitle && 'text-muted'
)}
>
{watchedTitle || titlePlaceholder}
<EditIcon className="icon-sm mx-8 mt-8 align-top text-muted" />
</h1>
);
}