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,55 @@
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Trans} from '@common/i18n/trans';
import {useFormContext} from 'react-hook-form';
import {
appearanceState,
AppearanceValues,
} from '@common/admin/appearance/appearance-store';
import {AceDialog} from '@common/ace-editor/ace-dialog';
import {Fragment} from 'react';
export function CustomCodeSection() {
return (
<Fragment>
<CustomCodeDialogTrigger mode="css" />
<CustomCodeDialogTrigger mode="html" />
</Fragment>
);
}
interface CustomCodeDialogTriggerProps {
mode: 'html' | 'css';
}
function CustomCodeDialogTrigger({mode}: CustomCodeDialogTriggerProps) {
const {getValues} = useFormContext<AppearanceValues>();
const {setValue} = useFormContext<AppearanceValues>();
const title =
mode === 'html' ? (
<Trans message="Custom HTML & JavaScript" />
) : (
<Trans message="Custom CSS" />
);
return (
<DialogTrigger
type="modal"
onClose={newValue => {
if (newValue != null) {
setValue(`appearance.custom_code.${mode}`, newValue, {
shouldDirty: true,
});
appearanceState().preview.setCustomCode(mode, newValue);
}
}}
>
<AppearanceButton>{title}</AppearanceButton>
<AceDialog
title={title}
defaultValue={getValues(`appearance.custom_code.${mode}`) || ''}
mode={mode}
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,94 @@
import {appearanceState, useAppearanceStore} from '../appearance-store';
import {FormImageSelector} from '@common/ui/images/image-selector';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import {Fragment, ReactNode} from 'react';
import {Settings} from '../../../core/settings/settings';
export function GeneralSection() {
return (
<Fragment>
<BrandingImageSelector
label={<Trans message="Favicon" />}
description={
<Trans message="This will generate different size favicons. Image should be at least 512x512 in size." />
}
type="favicon"
/>
<BrandingImageSelector
label={<Trans message="Light logo" />}
description={<Trans message="Will be used on dark backgrounds." />}
type="logo_light"
/>
<BrandingImageSelector
label={<Trans message="Dark logo" />}
description={
<Trans message="Will be used on light backgrounds. Will default to light logo if left empty." />
}
type="logo_dark"
/>
<BrandingImageSelector
label={<Trans message="Mobile light logo" />}
description={
<Trans message="Will be used on light backgrounds on mobile. Will default to desktop logo if left empty." />
}
type="logo_light_mobile"
/>
<BrandingImageSelector
label={<Trans message="Mobile dark logo" />}
description={
<Trans message="Will be used on dark backgrounds on mobile. Will default to desktop if left empty." />
}
type="logo_dark_mobile"
/>
<SiteNameTextField />
<SiteDescriptionTextArea />
</Fragment>
);
}
interface ImageSelectorProps {
label: ReactNode;
description: ReactNode;
type: keyof Settings['branding'];
}
function BrandingImageSelector({label, description, type}: ImageSelectorProps) {
const defaultValue = useAppearanceStore(
s => s.defaults?.settings.branding[type]
);
return (
<FormImageSelector
name={`settings.branding.${type}`}
className="border-b pb-30 mb-30"
label={label}
description={description}
diskPrefix="branding_media"
defaultValue={defaultValue}
onChange={() => {
appearanceState().preview.setHighlight('[data-logo="navbar"]');
}}
/>
);
}
function SiteNameTextField() {
return (
<FormTextField
name="appearance.env.app_name"
required
className="mt-20"
label={<Trans message="Site name" />}
/>
);
}
function SiteDescriptionTextArea() {
return (
<FormTextField
name="settings.branding.site_description"
className="mt-20"
inputElementType="textarea"
rows={4}
label={<Trans message="Site description" />}
/>
);
}

View File

@@ -0,0 +1,129 @@
import {useForm} from 'react-hook-form';
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {Form} from '@common/ui/forms/form';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {MenuItemConfig} from '@common/core/settings/settings';
import {AddIcon} from '@common/icons/material/Add';
import {Button} from '@common/ui/buttons/button';
import {useAvailableRoutes} from '@common/admin/appearance/sections/menus/hooks/available-routes';
import {ucFirst} from '@common/utils/string/uc-first';
import {List, ListItem} from '@common/ui/list/list';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {Trans} from '@common/i18n/trans';
import {useValueLists} from '@common/http/value-lists';
import {ReactNode} from 'react';
import {nanoid} from 'nanoid';
interface AddMenuItemDialogProps {
title?: ReactNode;
}
export function AddMenuItemDialog({
title = <Trans message="Add menu item" />,
}: AddMenuItemDialogProps) {
const {data} = useValueLists(['menuItemCategories']);
const categories = data?.menuItemCategories || [];
const routeItems = useAvailableRoutes();
return (
<Dialog size="sm">
<DialogHeader>{title}</DialogHeader>
<DialogBody>
<Accordion variant="outline">
<AccordionItem
label={<Trans message="Link" />}
bodyClassName="max-h-240 overflow-y-auto"
>
<AddCustomLink />
</AccordionItem>
<AccordionItem
label={<Trans message="Route" />}
bodyClassName="max-h-240 overflow-y-auto"
>
<AddRoute items={routeItems} />
</AccordionItem>
{categories.map(category => (
<AccordionItem
key={category.name}
label={<Trans message={category.name} />}
>
<AddRoute items={category.items} />
</AccordionItem>
))}
</Accordion>
</DialogBody>
</Dialog>
);
}
function AddCustomLink() {
const form = useForm<MenuItemConfig>({
defaultValues: {
id: nanoid(6),
type: 'link',
target: '_blank',
},
});
const {close} = useDialogContext();
return (
<Form
form={form}
onSubmit={value => {
close(value);
}}
>
<FormTextField
required
name="label"
label={<Trans message="Label" />}
className="mb-20"
/>
<FormTextField
required
type="url"
name="action"
placeholder="https://"
label={<Trans message="Url" />}
className="mb-20"
/>
<div className="text-right">
<Button type="submit" variant="flat" color="primary" size="xs">
<Trans message="Add to menu" />
</Button>
</div>
</Form>
);
}
interface AddRouteProps {
items: Partial<MenuItemConfig>[];
}
function AddRoute({items}: AddRouteProps) {
const {close} = useDialogContext();
return (
<List>
{items.map(item => {
return (
<ListItem
key={item.id}
startIcon={<AddIcon size="sm" />}
onSelected={() => {
if (item.label) {
const last = item.label.split('/').pop();
item.label = last ? ucFirst(last) : item.label;
item.id = nanoid(6);
}
close(item);
}}
>
{item.label}
</ListItem>
);
})}
</List>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,19 @@
import {MenuSectionConfig} from '../../../types/appearance-editor-config';
import {MenuItemConfig} from '../../../../../core/settings/settings';
import mergedAppearanceConfig from '../../../config/merged-appearance-config';
export function useAvailableRoutes(): Partial<MenuItemConfig>[] {
const menuConfig = mergedAppearanceConfig.sections.menus.config;
if (!menuConfig) return [];
return (menuConfig as MenuSectionConfig).availableRoutes.map(route => {
return {
id: route,
label: route,
action: route,
type: 'route',
target: '_self',
};
});
}

View File

@@ -0,0 +1,281 @@
import {
FieldArrayWithId,
useFieldArray,
UseFieldArrayReturn,
useFormContext,
} from 'react-hook-form';
import {Fragment, useEffect, useMemo, useRef} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom';
import {MenuSectionConfig} from '../../types/appearance-editor-config';
import {MenuItemConfig} from '@common/core/settings/settings';
import {
appearanceState,
AppearanceValues,
useAppearanceStore,
} from '../../appearance-store';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Button} from '@common/ui/buttons/button';
import {AddMenuItemDialog} from '@common/admin/appearance/sections/menus/add-menu-item-dialog';
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
import {AddIcon} from '@common/icons/material/Add';
import {DragIndicatorIcon} from '@common/icons/material/DragIndicator';
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
import {DeleteIcon} from '@common/icons/material/Delete';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Option} from '../../../../ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import dropdownMenu from './dropdown-menu.svg';
import {FormChipField} from '@common/ui/forms/input-field/chip-field/form-chip-field';
import {
useSortable,
UseSortableProps,
} from '@common/ui/interactions/dnd/sortable/use-sortable';
import {IconButton} from '@common/ui/buttons/icon-button';
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
import {useSettings} from '@common/core/settings/use-settings';
export function MenuEditor() {
const {menuIndex} = useParams();
const navigate = useNavigate();
const {getValues} = useFormContext<AppearanceValues>();
const formPath = `settings.menus.${menuIndex!}` as 'settings.menus.0';
const menu = getValues(formPath);
useEffect(() => {
// go to menu list, if menu can't be found
if (!menu) {
navigate('/admin/appearance/menus');
} else {
appearanceState().preview.setHighlight(`[data-menu-id="${menu.id}"]`);
}
}, [navigate, menu]);
if (!menu) {
return null;
}
return <MenuEditorSection formPath={formPath} />;
}
interface MenuEditorFormProps {
formPath: 'settings.menus.0';
}
function MenuEditorSection({formPath}: MenuEditorFormProps) {
const {
site: {has_mobile_app},
} = useSettings();
const menuSectionConfig = useAppearanceStore(
s => s.config?.sections.menus.config,
) as MenuSectionConfig;
const menuPositions = useMemo(() => {
const positions = [...menuSectionConfig?.positions];
if (has_mobile_app) {
positions.push('mobile-app-about');
}
return positions.map(position => ({
key: position,
name: position.replaceAll('-', ' '),
}));
}, [menuSectionConfig, has_mobile_app]);
const fieldArray = useFieldArray<
AppearanceValues,
`settings.menus.0.items`,
'key'
>({
name: `${formPath}.items`,
keyName: 'key',
});
return (
<Fragment>
<div className="mb-30 border-b pb-30">
<FormTextField
name={`${formPath}.name`}
label={<Trans message="Menu name" />}
className="mb-20"
autoFocus
/>
<FormChipField
chipSize="sm"
name={`${formPath}.positions`}
valueKey="id"
label={<Trans message="Menu positions" />}
description={
<Trans message="Where should this menu appear on the site" />
}
>
{menuPositions.map(item => (
<Option key={item.key} value={item.key} capitalizeFirst>
{item.name}
</Option>
))}
</FormChipField>
</div>
<MenuItemsManager fieldArray={fieldArray} />
<div className="text-right">
<DeleteMenuTrigger />
</div>
</Fragment>
);
}
interface ItemListProps {
fieldArray: UseFieldArrayReturn<
AppearanceValues,
'settings.menus.0.items',
'key'
>;
}
function MenuItemsManager({fieldArray: {append, fields, move}}: ItemListProps) {
const navigate = useNavigate();
return (
<Fragment>
<div className="flex flex-shrink-0 items-center justify-between gap-16">
<Trans message="Menu items" />
<DialogTrigger
type="popover"
placement="right"
offset={20}
onClose={(menuItemConfig?: MenuItemConfig) => {
if (menuItemConfig) {
append({...menuItemConfig});
navigate(`items/${fields.length}`);
}
}}
>
<Button
variant="outline"
color="primary"
size="xs"
startIcon={<AddIcon />}
>
<Trans message="Add" />
</Button>
<AddMenuItemDialog />
</DialogTrigger>
</div>
<div className="mt-20 flex-shrink-0">
{fields.map((item, index) => (
<MenuListItem
key={item.key}
item={item}
items={fields}
index={index}
onSortEnd={(oldIndex, newIndex) => {
move(oldIndex, newIndex);
}}
/>
))}
{!fields.length ? (
<IllustratedMessage
size="xs"
className="my-40"
image={<SvgImage src={dropdownMenu} />}
title={<Trans message="No menu items yet" />}
description={
<Trans message="Click “add“ button to start adding links, pages, routes and other items to this menu. " />
}
/>
) : null}
</div>
</Fragment>
);
}
function DeleteMenuTrigger() {
const navigate = useNavigate();
const {menuIndex} = useParams();
const {fields, remove} = useFieldArray<
AppearanceValues,
'settings.menus',
'key'
>({
name: 'settings.menus',
keyName: 'key',
});
if (!menuIndex) return null;
const menu = fields[+menuIndex];
return (
<DialogTrigger
type="modal"
onClose={isConfirmed => {
if (isConfirmed) {
const index = fields.findIndex(m => m.id === menu.id);
remove(index);
navigate('/admin/appearance/menus');
}
}}
>
<Button
variant="outline"
color="danger"
size="xs"
startIcon={<DeleteIcon />}
>
<Trans message="Delete menu" />
</Button>
<ConfirmationDialog
isDanger
title={<Trans message="Delete menu" />}
body={
<Trans
message="Are you sure you want to delete “:name“?"
values={{name: menu.name}}
/>
}
confirm={<Trans message="Delete" />}
/>
</DialogTrigger>
);
}
interface MenuListItemProps {
item: MenuItemConfig;
items: FieldArrayWithId[];
index: number;
onSortEnd: UseSortableProps['onSortEnd'];
}
function MenuListItem({item, items, index, onSortEnd}: MenuListItemProps) {
const ref = useRef<HTMLButtonElement>(null);
const {sortableProps, dragHandleRef} = useSortable({
item,
items,
type: 'menuEditorSortable',
ref,
onSortEnd,
strategy: 'liveSort',
});
const Icon = item.icon && createSvgIconFromTree(item.icon);
const iconOnlyLabel = (
<div className="flex items-center gap-4 text-xs text-muted">
{Icon && <Icon size="sm" />}
(<Trans message="No label..." />)
</div>
);
return (
<Fragment>
<AppearanceButton
elementType={Link}
to={`items/${index}`}
ref={ref}
{...sortableProps}
>
<div className="flex items-center gap-10">
<IconButton ref={dragHandleRef} size="sm">
<DragIndicatorIcon className="text-muted hover:cursor-move" />
</IconButton>
<div>{item.label || iconOnlyLabel}</div>
</div>
</AppearanceButton>
</Fragment>
);
}

View File

@@ -0,0 +1,12 @@
import {MenuItemConfig} from '@common/core/settings/settings';
export interface MenuItemCategory {
name: string;
type: string;
items: MenuItemCategoryItem[];
}
interface MenuItemCategoryItem extends Partial<MenuItemConfig> {
label: string;
model_id?: number;
}

View File

@@ -0,0 +1,100 @@
import {useFieldArray, useFormContext} from 'react-hook-form';
import {Fragment, useEffect} from 'react';
import {appearanceState, AppearanceValues} from '../../appearance-store';
import {Button} from '@common/ui/buttons/button';
import {DeleteIcon} from '../../../../icons/material/Delete';
import {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';
import {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';
import {Trans} from '../../../../i18n/trans';
import {useNavigate} from '../../../../utils/hooks/use-navigate';
import {MenuItemForm} from '../../../menus/menu-item-form';
import {useParams} from 'react-router-dom';
import {MenuItemConfig} from '../../../../core/settings/settings';
export function MenuItemEditor() {
const {menuIndex, menuItemIndex} = useParams();
const navigate = useNavigate();
const {getValues} = useFormContext<AppearanceValues>();
const formPath = `settings.menus.${menuIndex}.items.${menuItemIndex}`;
const item = getValues(formPath as any);
// go to menu editor, if menu item can't be found
useEffect(() => {
if (!item) {
//navigate(`../`);
} else {
appearanceState().preview.setHighlight(
`[data-menu-item-id="${item.id}"]`
);
}
}, [navigate, item]);
// only render form when menu and item are available to avoid issues with hook form default values
if (!item || menuItemIndex == null) {
return null;
}
return <MenuItemEditorSection formPath={formPath} />;
}
interface MenuItemEditorSectionProps {
formPath: string;
}
function MenuItemEditorSection({formPath}: MenuItemEditorSectionProps) {
return (
<Fragment>
<MenuItemForm formPathPrefix={formPath} />
<div className="text-right mt-40">
<DeleteItemTrigger />
</div>
</Fragment>
);
}
function DeleteItemTrigger() {
const navigate = useNavigate();
const {menuIndex, menuItemIndex} = useParams();
const {fields, remove} = useFieldArray<AppearanceValues>({
name: `settings.menus.${+menuIndex!}.items`,
});
if (!menuItemIndex) return null;
const item = fields[+menuItemIndex] as MenuItemConfig;
return (
<DialogTrigger
type="modal"
onClose={isConfirmed => {
if (isConfirmed) {
if (menuItemIndex) {
remove(+menuItemIndex);
navigate(`/admin/appearance/menus/${menuIndex}`);
}
}
}}
>
<Button
variant="outline"
color="danger"
size="xs"
startIcon={<DeleteIcon />}
>
<Trans message="Delete this item" />
</Button>
<ConfirmationDialog
isDanger
title={<Trans message="Delete menu item" />}
body={
<Trans
message="Are you sure you want to delete “:name“?"
values={{name: item.label}}
/>
}
confirm={<Trans message="Delete" />}
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,60 @@
import {Link, useNavigate} from 'react-router-dom';
import {AppearanceValues} from '../../appearance-store';
import {Button} from '../../../../ui/buttons/button';
import {AddIcon} from '../../../../icons/material/Add';
import {Trans} from '../../../../i18n/trans';
import {useFieldArray} from 'react-hook-form';
import {AppearanceButton} from '../../appearance-button';
import {nanoid} from 'nanoid';
import {useTrans} from '../../../../i18n/use-trans';
import {message} from '../../../../i18n/message';
import {Fragment} from 'react';
export function MenuList() {
const navigate = useNavigate();
const {trans} = useTrans();
const {fields, append} = useFieldArray<
AppearanceValues,
'settings.menus',
'key'
>({
name: 'settings.menus',
keyName: 'key',
});
return (
<Fragment>
<div>
{fields.map((field, index) => (
<AppearanceButton to={`${index}`} key={field.key} elementType={Link}>
{field.name}
</AppearanceButton>
))}
</div>
<div className="text-right">
<Button
variant="outline"
color="primary"
startIcon={<AddIcon />}
size="xs"
onClick={() => {
const id = nanoid(10);
append({
name: trans(
message('New menu :number', {
values: {number: fields.length + 1},
})
),
id,
positions: [],
items: [],
});
navigate(`${fields.length}`);
}}
>
<Trans message="Create menu" />
</Button>
</div>
</Fragment>
);
}

View File

@@ -0,0 +1,101 @@
import {Fragment, useRef} from 'react';
import {Trans} from '@common/i18n/trans';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
import {AceDialog} from '@common/ace-editor/ace-dialog';
import mergedAppearanceConfig from '@common/admin/appearance/config/merged-appearance-config';
import {SeoSettingsSectionConfig} from '@common/admin/appearance/types/appearance-editor-config';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {useSeoTags} from '@common/admin/appearance/sections/seo/use-seo-tags';
import {useUpdateSeoTags} from '@common/admin/appearance/sections/seo/use-update-seo-tags';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
import {Button} from '@common/ui/buttons/button';
import type ReactAce from 'react-ace';
const pages =
(
mergedAppearanceConfig.sections['seo-settings']
.config as SeoSettingsSectionConfig
)?.pages || [];
const names = pages.map(page => page.key);
export function SeoSection() {
const {isLoading} = useSeoTags(names);
if (isLoading) {
return <FullPageLoader />;
}
return (
<Fragment>
{pages.map(page => (
<TagEditorTrigger key={page.key} label={page.label} name={page.key} />
))}
</Fragment>
);
}
interface TagEditorTriggerProps {
label: MessageDescriptor;
name: string;
}
function TagEditorTrigger({label, name}: TagEditorTriggerProps) {
const {data, isLoading} = useSeoTags(names);
return (
<DialogTrigger type="modal">
<AppearanceButton disabled={isLoading}>
<Trans {...label} />
</AppearanceButton>
{data ? <TagsEditorDialog name={name} value={data[name]} /> : null}
</DialogTrigger>
);
}
interface TagsEditorDialogProps {
name: string;
value: {custom: string | null; original: string};
}
function TagsEditorDialog({name, value}: TagsEditorDialogProps) {
const {close} = useDialogContext();
const updateTags = useUpdateSeoTags(name);
const editorRef = useRef<ReactAce | null>(null);
const resetButton = (
<Button
variant="outline"
color="primary"
onClick={() => {
if (editorRef.current) {
editorRef.current.editor.setValue(value.original);
}
}}
>
<Trans message="Reset to original" />
</Button>
);
return (
<AceDialog
mode="php_laravel_blade"
title={<Trans message="Edit SEO meta tags" />}
footerStartAction={resetButton}
editorRef={editorRef}
defaultValue={value.custom || value.original}
isSaving={updateTags.isPending}
beautify={false}
onSave={newValue => {
if (newValue != null) {
updateTags.mutate(
{tags: newValue},
{
onSuccess: () => close(),
},
);
}
}}
/>
);
}

View File

@@ -0,0 +1,23 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
export function useSeoTags(name: string | string[]) {
return useQuery({
queryKey: ['admin', 'seo-tags', name],
queryFn: () => fetchTags(name),
});
}
function fetchTags(name: string | string[]) {
return apiClient
.get<
Record<
string,
{
custom: string | null;
original: string;
}
>
>(`admin/appearance/seo-tags/${name}`)
.then(response => response.data);
}

View File

@@ -0,0 +1,28 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {}
export function useUpdateSeoTags(name: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: {tags: string}) => updateTags(name, payload.tags),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['admin', 'seo-tags', name],
});
toast(message('Updated SEO tags'));
},
onError: err => showHttpErrorToast(err),
});
}
function updateTags(name: string, tags: string): Promise<Response> {
return apiClient
.put(`admin/appearance/seo-tags/${name}`, {tags})
.then(r => r.data);
}

