39
common/resources/client/admin/appearance/appearance-button.tsx
Executable file
39
common/resources/client/admin/appearance/appearance-button.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import clsx from 'clsx';
|
||||
import {forwardRef, ReactNode} from 'react';
|
||||
import {KeyboardArrowRightIcon} from '../../icons/material/KeyboardArrowRight';
|
||||
import {ButtonBase, ButtonBaseProps} from '../../ui/buttons/button-base';
|
||||
|
||||
interface Props extends ButtonBaseProps {
|
||||
startIcon?: ReactNode;
|
||||
description?: ReactNode;
|
||||
}
|
||||
export const AppearanceButton = forwardRef<HTMLButtonElement, Props>(
|
||||
({startIcon, children, className, description, ...other}, ref) => {
|
||||
return (
|
||||
<ButtonBase
|
||||
ref={ref}
|
||||
display="flex"
|
||||
className={clsx(
|
||||
'relative mb-10 h-54 w-full items-center gap-10 rounded-input border bg px-14 text-sm hover:bg-hover',
|
||||
className,
|
||||
)}
|
||||
variant={null}
|
||||
{...other}
|
||||
>
|
||||
{startIcon}
|
||||
<span className="block min-w-0">
|
||||
<span className="block">{children}</span>
|
||||
{description && (
|
||||
<span className="block overflow-hidden overflow-ellipsis whitespace-nowrap text-xs text-muted">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<KeyboardArrowRightIcon
|
||||
aria-hidden
|
||||
className="ml-auto text-muted icon-sm"
|
||||
/>
|
||||
</ButtonBase>
|
||||
);
|
||||
},
|
||||
);
|
||||
152
common/resources/client/admin/appearance/appearance-layout.tsx
Executable file
152
common/resources/client/admin/appearance/appearance-layout.tsx
Executable file
@@ -0,0 +1,152 @@
|
||||
import {Link, Navigate, Outlet, useLocation} from 'react-router-dom';
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {CloseIcon} from '../../icons/material/Close';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {appearanceState, AppearanceValues} from './appearance-store';
|
||||
import {useSaveAppearanceChanges} from './requests/save-appearance-changes';
|
||||
import {useAppearanceValues} from './requests/appearance-values';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {useForm, useFormContext} from 'react-hook-form';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {ProgressCircle} from '../../ui/progress/progress-circle';
|
||||
import {SectionHeader} from './section-header';
|
||||
import {FileUploadProvider} from '../../uploads/uploader/file-upload-provider';
|
||||
import {useAppearanceEditorMode} from './commands/use-appearance-editor-mode';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
|
||||
export function AppearanceLayout() {
|
||||
const {isAppearanceEditorActive} = useAppearanceEditorMode();
|
||||
const {data} = useAppearanceValues();
|
||||
const {base_url} = useSettings();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const {pathname} = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// only set defaults snapshot once on route init
|
||||
if (data?.defaults && !appearanceState().defaults) {
|
||||
appearanceState().setDefaults(data.defaults);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current) {
|
||||
appearanceState().setIframeWindow(iframeRef.current.contentWindow!);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sectionName = pathname.split('/')[3];
|
||||
appearanceState().preview.navigate(sectionName);
|
||||
}, [pathname]);
|
||||
|
||||
// make sure appearance editor iframe can't be nested
|
||||
if (isAppearanceEditorActive) {
|
||||
return <Navigate to="/admin" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen items-center md:flex">
|
||||
<StaticPageTitle>
|
||||
<Trans message="Appearance" />
|
||||
</StaticPageTitle>
|
||||
<Sidebar values={data?.values} />
|
||||
<div className="relative h-full flex-auto">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="h-full w-full max-md:hidden"
|
||||
src={`${base_url}?appearanceEditor=true`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
values: AppearanceValues | undefined;
|
||||
}
|
||||
function Sidebar({values}: SidebarProps) {
|
||||
const spinner = (
|
||||
<div className="flex h-full flex-auto items-center justify-center">
|
||||
<ProgressCircle isIndeterminate aria-label="Loading editor" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 h-full w-full border-r bg shadow-lg @container md:w-320">
|
||||
{values ? <AppearanceForm defaultValues={values} /> : spinner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppearanceFormProps {
|
||||
defaultValues: AppearanceValues;
|
||||
}
|
||||
|
||||
function AppearanceForm({defaultValues}: AppearanceFormProps) {
|
||||
const form = useForm<AppearanceValues>({defaultValues});
|
||||
const {watch, reset} = form;
|
||||
const saveChanges = useSaveAppearanceChanges();
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = watch(value => {
|
||||
appearanceState().preview.setValues(value as AppearanceValues);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="flex h-full flex-col"
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
saveChanges.mutate(values, {
|
||||
onSuccess: () => reset(values),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Header isLoading={saveChanges.isPending} />
|
||||
<SectionHeader />
|
||||
<div className="flex-auto overflow-y-auto px-14 py-20">
|
||||
<FileUploadProvider>
|
||||
<Outlet />
|
||||
</FileUploadProvider>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
function Header({isLoading}: HeaderProps) {
|
||||
const {
|
||||
formState: {dirtyFields},
|
||||
} = useFormContext<AppearanceValues>();
|
||||
const isDirty = Object.keys(dirtyFields).length;
|
||||
return (
|
||||
<div className="flex h-50 flex-shrink-0 items-center border-b pr-10">
|
||||
<IconButton
|
||||
border="border-r"
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to=".."
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<div className="pl-10">
|
||||
<Trans message="Appearance editor" />
|
||||
</div>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
className="ml-auto block"
|
||||
disabled={!isDirty || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
{isDirty ? <Trans message="Save" /> : <Trans message="Saved" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
common/resources/client/admin/appearance/appearance-store.ts
Executable file
134
common/resources/client/admin/appearance/appearance-store.ts
Executable file
@@ -0,0 +1,134 @@
|
||||
import {create} from 'zustand';
|
||||
import {subscribeWithSelector} from 'zustand/middleware';
|
||||
import {immer} from 'zustand/middleware/immer';
|
||||
import {Settings} from '../../core/settings/settings';
|
||||
import type {IAppearanceConfig} from './types/appearance-editor-config';
|
||||
import {AllCommands} from './commands/commands';
|
||||
import mergedAppearanceConfig from './config/merged-appearance-config';
|
||||
import {BootstrapData} from '../../core/bootstrap-data/bootstrap-data';
|
||||
import {FontConfig} from '@common/http/value-lists';
|
||||
|
||||
export interface AppearanceValues {
|
||||
appearance: {
|
||||
env: {app_name: string; app_url: string};
|
||||
seo: {
|
||||
key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
}[];
|
||||
themes: BootstrapData['themes'];
|
||||
custom_code: {
|
||||
css?: string;
|
||||
html?: string;
|
||||
};
|
||||
};
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
export interface AppearanceDefaults {
|
||||
appearance: {
|
||||
themes: {
|
||||
light: Record<string, string>;
|
||||
dark: Record<string, string>;
|
||||
};
|
||||
};
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
interface AppearanceStore {
|
||||
defaults: AppearanceDefaults | null;
|
||||
iframeWindow: Window | null;
|
||||
config: IAppearanceConfig | null;
|
||||
setDefaults: (value: AppearanceDefaults) => void;
|
||||
setIframeWindow: (value: Window) => void;
|
||||
preview: {
|
||||
navigate: (sectionName: string) => void;
|
||||
setValues: (settings: AppearanceValues) => void;
|
||||
setThemeFont: (font: FontConfig | null) => void;
|
||||
setThemeValue: (name: string, value: string) => void;
|
||||
setActiveTheme: (themeId: number | string) => void;
|
||||
setHighlight: (selector: string | null | undefined) => void;
|
||||
setCustomCode: (mode: 'css' | 'html', value?: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export const useAppearanceStore = create<AppearanceStore>()(
|
||||
subscribeWithSelector(
|
||||
immer((set, get) => ({
|
||||
defaults: null,
|
||||
iframeWindow: null,
|
||||
config: mergedAppearanceConfig,
|
||||
setDefaults: value => {
|
||||
set(state => {
|
||||
state.defaults = {...value};
|
||||
});
|
||||
},
|
||||
setIframeWindow: value => {
|
||||
set(() => {
|
||||
return {iframeWindow: value};
|
||||
});
|
||||
},
|
||||
|
||||
preview: {
|
||||
navigate: sectionName => {
|
||||
const section = get().config?.sections[sectionName];
|
||||
const route = section?.previewRoute || '/';
|
||||
const preview = get().iframeWindow;
|
||||
if (route) {
|
||||
postMessage(preview, {type: 'navigate', to: route});
|
||||
}
|
||||
},
|
||||
setValues: values => {
|
||||
const preview = get().iframeWindow;
|
||||
postMessage(preview, {type: 'setValues', values});
|
||||
},
|
||||
setThemeFont: font => {
|
||||
const preview = get().iframeWindow;
|
||||
postMessage(preview, {type: 'setThemeFont', value: font});
|
||||
},
|
||||
setThemeValue: (name, value) => {
|
||||
const preview = get().iframeWindow;
|
||||
postMessage(preview, {type: 'setThemeValue', name, value});
|
||||
},
|
||||
setActiveTheme: themeId => {
|
||||
const preview = get().iframeWindow;
|
||||
postMessage(preview, {type: 'setActiveTheme', themeId});
|
||||
},
|
||||
setCustomCode: (mode, value) => {
|
||||
const preview = get().iframeWindow;
|
||||
postMessage(preview, {type: 'setCustomCode', mode, value});
|
||||
},
|
||||
setHighlight: selector => {
|
||||
set(() => {
|
||||
let node: HTMLElement | null = null;
|
||||
const document = get().iframeWindow?.document;
|
||||
if (document && selector) {
|
||||
node = document.querySelector(selector);
|
||||
}
|
||||
if (node) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!node) return;
|
||||
node.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
function postMessage(window: Window | null, command: AllCommands) {
|
||||
if (window) {
|
||||
window.postMessage({source: 'be-appearance-editor', ...command}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
export function appearanceState() {
|
||||
return useAppearanceStore.getState();
|
||||
}
|
||||
100
common/resources/client/admin/appearance/commands/appearance-listener.tsx
Executable file
100
common/resources/client/admin/appearance/commands/appearance-listener.tsx
Executable file
@@ -0,0 +1,100 @@
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {AllCommands} from '@common/admin/appearance/commands/commands';
|
||||
import {
|
||||
removeThemeValue,
|
||||
setThemeValue,
|
||||
} from '@common/ui/themes/utils/set-theme-value';
|
||||
import {applyThemeToDom} from '@common/ui/themes/utils/apply-theme-to-dom';
|
||||
import {useBootstrapData} from '@common/core/bootstrap-data/bootstrap-data-context';
|
||||
import {loadFonts} from '@common/ui/font-picker/load-fonts';
|
||||
|
||||
export function AppearanceListener() {
|
||||
const navigate = useNavigate();
|
||||
const {mergeBootstrapData, data: currentData} = useBootstrapData();
|
||||
|
||||
const handleCommand = useCallback(
|
||||
(command: AllCommands) => {
|
||||
switch (command.type) {
|
||||
case 'navigate':
|
||||
return navigate(command.to);
|
||||
case 'setValues':
|
||||
return mergeBootstrapData({
|
||||
themes: {
|
||||
...currentData.themes,
|
||||
all: command.values.appearance.themes.all,
|
||||
},
|
||||
settings: {
|
||||
...currentData.settings,
|
||||
...command.values.settings,
|
||||
},
|
||||
});
|
||||
case 'setThemeFont':
|
||||
if (command.value) {
|
||||
setThemeValue('--be-font-family', command.value.family);
|
||||
loadFonts([command.value], {
|
||||
id: 'be-primary-font',
|
||||
forceAssetLoad: true,
|
||||
});
|
||||
} else {
|
||||
removeThemeValue('--be-font-family');
|
||||
}
|
||||
return;
|
||||
case 'setThemeValue':
|
||||
return setThemeValue(command.name, command.value);
|
||||
case 'setActiveTheme':
|
||||
const theme = currentData.themes.all.find(
|
||||
t => t.id === command.themeId,
|
||||
);
|
||||
if (theme) {
|
||||
applyThemeToDom(theme);
|
||||
}
|
||||
return;
|
||||
case 'setCustomCode':
|
||||
return renderCustomCode(command.mode, command.value);
|
||||
default:
|
||||
}
|
||||
},
|
||||
[currentData, mergeBootstrapData, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (isAppearanceEvent(e) && eventIsTrusted(e)) {
|
||||
handleCommand(e.data);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [navigate, handleCommand]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAppearanceEvent(e: MessageEvent) {
|
||||
return e.data?.source === 'be-appearance-editor';
|
||||
}
|
||||
|
||||
function eventIsTrusted(e: MessageEvent): boolean {
|
||||
return new URL(e.origin).hostname === window.location.hostname;
|
||||
}
|
||||
|
||||
function renderCustomCode(mode: 'html' | 'css', value?: string) {
|
||||
const parent = mode === 'html' ? document.body : document.head;
|
||||
const nodeType = mode === 'html' ? 'div' : 'style';
|
||||
let customNode = parent.querySelector('#be-custom-code');
|
||||
|
||||
if (!value) {
|
||||
if (customNode) {
|
||||
customNode.remove();
|
||||
}
|
||||
} else {
|
||||
if (!customNode) {
|
||||
customNode = document.createElement(nodeType);
|
||||
customNode.id = 'be-custom-code';
|
||||
parent.appendChild(customNode);
|
||||
}
|
||||
customNode.innerHTML = value;
|
||||
}
|
||||
}
|
||||
48
common/resources/client/admin/appearance/commands/commands.ts
Executable file
48
common/resources/client/admin/appearance/commands/commands.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
import {To} from 'react-router-dom';
|
||||
import {AppearanceValues} from '../appearance-store';
|
||||
import {FontConfig} from '@common/http/value-lists';
|
||||
|
||||
export interface AppearanceCommand {
|
||||
source: 'be-appearance-editor';
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Navigate {
|
||||
type: 'navigate';
|
||||
to: To;
|
||||
}
|
||||
|
||||
export interface SetAppearanceValues {
|
||||
type: 'setValues';
|
||||
values: AppearanceValues;
|
||||
}
|
||||
|
||||
export interface SetThemeValue {
|
||||
type: 'setThemeValue';
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SetActiveTheme {
|
||||
type: 'setActiveTheme';
|
||||
themeId: number | string;
|
||||
}
|
||||
|
||||
export interface SetCustomCode {
|
||||
type: 'setCustomCode';
|
||||
mode: 'css' | 'html';
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface SetThemeFont {
|
||||
type: 'setThemeFont';
|
||||
value: FontConfig | null;
|
||||
}
|
||||
|
||||
export type AllCommands =
|
||||
| Navigate
|
||||
| SetAppearanceValues
|
||||
| SetThemeValue
|
||||
| SetThemeFont
|
||||
| SetActiveTheme
|
||||
| SetCustomCode;
|
||||
@@ -0,0 +1,11 @@
|
||||
import {isSsr} from '@common/utils/dom/is-ssr';
|
||||
|
||||
export function useAppearanceEditorMode() {
|
||||
return {
|
||||
isAppearanceEditorActive:
|
||||
!isSsr() &&
|
||||
((window.frameElement as HTMLIFrameElement) || undefined)?.src.includes(
|
||||
'appearanceEditor=true'
|
||||
),
|
||||
};
|
||||
}
|
||||
159
common/resources/client/admin/appearance/config/default-appearance-config.ts
Executable file
159
common/resources/client/admin/appearance/config/default-appearance-config.ts
Executable file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
IAppearanceConfig,
|
||||
MenuSectionConfig,
|
||||
} from '@common/admin/appearance/types/appearance-editor-config';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {chunkArray} from '@common/utils/array/chunk-array';
|
||||
import {AppearanceEditorBreadcrumbItem} from '@common/admin/appearance/types/appearance-editor-section';
|
||||
|
||||
export const DefaultAppearanceConfig: IAppearanceConfig = {
|
||||
preview: {
|
||||
defaultRoute: '/',
|
||||
navigationRoutes: [],
|
||||
},
|
||||
sections: {
|
||||
general: {
|
||||
label: message('General'),
|
||||
position: 1,
|
||||
buildBreadcrumb: () => [
|
||||
{
|
||||
label: message('General'),
|
||||
location: `general`,
|
||||
},
|
||||
],
|
||||
},
|
||||
themes: {
|
||||
label: message('Themes'),
|
||||
position: 2,
|
||||
buildBreadcrumb: (pathname, formValue) => {
|
||||
const parts = pathname.split('/').filter(p => !!p);
|
||||
const [, , , themeIndex] = parts;
|
||||
const breadcrumb: AppearanceEditorBreadcrumbItem[] = [
|
||||
{
|
||||
label: message('Themes'),
|
||||
location: `themes`,
|
||||
},
|
||||
];
|
||||
if (themeIndex != null) {
|
||||
breadcrumb.push({
|
||||
label: formValue.appearance.themes.all[+themeIndex]?.name,
|
||||
location: `themes/${themeIndex}`,
|
||||
});
|
||||
}
|
||||
if (parts.at(-1) === 'font') {
|
||||
breadcrumb.push({
|
||||
label: message('Font'),
|
||||
location: `themes/${themeIndex}/font`,
|
||||
});
|
||||
}
|
||||
if (parts.at(-1) === 'radius') {
|
||||
breadcrumb.push({
|
||||
label: message('Rounding'),
|
||||
location: `themes/${themeIndex}/radius`,
|
||||
});
|
||||
}
|
||||
return breadcrumb;
|
||||
},
|
||||
},
|
||||
menus: {
|
||||
label: message('Menus'),
|
||||
position: 3,
|
||||
buildBreadcrumb: (pathname, formValue) => {
|
||||
// /admin/appearance/menus/0/items/1
|
||||
const parts = pathname.split('/').filter(p => !!p);
|
||||
const [, , ...rest] = parts;
|
||||
// admin/appearance
|
||||
const breadcrumb: AppearanceEditorBreadcrumbItem[] = [
|
||||
{
|
||||
label: message('Menus'),
|
||||
location: 'menus',
|
||||
},
|
||||
];
|
||||
// chunk every two items: [form group, item index]
|
||||
const chunked = chunkArray(rest, 2);
|
||||
chunked.forEach(([sectionName, sectionIndex], chunkIndex) => {
|
||||
// menu
|
||||
if (sectionName === 'menus' && sectionIndex != null) {
|
||||
breadcrumb.push({
|
||||
label: formValue.settings.menus[+sectionIndex]?.name,
|
||||
location: `menus/${sectionIndex}`,
|
||||
});
|
||||
// menu item
|
||||
} else if (sectionName === 'items' && sectionIndex != null) {
|
||||
const [, menuIndex] = chunked[chunkIndex - 1];
|
||||
breadcrumb.push({
|
||||
label:
|
||||
formValue.settings.menus[+menuIndex].items[+sectionIndex]
|
||||
?.label,
|
||||
location: `menus/${menuIndex}/${sectionIndex}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return breadcrumb;
|
||||
},
|
||||
config: {
|
||||
availableRoutes: [
|
||||
'/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/contact',
|
||||
'/pricing',
|
||||
'/account-settings',
|
||||
'/admin',
|
||||
'/admin/appearance',
|
||||
'/admin/settings',
|
||||
'/admin/plans',
|
||||
'/admin/subscriptions',
|
||||
'/admin/users',
|
||||
'/admin/roles',
|
||||
'/admin/pages',
|
||||
'/admin/tags',
|
||||
'/admin/files',
|
||||
'/admin/localizations',
|
||||
'/admin/ads',
|
||||
'/admin/settings/authentication',
|
||||
'/admin/settings/branding',
|
||||
'/admin/settings/cache',
|
||||
'/admin/settings/providers',
|
||||
'/api-docs',
|
||||
],
|
||||
positions: [
|
||||
'admin-navbar',
|
||||
'admin-sidebar',
|
||||
'custom-page-navbar',
|
||||
'auth-page-footer',
|
||||
'auth-dropdown',
|
||||
'account-settings-page',
|
||||
'billing-page',
|
||||
'checkout-page-navbar',
|
||||
'checkout-page-footer',
|
||||
'pricing-table-page',
|
||||
'contact-us-page',
|
||||
'notifications-page',
|
||||
'footer',
|
||||
'footer-secondary',
|
||||
],
|
||||
} as MenuSectionConfig,
|
||||
},
|
||||
'custom-code': {
|
||||
label: message('Custom Code'),
|
||||
position: 4,
|
||||
buildBreadcrumb: () => [
|
||||
{
|
||||
label: message('Custom code'),
|
||||
location: `custom-code`,
|
||||
},
|
||||
],
|
||||
},
|
||||
'seo-settings': {
|
||||
label: message('SEO Settings'),
|
||||
position: 5,
|
||||
buildBreadcrumb: () => [
|
||||
{
|
||||
label: message('SEO'),
|
||||
location: `seo`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
11
common/resources/client/admin/appearance/config/merged-appearance-config.ts
Executable file
11
common/resources/client/admin/appearance/config/merged-appearance-config.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
import deepMerge from 'deepmerge';
|
||||
import {DefaultAppearanceConfig} from '@common/admin/appearance/config/default-appearance-config';
|
||||
import {AppAppearanceConfig} from '@app/admin/appearance/app-appearance-config';
|
||||
import {IAppearanceConfig} from '@common/admin/appearance/types/appearance-editor-config';
|
||||
|
||||
const mergedAppearanceConfig = deepMerge.all([
|
||||
DefaultAppearanceConfig,
|
||||
AppAppearanceConfig,
|
||||
]);
|
||||
|
||||
export default mergedAppearanceConfig as IAppearanceConfig;
|
||||
23
common/resources/client/admin/appearance/requests/appearance-values.ts
Executable file
23
common/resources/client/admin/appearance/requests/appearance-values.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {AppearanceDefaults, AppearanceValues} from '../appearance-store';
|
||||
|
||||
export interface FetchAppearanceValuesResponse extends BackendResponse {
|
||||
values: AppearanceValues;
|
||||
defaults: AppearanceDefaults;
|
||||
}
|
||||
|
||||
export function useAppearanceValues() {
|
||||
return useQuery({
|
||||
queryKey: ['admin/appearance/values'],
|
||||
queryFn: () => fetchAppearanceValues(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAppearanceValues(): Promise<FetchAppearanceValuesResponse> {
|
||||
return apiClient
|
||||
.get('admin/appearance/values')
|
||||
.then(response => response.data);
|
||||
}
|
||||
29
common/resources/client/admin/appearance/requests/save-appearance-changes.ts
Executable file
29
common/resources/client/admin/appearance/requests/save-appearance-changes.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {AppearanceValues} from '@common/admin/appearance/appearance-store';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
export function useSaveAppearanceChanges() {
|
||||
return useMutation({
|
||||
mutationFn: (values: Partial<AppearanceValues>) =>
|
||||
saveAppearanceChanges(values),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin/appearance/values'],
|
||||
});
|
||||
toast(message('Changes saved'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function saveAppearanceChanges(
|
||||
changes: Partial<AppearanceValues>,
|
||||
): Promise<Response> {
|
||||
return apiClient.post(`admin/appearance`, {changes}).then(r => r.data);
|
||||
}
|
||||
92
common/resources/client/admin/appearance/section-header.tsx
Executable file
92
common/resources/client/admin/appearance/section-header.tsx
Executable file
@@ -0,0 +1,92 @@
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import {Fragment, useEffect, useState} from 'react';
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {KeyboardArrowLeftIcon} from '../../icons/material/KeyboardArrowLeft';
|
||||
import {KeyboardArrowRightIcon} from '../../icons/material/KeyboardArrowRight';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {MixedText} from '../../i18n/mixed-text';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {appearanceState, AppearanceValues} from './appearance-store';
|
||||
import {AppearanceEditorBreadcrumbItem} from './types/appearance-editor-section';
|
||||
import {message} from '../../i18n/message';
|
||||
|
||||
export function SectionHeader() {
|
||||
const {pathname} = useLocation();
|
||||
const {getValues} = useFormContext<AppearanceValues>();
|
||||
const [breadcrumb, setBreadcrumb] = useState<
|
||||
AppearanceEditorBreadcrumbItem[] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const [, , sectionName] = pathname.split('/').filter(p => !!p);
|
||||
if (sectionName) {
|
||||
const section = appearanceState().config?.sections[sectionName];
|
||||
if (section) {
|
||||
setBreadcrumb([
|
||||
{
|
||||
label: message('Appearance'),
|
||||
location: '',
|
||||
},
|
||||
...section.buildBreadcrumb(pathname, getValues()),
|
||||
]);
|
||||
// bail, so breadcrumb is not cleared below
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBreadcrumb(null);
|
||||
}, [pathname, getValues]);
|
||||
|
||||
// not need to show section header if already at root
|
||||
if (!breadcrumb || breadcrumb.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b h-60 flex-shrink-0">
|
||||
<IconButton
|
||||
iconSize="md"
|
||||
radius="rounded-none"
|
||||
className="text-muted h-full w-50 flex-shrink-0"
|
||||
elementType={Link}
|
||||
to={`/admin/appearance/${breadcrumb[breadcrumb.length - 2].location}`}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<div className="border-l p-10 min-w-0">
|
||||
<div className="text-xs text-muted">
|
||||
<Trans message="Customizing" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm mt-2">
|
||||
{breadcrumb.map((item, index) => {
|
||||
const isLast = breadcrumb.length - 1 === index;
|
||||
const isFirst = index === 0;
|
||||
const label = <MixedText value={item.label} />;
|
||||
|
||||
if (isFirst) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-nowrap overflow-hidden overflow-ellipsis min-w-0',
|
||||
isLast && 'text-primary',
|
||||
// don't overflow ellipses last item
|
||||
isLast ? 'flex-shrink-0' : 'flex-auto'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<KeyboardArrowRightIcon className="icon-sm text-muted flex-shrink-0" />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
common/resources/client/admin/appearance/section-list.tsx
Executable file
36
common/resources/client/admin/appearance/section-list.tsx
Executable file
@@ -0,0 +1,36 @@
|
||||
import {NavLink} from 'react-router-dom';
|
||||
import {AppearanceButton} from './appearance-button';
|
||||
import {useAppearanceStore} from './appearance-store';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {Fragment, useMemo} from 'react';
|
||||
|
||||
export function SectionList() {
|
||||
const sections = useAppearanceStore(s => s.config?.sections);
|
||||
const sortedSection = useMemo(() => {
|
||||
if (!sections) return [];
|
||||
return Object.entries(sections || [])
|
||||
.map(([key, value]) => {
|
||||
return {
|
||||
...value,
|
||||
key,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a?.position || 1) - (b?.position || 1));
|
||||
}, [sections]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{sortedSection.map(section => {
|
||||
return (
|
||||
<AppearanceButton
|
||||
key={section.key}
|
||||
to={section.key}
|
||||
elementType={NavLink}
|
||||
>
|
||||
<Trans {...section.label} />
|
||||
</AppearanceButton>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
94
common/resources/client/admin/appearance/sections/general-section.tsx
Executable file
94
common/resources/client/admin/appearance/sections/general-section.tsx
Executable 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" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
129
common/resources/client/admin/appearance/sections/menus/add-menu-item-dialog.tsx
Executable file
129
common/resources/client/admin/appearance/sections/menus/add-menu-item-dialog.tsx
Executable 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 |
@@ -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',
|
||||
};
|
||||
});
|
||||
}
|
||||
281
common/resources/client/admin/appearance/sections/menus/menu-editor.tsx
Executable file
281
common/resources/client/admin/appearance/sections/menus/menu-editor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
100
common/resources/client/admin/appearance/sections/menus/menu-item-editor.tsx
Executable file
100
common/resources/client/admin/appearance/sections/menus/menu-item-editor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
60
common/resources/client/admin/appearance/sections/menus/menu-list.tsx
Executable file
60
common/resources/client/admin/appearance/sections/menus/menu-list.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
101
common/resources/client/admin/appearance/sections/seo/seo-section.tsx
Executable file
101
common/resources/client/admin/appearance/sections/seo/seo-section.tsx
Executable 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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
common/resources/client/admin/appearance/sections/seo/use-seo-tags.ts
Executable file
23
common/resources/client/admin/appearance/sections/seo/use-seo-tags.ts
Executable 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);
|
||||
}
|
||||
28
common/resources/client/admin/appearance/sections/seo/use-update-seo-tags.ts
Executable file
28
common/resources/client/admin/appearance/sections/seo/use-update-seo-tags.ts
Executable 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);
|
||||
}
|
||||
8
common/resources/client/admin/appearance/sections/themes/color-icon.tsx
Executable file
8
common/resources/client/admin/appearance/sections/themes/color-icon.tsx
Executable 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"
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
184
common/resources/client/admin/appearance/sections/themes/theme-editor.tsx
Executable file
184
common/resources/client/admin/appearance/sections/themes/theme-editor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
common/resources/client/admin/appearance/sections/themes/theme-list.tsx
Executable file
64
common/resources/client/admin/appearance/sections/themes/theme-list.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
116
common/resources/client/admin/appearance/sections/themes/theme-radius-panel.tsx
Executable file
116
common/resources/client/admin/appearance/sections/themes/theme-radius-panel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
23
common/resources/client/admin/appearance/types/appearance-editor-config.ts
Executable file
23
common/resources/client/admin/appearance/types/appearance-editor-config.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import {To} from 'react-router-dom';
|
||||
import {AppearanceEditorSection} from './appearance-editor-section';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
|
||||
export interface IAppearanceConfig {
|
||||
preview: {
|
||||
navigationRoutes: string[];
|
||||
defaultRoute?: To;
|
||||
};
|
||||
sections: Record<string, AppearanceEditorSection>;
|
||||
}
|
||||
|
||||
export interface MenuSectionConfig {
|
||||
positions: string[];
|
||||
availableRoutes: string[];
|
||||
}
|
||||
|
||||
export interface SeoSettingsSectionConfig {
|
||||
pages: {
|
||||
key: string;
|
||||
label: MessageDescriptor;
|
||||
}[];
|
||||
}
|
||||
20
common/resources/client/admin/appearance/types/appearance-editor-section.ts
Executable file
20
common/resources/client/admin/appearance/types/appearance-editor-section.ts
Executable file
@@ -0,0 +1,20 @@
|
||||
import {RouteObject, To} from 'react-router-dom';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {AppearanceValues} from '../appearance-store';
|
||||
|
||||
export interface AppearanceEditorBreadcrumbItem {
|
||||
label: MessageDescriptor | string;
|
||||
location: To;
|
||||
}
|
||||
|
||||
export interface AppearanceEditorSection {
|
||||
label: MessageDescriptor;
|
||||
position?: number;
|
||||
previewRoute?: To;
|
||||
config?: unknown;
|
||||
routes?: RouteObject[];
|
||||
buildBreadcrumb: (
|
||||
pathname: string,
|
||||
formValue: AppearanceValues
|
||||
) => AppearanceEditorBreadcrumbItem[];
|
||||
}
|
||||
Reference in New Issue
Block a user