24
common/resources/client/.gitignore
vendored
Executable file
24
common/resources/client/.gitignore
vendored
Executable file
@@ -0,0 +1,24 @@
|
||||
# compiled output
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
101
common/resources/client/ace-editor/ace-dialog.tsx
Executable file
101
common/resources/client/ace-editor/ace-dialog.tsx
Executable file
@@ -0,0 +1,101 @@
|
||||
import React, {MutableRefObject, ReactNode, Suspense, useState} from 'react';
|
||||
import {Dialog} from '../ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '../ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '../i18n/trans';
|
||||
import {DialogBody} from '../ui/overlays/dialog/dialog-body';
|
||||
import {ProgressCircle} from '../ui/progress/progress-circle';
|
||||
import {useDialogContext} from '../ui/overlays/dialog/dialog-context';
|
||||
import {DialogFooter} from '../ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '../ui/buttons/button';
|
||||
import type ReactAce from 'react-ace';
|
||||
|
||||
const AceEditor = React.lazy(() => import('./ace-editor'));
|
||||
|
||||
interface TextEditorSourcecodeDialogProps {
|
||||
defaultValue: string;
|
||||
mode?: 'css' | 'html' | 'php_laravel_blade';
|
||||
title: ReactNode;
|
||||
onSave?: (value?: string) => void;
|
||||
isSaving?: boolean;
|
||||
footerStartAction?: ReactNode;
|
||||
beautify?: boolean;
|
||||
editorRef?: MutableRefObject<ReactAce | null>;
|
||||
}
|
||||
export function AceDialog({
|
||||
defaultValue,
|
||||
mode = 'html',
|
||||
title,
|
||||
onSave,
|
||||
isSaving,
|
||||
footerStartAction,
|
||||
beautify,
|
||||
editorRef,
|
||||
}: TextEditorSourcecodeDialogProps) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [isValid, setIsValid] = useState<boolean>(true);
|
||||
|
||||
return (
|
||||
<Dialog size="fullscreen" className="h-full w-full">
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<DialogBody className="relative flex-auto" padding="p-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-400 w-full items-center justify-center">
|
||||
<ProgressCircle
|
||||
aria-label="Loading editor..."
|
||||
isIndeterminate
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AceEditor
|
||||
beautify={beautify}
|
||||
mode={mode}
|
||||
onChange={newValue => setValue(newValue)}
|
||||
defaultValue={value || ''}
|
||||
onIsValidChange={setIsValid}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogBody>
|
||||
<Footer
|
||||
disabled={!isValid || isSaving}
|
||||
value={value}
|
||||
onSave={onSave}
|
||||
startAction={footerStartAction}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
disabled: boolean | undefined;
|
||||
value?: string;
|
||||
onSave?: (value?: string) => void;
|
||||
startAction?: ReactNode;
|
||||
}
|
||||
function Footer({disabled, value, onSave, startAction}: FooterProps) {
|
||||
const {close} = useDialogContext();
|
||||
return (
|
||||
<DialogFooter dividerTop startAction={startAction}>
|
||||
<Button onClick={() => close()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
variant="flat"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (onSave) {
|
||||
onSave(value);
|
||||
} else {
|
||||
close(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
73
common/resources/client/ace-editor/ace-editor.tsx
Executable file
73
common/resources/client/ace-editor/ace-editor.tsx
Executable file
@@ -0,0 +1,73 @@
|
||||
import ace from 'ace-builds/src-noconflict/ace';
|
||||
import cssWorkerUrl from 'ace-builds/src-noconflict/worker-css?url';
|
||||
import htmlWorkerUrl from 'ace-builds/src-noconflict/worker-html?url';
|
||||
import phpWorkerUrl from 'ace-builds/src-noconflict/worker-php?url';
|
||||
import javascriptWorkerUrl from 'ace-builds/src-noconflict/worker-javascript?url';
|
||||
import React, {MutableRefObject, useEffect, useRef} from 'react';
|
||||
import AceEditorRender from 'react-ace';
|
||||
import ReactAce from 'react-ace';
|
||||
import 'ace-builds/src-noconflict/mode-css';
|
||||
import 'ace-builds/src-noconflict/mode-html';
|
||||
import 'ace-builds/src-noconflict/mode-javascript';
|
||||
import 'ace-builds/src-noconflict/mode-php_laravel_blade';
|
||||
import 'ace-builds/src-noconflict/theme-chrome';
|
||||
import 'ace-builds/src-noconflict/theme-tomorrow_night';
|
||||
import 'ace-builds/src-noconflict/ext-language_tools';
|
||||
import Beautify from 'ace-builds/src-noconflict/ext-beautify';
|
||||
import {useIsDarkMode} from '../ui/themes/use-is-dark-mode';
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);
|
||||
ace.config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);
|
||||
ace.config.setModuleUrl('ace/mode/php_worker', phpWorkerUrl);
|
||||
ace.config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);
|
||||
|
||||
interface Props {
|
||||
mode: 'css' | 'html' | 'javascript' | 'php_laravel_blade';
|
||||
onChange: (value: string) => void;
|
||||
onIsValidChange: (isValid: boolean) => void;
|
||||
defaultValue: string;
|
||||
beautify?: boolean;
|
||||
editorRef?: MutableRefObject<ReactAce | null>;
|
||||
}
|
||||
export default function AceEditor({
|
||||
mode,
|
||||
onChange,
|
||||
onIsValidChange,
|
||||
defaultValue,
|
||||
beautify = true,
|
||||
editorRef: propsEditorRef,
|
||||
}: Props) {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const defaultRef = useRef<ReactAce | null>(null);
|
||||
const editorRef = propsEditorRef || defaultRef;
|
||||
|
||||
useEffect(() => {
|
||||
if (beautify && editorRef.current) {
|
||||
Beautify.beautify(editorRef.current.editor.session);
|
||||
}
|
||||
}, [beautify, editorRef]);
|
||||
|
||||
return (
|
||||
<AceEditorRender
|
||||
ref={editorRef}
|
||||
width="auto"
|
||||
height="auto"
|
||||
wrapEnabled
|
||||
className="absolute inset-0"
|
||||
focus
|
||||
mode={mode}
|
||||
theme={isDarkMode ? 'tomorrow_night' : 'chrome'}
|
||||
enableBasicAutocompletion
|
||||
enableLiveAutocompletion
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
editorProps={{$blockScrolling: true}}
|
||||
commands={Beautify.commands}
|
||||
onValidate={annotations => {
|
||||
const isValid =
|
||||
annotations.filter(a => a.type === 'error').length === 0;
|
||||
onIsValidChange(isValid);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
common/resources/client/admin/admin-layout.tsx
Executable file
77
common/resources/client/admin/admin-layout.tsx
Executable file
@@ -0,0 +1,77 @@
|
||||
import {Outlet} from 'react-router-dom';
|
||||
import {AdminSidebar} from './admin-sidebar';
|
||||
import {DashboardLayout} from '../ui/layout/dashboard-layout';
|
||||
import {DashboardContent} from '../ui/layout/dashboard-content';
|
||||
import {DashboardSidenav} from '../ui/layout/dashboard-sidenav';
|
||||
import {DashboardNavbar} from '../ui/layout/dashboard-navbar';
|
||||
import {
|
||||
AdminSetupAlert,
|
||||
useAdminSetupAlerts,
|
||||
} from '@common/admin/use-admin-setup-alerts';
|
||||
import {SectionHelper} from '@common/ui/section-helper';
|
||||
import {ErrorIcon} from '@common/icons/material/Error';
|
||||
import {
|
||||
setInLocalStorage,
|
||||
useLocalStorage,
|
||||
} from '@common/utils/hooks/local-storage';
|
||||
|
||||
export function AdminLayout() {
|
||||
return (
|
||||
<DashboardLayout name="admin" leftSidenavCanBeCompact>
|
||||
<DashboardNavbar size="sm" menuPosition="admin-navbar" />
|
||||
<DashboardSidenav position="left" size="sm">
|
||||
<AdminSidebar />
|
||||
</DashboardSidenav>
|
||||
<DashboardContent>
|
||||
<div className="bg dark:bg-alt">
|
||||
<SetupAlertsList />
|
||||
<Outlet />
|
||||
</div>
|
||||
</DashboardContent>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupAlertsList() {
|
||||
const {data} = useAdminSetupAlerts();
|
||||
const [dismissValue] = useLocalStorage<{
|
||||
timestamp: number;
|
||||
} | null>('admin-setup-alert-dismissed', null);
|
||||
|
||||
// show alert if 1 day passed since last dismiss
|
||||
const shouldShowAlert =
|
||||
!dismissValue || Date.now() - dismissValue.timestamp > 86400000;
|
||||
|
||||
if (!data?.alerts.length || !shouldShowAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-24 z-10 mx-auto w-max overflow-hidden rounded-panel bg shadow-md">
|
||||
<SetupAlert alert={data.alerts[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SetupAlertProps {
|
||||
alert: AdminSetupAlert;
|
||||
}
|
||||
function SetupAlert({alert}: SetupAlertProps) {
|
||||
const description = (
|
||||
<div dangerouslySetInnerHTML={{__html: alert.description}}></div>
|
||||
);
|
||||
return (
|
||||
<SectionHelper
|
||||
leadingIcon={<ErrorIcon size="xs" className="text-danger" />}
|
||||
onClose={() => {
|
||||
setInLocalStorage('admin-setup-alert-dismissed', {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}}
|
||||
key={alert.title}
|
||||
title={alert.title}
|
||||
description={description}
|
||||
color="neutral"
|
||||
/>
|
||||
);
|
||||
}
|
||||
298
common/resources/client/admin/admin-routes.tsx
Executable file
298
common/resources/client/admin/admin-routes.tsx
Executable file
@@ -0,0 +1,298 @@
|
||||
import {Navigate, RouteObject, useRoutes} from 'react-router-dom';
|
||||
import {AdminLayout} from './admin-layout';
|
||||
import {UserDatatable} from './users/user-datatable';
|
||||
import {AppearanceLayout} from './appearance/appearance-layout';
|
||||
import {MenuList} from './appearance/sections/menus/menu-list';
|
||||
import {MenuEditor} from './appearance/sections/menus/menu-editor';
|
||||
import {MenuItemEditor} from './appearance/sections/menus/menu-item-editor';
|
||||
import {GeneralSection} from './appearance/sections/general-section';
|
||||
import {ThemeList} from './appearance/sections/themes/theme-list';
|
||||
import {SeoSection} from './appearance/sections/seo/seo-section';
|
||||
import {CustomCodeSection} from './appearance/sections/code/custom-code-section';
|
||||
import {CustomPageDatablePage} from './custom-pages/custom-page-datable-page';
|
||||
import {SettingsLayout} from './settings/settings-layout';
|
||||
import {GeneralSettings} from './settings/pages/general-settings';
|
||||
import {ThemeEditor} from './appearance/sections/themes/theme-editor';
|
||||
import {AppSettingsRoutes} from '@app/admin/settings/app-settings-routes';
|
||||
import {SubscriptionSettings} from './settings/pages/subscription-settings';
|
||||
import {LocalizationSettings} from './settings/pages/localization-settings';
|
||||
import {AuthenticationSettings} from './settings/pages/authentication-settings';
|
||||
import {UploadingSettings} from './settings/pages/uploading-settings/uploading-settings';
|
||||
import {OutgoingEmailSettings} from './settings/pages/mail-settings/outgoing-email-settings';
|
||||
import {CacheSettings} from './settings/pages/cache-settings/cache-settings';
|
||||
import {LoggingSettings} from './settings/pages/logging-settings';
|
||||
import {QueueSettings} from './settings/pages/queue-settings';
|
||||
import {RecaptchaSettings} from './settings/pages/recaptcha-settings';
|
||||
import {ReportsSettings} from './settings/pages/reports-settings';
|
||||
import {UpdateUserPage} from './users/update-user-page';
|
||||
import {CreateUserPage} from './users/create-user-page';
|
||||
import {LocalizationIndex} from './translations/localization-index';
|
||||
import {TranslationManagementPage} from './translations/translation-management-page';
|
||||
import {AdsPage} from './ads/ads-page';
|
||||
import React from 'react';
|
||||
import {FullPageLoader} from '../ui/progress/full-page-loader';
|
||||
import {SectionList} from './appearance/section-list';
|
||||
import {RolesIndexPage} from './roles/roles-index-page';
|
||||
import {EditRolePage} from './roles/crupdate-role-page/edit-role-page';
|
||||
import {CreateRolePage} from './roles/crupdate-role-page/create-role-page';
|
||||
import {TagIndexPage} from './tags/tag-index-page';
|
||||
import {FileEntryIndexPage} from './file-entry/file-entry-index-page';
|
||||
import {SubscriptionsIndexPage} from './subscriptions/subscriptions-index-page';
|
||||
import {PlansIndexPage} from './plans/plans-index-page';
|
||||
import {EditPlanPage} from './plans/crupdate-plan-page/edit-plan-page';
|
||||
import {CreatePlanPage} from './plans/crupdate-plan-page/create-plan-page';
|
||||
import {GdprSettings} from './settings/pages/gdpr-settings';
|
||||
import {AuthRoute} from '../auth/guards/auth-route';
|
||||
import {NotFoundPage} from '../ui/not-found-page/not-found-page';
|
||||
import {AppAppearanceConfig} from '@app/admin/appearance/app-appearance-config';
|
||||
import {AppAdminRoutes} from '@app/admin/app-admin-routes';
|
||||
import {EditCustomPage} from '@common/admin/custom-pages/edit-custom-page';
|
||||
import {CreateCustomPage} from '@common/admin/custom-pages/create-custom-page';
|
||||
import {ThemeFontPanel} from '@common/admin/appearance/sections/themes/theme-font-panel';
|
||||
import {ThemeRadiusPanel} from '@common/admin/appearance/sections/themes/theme-radius-panel';
|
||||
import {LogsPage} from '@common/admin/logging/logs-page';
|
||||
import {ScheduleLogDatatable} from '@common/admin/logging/schedule/schedule-log-datatable';
|
||||
import {ErrorLogDatatable} from '@common/admin/logging/error/error-log-datatable';
|
||||
import {OutgoingEmailLogDatatable} from '@common/admin/logging/outgoing-email/outgoing-email-log-datatable';
|
||||
|
||||
const ReportsPage = React.lazy(() => import('./analytics/admin-report-page'));
|
||||
|
||||
const AdminRouteConfig: RouteObject[] = [
|
||||
{
|
||||
path: 'appearance',
|
||||
element: (
|
||||
<AuthRoute permission="appearance.update">
|
||||
<AppearanceLayout />
|
||||
</AuthRoute>
|
||||
),
|
||||
children: [
|
||||
{index: true, element: <SectionList />},
|
||||
{path: 'general', element: <GeneralSection />},
|
||||
{path: 'seo-settings', element: <SeoSection />},
|
||||
{path: 'custom-code', element: <CustomCodeSection />},
|
||||
{path: 'themes', element: <ThemeList />},
|
||||
{path: 'themes/:themeIndex', element: <ThemeEditor />},
|
||||
{path: 'themes/:themeIndex/font', element: <ThemeFontPanel />},
|
||||
{path: 'themes/:themeIndex/radius', element: <ThemeRadiusPanel />},
|
||||
{path: 'menus', element: <MenuList />},
|
||||
{path: 'menus/:menuIndex', element: <MenuEditor />},
|
||||
{
|
||||
path: 'menus/:menuIndex/items/:menuItemIndex',
|
||||
element: <MenuItemEditor />,
|
||||
},
|
||||
...Object.values(AppAppearanceConfig.sections).flatMap(
|
||||
s => s.routes || [],
|
||||
),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: '/',
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
...AppAdminRoutes,
|
||||
// REPORT PAGE
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<React.Suspense fallback={<FullPageLoader screen />}>
|
||||
<ReportsPage />
|
||||
</React.Suspense>
|
||||
),
|
||||
},
|
||||
// USERS
|
||||
{
|
||||
path: 'users',
|
||||
element: (
|
||||
<AuthRoute permission="users.update">
|
||||
<UserDatatable />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'users/new',
|
||||
element: (
|
||||
<AuthRoute permission="users.update">
|
||||
<CreateUserPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'users/:userId/edit',
|
||||
element: (
|
||||
<AuthRoute permission="users.update">
|
||||
<UpdateUserPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// ROLES
|
||||
{
|
||||
path: 'roles',
|
||||
element: (
|
||||
<AuthRoute permission="roles.update">
|
||||
<RolesIndexPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'roles/new',
|
||||
element: (
|
||||
<AuthRoute permission="roles.update">
|
||||
<CreateRolePage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'roles/:roleId/edit',
|
||||
element: (
|
||||
<AuthRoute permission="roles.update">
|
||||
<EditRolePage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// SUBSCRIPTIONS and PLANS
|
||||
{
|
||||
path: 'subscriptions',
|
||||
element: (
|
||||
<AuthRoute permission="subscriptions.update">
|
||||
<SubscriptionsIndexPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
element: (
|
||||
<AuthRoute permission="plans.update">
|
||||
<PlansIndexPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'plans/new',
|
||||
element: (
|
||||
<AuthRoute permission="plans.update">
|
||||
<CreatePlanPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'plans/:productId/edit',
|
||||
element: (
|
||||
<AuthRoute permission="plans.update">
|
||||
<EditPlanPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// CUSTOM PAGES
|
||||
{
|
||||
path: 'custom-pages',
|
||||
element: (
|
||||
<AuthRoute permission="custom_pages.update">
|
||||
<CustomPageDatablePage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'custom-pages/new',
|
||||
element: (
|
||||
<AuthRoute permission="custom_pages.update">
|
||||
<CreateCustomPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'custom-pages/:pageId/edit',
|
||||
element: (
|
||||
<AuthRoute permission="custom_pages.update">
|
||||
<EditCustomPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// TAGS
|
||||
{
|
||||
path: 'tags',
|
||||
element: (
|
||||
<AuthRoute permission="tags.update">
|
||||
<TagIndexPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// LOCALIZATIONS
|
||||
{
|
||||
path: 'localizations',
|
||||
element: (
|
||||
<AuthRoute permission="localizations.update">
|
||||
<LocalizationIndex />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'localizations/:localeId/translate',
|
||||
element: <TranslationManagementPage />,
|
||||
},
|
||||
// FILE ENTRIES
|
||||
{
|
||||
path: 'files',
|
||||
element: (
|
||||
<AuthRoute permission="files.update">
|
||||
<FileEntryIndexPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// ADS
|
||||
{
|
||||
path: 'ads',
|
||||
element: (
|
||||
<AuthRoute permission="settings.update">
|
||||
<AdsPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
},
|
||||
// SETTINGS
|
||||
{
|
||||
path: 'settings',
|
||||
element: (
|
||||
<AuthRoute permission="settings.update">
|
||||
<SettingsLayout />
|
||||
</AuthRoute>
|
||||
),
|
||||
children: [
|
||||
{index: true, element: <Navigate to="general" replace />},
|
||||
{path: 'general', element: <GeneralSettings />},
|
||||
{path: 'subscriptions', element: <SubscriptionSettings />},
|
||||
{path: 'localization', element: <LocalizationSettings />},
|
||||
{path: 'authentication', element: <AuthenticationSettings />},
|
||||
{path: 'uploading', element: <UploadingSettings />},
|
||||
{path: 'outgoing-email', element: <OutgoingEmailSettings />},
|
||||
{path: 'cache', element: <CacheSettings />},
|
||||
{path: 'analytics', element: <ReportsSettings />},
|
||||
{path: 'logging', element: <LoggingSettings />},
|
||||
{path: 'queue', element: <QueueSettings />},
|
||||
{path: 'recaptcha', element: <RecaptchaSettings />},
|
||||
{path: 'gdpr', element: <GdprSettings />},
|
||||
...AppSettingsRoutes,
|
||||
],
|
||||
},
|
||||
// LOGS
|
||||
{
|
||||
path: 'logs',
|
||||
element: (
|
||||
<AuthRoute permission="logs.view">
|
||||
<LogsPage />
|
||||
</AuthRoute>
|
||||
),
|
||||
children: [
|
||||
{index: true, element: <ScheduleLogDatatable />},
|
||||
{path: 'schedule', element: <ScheduleLogDatatable />},
|
||||
{path: 'error', element: <ErrorLogDatatable />},
|
||||
{path: 'outgoing-email', element: <OutgoingEmailLogDatatable />},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{path: '*', element: <NotFoundPage />},
|
||||
];
|
||||
|
||||
export default function AdminRoutes() {
|
||||
return useRoutes(AdminRouteConfig);
|
||||
}
|
||||
42
common/resources/client/admin/admin-sidebar.tsx
Executable file
42
common/resources/client/admin/admin-sidebar.tsx
Executable file
@@ -0,0 +1,42 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import {CustomMenu} from '../menus/custom-menu';
|
||||
import {Trans} from '../i18n/trans';
|
||||
import {useSettings} from '../core/settings/use-settings';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
isCompactMode?: boolean;
|
||||
}
|
||||
export function AdminSidebar({className, isCompactMode}: Props) {
|
||||
const {version} = useSettings();
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'relative flex flex-col gap-20 overflow-y-auto border-r bg-alt px-12 pb-16 pt-26 text-sm font-medium text-muted',
|
||||
)}
|
||||
>
|
||||
<CustomMenu
|
||||
matchDescendants={to => to === '/admin'}
|
||||
menu="admin-sidebar"
|
||||
orientation="vertical"
|
||||
onlyShowIcons={isCompactMode}
|
||||
itemClassName={({isActive}) =>
|
||||
clsx(
|
||||
'block w-full rounded-button py-12 px-16',
|
||||
isActive
|
||||
? 'bg-primary/6 text-primary font-semibold'
|
||||
: 'hover:bg-hover',
|
||||
)
|
||||
}
|
||||
gap="gap-8"
|
||||
/>
|
||||
{!isCompactMode && (
|
||||
<div className="mt-auto gap-14 px-16 text-xs">
|
||||
<Trans message="Version: :number" values={{number: version}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
common/resources/client/admin/ads/ad-host.css
vendored
Executable file
21
common/resources/client/admin/ads/ad-host.css
vendored
Executable file
@@ -0,0 +1,21 @@
|
||||
.ad-host:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ad-host > *:not(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
min-height: 90px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.ad-host img {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.ad-host > *:not(img) {
|
||||
max-width: 370px;
|
||||
}
|
||||
}
|
||||
123
common/resources/client/admin/ads/ad-host.tsx
Executable file
123
common/resources/client/admin/ads/ad-host.tsx
Executable file
@@ -0,0 +1,123 @@
|
||||
import {useAuth} from '../../auth/use-auth';
|
||||
import {memo, useEffect, useId, useMemo, useRef} from 'react';
|
||||
import lazyLoader from '../../utils/http/lazy-loader';
|
||||
import clsx from 'clsx';
|
||||
import {useSettings} from '../../core/settings/use-settings';
|
||||
import dot from 'dot-object';
|
||||
import {Settings} from '@common/core/settings/settings';
|
||||
import {getScrollParent} from '@react-aria/utils';
|
||||
|
||||
interface AdHostProps {
|
||||
slot: keyof Omit<NonNullable<Settings['ads']>, 'disable'>;
|
||||
className?: string;
|
||||
}
|
||||
export function AdHost({slot, className}: AdHostProps) {
|
||||
const settings = useSettings();
|
||||
const {isSubscribed} = useAuth();
|
||||
const adCode = useMemo(() => {
|
||||
return dot.pick(`ads.${slot}`, settings);
|
||||
}, [slot, settings]);
|
||||
|
||||
if (settings.ads?.disable || isSubscribed || !adCode) return null;
|
||||
|
||||
return <InvariantAd className={className} slot={slot} adCode={adCode} />;
|
||||
}
|
||||
|
||||
interface InvariantAdProps {
|
||||
slot: string;
|
||||
adCode: string;
|
||||
className?: string;
|
||||
}
|
||||
const InvariantAd = memo(
|
||||
({slot, adCode, className}: InvariantAdProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
loadAdScripts(adCode, ref.current).then(() => {
|
||||
executeAdJavascript(adCode, id);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
// @ts-ignore
|
||||
delete window['google_ad_modifications'];
|
||||
};
|
||||
}, [adCode, id]);
|
||||
|
||||
// remove height modifications added by adsense
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const scrollParent = getScrollParent(ref.current) as HTMLElement;
|
||||
if (scrollParent) {
|
||||
const observer = new MutationObserver(function () {
|
||||
scrollParent.style.height = '';
|
||||
scrollParent.style.minHeight = '';
|
||||
});
|
||||
observer.observe(scrollParent, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={clsx(
|
||||
'ad-host flex max-h-[600px] min-h-90 w-full max-w-full items-center justify-center overflow-hidden',
|
||||
`${slot.replace(/\./g, '-')}-host`,
|
||||
className,
|
||||
)}
|
||||
dangerouslySetInnerHTML={{__html: getAdHtml(adCode)}}
|
||||
></div>
|
||||
);
|
||||
},
|
||||
() => {
|
||||
// never re-render
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
function getAdHtml(adCode: string) {
|
||||
// strip out all script tags from ad code and leave only html
|
||||
return adCode
|
||||
?.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Load any external scripts needed by ad.
|
||||
function loadAdScripts(adCode: string, parentEl: HTMLDivElement): Promise<any> {
|
||||
const promises = [];
|
||||
|
||||
// load ad code script
|
||||
const pattern = /<script.*?src=['"](.*?)['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(adCode))) {
|
||||
if (match[1]) {
|
||||
promises.push(lazyLoader.loadAsset(match[1], {type: 'js', parentEl}));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// Execute ad code javascript and replace document.write if needed.
|
||||
function executeAdJavascript(adCode: string, id: string) {
|
||||
// find any ad code javascript that needs to be executed
|
||||
const pattern = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
|
||||
let content;
|
||||
|
||||
while ((content = pattern.exec(adCode))) {
|
||||
if (content[1]) {
|
||||
const r = `var d = document.createElement('div'); d.innerHTML = $1; document.getElementById('${id}').appendChild(d.firstChild);`;
|
||||
const toEval = content[1].replace(/document.write\((.+?)\);/, r);
|
||||
eval(toEval);
|
||||
}
|
||||
}
|
||||
}
|
||||
115
common/resources/client/admin/ads/ads-page.tsx
Executable file
115
common/resources/client/admin/ads/ads-page.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
import {useContext} from 'react';
|
||||
import {
|
||||
AdConfig,
|
||||
SiteConfigContext,
|
||||
} from '../../core/settings/site-config-context';
|
||||
import {Form} from '../../ui/forms/form';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {FormTextField} from '../../ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {Button} from '../../ui/buttons/button';
|
||||
import {FormSwitch} from '../../ui/forms/toggle/switch';
|
||||
import {useAdminSettings} from '../settings/requests/use-admin-settings';
|
||||
import {ProgressCircle} from '../../ui/progress/progress-circle';
|
||||
import {Settings} from '../../core/settings/settings';
|
||||
import {
|
||||
AdminSettingsWithFiles,
|
||||
useUpdateAdminSettings,
|
||||
} from '../settings/requests/update-admin-settings';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {ImageZoomDialog} from '../../ui/overlays/dialog/image-zoom-dialog';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
|
||||
export function AdsPage() {
|
||||
const query = useAdminSettings();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-12 md:p-24">
|
||||
<StaticPageTitle>
|
||||
<Trans message="Ads" />
|
||||
</StaticPageTitle>
|
||||
<h1 className="mb-20 text-2xl font-light md:mb-40 md:text-3xl">
|
||||
<Trans message="Predefined Ad slots" />
|
||||
</h1>
|
||||
{query.isLoading ? (
|
||||
<ProgressCircle isIndeterminate />
|
||||
) : (
|
||||
<AdsForm defaultValues={query.data?.client.ads || {}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdsFormProps {
|
||||
defaultValues: Settings['ads'];
|
||||
}
|
||||
function AdsForm({defaultValues}: AdsFormProps) {
|
||||
const {
|
||||
admin: {ads},
|
||||
} = useContext(SiteConfigContext);
|
||||
|
||||
const form = useForm<AdminSettingsWithFiles>({
|
||||
defaultValues: {client: {ads: defaultValues}},
|
||||
});
|
||||
const updateSettings = useUpdateAdminSettings(form);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={value => {
|
||||
updateSettings.mutate(value);
|
||||
}}
|
||||
>
|
||||
{ads.map(ad => {
|
||||
return <AdSection key={ad.slot} adConfig={ad} />;
|
||||
})}
|
||||
<FormSwitch
|
||||
name="client.ads.disable"
|
||||
className="mb-30"
|
||||
description={
|
||||
<Trans message="Disable all add related functionality across the site." />
|
||||
}
|
||||
>
|
||||
<Trans message="Disable ads" />
|
||||
</FormSwitch>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={updateSettings.isPending}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdSectionProps {
|
||||
adConfig: AdConfig;
|
||||
}
|
||||
function AdSection({adConfig}: AdSectionProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-24">
|
||||
<FormTextField
|
||||
className="mb-30 flex-auto"
|
||||
name={`client.${adConfig.slot}`}
|
||||
inputElementType="textarea"
|
||||
rows={8}
|
||||
label={<Trans {...adConfig.description} />}
|
||||
/>
|
||||
<DialogTrigger type="modal">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-zoom-in overflow-hidden rounded outline-none transition hover:scale-105 focus-visible:ring max-md:hidden"
|
||||
>
|
||||
<img
|
||||
src={adConfig.image}
|
||||
className="h-[186px] w-auto border"
|
||||
alt="Ad slot example"
|
||||
/>
|
||||
</button>
|
||||
<ImageZoomDialog image={adConfig.image} />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
common/resources/client/admin/analytics/admin-header-report.tsx
Executable file
145
common/resources/client/admin/analytics/admin-header-report.tsx
Executable file
@@ -0,0 +1,145 @@
|
||||
import {HeaderDatum} from '@common/admin/analytics/use-admin-report';
|
||||
import React, {
|
||||
cloneElement,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import {TrendingUpIcon} from '@common/icons/material/TrendingUp';
|
||||
import {TrendingDownIcon} from '@common/icons/material/TrendingDown';
|
||||
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
|
||||
import {AdminReportPageColGap} from '@common/admin/analytics/visitors-report-charts';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {FormattedBytes} from '@common/uploads/formatted-bytes';
|
||||
import {TrendingFlatIcon} from '@common/icons/material/TrendingFlat';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
|
||||
interface AdminHeaderReportProps {
|
||||
report?: HeaderDatum[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
export function AdminHeaderReport({report, isLoading}: AdminHeaderReportProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[97px] flex-shrink-0 items-center overflow-x-auto ${AdminReportPageColGap}`}
|
||||
>
|
||||
{report?.map(datum => (
|
||||
<ReportItem key={datum.name} datum={datum} isLoading={isLoading} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueMetricItemProps {
|
||||
datum: HeaderDatum;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
function ReportItem({datum, isLoading = false}: ValueMetricItemProps) {
|
||||
let icon;
|
||||
if (isValidElement(datum.icon)) {
|
||||
icon = cloneElement(datum.icon, {size: 'lg'});
|
||||
} else {
|
||||
const IconEl = createSvgIconFromTree(datum.icon);
|
||||
icon = <IconEl size="lg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={datum.name}
|
||||
className="rounded-panel flex h-full flex-auto items-center gap-18 whitespace-nowrap border p-20"
|
||||
>
|
||||
<div className="flex-shrink-0 rounded-lg bg-primary-light/20 p-10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
<div className="flex items-center justify-between gap-20">
|
||||
<div className="text-lg font-bold text-main">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isLoading ? (
|
||||
<m.div key="skeleton" {...opacityAnimation}>
|
||||
<Skeleton className="min-w-24" />
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key="value" {...opacityAnimation}>
|
||||
<FormattedValue datum={datum} />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-20">
|
||||
<h2 className="text-sm text-muted">{datum.name}</h2>
|
||||
{(datum.percentageChange != null || datum.previousValue != null) && (
|
||||
<div className="flex items-center gap-10">
|
||||
<TrendingIndicator datum={datum} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormattedValueProps {
|
||||
datum: HeaderDatum;
|
||||
}
|
||||
function FormattedValue({datum}: FormattedValueProps) {
|
||||
switch (datum.type) {
|
||||
case 'fileSize':
|
||||
return <FormattedBytes bytes={datum.currentValue} />;
|
||||
case 'percentage':
|
||||
return (
|
||||
<FormattedNumber
|
||||
value={datum.currentValue}
|
||||
style="percent"
|
||||
maximumFractionDigits={1}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <FormattedNumber value={datum.currentValue} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface TrendingIndicatorProps {
|
||||
datum: HeaderDatum;
|
||||
}
|
||||
function TrendingIndicator({datum}: TrendingIndicatorProps) {
|
||||
const percentage = calculatePercentage(datum);
|
||||
let icon: ReactElement;
|
||||
if (percentage > 0) {
|
||||
icon = <TrendingUpIcon size="md" className="text-positive" />;
|
||||
} else if (percentage === 0) {
|
||||
icon = <TrendingFlatIcon className="text-muted" />;
|
||||
} else {
|
||||
icon = <TrendingDownIcon className="text-danger" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{icon}
|
||||
<div className="text-sm font-semibold text-muted">{percentage}%</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function calculatePercentage({
|
||||
percentageChange,
|
||||
previousValue,
|
||||
currentValue,
|
||||
}: HeaderDatum) {
|
||||
if (
|
||||
percentageChange != null ||
|
||||
previousValue == null ||
|
||||
currentValue == null
|
||||
) {
|
||||
return percentageChange ?? 0;
|
||||
}
|
||||
|
||||
if (previousValue === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.round(((currentValue - previousValue) / previousValue) * 100);
|
||||
}
|
||||
33
common/resources/client/admin/analytics/admin-report-page.tsx
Executable file
33
common/resources/client/admin/analytics/admin-report-page.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import React, {useState} from 'react';
|
||||
import {useAdminReport} from './use-admin-report';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {AdminHeaderReport} from '@common/admin/analytics/admin-header-report';
|
||||
import {VisitorsReportCharts} from '@common/admin/analytics/visitors-report-charts';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {ReportDateSelector} from '@common/admin/analytics/report-date-selector';
|
||||
|
||||
export default function AdminReportPage() {
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(() => {
|
||||
// This week
|
||||
return DateRangePresets[2].getRangeValue();
|
||||
});
|
||||
const {isLoading, data} = useAdminReport({dateRange});
|
||||
const title = <Trans message="Visitors report" />;
|
||||
|
||||
return (
|
||||
<div className="min-h-full gap-12 overflow-x-hidden p-12 md:gap-18 md:p-18">
|
||||
<div className="mb-24 items-center justify-between gap-24 md:flex">
|
||||
<StaticPageTitle>{title}</StaticPageTitle>
|
||||
<h1 className="mb-24 text-3xl font-light md:mb-0">{title}</h1>
|
||||
<ReportDateSelector value={dateRange} onChange={setDateRange} />
|
||||
</div>
|
||||
<AdminHeaderReport report={data?.headerReport} />
|
||||
<VisitorsReportCharts
|
||||
report={data?.visitorsReport}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
common/resources/client/admin/analytics/geo-chart/geo-chart.tsx
Executable file
110
common/resources/client/admin/analytics/geo-chart/geo-chart.tsx
Executable file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
LocationDatasetItem,
|
||||
ReportMetric,
|
||||
} from '@common/admin/analytics/report-metric';
|
||||
import React, {useMemo, useRef} from 'react';
|
||||
import {useGoogleGeoChart} from './use-google-geo-chart';
|
||||
import {ChartLayout, ChartLayoutProps} from '@common/charts/chart-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ChartLoadingIndicator} from '@common/charts/chart-loading-indicator';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
import clsx from 'clsx';
|
||||
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
|
||||
import {FormattedCountryName} from '@common/i18n/formatted-country-name';
|
||||
|
||||
interface GeoChartData extends Partial<ChartLayoutProps> {
|
||||
data?: ReportMetric<LocationDatasetItem>;
|
||||
onCountrySelected?: (countryCode: string | undefined) => void;
|
||||
country?: string;
|
||||
}
|
||||
export function GeoChart({
|
||||
data: metricData,
|
||||
isLoading,
|
||||
onCountrySelected,
|
||||
country,
|
||||
...layoutProps
|
||||
}: GeoChartData) {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const regionInteractivity = !!onCountrySelected;
|
||||
|
||||
// memo data to avoid redrawing chart on rerender
|
||||
const initialData = metricData?.datasets[0].data;
|
||||
const data = useMemo(() => {
|
||||
return initialData || [];
|
||||
}, [initialData]);
|
||||
useGoogleGeoChart({placeholderRef, data, country, onCountrySelected});
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
{...layoutProps}
|
||||
className="min-w-500"
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<Trans message="Top Locations" />
|
||||
{country ? (
|
||||
<span className="pl-4">
|
||||
({<FormattedCountryName code={country} />})
|
||||
</span>
|
||||
) : null}
|
||||
{regionInteractivity && <InfoTrigger />}
|
||||
</div>
|
||||
}
|
||||
contentIsFlex={isLoading}
|
||||
>
|
||||
{isLoading && <ChartLoadingIndicator />}
|
||||
<div className="flex gap-24">
|
||||
<div
|
||||
ref={placeholderRef}
|
||||
className="flex-auto w-[480px] min-h-[340px]"
|
||||
/>
|
||||
<div className="w-[170px]">
|
||||
<div className="text-sm max-h-[340px] w-full flex-initial overflow-y-auto">
|
||||
{data.map(location => (
|
||||
<div
|
||||
key={location.label}
|
||||
className={clsx(
|
||||
'flex items-center gap-4 mb-4',
|
||||
regionInteractivity && 'cursor-pointer hover:underline'
|
||||
)}
|
||||
role={regionInteractivity ? 'button' : undefined}
|
||||
onClick={() => {
|
||||
onCountrySelected?.(location.code);
|
||||
}}
|
||||
>
|
||||
<div className="max-w-110 whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
{location.label}
|
||||
</div>
|
||||
<div>({location.percentage})%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{country && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="mt-14"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => {
|
||||
onCountrySelected?.(undefined);
|
||||
}}
|
||||
>
|
||||
<Trans message="Back to countries" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChartLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTrigger() {
|
||||
return (
|
||||
<InfoDialogTrigger
|
||||
title={<Trans message="Zooming in" />}
|
||||
body={
|
||||
<Trans message="Click on a country inside the map or country list to zoom in and see city data for that country." />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
116
common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts
Executable file
116
common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts
Executable file
@@ -0,0 +1,116 @@
|
||||
import lazyLoader from '../../../utils/http/lazy-loader';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {RefObject, useCallback, useEffect, useRef} from 'react';
|
||||
import {useThemeSelector} from '@common/ui/themes/theme-selector-context';
|
||||
import {themeValueToHex} from '@common/ui/themes/utils/theme-value-to-hex';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {LocationDatasetItem} from '@common/admin/analytics/report-metric';
|
||||
|
||||
const loaderUrl = 'https://www.gstatic.com/charts/loader.js';
|
||||
|
||||
interface UseGoogleGeoChartProps {
|
||||
placeholderRef: RefObject<HTMLDivElement>;
|
||||
data: LocationDatasetItem[];
|
||||
onCountrySelected?: (countryCode: string) => void;
|
||||
country?: string;
|
||||
}
|
||||
export function useGoogleGeoChart({
|
||||
placeholderRef,
|
||||
data,
|
||||
country,
|
||||
onCountrySelected,
|
||||
}: UseGoogleGeoChartProps) {
|
||||
const {trans} = useTrans();
|
||||
const {analytics} = useSettings();
|
||||
const apiKey = analytics?.gchart_api_key;
|
||||
const {selectedTheme} = useThemeSelector();
|
||||
const geoChartRef = useRef<google.visualization.GeoChart>();
|
||||
// only allow selecting countries, not cities
|
||||
const regionInteractivity = !!onCountrySelected && !country;
|
||||
const drawGoogleChart = useCallback(() => {
|
||||
if (typeof google === 'undefined') return;
|
||||
|
||||
const seedData = data.map(location => [location.label, location.value]);
|
||||
seedData.unshift([
|
||||
country ? trans(message('City')) : trans(message('Country')),
|
||||
trans(message('Clicks')),
|
||||
]);
|
||||
|
||||
const backgroundColor = `${themeValueToHex(
|
||||
selectedTheme.values['--be-paper'],
|
||||
)}`;
|
||||
const chartColor = `${themeValueToHex(
|
||||
selectedTheme.values['--be-primary'],
|
||||
)}`;
|
||||
|
||||
const options: google.visualization.GeoChartOptions = {
|
||||
colorAxis: {colors: [chartColor]},
|
||||
backgroundColor,
|
||||
region: country ? country.toUpperCase() : undefined,
|
||||
resolution: country ? 'provinces' : 'countries',
|
||||
displayMode: country ? 'markers' : 'regions',
|
||||
enableRegionInteractivity: regionInteractivity,
|
||||
};
|
||||
|
||||
if (
|
||||
!geoChartRef.current &&
|
||||
placeholderRef.current &&
|
||||
google?.visualization?.GeoChart
|
||||
) {
|
||||
geoChartRef.current = new google.visualization.GeoChart(
|
||||
placeholderRef.current,
|
||||
);
|
||||
}
|
||||
geoChartRef.current?.draw(
|
||||
google.visualization.arrayToDataTable(seedData),
|
||||
options,
|
||||
);
|
||||
}, [
|
||||
selectedTheme,
|
||||
data,
|
||||
placeholderRef,
|
||||
trans,
|
||||
country,
|
||||
regionInteractivity,
|
||||
]);
|
||||
|
||||
const initGoogleGeoChart = useCallback(async () => {
|
||||
if (lazyLoader.isLoadingOrLoaded(loaderUrl)) return;
|
||||
await lazyLoader.loadAsset(loaderUrl, {type: 'js', id: 'google-charts-js'});
|
||||
await google.charts.load('current', {
|
||||
packages: ['geochart'],
|
||||
mapsApiKey: apiKey,
|
||||
});
|
||||
drawGoogleChart();
|
||||
}, [apiKey, drawGoogleChart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geoChartRef.current && onCountrySelected) {
|
||||
google.visualization.events.addListener(
|
||||
geoChartRef.current,
|
||||
'regionClick',
|
||||
(a: {region: string}) => onCountrySelected?.(a.region),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (geoChartRef.current) {
|
||||
google.visualization.events.removeAllListeners(geoChartRef.current);
|
||||
}
|
||||
};
|
||||
// this will correctly run when geochart instance is set on ref
|
||||
}, [onCountrySelected, geoChartRef.current]);
|
||||
|
||||
// on component load: load chart library then draw, otherwise just draw
|
||||
useEffect(() => {
|
||||
initGoogleGeoChart();
|
||||
}, [initGoogleGeoChart]);
|
||||
|
||||
// redraw chart if data or theme changes
|
||||
useEffect(() => {
|
||||
drawGoogleChart();
|
||||
}, [selectedTheme, drawGoogleChart, data]);
|
||||
|
||||
return {drawGoogleChart};
|
||||
}
|
||||
109
common/resources/client/admin/analytics/report-date-selector.tsx
Executable file
109
common/resources/client/admin/analytics/report-date-selector.tsx
Executable file
@@ -0,0 +1,109 @@
|
||||
import {useDateRangePickerState} from '@common/ui/forms/input-field/date/date-range-picker/use-date-range-picker-state';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {DateRangeIcon} from '@common/icons/material/DateRange';
|
||||
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
|
||||
import {DateRangeDialog} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-dialog';
|
||||
import React from 'react';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {DateFormatPresets} from '@common/i18n/formatted-date';
|
||||
import {DateRangeComparePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-presets';
|
||||
import {Granularity} from '@common/ui/forms/input-field/date/date-picker/use-date-picker-state';
|
||||
|
||||
const monthDayFormat: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
};
|
||||
|
||||
interface ReportDataSelectorProps {
|
||||
value: DateRangeValue;
|
||||
disabled?: boolean;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
compactOnMobile?: boolean;
|
||||
enableCompare?: boolean;
|
||||
granularity?: Granularity;
|
||||
}
|
||||
export function ReportDateSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
compactOnMobile = true,
|
||||
enableCompare = false,
|
||||
granularity = 'minute',
|
||||
}: ReportDataSelectorProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="popover"
|
||||
onClose={value => {
|
||||
if (value) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="chip"
|
||||
endIcon={<DateRangeIcon />}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormattedDateTimeRange
|
||||
start={value.start}
|
||||
end={value.end}
|
||||
options={
|
||||
isMobile && compactOnMobile
|
||||
? monthDayFormat
|
||||
: DateFormatPresets.short
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<DateSelectorDialog
|
||||
value={value}
|
||||
enableCompare={enableCompare}
|
||||
granularity={granularity}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface DateSelectorDialogProps {
|
||||
value: DateRangeValue;
|
||||
enableCompare: boolean;
|
||||
granularity: Granularity;
|
||||
}
|
||||
function DateSelectorDialog({
|
||||
value,
|
||||
enableCompare,
|
||||
granularity,
|
||||
}: DateSelectorDialogProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const state = useDateRangePickerState({
|
||||
granularity,
|
||||
defaultValue: {
|
||||
start: value.start,
|
||||
end: value.end,
|
||||
preset: value.preset,
|
||||
},
|
||||
closeDialogOnSelection: false,
|
||||
});
|
||||
const compareHasInitialValue = !!value.compareStart && !!value.compareEnd;
|
||||
const compareState = useDateRangePickerState({
|
||||
granularity,
|
||||
defaultValue: compareHasInitialValue
|
||||
? {
|
||||
start: value.compareStart,
|
||||
end: value.compareEnd,
|
||||
preset: value.comparePreset,
|
||||
}
|
||||
: DateRangeComparePresets[0].getRangeValue(state.selectedValue),
|
||||
});
|
||||
return (
|
||||
<DateRangeDialog
|
||||
state={state}
|
||||
compareState={enableCompare ? compareState : undefined}
|
||||
compareVisibleDefault={compareHasInitialValue}
|
||||
showInlineDatePickerField={!isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
common/resources/client/admin/analytics/report-metric.ts
Executable file
26
common/resources/client/admin/analytics/report-metric.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
export type RangedDatasetGranularity =
|
||||
| 'minute'
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year';
|
||||
|
||||
export interface ReportMetric<T = unknown, E = unknown> {
|
||||
labels?: string[];
|
||||
granularity?: RangedDatasetGranularity;
|
||||
total?: number;
|
||||
datasets: ({label: string; data: T[]} & E)[];
|
||||
}
|
||||
|
||||
export interface DatasetItem {
|
||||
label?: string;
|
||||
value: number;
|
||||
date?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface LocationDatasetItem extends DatasetItem {
|
||||
percentage: number;
|
||||
code: string;
|
||||
}
|
||||
52
common/resources/client/admin/analytics/use-admin-report.ts
Executable file
52
common/resources/client/admin/analytics/use-admin-report.ts
Executable file
@@ -0,0 +1,52 @@
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {VisitorsReportData} from './visitors-report-data';
|
||||
import {IconTree} from '../../icons/create-svg-icon';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {ReactElement} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
|
||||
const Endpoint = 'admin/reports';
|
||||
|
||||
export interface HeaderDatum {
|
||||
icon: IconTree[] | ReactElement<SvgIconProps>;
|
||||
name: string;
|
||||
type?: 'number' | 'fileSize' | 'percentage';
|
||||
currentValue: number;
|
||||
previousValue?: number;
|
||||
percentageChange?: number;
|
||||
}
|
||||
|
||||
interface FetchAnalyticsReportResponse extends BackendResponse {
|
||||
visitorsReport: VisitorsReportData;
|
||||
headerReport: HeaderDatum[];
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
types?: ('visitors' | 'header')[];
|
||||
dateRange?: DateRangeValue;
|
||||
}
|
||||
export function useAdminReport(payload: Payload = {}) {
|
||||
return useQuery({
|
||||
queryKey: [Endpoint, payload],
|
||||
queryFn: () => fetchAnalyticsReport(payload),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAnalyticsReport({
|
||||
types,
|
||||
dateRange,
|
||||
}: Payload): Promise<FetchAnalyticsReportResponse> {
|
||||
const params: Record<string, any> = {};
|
||||
if (types) {
|
||||
params.types = types.join(',');
|
||||
}
|
||||
if (dateRange) {
|
||||
params.startDate = dateRange.start.toAbsoluteString();
|
||||
params.endDate = dateRange.end.toAbsoluteString();
|
||||
params.timezone = dateRange.start.timeZone;
|
||||
}
|
||||
return apiClient.get(Endpoint, {params}).then(response => response.data);
|
||||
}
|
||||
64
common/resources/client/admin/analytics/visitors-report-charts.tsx
Executable file
64
common/resources/client/admin/analytics/visitors-report-charts.tsx
Executable file
@@ -0,0 +1,64 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {LineChart} from '@common/charts/line-chart';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {PolarAreaChart} from '@common/charts/polar-area-chart';
|
||||
import {BarChart} from '@common/charts/bar-chart';
|
||||
import {VisitorsReportData} from '@common/admin/analytics/visitors-report-data';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {GeoChart} from '@common/admin/analytics/geo-chart/geo-chart';
|
||||
|
||||
export const AdminReportPageColGap = 'gap-12 md:gap-16 mb-12 md:mb-16';
|
||||
const rowClassName = `flex flex-col md:flex-row md:items-center overflow-x-auto ${AdminReportPageColGap}`;
|
||||
|
||||
interface AdminReportChartsProps {
|
||||
report?: VisitorsReportData;
|
||||
isLoading: boolean;
|
||||
}
|
||||
export function VisitorsReportCharts({
|
||||
report,
|
||||
isLoading,
|
||||
}: AdminReportChartsProps) {
|
||||
const totalViews = report?.pageViews.total;
|
||||
return (
|
||||
<Fragment>
|
||||
<div className={rowClassName}>
|
||||
<LineChart
|
||||
isLoading={isLoading}
|
||||
className="flex-auto"
|
||||
data={report?.pageViews}
|
||||
title={<Trans message="Pageviews" />}
|
||||
description={
|
||||
totalViews ? (
|
||||
<Trans
|
||||
message=":count total views"
|
||||
values={{count: <FormattedNumber value={totalViews} />}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<PolarAreaChart
|
||||
isLoading={isLoading}
|
||||
data={report?.devices}
|
||||
title={<Trans message="Top devices" />}
|
||||
/>
|
||||
</div>
|
||||
<div className={rowClassName}>
|
||||
<BarChart
|
||||
isLoading={isLoading}
|
||||
data={report?.browsers}
|
||||
className="flex-auto md:w-1/3"
|
||||
direction="horizontal"
|
||||
individualBarColors
|
||||
hideLegend
|
||||
title={<Trans message="Top browsers" />}
|
||||
/>
|
||||
<GeoChart
|
||||
isLoading={isLoading}
|
||||
className="flex-auto"
|
||||
data={report?.locations}
|
||||
title={<Trans message="Top locations" />}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
9
common/resources/client/admin/analytics/visitors-report-data.ts
Executable file
9
common/resources/client/admin/analytics/visitors-report-data.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
import {DatasetItem, LocationDatasetItem, ReportMetric} from './report-metric';
|
||||
|
||||
export interface VisitorsReportData {
|
||||
browsers: ReportMetric<DatasetItem>;
|
||||
platforms: ReportMetric<DatasetItem>;
|
||||
devices: ReportMetric<DatasetItem>;
|
||||
locations: ReportMetric<LocationDatasetItem>;
|
||||
pageViews: ReportMetric<DatasetItem>;
|
||||
}
|
||||
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[];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {ReactElement} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
|
||||
export interface ChannelContentConfig {
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
sortMethods: string[];
|
||||
layoutMethods: string[];
|
||||
autoUpdateMethods?: string[];
|
||||
}
|
||||
>;
|
||||
sortingMethods: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
contentTypes?: Channel['config']['contentType'][];
|
||||
}
|
||||
>;
|
||||
layoutMethods: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
icon?: ReactElement<SvgIconProps>;
|
||||
}
|
||||
>;
|
||||
autoUpdateMethods: Record<
|
||||
string,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
provider?: string;
|
||||
value?: {
|
||||
label: MessageDescriptor;
|
||||
inputType: 'text' | 'number';
|
||||
};
|
||||
}
|
||||
>;
|
||||
userSelectableLayouts: string[];
|
||||
}
|
||||
351
common/resources/client/admin/channels/channel-editor/channel-content-editor.tsx
Executable file
351
common/resources/client/admin/channels/channel-editor/channel-content-editor.tsx
Executable file
@@ -0,0 +1,351 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Table} from '@common/ui/tables/table';
|
||||
import {RowElementProps} from '@common/ui/tables/table-row';
|
||||
import {useIsTouchDevice} from '@common/utils/hooks/is-touch-device';
|
||||
import React, {
|
||||
cloneElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {TableContext} from '@common/ui/tables/table-context';
|
||||
import {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';
|
||||
import {
|
||||
DropPosition,
|
||||
useSortable,
|
||||
} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {DragHandleIcon} from '@common/icons/material/DragHandle';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {CloseIcon} from '@common/icons/material/Close';
|
||||
import {DragPreview} from '@common/ui/interactions/dnd/drag-preview';
|
||||
import {WarningIcon} from '@common/icons/material/Warning';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import playlist from '../playlist.svg';
|
||||
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {RefreshIcon} from '@common/icons/material/Refresh';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {useUpdateChannelContent} from '@common/admin/channels/requests/use-update-channel-content';
|
||||
import {ChannelContentSearchFieldProps} from '@common/admin/channels/channel-editor/channel-content-search-field';
|
||||
import {useChannelContent} from '@common/channels/requests/use-channel-content';
|
||||
import {PaginationControls} from '@common/ui/navigation/pagination-controls';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';
|
||||
import {useReorderChannelContent} from '@common/admin/channels/requests/use-reorder-channel-content';
|
||||
import {useAddToChannel} from '@common/admin/channels/requests/use-add-to-channel';
|
||||
import {useRemoveFromChannel} from '@common/admin/channels/requests/use-remove-from-channel';
|
||||
import {ChannelContentItem} from '@common/channels/channel';
|
||||
|
||||
const columnConfig: ColumnConfig<NormalizedModel>[] = [
|
||||
{
|
||||
key: 'dragHandle',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
header: () => <Trans message="Drag handle" />,
|
||||
hideHeader: true,
|
||||
body: () => (
|
||||
<DragHandleIcon className="cursor-pointer text-muted hover:text" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: () => <Trans message="Content item" />,
|
||||
visibleInMode: 'all',
|
||||
body: item => {
|
||||
return (
|
||||
<NameWithAvatar
|
||||
image={item.image}
|
||||
label={
|
||||
item.model_type === 'channel' ? (
|
||||
<Link
|
||||
className="hover:underline"
|
||||
to={`/admin/channels/${item.id}/edit`}
|
||||
target="_blank"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
item.name
|
||||
)
|
||||
}
|
||||
description={item.description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: () => <Trans message="Content type" />,
|
||||
width: 'w-100 flex-shrink-0',
|
||||
body: item => <span className="capitalize">{item.model_type}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: item => <RemoveItemColumn item={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
searchField: ReactElement<ChannelContentSearchFieldProps>;
|
||||
title?: ReactNode;
|
||||
noResultsMessage?: ReactNode;
|
||||
}
|
||||
export function ChannelContentEditor({
|
||||
searchField,
|
||||
title,
|
||||
noResultsMessage,
|
||||
}: Props) {
|
||||
const {watch, getValues} = useFormContext<UpdateChannelPayload>();
|
||||
const channel = getValues();
|
||||
const contentType = watch('config.contentType');
|
||||
const addToChannel = useAddToChannel();
|
||||
const query = useChannelContent<ChannelContentItem<NormalizedModel>>(
|
||||
channel,
|
||||
{loader: 'editChannelPage', paginate: 'simple'},
|
||||
{paginate: true},
|
||||
);
|
||||
const pagination = query.data!;
|
||||
|
||||
// only show delete and drag buttons when channel content is managed manually
|
||||
const filteredColumns = columnConfig.filter(col => {
|
||||
return !(
|
||||
contentType !== 'manual' &&
|
||||
(col.key === 'actions' || col.key === 'dragHandle')
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-40">
|
||||
<div className="mb-40">
|
||||
<h2 className="mb-10 text-2xl">
|
||||
{title || <Trans message="Channel content" />}
|
||||
</h2>
|
||||
<ContentNotEditableWarning />
|
||||
<UpdateContentButton />
|
||||
{contentType === 'manual'
|
||||
? cloneElement<ChannelContentSearchFieldProps>(searchField, {
|
||||
onResultSelected: result => {
|
||||
addToChannel.mutate({
|
||||
channelId: channel.id,
|
||||
item: result,
|
||||
});
|
||||
},
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type="simple"
|
||||
className="mb-24"
|
||||
/>
|
||||
<Table
|
||||
className="mt-24"
|
||||
columns={filteredColumns}
|
||||
data={pagination?.data || []}
|
||||
meta={query.queryKey}
|
||||
renderRowAs={contentType === 'manual' ? ContentTableRow : undefined}
|
||||
enableSelection={false}
|
||||
hideHeaderRow
|
||||
/>
|
||||
<PaginationControls
|
||||
pagination={query.data}
|
||||
type="simple"
|
||||
className="mt-24"
|
||||
scrollToTop
|
||||
/>
|
||||
{!pagination.data?.length && contentType === 'manual'
|
||||
? noResultsMessage || (
|
||||
<IllustratedMessage
|
||||
title={<Trans message="Channel is empty" />}
|
||||
description={
|
||||
<Trans message="No content is attached to this channel yet." />
|
||||
}
|
||||
image={<SvgImage src={playlist} />}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentTableRow({
|
||||
item,
|
||||
children,
|
||||
className,
|
||||
...domProps
|
||||
}: RowElementProps<NormalizedModel>) {
|
||||
const isTouchDevice = useIsTouchDevice();
|
||||
const {data, meta} = useContext(TableContext);
|
||||
const {getValues} = useFormContext<UpdateChannelPayload>();
|
||||
const domRef = useRef<HTMLTableRowElement>(null);
|
||||
const reorderContent = useReorderChannelContent();
|
||||
const previewRef = useRef<DragPreviewRenderer>(null);
|
||||
const [dropPosition, setDropPosition] = useState<DropPosition>(null);
|
||||
|
||||
const {sortableProps} = useSortable({
|
||||
ref: domRef,
|
||||
disabled: isTouchDevice ?? false,
|
||||
item,
|
||||
items: data,
|
||||
type: 'channelContentItem',
|
||||
preview: previewRef,
|
||||
strategy: 'line',
|
||||
onDropPositionChange: position => {
|
||||
setDropPosition(position);
|
||||
},
|
||||
onSortEnd: (oldIndex, newIndex) => {
|
||||
// do optimistic reorder
|
||||
const newPagination = queryClient.setQueryData<
|
||||
PaginationResponse<unknown>
|
||||
>(meta, pagination => {
|
||||
if (pagination) {
|
||||
pagination = {
|
||||
...pagination,
|
||||
data: moveItemInNewArray(pagination.data, oldIndex, newIndex),
|
||||
};
|
||||
}
|
||||
return pagination;
|
||||
});
|
||||
|
||||
// reorder on backend
|
||||
if (newPagination) {
|
||||
reorderContent.mutate({
|
||||
channelId: getValues('id'),
|
||||
modelType: item.model_type,
|
||||
ids: newPagination.data.map(item => (item as NormalizedModel).id),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
dropPosition === 'before' && 'sort-preview-before',
|
||||
dropPosition === 'after' && 'sort-preview-after',
|
||||
)}
|
||||
ref={domRef}
|
||||
{...mergeProps(sortableProps, domProps)}
|
||||
>
|
||||
{children}
|
||||
{!item.isPlaceholder && <RowDragPreview item={item} ref={previewRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowDragPreviewProps {
|
||||
item: NormalizedModel;
|
||||
}
|
||||
const RowDragPreview = React.forwardRef<
|
||||
DragPreviewRenderer,
|
||||
RowDragPreviewProps
|
||||
>(({item}, ref) => {
|
||||
return (
|
||||
<DragPreview ref={ref}>
|
||||
{() => (
|
||||
<div className="rounded bg-chip p-8 text-base shadow">{item.name}</div>
|
||||
)}
|
||||
</DragPreview>
|
||||
);
|
||||
});
|
||||
|
||||
interface RemoveItemColumnProps {
|
||||
item: NormalizedModel;
|
||||
}
|
||||
function RemoveItemColumn({item}: RemoveItemColumnProps) {
|
||||
const removeFromChannel = useRemoveFromChannel();
|
||||
const {getValues} = useFormContext<UpdateChannelPayload>();
|
||||
return (
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
disabled={removeFromChannel.isPending}
|
||||
onClick={() => {
|
||||
removeFromChannel.mutate({
|
||||
channelId: getValues('id'),
|
||||
item: item,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentNotEditableWarning() {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentType = watch('config.contentType');
|
||||
|
||||
if (contentType === 'manual') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-20 mt-4 flex items-center gap-8">
|
||||
<WarningIcon size="xs" />
|
||||
<div className="text-xs text-muted">
|
||||
{contentType === 'listAll' ? (
|
||||
<Trans message="This channel is listing all available content of specified type, and can't be curated manually." />
|
||||
) : null}
|
||||
{contentType === 'autoUpdate' ? (
|
||||
<Trans message="This channel content is set to update automatically and can't be curated manually." />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateContentButton() {
|
||||
const {slugOrId} = useParams();
|
||||
const updateContent = useUpdateChannelContent(slugOrId!);
|
||||
const {setValue, watch, getValues} = useFormContext<UpdateChannelPayload>();
|
||||
|
||||
if (watch('config.contentType') !== 'autoUpdate') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => {
|
||||
updateContent.mutate(
|
||||
{
|
||||
channelConfig: (getValues as any)('config'),
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
if (response.channel.content) {
|
||||
(setValue as any)('content', response.channel.content);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
updateContent.isPending ||
|
||||
!watch('config.autoUpdateMethod') ||
|
||||
!watch('id')
|
||||
}
|
||||
>
|
||||
<Trans message="Update content now" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import React, {useState} from 'react';
|
||||
import {useAddableContent} from '@common/admin/channels/requests/use-addable-content';
|
||||
import {ComboBox} from '@common/ui/forms/combobox/combobox';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
|
||||
export interface ChannelContentSearchFieldProps {
|
||||
onResultSelected?: (result: NormalizedModel) => void;
|
||||
imgRenderer?: (result: NormalizedModel) => React.ReactNode;
|
||||
}
|
||||
export function ChannelContentSearchField({
|
||||
onResultSelected,
|
||||
imgRenderer,
|
||||
}: ChannelContentSearchFieldProps) {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentModel = watch('config.contentModel');
|
||||
const {trans} = useTrans();
|
||||
const [query, setQuery] = useState('');
|
||||
const {isFetching, data} = useAddableContent({
|
||||
query,
|
||||
modelType: contentModel,
|
||||
limit: 20,
|
||||
});
|
||||
return (
|
||||
<ComboBox
|
||||
isAsync
|
||||
placeholder={trans(message('Search for content to add...'))}
|
||||
isLoading={isFetching}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
clearInputOnItemSelection
|
||||
blurReferenceOnItemSelection
|
||||
selectionMode="none"
|
||||
openMenuOnFocus
|
||||
floatingMaxHeight={670}
|
||||
startAdornment={<SearchIcon />}
|
||||
hideEndAdornment
|
||||
>
|
||||
{data?.results.map(result => (
|
||||
<Item
|
||||
key={result.id}
|
||||
value={result.id}
|
||||
onSelected={() => onResultSelected?.(result)}
|
||||
startIcon={imgRenderer ? imgRenderer(result) : null}
|
||||
description={result.description}
|
||||
textLabel={result.name}
|
||||
>
|
||||
{result.name}
|
||||
</Item>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {TabList} from '@common/ui/tabs/tab-list';
|
||||
import {Tab} from '@common/ui/tabs/tab';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';
|
||||
import {Tabs} from '@common/ui/tabs/tabs';
|
||||
import React, {Fragment, ReactNode} from 'react';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
export function ChannelEditorTabs({children}: Props) {
|
||||
return (
|
||||
<Tabs isLazy>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Trans message="Details" />
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Trans message="SEO" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="pt-20">
|
||||
<TabPanel>{children}</TabPanel>
|
||||
<TabPanel>
|
||||
<SeoFields />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function SeoFields() {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="config.seoTitle"
|
||||
label={<Trans message="SEO title" />}
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormTextField
|
||||
name="config.seoDescription"
|
||||
label={<Trans message="SEO description" />}
|
||||
inputElementType="textarea"
|
||||
rows={6}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {Fragment} from 'react';
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {SlugEditor} from '@common/ui/slug-editor';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export function ChannelNameField({className, autoFocus}: Props) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Title" />}
|
||||
required
|
||||
autoFocus={autoFocus}
|
||||
className={clsx('mb-10', className)}
|
||||
/>
|
||||
<FormSlugField />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function FormSlugField() {
|
||||
const {watch, setValue} = useFormContext<UpdateChannelPayload>();
|
||||
const value = watch('slug');
|
||||
const name = watch('name');
|
||||
const disableSlugEditing = watch('config.lockSlug');
|
||||
const restriction = watch('config.restriction');
|
||||
const restrictionId = watch('config.restrictionModelId');
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<SlugEditor
|
||||
hideButton={disableSlugEditing}
|
||||
placeholder={name}
|
||||
suffix={
|
||||
restriction && restrictionId === 'urlParam'
|
||||
? trans(message(':restriction_name', {values: {restriction}}))
|
||||
: undefined
|
||||
}
|
||||
className="text-sm"
|
||||
pattern="[A-Za-z0-9_-]+"
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
value={value}
|
||||
onChange={newSlug => {
|
||||
setValue('slug', newSlug);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ChannelPaginationTypeField({className}: Props) {
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.paginationType"
|
||||
label={<Trans message="Pagination type" />}
|
||||
>
|
||||
<Option value="infiniteScroll">
|
||||
<Trans message="Infinite scroll" />
|
||||
</Option>
|
||||
<Option value="lengthAware">
|
||||
<Trans message="List of page buttons" />
|
||||
</Option>
|
||||
<Option value="simple">
|
||||
<Trans message="Next/previous page buttons only" />
|
||||
</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
|
||||
import {Fragment, ReactNode} from 'react';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import clsx from 'clsx';
|
||||
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentAutoUpdateField({children, config, className}: Props) {
|
||||
const {watch, setValue} = useFormContext<UpdateChannelPayload>();
|
||||
const modelConfig = config.models[watch('config.contentModel')];
|
||||
const selectedMethodConfig =
|
||||
config.autoUpdateMethods[watch('config.autoUpdateMethod')!];
|
||||
|
||||
if (
|
||||
watch('config.contentType') !== 'autoUpdate' ||
|
||||
!modelConfig.autoUpdateMethods?.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('items-end gap-14 md:flex', className)}>
|
||||
<FormSelect
|
||||
required
|
||||
className="flex-auto"
|
||||
selectionMode="single"
|
||||
name="config.autoUpdateMethod"
|
||||
onSelectionChange={value => {
|
||||
if (config.autoUpdateMethods[value].provider) {
|
||||
setValue(
|
||||
'config.autoUpdateProvider',
|
||||
config.autoUpdateMethods[value].provider,
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={
|
||||
<Fragment>
|
||||
<Trans message="Auto update method" />
|
||||
<InfoDialogTrigger
|
||||
body={
|
||||
<Fragment>
|
||||
<div className="mb-20">
|
||||
<Trans message="This option will automatically update channel content every 24 hours using the specified method." />
|
||||
</div>
|
||||
<ChannelsDocsLink hash="automatically-update-content-with-specified-method" />
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
>
|
||||
{modelConfig.autoUpdateMethods.map(method => (
|
||||
<Option value={method} key={method}>
|
||||
<Trans {...config.autoUpdateMethods[method].label} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
{selectedMethodConfig?.value ? (
|
||||
<FormTextField
|
||||
name="config.autoUpdateValue"
|
||||
required
|
||||
className="flex-auto"
|
||||
label={<Trans {...selectedMethodConfig?.value.label} />}
|
||||
type={selectedMethodConfig?.value.inputType}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ReactNode} from 'react';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentLayoutFields({config, className}: Props) {
|
||||
return (
|
||||
<div className={clsx('items-end gap-14 md:flex', className)}>
|
||||
<LayoutField
|
||||
config={config}
|
||||
name="config.layout"
|
||||
label={<Trans message="Layout" />}
|
||||
/>
|
||||
<LayoutField
|
||||
config={config}
|
||||
name="config.nestedLayout"
|
||||
label={<Trans message="Layout when nested" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LayoutFieldProps extends Props {
|
||||
name: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
function LayoutField({config, name, label}: LayoutFieldProps) {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentModel = watch('config.contentModel');
|
||||
const modelConfig = config.models[contentModel];
|
||||
|
||||
if (!modelConfig.layoutMethods?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
className="w-full flex-auto"
|
||||
selectionMode="single"
|
||||
name={name}
|
||||
label={label}
|
||||
>
|
||||
{modelConfig.layoutMethods.map(method => {
|
||||
const label = config.layoutMethods[method].label;
|
||||
return (
|
||||
<Option key={method} value={method}>
|
||||
<Trans {...label} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import React from 'react';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
exclude?: string[];
|
||||
}
|
||||
export function ContentModelField({config, className, exclude}: Props) {
|
||||
const {setValue, getValues} = useFormContext<UpdateChannelPayload>();
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.contentModel"
|
||||
label={<Trans message="Type of content" />}
|
||||
onSelectionChange={newValue => {
|
||||
const modelConfig = config.models[newValue];
|
||||
if (
|
||||
getValues('config.contentType') === 'autoUpdate' &&
|
||||
!modelConfig.autoUpdateMethods?.length
|
||||
) {
|
||||
(setValue as any)('config.contentType', 'manual');
|
||||
}
|
||||
setValue('config.autoUpdateMethod', modelConfig.autoUpdateMethods?.[0]);
|
||||
setValue(
|
||||
'config.contentOrder',
|
||||
modelConfig.sortMethods[0] || 'channelables.order:asc',
|
||||
);
|
||||
setValue('config.layout', modelConfig.layoutMethods[0]);
|
||||
}}
|
||||
>
|
||||
{Object.entries(config.models)
|
||||
.filter(([model]) => !exclude?.includes(model))
|
||||
.map(([model, {label}]) => (
|
||||
<Option value={model} key={model}>
|
||||
<Trans {...label} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentOrderField({config, className}: Props) {
|
||||
const {watch} = useFormContext<UpdateChannelPayload>();
|
||||
const contentType = watch('config.contentType');
|
||||
const modelConfig = config.models[watch('config.contentModel')];
|
||||
const sortMethods = [...modelConfig.sortMethods, 'channelables.order:asc'];
|
||||
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.contentOrder"
|
||||
label={<Trans message="How to order content" />}
|
||||
>
|
||||
{sortMethods.map(method => {
|
||||
const sortConfig = config.sortingMethods[method];
|
||||
if (
|
||||
!sortConfig.contentTypes ||
|
||||
sortConfig.contentTypes.includes(contentType)
|
||||
) {
|
||||
return (
|
||||
<Option value={method} key={method}>
|
||||
<Trans {...sortConfig.label} />
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useFormContext} from 'react-hook-form';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
|
||||
|
||||
interface Props {
|
||||
config: ChannelContentConfig;
|
||||
className?: string;
|
||||
}
|
||||
export function ContentTypeField({config, className}: Props) {
|
||||
const {setValue} = useFormContext<UpdateChannelPayload>();
|
||||
return (
|
||||
<FormSelect
|
||||
className={className}
|
||||
selectionMode="single"
|
||||
name="config.contentType"
|
||||
label={<Trans message="Content" />}
|
||||
onSelectionChange={newValue => {
|
||||
// if content type is "auto update" select first model that
|
||||
// can be auto updated, otherwise select first available model
|
||||
let model = Object.entries(config.models)[0];
|
||||
if (newValue === 'autoUpdate') {
|
||||
const newModel = Object.entries(config.models).find(
|
||||
([, modelConfig]) => modelConfig.autoUpdateMethods?.length,
|
||||
);
|
||||
if (newModel) {
|
||||
model = newModel;
|
||||
}
|
||||
}
|
||||
const [modelName, modelConfig] = model;
|
||||
|
||||
setValue('config.contentModel', modelName);
|
||||
setValue('config.restrictionModelId', undefined);
|
||||
setValue(
|
||||
'config.autoUpdateMethod',
|
||||
newValue === 'autoUpdate' ? modelConfig.autoUpdateMethods?.[0] : '',
|
||||
);
|
||||
setValue('config.contentOrder', modelConfig.sortMethods[0]);
|
||||
(setValue as any)('config.restriction', null);
|
||||
}}
|
||||
>
|
||||
<Option value="listAll">
|
||||
<Trans message="List all content of specified type" />
|
||||
</Option>
|
||||
<Option value="manual">
|
||||
<Trans message="Manage content manually" />
|
||||
</Option>
|
||||
<Option value="autoUpdate">
|
||||
<Trans message="Automatically update content with specified method" />
|
||||
</Option>
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {EMPTY_PAGINATION_RESPONSE} from '@common/http/backend-response/pagination-response';
|
||||
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
|
||||
import {useCreateChannel} from '@common/admin/channels/requests/use-create-channel';
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<UpdateChannelPayload['config']>;
|
||||
children: ReactNode;
|
||||
}
|
||||
export function CreateChannelPageLayout({defaultValues, children}: Props) {
|
||||
const form = useForm<UpdateChannelPayload>({
|
||||
defaultValues: {
|
||||
content: EMPTY_PAGINATION_RESPONSE.pagination,
|
||||
config: {
|
||||
contentType: 'listAll',
|
||||
contentOrder: 'created_at:desc',
|
||||
nestedLayout: 'carousel',
|
||||
...defaultValues,
|
||||
},
|
||||
},
|
||||
});
|
||||
const createChannel = useCreateChannel(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
createChannel.mutate(values);
|
||||
}}
|
||||
title={<Trans message="Add new channel" />}
|
||||
isLoading={createChannel.isPending}
|
||||
>
|
||||
{children}
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useForm} from 'react-hook-form';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
import {useChannel} from '@common/channels/requests/use-channel';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {
|
||||
UpdateChannelPayload,
|
||||
useUpdateChannel,
|
||||
} from '@common/admin/channels/requests/use-update-channel';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
export function EditChannelPageLayout({children}: Props) {
|
||||
const query = useChannel(undefined, 'editChannelPage');
|
||||
if (query.data) {
|
||||
return <PageContent channel={query.data.channel}>{children}</PageContent>;
|
||||
}
|
||||
return <PageStatus query={query} loaderIsScreen={false} />;
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
channel: Channel;
|
||||
children: ReactNode;
|
||||
}
|
||||
function PageContent({channel, children}: PageContentProps) {
|
||||
const form = useForm<UpdateChannelPayload>({
|
||||
// @ts-ignore
|
||||
defaultValues: {
|
||||
...channel,
|
||||
},
|
||||
});
|
||||
const updateChannel = useUpdateChannel(form);
|
||||
|
||||
return (
|
||||
<CrupdateResourceLayout
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
updateChannel.mutate(values);
|
||||
}}
|
||||
title={
|
||||
<Trans message="Edit “:name“ channel" values={{name: channel.name}} />
|
||||
}
|
||||
isLoading={updateChannel.isPending}
|
||||
>
|
||||
{children}
|
||||
</CrupdateResourceLayout>
|
||||
);
|
||||
}
|
||||
151
common/resources/client/admin/channels/channels-datatable-columns.tsx
Executable file
151
common/resources/client/admin/channels/channels-datatable-columns.tsx
Executable file
@@ -0,0 +1,151 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import React from 'react';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {Chip} from '@common/ui/forms/input-field/chip-field/chip';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {HomeIcon} from '@common/icons/material/Home';
|
||||
|
||||
export const ChannelsDatatableColumns: ColumnConfig<Channel>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
allowsSorting: true,
|
||||
width: 'flex-3',
|
||||
visibleInMode: 'all',
|
||||
header: () => <Trans message="Name" />,
|
||||
body: channel => {
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden overflow-ellipsis whitespace-nowrap font-medium">
|
||||
<ChannelName channel={channel} />
|
||||
</div>
|
||||
{channel.config.adminDescription && (
|
||||
<p className="max-w-680 whitespace-normal text-xs text-muted">
|
||||
{channel.config.adminDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
allowsSorting: false,
|
||||
header: () => <Trans message="Content" />,
|
||||
body: channel => <ContentType channel={channel} />,
|
||||
},
|
||||
{
|
||||
key: 'content_type',
|
||||
allowsSorting: false,
|
||||
header: () => <Trans message="Content type" />,
|
||||
body: channel => (
|
||||
<span className="capitalize">
|
||||
{channel.config.contentModel ? (
|
||||
<Trans message={channel.config.contentModel} />
|
||||
) : undefined}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'internal',
|
||||
allowsSorting: true,
|
||||
maxWidth: 'max-w-100',
|
||||
hideHeader: true,
|
||||
header: () => <Trans message="Internal" />,
|
||||
body: channel => <InternalColumn channel={channel} />,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
allowsSorting: true,
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: channel =>
|
||||
channel.updated_at ? <FormattedDate date={channel.updated_at} /> : '',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
visibleInMode: 'all',
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
body: channel => (
|
||||
<Link to={`${channel.id}/edit`} className="text-muted">
|
||||
<IconButton size="md">
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface ContentTypeProps {
|
||||
channel: Channel;
|
||||
}
|
||||
function ContentType({channel}: ContentTypeProps) {
|
||||
switch (channel.config.contentType) {
|
||||
case 'listAll':
|
||||
return <Trans message="List all" />;
|
||||
case 'manual':
|
||||
return <Trans message="Managed manually" />;
|
||||
case 'autoUpdate':
|
||||
return <Trans message="Updated automatically" />;
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelNameProps {
|
||||
channel: Channel;
|
||||
}
|
||||
function ChannelName({channel}: ChannelNameProps) {
|
||||
// link will not work without specific genre name in channel url
|
||||
if (
|
||||
channel.config.restriction &&
|
||||
channel.config.restrictionModelId === 'urlParam'
|
||||
) {
|
||||
return channel.name;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
className="outline-none hover:underline focus-visible:underline"
|
||||
href={`channel/${channel.slug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{channel.name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function InternalColumn({channel}: ChannelNameProps) {
|
||||
const {homepage} = useSettings();
|
||||
const internalLabel = channel.internal ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<Trans message="This channel is required for some site functionality to work properly and can't be deleted." />
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Chip className="w-max" size="xs" radius="rounded-panel">
|
||||
<Trans message="Internal" />
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
|
||||
const isHomepage =
|
||||
homepage?.type === 'channels' && `${homepage.value}` === `${channel.id}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
{internalLabel}
|
||||
{isHomepage ? <HomeIcon className="text-muted" size="sm" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
common/resources/client/admin/channels/channels-datatable-page.tsx
Executable file
120
common/resources/client/admin/channels/channels-datatable-page.tsx
Executable file
@@ -0,0 +1,120 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import playlist from './playlist.svg';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {ChannelsDatatableColumns} from '@common/admin/channels/channels-datatable-columns';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useApplyChannelPreset} from '@common/admin/channels/requests/use-apply-channel-preset';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
|
||||
import {useDataTable} from '@common/datatable/page/data-table-context';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {Menu, MenuTrigger} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
import {openDialog} from '@common/ui/overlays/store/dialog-store';
|
||||
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
|
||||
|
||||
interface ChannelPresetConfig {
|
||||
preset: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ChannelsDatatablePage() {
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="channel"
|
||||
title={<Trans message="Channels" />}
|
||||
headerContent={<InfoTrigger />}
|
||||
headerItemsAlign="items-center"
|
||||
queryParams={{type: 'channel'}}
|
||||
columns={ChannelsDatatableColumns}
|
||||
actions={<Actions />}
|
||||
selectedActions={<DeleteSelectedItemsAction />}
|
||||
cellHeight="h-52"
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={playlist}
|
||||
title={<Trans message="No channels have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching channels" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTrigger() {
|
||||
return (
|
||||
<InfoDialogTrigger
|
||||
body={
|
||||
<Fragment>
|
||||
<Trans message="Channels are used to create pages that show various content on the site." />
|
||||
<ChannelsDocsLink className="mt-14" />
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
const {query} = useDataTable<Channel, {presets: ChannelPresetConfig[]}>();
|
||||
return (
|
||||
<Fragment>
|
||||
<MenuTrigger
|
||||
onItemSelected={preset => openDialog(ApplyPresetDialog, {preset})}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
size="sm"
|
||||
endIcon={<KeyboardArrowDownIcon />}
|
||||
disabled={!query.data?.presets.length}
|
||||
>
|
||||
<Trans message="Apply preset" />
|
||||
</Button>
|
||||
<Menu>
|
||||
{query.data?.presets.map(preset => (
|
||||
<Item
|
||||
key={preset.preset}
|
||||
value={preset.preset}
|
||||
description={<Trans message={preset.description} />}
|
||||
>
|
||||
<Trans message={preset.name} />
|
||||
</Item>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
<DataTableAddItemButton elementType={Link} to="new">
|
||||
<Trans message="Add new channel" />
|
||||
</DataTableAddItemButton>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplyPresetDialogProps {
|
||||
preset: string;
|
||||
}
|
||||
function ApplyPresetDialog({preset}: ApplyPresetDialogProps) {
|
||||
const {close} = useDialogContext();
|
||||
const resetChannels = useApplyChannelPreset();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
isLoading={resetChannels.isPending}
|
||||
onConfirm={() => {
|
||||
resetChannels.mutate({preset}, {onSuccess: () => close()});
|
||||
}}
|
||||
isDanger
|
||||
title={<Trans message="Apply preset" />}
|
||||
body={
|
||||
<Trans message="Are you sure you want to apply this channel preset? This will delete all current channels and leave only channels from the selected preset." />
|
||||
}
|
||||
confirm={<Trans message="Apply" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
common/resources/client/admin/channels/channels-docs-link.tsx
Executable file
16
common/resources/client/admin/channels/channels-docs-link.tsx
Executable file
@@ -0,0 +1,16 @@
|
||||
import {LearnMoreLink} from '@common/admin/settings/learn-more-link';
|
||||
import {useContext} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
hash?: string;
|
||||
}
|
||||
export function ChannelsDocsLink({className, hash}: Props) {
|
||||
const {admin} = useContext(SiteConfigContext);
|
||||
if (!admin?.channelsDocsLink) return null;
|
||||
const link = hash
|
||||
? `${admin.channelsDocsLink}#${hash}`
|
||||
: admin.channelsDocsLink;
|
||||
return <LearnMoreLink link={link} className={className} />;
|
||||
}
|
||||
2
common/resources/client/admin/channels/playlist.svg
Executable file
2
common/resources/client/admin/channels/playlist.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.5 KiB |
34
common/resources/client/admin/channels/requests/use-add-to-channel.ts
Executable file
34
common/resources/client/admin/channels/requests/use-add-to-channel.ts
Executable file
@@ -0,0 +1,34 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {channelQueryKey} from '@common/channels/requests/use-channel';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
channelId: number | string;
|
||||
item: NormalizedModel;
|
||||
}
|
||||
|
||||
export function useAddToChannel() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => addToChannel(payload),
|
||||
onSuccess: async (_, payload) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: channelQueryKey(payload.channelId),
|
||||
});
|
||||
},
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function addToChannel({channelId, item}: Payload): Promise<Response> {
|
||||
return apiClient
|
||||
.post(`channel/${channelId}/add`, {
|
||||
itemId: item.id,
|
||||
itemType: item.model_type,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
29
common/resources/client/admin/channels/requests/use-addable-content.ts
Executable file
29
common/resources/client/admin/channels/requests/use-addable-content.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
|
||||
export interface SearchResponse extends BackendResponse {
|
||||
results: NormalizedModel[];
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
query?: string;
|
||||
limit?: number;
|
||||
modelType: string;
|
||||
}
|
||||
|
||||
export function useAddableContent(params: SearchParams) {
|
||||
return useQuery({
|
||||
queryKey: ['search', params],
|
||||
queryFn: () => search(params),
|
||||
//enabled: !!params.query,
|
||||
placeholderData: params.query ? keepPreviousData : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function search(params: SearchParams) {
|
||||
return apiClient
|
||||
.get<SearchResponse>(`channel/search-for-addable-content`, {params})
|
||||
.then(response => response.data);
|
||||
}
|
||||
34
common/resources/client/admin/channels/requests/use-apply-channel-preset.ts
Executable file
34
common/resources/client/admin/channels/requests/use-apply-channel-preset.ts
Executable file
@@ -0,0 +1,34 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {}
|
||||
|
||||
interface Payload {
|
||||
preset: string;
|
||||
}
|
||||
|
||||
export function useApplyChannelPreset() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => resetChannels(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('channel'),
|
||||
});
|
||||
toast(trans(message('Channel preset applied')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function resetChannels(payload: Payload) {
|
||||
return apiClient
|
||||
.post<Response>('channel/apply-preset', payload)
|
||||
.then(r => r.data);
|
||||
}
|
||||
47
common/resources/client/admin/channels/requests/use-create-channel.ts
Executable file
47
common/resources/client/admin/channels/requests/use-create-channel.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {PaginationResponse} from '@common/http/backend-response/pagination-response';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
|
||||
const endpoint = 'channel';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
export interface CreateChannelPayload
|
||||
extends Omit<Channel, 'content' | 'items'> {
|
||||
content: PaginationResponse<NormalizedModel>;
|
||||
}
|
||||
|
||||
export function useCreateChannel(form: UseFormReturn<CreateChannelPayload>) {
|
||||
const {trans} = useTrans();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateChannelPayload) => createChannel(payload),
|
||||
onSuccess: async response => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey(endpoint),
|
||||
});
|
||||
toast(trans(message('Channel created')));
|
||||
navigate(`/admin/channels/${response.channel.id}/edit`, {
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
onError: err => onFormQueryError(err, form),
|
||||
});
|
||||
}
|
||||
|
||||
function createChannel(payload: CreateChannelPayload) {
|
||||
return apiClient.post<Response>(endpoint, payload).then(r => r.data);
|
||||
}
|
||||
31
common/resources/client/admin/channels/requests/use-remove-from-channel.ts
Executable file
31
common/resources/client/admin/channels/requests/use-remove-from-channel.ts
Executable file
@@ -0,0 +1,31 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {channelQueryKey} from '@common/channels/requests/use-channel';
|
||||
|
||||
interface Payload {
|
||||
channelId: number | string;
|
||||
item: NormalizedModel;
|
||||
}
|
||||
|
||||
export function useRemoveFromChannel() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => removeFromChannel(payload),
|
||||
onSuccess: async (_, payload) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: channelQueryKey(payload.channelId),
|
||||
});
|
||||
},
|
||||
onError: r => showHttpErrorToast(r),
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromChannel({channelId, item}: Payload) {
|
||||
return apiClient
|
||||
.post(`channel/${channelId}/remove`, {
|
||||
itemId: item.id,
|
||||
itemType: item.model_type,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useMutation} 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 {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
channel: Channel<NormalizedModel>;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
channelId: number | string;
|
||||
modelType: string;
|
||||
ids: (number | string)[];
|
||||
}
|
||||
|
||||
export function useReorderChannelContent() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => reorderContent(payload),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function reorderContent({channelId, ids, modelType}: Payload) {
|
||||
return apiClient
|
||||
.post<Response>(`channel/${channelId}/reorder-content`, {
|
||||
modelType,
|
||||
ids,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient, queryClient} 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 {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {Channel, ChannelConfig} from '@common/channels/channel';
|
||||
import {channelQueryKey} from '@common/channels/requests/use-channel';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
channel: Channel<NormalizedModel>;
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
channelConfig?: Partial<ChannelConfig>;
|
||||
}
|
||||
|
||||
export function useUpdateChannelContent(channelId: number | string) {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => updateChannel(channelId, payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: channelQueryKey(channelId),
|
||||
});
|
||||
toast(trans(message('Channel content updated')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function updateChannel(channelId: number | string, payload: Payload) {
|
||||
return apiClient
|
||||
.post<Response>(`channel/${channelId}/update-content`, {
|
||||
...payload,
|
||||
normalizeContent: true,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
45
common/resources/client/admin/channels/requests/use-update-channel.ts
Executable file
45
common/resources/client/admin/channels/requests/use-update-channel.ts
Executable file
@@ -0,0 +1,45 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {UseFormReturn} from 'react-hook-form';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {Channel} from '@common/channels/channel';
|
||||
import {CreateChannelPayload} from '@common/admin/channels/requests/use-create-channel';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
export interface UpdateChannelPayload extends CreateChannelPayload {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const Endpoint = (id: number) => `channel/${id}`;
|
||||
|
||||
export function useUpdateChannel(form: UseFormReturn<UpdateChannelPayload>) {
|
||||
const {trans} = useTrans();
|
||||
const navigate = useNavigate();
|
||||
return useMutation({
|
||||
mutationFn: (payload: UpdateChannelPayload) => updateChannel(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('channel'),
|
||||
});
|
||||
toast(trans(message('Channel updated')));
|
||||
navigate('/admin/channels');
|
||||
},
|
||||
onError: err => onFormQueryError(err, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updateChannel({
|
||||
id,
|
||||
...payload
|
||||
}: UpdateChannelPayload): Promise<Response> {
|
||||
return apiClient.put(Endpoint(id), payload).then(r => r.data);
|
||||
}
|
||||
85
common/resources/client/admin/crupdate-resource-layout.tsx
Executable file
85
common/resources/client/admin/crupdate-resource-layout.tsx
Executable file
@@ -0,0 +1,85 @@
|
||||
import {FieldValues, SubmitHandler, UseFormReturn} from 'react-hook-form';
|
||||
import clsx from 'clsx';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {useStickySentinel} from '../utils/hooks/sticky-sentinel';
|
||||
import {Form} from '../ui/forms/form';
|
||||
import {Button} from '../ui/buttons/button';
|
||||
import {Trans} from '../i18n/trans';
|
||||
|
||||
interface Props<T extends FieldValues> {
|
||||
onSubmit: SubmitHandler<T>;
|
||||
form: UseFormReturn<T>;
|
||||
title: ReactNode;
|
||||
subTitle?: ReactNode;
|
||||
isLoading: boolean;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
backButton?: ReactNode;
|
||||
disableSaveWhenNotDirty?: boolean;
|
||||
wrapInContainer?: boolean;
|
||||
}
|
||||
export function CrupdateResourceLayout<T extends FieldValues>({
|
||||
onSubmit,
|
||||
form,
|
||||
title,
|
||||
subTitle,
|
||||
children,
|
||||
actions,
|
||||
backButton,
|
||||
isLoading = false,
|
||||
disableSaveWhenNotDirty = false,
|
||||
wrapInContainer = true,
|
||||
}: Props<T>) {
|
||||
const {isSticky, sentinelRef} = useStickySentinel();
|
||||
const isDirty = !disableSaveWhenNotDirty
|
||||
? true
|
||||
: Object.keys(form.formState.dirtyFields).length;
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
onBeforeSubmit={() => form.clearErrors()}
|
||||
form={form}
|
||||
>
|
||||
<div ref={sentinelRef} />
|
||||
<div
|
||||
className={clsx(
|
||||
'sticky top-0 z-10 my-12 transition-shadow md:my-24',
|
||||
isSticky && 'bg shadow',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-24 py-14 md:items-start',
|
||||
wrapInContainer && 'container mx-auto px-24',
|
||||
)}
|
||||
>
|
||||
{backButton}
|
||||
<div className="overflow-hidden overflow-ellipsis md:mr-64">
|
||||
<h1 className="overflow-hidden overflow-ellipsis whitespace-nowrap text-xl md:text-3xl">
|
||||
{title}
|
||||
</h1>
|
||||
{subTitle && <div className="mt-4">{subTitle}</div>}
|
||||
</div>
|
||||
<div className="mr-auto"></div>
|
||||
{actions}
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={isLoading || !isDirty}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
wrapInContainer ? 'container mx-auto px-24 pb-24' : undefined
|
||||
}
|
||||
>
|
||||
<div className="rounded">{children}</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/admin/custom-pages/articles.svg
Executable file
1
common/resources/client/admin/custom-pages/articles.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
58
common/resources/client/admin/custom-pages/create-custom-page.tsx
Executable file
58
common/resources/client/admin/custom-pages/create-custom-page.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
import React, {Suspense} from 'react';
|
||||
import {FormProvider, useForm} from 'react-hook-form';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {ArticleEditorTitle} from '@common/article-editor/article-editor-title';
|
||||
import {ArticleEditorStickyHeader} from '@common/article-editor/article-editor-sticky-header';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {
|
||||
CreateCustomPagePayload,
|
||||
useCreateCustomPage,
|
||||
} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
|
||||
const ArticleBodyEditor = React.lazy(
|
||||
() => import('@common/article-editor/article-body-editor'),
|
||||
);
|
||||
|
||||
export function CreateCustomPage() {
|
||||
const navigate = useNavigate();
|
||||
const createPage = useCreateCustomPage();
|
||||
const form = useForm<CreateCustomPagePayload>();
|
||||
|
||||
const handleSave = (editorContent: string) => {
|
||||
createPage.mutate(
|
||||
{
|
||||
...form.getValues(),
|
||||
body: editorContent,
|
||||
},
|
||||
{
|
||||
onSuccess: () => navigate('../', {relative: 'path'}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FullPageLoader />}>
|
||||
<ArticleBodyEditor>
|
||||
{(content, editor) => (
|
||||
<FileUploadProvider>
|
||||
<FormProvider {...form}>
|
||||
<ArticleEditorStickyHeader
|
||||
editor={editor}
|
||||
isLoading={createPage.isPending}
|
||||
onSave={handleSave}
|
||||
backLink="../"
|
||||
/>
|
||||
<div className="mx-20">
|
||||
<div className="prose dark:prose-invert mx-auto flex-auto">
|
||||
<ArticleEditorTitle />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</FileUploadProvider>
|
||||
)}
|
||||
</ArticleBodyEditor>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
45
common/resources/client/admin/custom-pages/custom-page-datable-page.tsx
Executable file
45
common/resources/client/admin/custom-pages/custom-page-datable-page.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import React, {useContext, useMemo} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {DataTablePage} from '../../datatable/page/data-table-page';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';
|
||||
import articlesSvg from './articles.svg';
|
||||
import {DataTableAddItemButton} from '../../datatable/data-table-add-item-button';
|
||||
import {CustomPageDatatableFilters} from './custom-page-datatable-filters';
|
||||
import {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';
|
||||
import {CustomPageDatatableColumns} from '@common/admin/custom-pages/custom-page-datatable-columns';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
|
||||
export function CustomPageDatablePage() {
|
||||
const config = useContext(SiteConfigContext);
|
||||
const filters = useMemo(() => {
|
||||
return CustomPageDatatableFilters(config);
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="custom-pages"
|
||||
title={<Trans message="Custom pages" />}
|
||||
filters={filters}
|
||||
columns={CustomPageDatatableColumns}
|
||||
queryParams={{with: 'user'}}
|
||||
actions={<Actions />}
|
||||
selectedActions={<DeleteSelectedItemsAction />}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={articlesSvg}
|
||||
title={<Trans message="No pages have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching pages" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
return (
|
||||
<DataTableAddItemButton elementType={Link} to="new">
|
||||
<Trans message="New page" />
|
||||
</DataTableAddItemButton>
|
||||
);
|
||||
}
|
||||
70
common/resources/client/admin/custom-pages/custom-page-datatable-columns.tsx
Executable file
70
common/resources/client/admin/custom-pages/custom-page-datatable-columns.tsx
Executable file
@@ -0,0 +1,70 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {CustomPage} from '@common/admin/custom-pages/custom-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {LinkStyle} from '@common/ui/buttons/external-link';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import React from 'react';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
|
||||
export const CustomPageDatatableColumns: ColumnConfig<CustomPage>[] = [
|
||||
{
|
||||
key: 'slug',
|
||||
allowsSorting: true,
|
||||
width: 'flex-2 min-w-200',
|
||||
visibleInMode: 'all',
|
||||
header: () => <Trans message="Slug" />,
|
||||
body: page => (
|
||||
<Link target="_blank" to={`/pages/${page.slug}`} className={LinkStyle}>
|
||||
{page.slug}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'user_id',
|
||||
allowsSorting: true,
|
||||
width: 'flex-2 min-w-140',
|
||||
header: () => <Trans message="Owner" />,
|
||||
body: page =>
|
||||
page.user && (
|
||||
<NameWithAvatar
|
||||
image={page.user.avatar}
|
||||
label={page.user.display_name}
|
||||
description={page.user.email}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="Type" />,
|
||||
body: page => <Trans message={page.type} />,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
allowsSorting: true,
|
||||
width: 'w-100',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: page => <FormattedDate date={page.updated_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-84 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: page => (
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
elementType={Link}
|
||||
to={`${page.id}/edit`}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
58
common/resources/client/admin/custom-pages/custom-page-datatable-filters.tsx
Executable file
58
common/resources/client/admin/custom-pages/custom-page-datatable-filters.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '../../datatable/filters/backend-filter';
|
||||
import {message} from '../../i18n/message';
|
||||
import {USER_MODEL} from '../../auth/user';
|
||||
import {SiteConfigContextValue} from '@common/core/settings/site-config-context';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const CustomPageDatatableFilters = (
|
||||
config: SiteConfigContextValue
|
||||
): BackendFilter[] => {
|
||||
const dynamicFilters: BackendFilter[] =
|
||||
config.customPages.types.length > 1
|
||||
? [
|
||||
{
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: 'default',
|
||||
options: config.customPages.types.map(type => ({
|
||||
value: type.type,
|
||||
label: type.label,
|
||||
key: type.type,
|
||||
})),
|
||||
},
|
||||
|
||||
key: 'type',
|
||||
label: message('Type'),
|
||||
description: message('Type of the page'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'user_id',
|
||||
label: message('User'),
|
||||
description: message('User page was created by'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: USER_MODEL,
|
||||
},
|
||||
},
|
||||
...dynamicFilters,
|
||||
createdAtFilter({
|
||||
description: message('Date page was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date page was last updated'),
|
||||
}),
|
||||
];
|
||||
};
|
||||
16
common/resources/client/admin/custom-pages/custom-page.ts
Executable file
16
common/resources/client/admin/custom-pages/custom-page.ts
Executable file
@@ -0,0 +1,16 @@
|
||||
import {User} from '../../auth/user';
|
||||
|
||||
export interface CustomPage {
|
||||
id: number;
|
||||
title?: string;
|
||||
body: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
user?: User;
|
||||
user_id?: number;
|
||||
hide_nav: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
model_type: 'customPage';
|
||||
}
|
||||
84
common/resources/client/admin/custom-pages/edit-custom-page.tsx
Executable file
84
common/resources/client/admin/custom-pages/edit-custom-page.tsx
Executable file
@@ -0,0 +1,84 @@
|
||||
import {useCustomPage} from '@common/custom-page/use-custom-page';
|
||||
import React, {Fragment, Suspense} from 'react';
|
||||
import {PageMetaTags} from '@common/http/page-meta-tags';
|
||||
import {PageStatus} from '@common/http/page-status';
|
||||
import {CustomPage} from '@common/admin/custom-pages/custom-page';
|
||||
import {FormProvider, useForm} from 'react-hook-form';
|
||||
import {useUpdateCustomPage} from '@common/admin/custom-pages/requests/use-update-custom-page';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {ArticleEditorTitle} from '@common/article-editor/article-editor-title';
|
||||
import {ArticleEditorStickyHeader} from '@common/article-editor/article-editor-sticky-header';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
|
||||
const ArticleBodyEditor = React.lazy(
|
||||
() => import('@common/article-editor/article-body-editor'),
|
||||
);
|
||||
|
||||
export function EditCustomPage() {
|
||||
const query = useCustomPage();
|
||||
|
||||
return query.data ? (
|
||||
<Fragment>
|
||||
<PageMetaTags query={query} />
|
||||
<PageContent page={query.data.page} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<div className="relative w-full h-full">
|
||||
<PageStatus query={query} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageContentProps {
|
||||
page: CustomPage;
|
||||
}
|
||||
function PageContent({page}: PageContentProps) {
|
||||
const navigate = useNavigate();
|
||||
const crupdatePage = useUpdateCustomPage();
|
||||
const form = useForm<CreateCustomPagePayload>({
|
||||
defaultValues: {
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
body: page.body,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (editorContent: string) => {
|
||||
crupdatePage.mutate(
|
||||
{
|
||||
...form.getValues(),
|
||||
body: editorContent,
|
||||
},
|
||||
{
|
||||
onSuccess: () => navigate('../..', {relative: 'path'}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense fallback={<FullPageLoader />}>
|
||||
<ArticleBodyEditor initialContent={page.body}>
|
||||
{(content, editor) => (
|
||||
<FileUploadProvider>
|
||||
<FormProvider {...form}>
|
||||
<ArticleEditorStickyHeader
|
||||
editor={editor}
|
||||
backLink="../.."
|
||||
isLoading={crupdatePage.isPending}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<div className="mx-20">
|
||||
<div className="prose dark:prose-invert mx-auto flex-auto">
|
||||
<ArticleEditorTitle />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
</FileUploadProvider>
|
||||
)}
|
||||
</ArticleBodyEditor>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {CustomPage} from '../custom-page';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
page: CustomPage;
|
||||
}
|
||||
|
||||
export interface CreateCustomPagePayload {
|
||||
title?: string;
|
||||
body?: string;
|
||||
slug?: string;
|
||||
hide_nav?: boolean;
|
||||
}
|
||||
|
||||
export function useCreateCustomPage(endpoint?: string) {
|
||||
const finalEndpoint = endpoint || 'custom-pages';
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateCustomPagePayload) =>
|
||||
createPage(payload, finalEndpoint),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['custom-pages']});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey(finalEndpoint),
|
||||
});
|
||||
toast(message('Page created'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPage(
|
||||
payload: CreateCustomPagePayload,
|
||||
endpoint: string,
|
||||
): Promise<Response> {
|
||||
return apiClient.post(`${endpoint}`, payload).then(r => r.data);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {CustomPage} from '../custom-page';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {CreateCustomPagePayload} from '@common/admin/custom-pages/requests/use-create-custom-page';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
page: CustomPage;
|
||||
}
|
||||
|
||||
export function useUpdateCustomPage(endpoint?: string) {
|
||||
const {pageId} = useParams();
|
||||
const finalEndpoint = `${endpoint || 'custom-pages'}/${pageId}`;
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateCustomPagePayload) =>
|
||||
updatePage(payload, finalEndpoint),
|
||||
onError: err => showHttpErrorToast(err),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['custom-pages']});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey(finalEndpoint),
|
||||
});
|
||||
toast(message('Page updated'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updatePage(
|
||||
payload: CreateCustomPagePayload,
|
||||
endpoint: string,
|
||||
): Promise<Response> {
|
||||
return apiClient.put(`${endpoint}`, payload).then(r => r.data);
|
||||
}
|
||||
101
common/resources/client/admin/file-entry/file-entry-index-filters.ts
Executable file
101
common/resources/client/admin/file-entry/file-entry-index-filters.ts
Executable file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
FilterSelectControl,
|
||||
} from '../../datatable/filters/backend-filter';
|
||||
import {message} from '../../i18n/message';
|
||||
import {USER_MODEL} from '../../auth/user';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const FILE_ENTRY_TYPE_FILTER: BackendFilter<FilterSelectControl> = {
|
||||
key: 'type',
|
||||
label: message('Type'),
|
||||
description: message('Type of the file'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: '05',
|
||||
options: [
|
||||
{key: '02', label: message('Text'), value: 'text'},
|
||||
{
|
||||
key: '03',
|
||||
label: message('Audio'),
|
||||
value: 'audio',
|
||||
},
|
||||
{
|
||||
key: '04',
|
||||
label: message('Video'),
|
||||
value: 'video',
|
||||
},
|
||||
{
|
||||
key: '05',
|
||||
label: message('Image'),
|
||||
value: 'image',
|
||||
},
|
||||
{key: '06', label: message('PDF'), value: 'pdf'},
|
||||
{
|
||||
key: '07',
|
||||
label: message('Spreadsheet'),
|
||||
value: 'spreadsheet',
|
||||
},
|
||||
{
|
||||
key: '08',
|
||||
label: message('Word Document'),
|
||||
value: 'word',
|
||||
},
|
||||
{
|
||||
key: '09',
|
||||
label: message('Photoshop'),
|
||||
value: 'photoshop',
|
||||
},
|
||||
{
|
||||
key: '10',
|
||||
label: message('Archive'),
|
||||
value: 'archive',
|
||||
},
|
||||
{
|
||||
key: '11',
|
||||
label: message('Folder'),
|
||||
value: 'folder',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FILE_ENTRY_INDEX_FILTERS: BackendFilter[] = [
|
||||
FILE_ENTRY_TYPE_FILTER,
|
||||
{
|
||||
key: 'public',
|
||||
label: message('Visibility'),
|
||||
description: message('Whether file is publicly accessible'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: '01',
|
||||
options: [
|
||||
{key: '01', label: message('Private'), value: false},
|
||||
{key: '02', label: message('Public'), value: true},
|
||||
],
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date file was uploaded'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date file was last changed'),
|
||||
}),
|
||||
{
|
||||
key: 'owner_id',
|
||||
label: message('Uploader'),
|
||||
description: message('User that this file was uploaded by'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: USER_MODEL,
|
||||
},
|
||||
},
|
||||
];
|
||||
130
common/resources/client/admin/file-entry/file-entry-index-page.tsx
Executable file
130
common/resources/client/admin/file-entry/file-entry-index-page.tsx
Executable file
@@ -0,0 +1,130 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {DataTablePage} from '../../datatable/page/data-table-page';
|
||||
import {IconButton} from '../../ui/buttons/icon-button';
|
||||
import {FormattedDate} from '../../i18n/formatted-date';
|
||||
import {ColumnConfig} from '../../datatable/column-config';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {DeleteSelectedItemsAction} from '../../datatable/page/delete-selected-items-action';
|
||||
import {DataTableEmptyStateMessage} from '../../datatable/page/data-table-emty-state-message';
|
||||
import {DialogTrigger} from '../../ui/overlays/dialog/dialog-trigger';
|
||||
import {FileEntry} from '../../uploads/file-entry';
|
||||
import {NameWithAvatar} from '../../datatable/column-templates/name-with-avatar';
|
||||
import {User} from '../../auth/user';
|
||||
import {CheckIcon} from '../../icons/material/Check';
|
||||
import {CloseIcon} from '../../icons/material/Close';
|
||||
import {FormattedBytes} from '../../uploads/formatted-bytes';
|
||||
import {VisibilityIcon} from '../../icons/material/Visibility';
|
||||
import uploadSvg from './upload.svg';
|
||||
import {FilePreviewDialog} from '../../uploads/preview/file-preview-dialog';
|
||||
import {FILE_ENTRY_INDEX_FILTERS} from './file-entry-index-filters';
|
||||
import {FileTypeIcon} from '../../uploads/file-type-icon/file-type-icon';
|
||||
|
||||
const columnConfig: ColumnConfig<FileEntry>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
allowsSorting: true,
|
||||
visibleInMode: 'all',
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Name" />,
|
||||
body: entry => (
|
||||
<Fragment>
|
||||
<div className="overflow-x-hidden overflow-ellipsis">{entry.name}</div>
|
||||
<div className="text-muted text-xs overflow-x-hidden overflow-ellipsis">
|
||||
{entry.file_name}
|
||||
</div>
|
||||
</Fragment>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'owner_id',
|
||||
allowsSorting: true,
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Uploader" />,
|
||||
body: entry => {
|
||||
const user = entry.users?.[0] as User;
|
||||
if (!user) return null;
|
||||
return (
|
||||
<NameWithAvatar
|
||||
image={user.avatar}
|
||||
label={user.display_name}
|
||||
description={user.email}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
width: 'w-100 flex-shrink-0',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Type" />,
|
||||
body: entry => (
|
||||
<div className="flex items-center gap-12">
|
||||
<FileTypeIcon type={entry.type} className="w-24 h-24 overflow-hidden" />
|
||||
<div className="capitalize">{entry.type}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'public',
|
||||
allowsSorting: true,
|
||||
width: 'w-60 flex-shrink-0',
|
||||
header: () => <Trans message="Public" />,
|
||||
body: entry =>
|
||||
entry.public ? (
|
||||
<CheckIcon className="icon-md text-positive" />
|
||||
) : (
|
||||
<CloseIcon className="icon-md text-danger" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'file_size',
|
||||
allowsSorting: true,
|
||||
maxWidth: 'max-w-100',
|
||||
header: () => <Trans message="File size" />,
|
||||
body: entry => <FormattedBytes bytes={entry.file_size} />,
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
allowsSorting: true,
|
||||
width: 'w-100',
|
||||
header: () => <Trans message="Last updated" />,
|
||||
body: entry => <FormattedDate date={entry.updated_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: entry => {
|
||||
return (
|
||||
<DialogTrigger type="modal">
|
||||
<IconButton size="md" className="text-muted">
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
<FilePreviewDialog entries={[entry]} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function FileEntryIndexPage() {
|
||||
return (
|
||||
<DataTablePage
|
||||
endpoint="file-entries"
|
||||
title={<Trans message="Uploaded files and folders" />}
|
||||
columns={columnConfig}
|
||||
filters={FILE_ENTRY_INDEX_FILTERS}
|
||||
selectedActions={<DeleteSelectedItemsAction />}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={uploadSvg}
|
||||
title={<Trans message="Nothing has been uploaded yet" />}
|
||||
filteringTitle={<Trans message="No matching files or folders" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/admin/file-entry/upload.svg
Executable file
1
common/resources/client/admin/file-entry/upload.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
1
common/resources/client/admin/logging/error/bug-fixing.svg
Executable file
1
common/resources/client/admin/logging/error/bug-fixing.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 40 KiB |
44
common/resources/client/admin/logging/error/error-log-datatable-columns.tsx
Executable file
44
common/resources/client/admin/logging/error/error-log-datatable-columns.tsx
Executable file
@@ -0,0 +1,44 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {ErrorLogItem} from '@common/admin/logging/error/error-log-item';
|
||||
import {InfoIcon} from '@common/icons/material/Info';
|
||||
import {ErrorIcon} from '@common/icons/material/Error';
|
||||
import clsx from 'clsx';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
|
||||
export const ErrorLogDatatableColumns: ColumnConfig<ErrorLogItem>[] = [
|
||||
{
|
||||
key: 'message',
|
||||
visibleInMode: 'all',
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Message" />,
|
||||
body: item => item.message,
|
||||
},
|
||||
{
|
||||
key: 'datetime',
|
||||
header: () => <Trans message="Date" />,
|
||||
body: item => <FormattedRelativeTime date={item.datetime} />,
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: () => <Trans message="Severity" />,
|
||||
body: item => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-6 text-xs capitalize',
|
||||
item.level === 'error' ? 'text-danger' : 'text-primary',
|
||||
)}
|
||||
>
|
||||
{item.level === 'error' ? (
|
||||
<ErrorIcon size="sm" />
|
||||
) : (
|
||||
<InfoIcon size="sm" />
|
||||
)}
|
||||
{item.level}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
161
common/resources/client/admin/logging/error/error-log-datatable.tsx
Executable file
161
common/resources/client/admin/logging/error/error-log-datatable.tsx
Executable file
@@ -0,0 +1,161 @@
|
||||
import {DataTablePage} from '@common/datatable/page/data-table-page';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import React, {Fragment, useEffect, useRef, useState} from 'react';
|
||||
import bugFixingImage from '@common/admin/logging/error/bug-fixing.svg';
|
||||
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
|
||||
import {DownloadIcon} from '@common/icons/material/Download';
|
||||
import {ErrorLogDatatableColumns} from '@common/admin/logging/error/error-log-datatable-columns';
|
||||
import {closeDialog, openDialog} from '@common/ui/overlays/store/dialog-store';
|
||||
import {ErrorLogEntryDialog} from '@common/admin/logging/error/error-log-entry-dialog';
|
||||
import {useDataTable} from '@common/datatable/page/data-table-context';
|
||||
import {Select} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {ErrorLogItem} from '@common/admin/logging/error/error-log-item';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteErrorLog} from '@common/admin/logging/error/use-delete-error-log';
|
||||
import {FormattedBytes} from '@common/uploads/formatted-bytes';
|
||||
|
||||
interface ErrorLogFile {
|
||||
name: string;
|
||||
identifier: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function ErrorLogDatatable() {
|
||||
return (
|
||||
<DataTablePage
|
||||
padding="pt-12 md:pt-24"
|
||||
endpoint="logs/error"
|
||||
title={<Trans message="Error log" />}
|
||||
onRowAction={item => {
|
||||
openDialog(ErrorLogEntryDialog, {error: item});
|
||||
}}
|
||||
columns={ErrorLogDatatableColumns}
|
||||
actions={<Actions />}
|
||||
enableSelection={false}
|
||||
emptyStateMessage={
|
||||
<DataTableEmptyStateMessage
|
||||
image={bugFixingImage}
|
||||
title={<Trans message="No errors have been logged yet" />}
|
||||
filteringTitle={<Trans message="No matching error log entries" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Actions() {
|
||||
const {query, setParams} = useDataTable<
|
||||
ErrorLogItem,
|
||||
{files: ErrorLogFile[]; selectedFile?: string}
|
||||
>();
|
||||
|
||||
const setOnce = useRef(false);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
// set initial selected file once files are loaded
|
||||
useEffect(() => {
|
||||
if (query.data?.files?.length && !setOnce.current) {
|
||||
setOnce.current = true;
|
||||
const firstFile = query.data.files[0].identifier;
|
||||
setSelectedFile(query.data.files[0].identifier);
|
||||
// prevent unnecessary http call
|
||||
if (firstFile !== query.data.selectedFile) {
|
||||
setParams({file: query.data.files[0].identifier});
|
||||
}
|
||||
}
|
||||
}, [query.data, setParams, setOnce]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FileSelector
|
||||
files={query.data?.files ?? null}
|
||||
selectedFile={selectedFile}
|
||||
onSelected={file => {
|
||||
setSelectedFile(file.identifier);
|
||||
setParams({file: file.identifier});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="danger"
|
||||
disabled={!selectedFile}
|
||||
onClick={() =>
|
||||
openDialog(ConfirmDeleteDialog, {identifier: selectedFile})
|
||||
}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
{selectedFile && (
|
||||
<DataTableAddItemButton
|
||||
elementType="a"
|
||||
download={
|
||||
query.data?.files.find(f => f.identifier === selectedFile)?.name
|
||||
}
|
||||
href={`api/v1/logs/error/${selectedFile}/download`}
|
||||
icon={<DownloadIcon />}
|
||||
>
|
||||
<Trans message="Download log" />
|
||||
</DataTableAddItemButton>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileSelectorProps {
|
||||
files: ErrorLogFile[] | null;
|
||||
selectedFile: string | null;
|
||||
onSelected: (file: ErrorLogFile) => void;
|
||||
}
|
||||
function FileSelector({files, selectedFile, onSelected}: FileSelectorProps) {
|
||||
// files have not loaded yet, show skeleton
|
||||
if (!files) {
|
||||
return <Skeleton variant="rect" className="max-w-[210px]" />;
|
||||
}
|
||||
|
||||
// no error logs yet, hide select completely
|
||||
if (!files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
selectionMode="single"
|
||||
selectedValue={selectedFile}
|
||||
size="sm"
|
||||
minWidth="min-w-[210px]"
|
||||
>
|
||||
{files?.map(file => (
|
||||
<Item
|
||||
key={file.identifier}
|
||||
value={file.identifier}
|
||||
onSelected={() => onSelected(file)}
|
||||
>
|
||||
{file.name} (<FormattedBytes bytes={file.size} />)
|
||||
</Item>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
identifier: string;
|
||||
}
|
||||
function ConfirmDeleteDialog({identifier}: ConfirmDeleteDialogProps) {
|
||||
const deleteLog = useDeleteErrorLog();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
title={<Trans message="Delete log file" />}
|
||||
body={<Trans message="Are you sure you want to delete this log file?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
onConfirm={() =>
|
||||
deleteLog.mutate({identifier}, {onSuccess: () => closeDialog()})
|
||||
}
|
||||
isLoading={deleteLog.isPending}
|
||||
isDanger
|
||||
/>
|
||||
);
|
||||
}
|
||||
50
common/resources/client/admin/logging/error/error-log-entry-dialog.tsx
Executable file
50
common/resources/client/admin/logging/error/error-log-entry-dialog.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ErrorLogItem} from '@common/admin/logging/error/error-log-item';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
interface Props {
|
||||
error: ErrorLogItem;
|
||||
}
|
||||
export function ErrorLogEntryDialog({error}: Props) {
|
||||
return (
|
||||
<Dialog size="fullscreen">
|
||||
<DialogHeader
|
||||
showDivider
|
||||
padding="px-24 py-10"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => downloadLogItem(error)}
|
||||
>
|
||||
<Trans message="Download" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Trans message="Error details" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<pre className="whitespace-pre-wrap break-words text-xs leading-5">
|
||||
{error.exception}
|
||||
</pre>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadLogItem(item: ErrorLogItem) {
|
||||
const el = document.createElement('a');
|
||||
el.setAttribute(
|
||||
'href',
|
||||
'data:text/plain;charset=utf-8,' + encodeURIComponent(item.exception),
|
||||
);
|
||||
el.setAttribute('download', `error-${item.id}.log`);
|
||||
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
el.click();
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
8
common/resources/client/admin/logging/error/error-log-item.tsx
Executable file
8
common/resources/client/admin/logging/error/error-log-item.tsx
Executable file
@@ -0,0 +1,8 @@
|
||||
export interface ErrorLogItem {
|
||||
id: number;
|
||||
index: number;
|
||||
level: string;
|
||||
datetime: string;
|
||||
message: string;
|
||||
exception: string;
|
||||
}
|
||||
29
common/resources/client/admin/logging/error/use-delete-error-log.ts
Executable file
29
common/resources/client/admin/logging/error/use-delete-error-log.ts
Executable file
@@ -0,0 +1,29 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
|
||||
|
||||
interface Payload {
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
export function useDeleteErrorLog() {
|
||||
const {trans} = useTrans();
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => deleteLogFile(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: DatatableDataQueryKey('logs/error'),
|
||||
});
|
||||
toast(trans(message('Log file deleted')));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteLogFile({identifier}: Payload) {
|
||||
return apiClient.delete(`logs/error/${identifier}`).then(r => r.data);
|
||||
}
|
||||
29
common/resources/client/admin/logging/logs-page.tsx
Executable file
29
common/resources/client/admin/logging/logs-page.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import {Tabs} from '@common/ui/tabs/tabs';
|
||||
import {Tab} from '@common/ui/tabs/tab';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TabList} from '@common/ui/tabs/tab-list';
|
||||
import {Link, Outlet, useLocation} from 'react-router-dom';
|
||||
|
||||
const tabs = ['schedule', 'error', 'outgoing-email'];
|
||||
|
||||
export function LogsPage() {
|
||||
const {pathname} = useLocation();
|
||||
const activeTab = pathname.split('/').pop() as string;
|
||||
const activeIndex = tabs.includes(activeTab) ? tabs.indexOf(activeTab) : 0;
|
||||
return (
|
||||
<Tabs className="p-12 md:p-24" selectedTab={activeIndex}>
|
||||
<TabList>
|
||||
<Tab elementType={Link} to="/admin/logs/schedule" replace>
|
||||
<Trans message="Schedule" />
|
||||
</Tab>
|
||||
<Tab elementType={Link} to="/admin/logs/error" replace>
|
||||
<Trans message="Error" />
|
||||
</Tab>
|
||||
<Tab elementType={Link} to="/admin/logs/outgoing-email" replace>
|
||||
<Trans message="Email" />
|
||||
</Tab>
|
||||
</TabList>
|
||||
<Outlet />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
1
common/resources/client/admin/logging/outgoing-email/opened.svg
Executable file
1
common/resources/client/admin/logging/outgoing-email/opened.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="561" height="493" viewBox="0 0 561 493" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M876.03027,689.45c-.98047,1.37-1.97021,2.73-2.95019,4.08A16.82838,16.82838,0,0,1,863.5,696.5h-527a16.90383,16.90383,0,0,1-9.21-2.72c-.91016-1.2-1.81006-2.41-2.72022-3.62006l.91016-.5L592.27,541.78a16.01919,16.01919,0,0,1,15.47021-.02L875.12988,688.95Z" transform="translate(-319.5 -203.5)" fill="rgb(var(--be-primary))"/><path d="M863.5,378.5,632.28169,244.96964a64.023,64.023,0,0,0-63.98147-.03153L336.5,378.5a17.0241,17.0241,0,0,0-17,17v284a17.01984,17.01984,0,0,0,17,17h527a17.02879,17.02879,0,0,0,17-17v-284A17.02408,17.02408,0,0,0,863.5,378.5Zm15,301a15.03649,15.03649,0,0,1-15,15h-527a15.02706,15.02706,0,0,1-15-15v-284a15.01828,15.01828,0,0,1,15-15L568.30022,246.93811a64.023,64.023,0,0,1,63.98147.03153L863.5,380.5a15.01828,15.01828,0,0,1,15,15Z" transform="translate(-319.5 -203.5)" fill="#3f3d56"/><path d="M600.2998,539.18018a15.36345,15.36345,0,0,1-5.116-.8584l-.30249-.10694-.06128-.67236c-.18848.09277-.37866.18164-.56909.26563l-.20118.08837-.20141-.08886c-.42139-.18506-.83985-.39453-1.24365-.62207L408.5,433.73242V222.5A18.5208,18.5208,0,0,1,427,204H773a18.5208,18.5208,0,0,1,18.5,18.5V434.00244l-.25488.14356-183.25,103.04A15.75694,15.75694,0,0,1,600.2998,539.18018Z" transform="translate(-319.5 -203.5)" fill="#fff"/><path d="M600.2998,539.68018a15.85649,15.85649,0,0,1-5.282-.88672l-.60547-.21338-.02588-.28565-.33691.14795-.40234-.17676c-.43653-.19189-.86963-.40869-1.28784-.64453L408,434.02539V222.5a19.02154,19.02154,0,0,1,19-19H773a19.02162,19.02162,0,0,1,19,19V434.29492L608.24,537.62158A16.2527,16.2527,0,0,1,600.2998,539.68018Zm-4.01342-2.57666a14.49247,14.49247,0,0,0,10.97436-1.22559L790,433.125V222.5a17.01917,17.01917,0,0,0-17-17H427a17.01909,17.01909,0,0,0-17,17V432.85449l11.98962,6.7334,171.35047,96.29053q.34973.197.71.3706.36035-.17358.70923-.37011l1.34668-.75879Z" transform="translate(-319.5 -203.5)" fill="#3f3d56"/><path d="M876.06982,385.88,803.5,426.68,791,433.71,607.75,536.75a15.24213,15.24213,0,0,1-7.4502,1.93,14.91079,14.91079,0,0,1-4.9497-.83,12.05366,12.05366,0,0,1-1.3003-.5q-.61449-.27-1.1997-.6L421.5,440.46,409,433.44l-84.91992-47.72a1.011,1.011,0,0,1-.37988-1.37.99933.99933,0,0,1,1.35986-.38L409,431.14l12.5,7.02L593.83008,535a13.07441,13.07441,0,0,0,1.77978.83c.26026.1.53028.19.8003.27A13.26424,13.26424,0,0,0,606.77,535L791,431.42l12.5-7.03,71.58984-40.25a.99849.99849,0,1,1,.98,1.74Z" transform="translate(-319.5 -203.5)" fill="#3f3d56"/><path d="M483.5748,269.5h-28a8,8,0,0,1,0-16h28a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="rgb(var(--be-primary))"/><path d="M516.5748,296.5h-61a8,8,0,0,1,0-16h61a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="#e6e6e6"/><path d="M687,368.5H514a8,8,0,0,1,0-16H687a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="rgb(var(--be-primary))"/><path d="M703,399.5H497a8,8,0,0,1,0-16H703a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="#e6e6e6"/><path d="M703,429.5H497a8,8,0,0,1,0-16H703a8,8,0,0,1,0,16Z" transform="translate(-319.5 -203.5)" fill="#e6e6e6"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,117 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {useRerunScheduledCommand} from '@common/admin/logging/schedule/use-rerurun-scheduled-command';
|
||||
import {OutgoingEmailLogItem} from '@common/admin/logging/outgoing-email/outgoing-email-log-item';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {Chip, ChipProps} from '@common/ui/forms/input-field/chip-field/chip';
|
||||
import {VisibilityIcon} from '@common/icons/material/Visibility';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {OutgoingEmailLogEntryDialog} from '@common/admin/logging/outgoing-email/outgoing-email-log-entry-dialog';
|
||||
|
||||
export const OutgoingEmailLogDatatableColumns: ColumnConfig<OutgoingEmailLogItem>[] =
|
||||
[
|
||||
{
|
||||
key: 'message_id',
|
||||
allowsSorting: true,
|
||||
visibleInMode: 'all',
|
||||
width: 'flex-3 min-w-200',
|
||||
header: () => <Trans message="Subject" />,
|
||||
body: item => (
|
||||
<NameWithAvatar label={item.subject} description={item.message_id} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Status" />,
|
||||
body: item => {
|
||||
switch (item.status) {
|
||||
case 'sent':
|
||||
return (
|
||||
<StatusChip color="positive">
|
||||
<Trans message="Sent" />
|
||||
</StatusChip>
|
||||
);
|
||||
case 'not-sent':
|
||||
return (
|
||||
<StatusChip color={undefined}>
|
||||
<Trans message="Not sent" />
|
||||
</StatusChip>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<StatusChip color="danger">
|
||||
<Trans message="Error" />
|
||||
</StatusChip>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'from',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="From" />,
|
||||
body: item => item.from,
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="To" />,
|
||||
body: item => item.to,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
allowsSorting: true,
|
||||
header: () => <Trans message="Date" />,
|
||||
body: item => <FormattedRelativeTime date={item.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: item => <PreviewEmailButton item={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
interface PreviewButtonProps {
|
||||
item: OutgoingEmailLogItem;
|
||||
}
|
||||
function PreviewEmailButton({item}: PreviewButtonProps) {
|
||||
const rerunCommand = useRerunScheduledCommand();
|
||||
return (
|
||||
<DialogTrigger type="modal">
|
||||
<Tooltip label={<Trans message="Preview" />}>
|
||||
<IconButton
|
||||
size="md"
|
||||
className="text-muted"
|
||||
disabled={rerunCommand.isPending}
|
||||
onClick={() => {
|
||||
rerunCommand.mutate({id: item.id});
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<OutgoingEmailLogEntryDialog logItemId={item.id} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusChipProps {
|
||||
color: ChipProps['color'];
|
||||
children: ReactNode;
|
||||
}
|
||||
function StatusChip({color, children}: StatusChipProps) {
|
||||
return (
|
||||
<Chip color={color} size="xs" className="w-max min-w-50 text-center">
|
||||
{children}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {createdAtFilter} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const OutgoingEmailLogDatatableFilters: BackendFilter[] = [
|
||||
{
|
||||
key: 'status',
|
||||
label: message('Status'),
|
||||
description: message('Status of the outgoing email'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: '01',
|
||||
options: [
|
||||
{
|
||||
key: '01',
|
||||
label: message('Not sent'),
|
||||
value: 'no-sent',
|
||||
},
|
||||
{
|
||||
key: '02',
|
||||
label: message('Sent'),
|
||||
value: 'sent',
|
||||
},
|
||||
{
|
||||
key: '03',
|
||||
label: message('Error'),
|
||||
value: 'error',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date email send was attempted'),
|
||||
}),
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user