View File

@@ -0,0 +1,8 @@
import {createSvgIcon} from '../../../../icons/create-svg-icon';
export const ColorIcon = createSvgIcon(
<path
stroke="#E0E0E0"
d="M24,44c-7.168,0-13-5.816-13-12.971C11,24,24,4,24,4s13,20,13,27.029C37,38.184,31.168,44,24,44z"
/>
);

View File

@@ -0,0 +1,81 @@
import {message} from '@common/i18n/message';
import {useParams} from 'react-router-dom';
import {useFormContext} from 'react-hook-form';
import {AppearanceValues} from '@common/admin/appearance/appearance-store';
import {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
import {ColorIcon} from '@common/admin/appearance/sections/themes/color-icon';
import clsx from 'clsx';
import {Trans} from '@common/i18n/trans';
import {Item} from '@common/ui/forms/listbox/item';
const navbarColorMap = [
{
label: message('Accent'),
value: 'primary',
bgColor: 'bg-primary',
previewBgColor: 'text-primary',
},
{
label: message('Background'),
value: 'bg',
bgColor: 'bg-background',
previewBgColor: 'text-background',
},
{
label: message('Background alt'),
value: 'bg-alt',
bgColor: 'bg-alt',
previewBgColor: 'text-background-alt',
},
{
label: message('Transparent'),
value: 'transparent',
bgColor: 'bg-transparent',
previewBgColor: 'text-transparent',
},
];
export function NavbarColorPicker() {
const {themeIndex} = useParams();
const {watch, setValue} = useFormContext<AppearanceValues>();
const key =
`appearance.themes.all.${themeIndex!}.values.--be-navbar-color` as 'appearance.themes.all.1.values.--be-navbar-color';
const selectedValue = watch(key);
const previewColor = navbarColorMap.find(({value}) => value === selectedValue)
?.previewBgColor;
return (
<MenuTrigger
placement="right"
selectionMode="single"
selectedValue={selectedValue}
onSelectionChange={value => {
setValue(key, value as string, {shouldDirty: true});
}}
>
<AppearanceButton
startIcon={
<ColorIcon
viewBox="0 0 48 48"
className={clsx('icon-lg', previewColor)}
/>
}
>
<Trans message="Navbar" />
</AppearanceButton>
<Menu>
{navbarColorMap.map(({label, value, bgColor}) => (
<Item
key={value}
value={value}
startIcon={
<div className={clsx('h-20 w-20 rounded border', bgColor)} />
}
>
<Trans {...label} />
</Item>
))}
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,184 @@
import {Link, useNavigate, useParams} from 'react-router-dom';
import {Fragment, ReactNode, useEffect, useState} from 'react';
import {
appearanceState,
AppearanceValues,
} from '@common/admin/appearance/appearance-store';
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
import {ColorIcon} from '@common/admin/appearance/sections/themes/color-icon';
import {CssTheme} from '@common/ui/themes/css-theme';
import {colorToThemeValue} from '@common/ui/themes/utils/color-to-theme-value';
import {ThemeSettingsDialogTrigger} from '@common/admin/appearance/sections/themes/theme-settings-dialog-trigger';
import {ThemeMoreOptionsButton} from '@common/admin/appearance/sections/themes/theme-more-options-button';
import {ColorPickerDialog} from '@common/ui/color-picker/color-picker-dialog';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {useFormContext} from 'react-hook-form';
import {Trans} from '@common/i18n/trans';
import {NavbarColorPicker} from '@common/admin/appearance/sections/themes/navbar-color-picker';
import {message} from '@common/i18n/message';
import {themeValueToHex} from '@common/ui/themes/utils/theme-value-to-hex';
const colorList = [
{
label: message('Background'),
key: '--be-background',
},
{
label: message('Background alt'),
key: '--be-background-alt',
},
{
label: message('Foreground'),
key: '--be-foreground-base',
},
{
label: message('Accent light'),
key: '--be-primary-light',
},
{
label: message('Accent'),
key: '--be-primary',
},
{
label: message('Accent dark'),
key: '--be-primary-dark',
},
{
label: message('Text on accent'),
key: '--be-on-primary',
},
{
label: message('Chip'),
key: '--be-background-chip',
},
];
export function ThemeEditor() {
const navigate = useNavigate();
const {themeIndex} = useParams();
const {getValues, watch} = useFormContext<AppearanceValues>();
const theme = getValues(`appearance.themes.all.${+themeIndex!}`);
const selectedFont = watch(
`appearance.themes.all.${+themeIndex!}.font.family`,
);
// go to theme list, if theme can't be found
useEffect(() => {
if (!theme) {
navigate('/admin/appearance/themes');
}
}, [navigate, theme]);
// select theme in preview on initial render
useEffect(() => {
if (theme?.id) {
appearanceState().preview.setActiveTheme(theme.id);
}
}, [theme?.id]);
if (!theme) return null;
return (
<Fragment>
<div className="mb-20 flex items-center justify-between gap-10">
<ThemeSettingsDialogTrigger />
<ThemeMoreOptionsButton />
</div>
<div>
<AppearanceButton
elementType={Link}
to="font"
description={selectedFont ? selectedFont : <Trans message="System" />}
>
<Trans message="Font" />
</AppearanceButton>
<AppearanceButton elementType={Link} to="radius">
<Trans message="Rounding" />
</AppearanceButton>
<div className="mb-6 mt-22 text-sm font-semibold">
<Trans message="Colors" />
</div>
<NavbarColorPicker />
{colorList.map(color => (
<ColorPickerTrigger
key={color.key}
colorName={color.key}
label={<Trans {...color.label} />}
initialThemeValue={theme.values[color.key]}
theme={theme}
/>
))}
</div>
</Fragment>
);
}
interface ColorPickerTriggerProps {
label: ReactNode;
theme: CssTheme;
colorName: string;
initialThemeValue: string;
}
function ColorPickerTrigger({
label,
theme,
colorName,
initialThemeValue,
}: ColorPickerTriggerProps) {
const {setValue} = useFormContext<AppearanceValues>();
const {themeIndex} = useParams();
const [selectedThemeValue, setSelectedThemeValue] =
useState<string>(initialThemeValue);
// set color as css variable in preview and on button preview, but not in appearance values
// this way color change can be canceled when color picker is closed and applied explicitly via apply button
const selectThemeValue = (themeValue: string) => {
setSelectedThemeValue(themeValue);
appearanceState().preview.setThemeValue(colorName, themeValue);
};
useEffect(() => {
// need to update the color here so changes via "reset colors" button are reflected
setSelectedThemeValue(initialThemeValue);
}, [initialThemeValue]);
return (
<DialogTrigger
value={themeValueToHex(selectedThemeValue)}
type="popover"
placement="right"
offset={10}
onValueChange={newColor => {
selectThemeValue(colorToThemeValue(newColor));
}}
onClose={(newColor, {valueChanged, initialValue}) => {
if (newColor && valueChanged) {
setValue(
`appearance.themes.all.${+themeIndex!}.values.${colorName}`,
colorToThemeValue(newColor),
{shouldDirty: true},
);
setValue('appearance.themes.selectedThemeId', theme.id);
} else {
// reset to initial value, if apply button was not clicked
selectThemeValue(initialValue);
}
}}
>
<AppearanceButton
className="capitalize"
startIcon={
<ColorIcon
viewBox="0 0 48 48"
className="icon-lg"
style={{fill: `rgb(${selectedThemeValue})`}}
/>
}
>
{label}
</AppearanceButton>
<ColorPickerDialog />
</DialogTrigger>
);
}

View File

@@ -0,0 +1,24 @@
import {FontSelector} from '@common/ui/font-selector/font-selector';
import {useFormContext} from 'react-hook-form';
import {
appearanceState,
AppearanceValues,
} from '@common/admin/appearance/appearance-store';
import {useParams} from 'react-router-dom';
type Font = 'appearance.themes.all.1.font';
export function ThemeFontPanel() {
const {setValue, watch} = useFormContext<AppearanceValues>();
const {themeIndex} = useParams();
const key = `appearance.themes.all.${themeIndex}.font` as Font;
return (
<FontSelector
value={watch(key)}
onChange={font => {
setValue(key, font, {shouldDirty: true});
appearanceState().preview.setThemeFont(font);
}}
/>
);
}

View File

@@ -0,0 +1,64 @@
import {NavLink, useNavigate} from 'react-router-dom';
import {Fragment, useEffect} from 'react';
import {appearanceState, AppearanceValues} from '../../appearance-store';
import {AppearanceButton} from '../../appearance-button';
import {Button} from '../../../../ui/buttons/button';
import {AddIcon} from '../../../../icons/material/Add';
import {randomNumber} from '../../../../utils/string/random-number';
import {Trans} from '../../../../i18n/trans';
import {useFieldArray} from 'react-hook-form';
import {useTrans} from '../../../../i18n/use-trans';
import {message} from '../../../../i18n/message';
import {useBootstrapData} from '../../../../core/bootstrap-data/bootstrap-data-context';
export function ThemeList() {
const {trans} = useTrans();
const navigate = useNavigate();
const {
data: {themes},
} = useBootstrapData();
const {fields, append} = useFieldArray<
AppearanceValues,
'appearance.themes.all',
'key'
>({
name: 'appearance.themes.all',
keyName: 'key',
});
useEffect(() => {
if (themes.selectedThemeId) {
appearanceState().preview.setActiveTheme(themes.selectedThemeId);
}
}, [themes.selectedThemeId]);
return (
<Fragment>
<div className="mb-20">
<Button
size="xs"
variant="outline"
color="primary"
startIcon={<AddIcon />}
onClick={() => {
const lightThemeColors =
appearanceState().defaults?.appearance.themes.light!;
append({
id: randomNumber(),
name: trans(message('New theme')),
values: lightThemeColors,
});
navigate(`${fields.length + 1}`);
}}
>
<Trans message="New theme" />
</Button>
</div>
{fields.map((field, index) => (
<AppearanceButton key={field.key} to={`${index}`} elementType={NavLink}>
{field.name}
</AppearanceButton>
))}
</Fragment>
);
}

View File

@@ -0,0 +1,100 @@
import {Fragment, useState} from 'react';
import {DeleteIcon} from '../../../../icons/material/Delete';
import {ConfirmationDialog} from '../../../../ui/overlays/dialog/confirmation-dialog';
import {IconButton} from '../../../../ui/buttons/icon-button';
import {MoreVertIcon} from '../../../../icons/material/MoreVert';
import {RestartAltIcon} from '../../../../icons/material/RestartAlt';
import {appearanceState, AppearanceValues} from '../../appearance-store';
import {toast} from '../../../../ui/toast/toast';
import {
Menu,
MenuItem,
MenuTrigger,
} from '../../../../ui/navigation/menu/menu-trigger';
import {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';
import {message} from '../../../../i18n/message';
import {Trans} from '../../../../i18n/trans';
import {useNavigate} from '../../../../utils/hooks/use-navigate';
import {useFieldArray, useFormContext} from 'react-hook-form';
import {useParams} from 'react-router-dom';
export function ThemeMoreOptionsButton() {
const navigate = useNavigate();
const {themeIndex} = useParams();
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const {setValue, getValues} = useFormContext<AppearanceValues>();
const {fields, remove} = useFieldArray<AppearanceValues>({
name: 'appearance.themes.all',
});
const deleteTheme = () => {
if (fields.length <= 1) {
toast.danger(message('At least one theme is required'));
return;
}
if (themeIndex) {
navigate('/admin/appearance/themes');
remove(+themeIndex);
setValue('appearance.themes.selectedThemeId', null);
}
};
return (
<Fragment>
<MenuTrigger
onItemSelected={key => {
if (key === 'delete') {
setConfirmDialogOpen(true);
} else if (key === 'reset') {
const path =
`appearance.themes.all.${+themeIndex!}` as 'appearance.themes.all.0';
const defaultColors = getValues(`${path}.is_dark`)
? appearanceState().defaults!.appearance.themes.dark
: appearanceState().defaults!.appearance.themes.light;
Object.entries(defaultColors).forEach(([colorName, themeValue]) => {
appearanceState().preview.setThemeValue(colorName, themeValue);
});
appearanceState().preview.setThemeFont(null);
setValue(`${path}.values`, defaultColors, {
shouldDirty: true,
});
setValue(`${path}.font`, undefined, {
shouldDirty: true,
});
}
}}
>
<IconButton size="md" className="text-muted">
<MoreVertIcon />
</IconButton>
<Menu>
<MenuItem value="reset" startIcon={<RestartAltIcon />}>
<Trans message="Reset colors" />
</MenuItem>
<MenuItem value="delete" startIcon={<DeleteIcon />}>
<Trans message="Delete" />
</MenuItem>
</Menu>
</MenuTrigger>
<DialogTrigger
type="modal"
isOpen={confirmDialogOpen}
onClose={isConfirmed => {
if (isConfirmed) {
deleteTheme();
}
setConfirmDialogOpen(false);
}}
>
<ConfirmationDialog
isDanger
title={<Trans message="Delete theme" />}
body={<Trans message="Are you sure you want to delete this theme?" />}
confirm={<Trans message="Delete" />}
/>
</DialogTrigger>
</Fragment>
);
}

View File

@@ -0,0 +1,116 @@
import {Trans} from '@common/i18n/trans';
import {ButtonBase} from '@common/ui/buttons/button-base';
import {ReactNode} from 'react';
import clsx from 'clsx';
import {useFormContext} from 'react-hook-form';
import {AppearanceValues} from '@common/admin/appearance/appearance-store';
import {message} from '@common/i18n/message';
import {useParams} from 'react-router-dom';
const radiusMap = {
'rounded-none': {
label: message('Square'),
value: '0px',
},
rounded: {
label: message('Small'),
value: '0.25rem',
},
'rounded-md': {
label: message('Medium'),
value: '0.375rem',
},
'rounded-lg': {
label: message('Large'),
value: '0.5rem',
},
'rounded-xl': {
label: message('Larger'),
value: '0.75rem',
},
'rounded-full': {
label: message('Pill'),
value: '9999px',
},
};
export function ThemeRadiusPanel() {
return (
<div className="space-y-24">
<RadiusSelector
label={<Trans message="Button rounding" />}
name="button-radius"
/>
<RadiusSelector
label={<Trans message="Input rounding" />}
name="input-radius"
/>
<RadiusSelector
label={<Trans message="Panel rounding" />}
name="panel-radius"
hidePill
/>
</div>
);
}
interface RadiusSelectorProps {
label: ReactNode;
name: string;
hidePill?: boolean;
}
function RadiusSelector({label, name, hidePill}: RadiusSelectorProps) {
const {themeIndex} = useParams();
const {watch, setValue} = useFormContext<AppearanceValues>();
const formKey =
`appearance.themes.all.${themeIndex}.values.--be-${name}` as 'appearance.themes.all.1.values.--be-button-radius';
const currentValue = watch(formKey);
return (
<div>
<div className="mb-10 text-sm font-semibold">{label}</div>
<div className="grid grid-cols-3 gap-10 text-sm">
{Object.entries(radiusMap)
.filter(([key]) => !hidePill || !key.includes('full'))
.map(([key, {label, value}]) => (
<PreviewButton
key={key}
radius={key}
isActive={value === currentValue}
onClick={() => {
setValue(formKey, value, {shouldDirty: true});
}}
>
<Trans {...label} />
</PreviewButton>
))}
</div>
</div>
);
}
interface PreviewButtonProps {
radius: string;
children: ReactNode;
isActive: boolean;
onClick: () => void;
}
function PreviewButton({
radius,
children,
isActive,
onClick,
}: PreviewButtonProps) {
return (
<ButtonBase
display="block"
className={clsx(
'h-36 border-2 hover:bg-hover',
radius,
isActive && 'border-primary',
)}
onClick={onClick}
>
{children}
</ButtonBase>
);
}

View File

@@ -0,0 +1,159 @@
import {useForm, useFormContext} from 'react-hook-form';
import {useEffect} from 'react';
import {TuneIcon} from '../../../../icons/material/Tune';
import {Button} from '../../../../ui/buttons/button';
import {CssTheme} from '../../../../ui/themes/css-theme';
import {FormTextField} from '../../../../ui/forms/input-field/text-field/text-field';
import {FormSwitch} from '../../../../ui/forms/toggle/switch';
import {AppearanceValues} from '../../appearance-store';
import {DialogTrigger} from '../../../../ui/overlays/dialog/dialog-trigger';
import {DialogFooter} from '../../../../ui/overlays/dialog/dialog-footer';
import {useDialogContext} from '../../../../ui/overlays/dialog/dialog-context';
import {Dialog} from '../../../../ui/overlays/dialog/dialog';
import {DialogHeader} from '../../../../ui/overlays/dialog/dialog-header';
import {DialogBody} from '../../../../ui/overlays/dialog/dialog-body';
import {Trans} from '../../../../i18n/trans';
import {Form} from '../../../../ui/forms/form';
import {useParams} from 'react-router-dom';
export function ThemeSettingsDialogTrigger() {
const {getValues, setValue} = useFormContext<AppearanceValues>();
const {themeIndex} = useParams();
const theme = getValues(`appearance.themes.all.${+themeIndex!}`);
return (
<DialogTrigger
type="modal"
onClose={(value?: CssTheme) => {
if (!value) return;
getValues('appearance.themes.all').forEach((currentTheme, index) => {
// update changed theme
if (currentTheme.id === value.id) {
setValue(`appearance.themes.all.${index}`, value, {
shouldDirty: true,
});
return;
}
// unset "default_light" and "default_dark" on other themes
if (value.default_light) {
setValue(
`appearance.themes.all.${index}`,
{...currentTheme, default_light: false},
{shouldDirty: true}
);
return;
}
if (value.default_dark) {
setValue(
`appearance.themes.all.${index}`,
{...currentTheme, default_dark: false},
{shouldDirty: true}
);
return;
}
});
}}
>
<Button
size="xs"
variant="outline"
color="primary"
startIcon={<TuneIcon />}
>
<Trans message="Settings" />
</Button>
<SettingsDialog theme={theme} />
</DialogTrigger>
);
}
interface SettingsDialogProps {
theme: CssTheme;
}
function SettingsDialog({theme}: SettingsDialogProps) {
const form = useForm<CssTheme>({defaultValues: theme});
const {close, formId} = useDialogContext();
useEffect(() => {
const subscription = form.watch((value, {name}) => {
// theme can only be set as either light or dark default
if (name === 'default_light' && value.default_light) {
form.setValue('default_dark', false);
}
if (name === 'default_dark' && value.default_dark) {
form.setValue('default_light', false);
}
});
return () => subscription.unsubscribe();
}, [form]);
return (
<Dialog>
<DialogHeader>
<Trans message="Update settings" />
</DialogHeader>
<DialogBody>
<Form
form={form}
id={formId}
onSubmit={values => {
close(values);
}}
>
<FormTextField
name="name"
label={<Trans message="Name" />}
className="mb-30"
autoFocus
/>
<FormSwitch
name="is_dark"
className="mb-20 pb-20 border-b"
description={
<Trans message="Whether this theme has light text on dark background." />
}
>
<Trans message="Dark theme" />
</FormSwitch>
<FormSwitch
name="default_light"
className="mb-30"
description={
<Trans message="When light mode is selected, this theme will be used." />
}
>
<Trans message="Default for light mode" />
</FormSwitch>
<FormSwitch
name="default_dark"
description={
<Trans message="When dark mode is selected, this theme will be used." />
}
>
<Trans message="Default for dark mode" />
</FormSwitch>
</Form>
</DialogBody>
<DialogFooter>
<Button
onClick={() => {
close();
}}
>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
type="submit"
form={formId}
disabled={!form.formState.isDirty}
>
<Trans message="Save" />
</Button>
</DialogFooter>
</Dialog>
);
}