Refined navigation design in AdminX

refs. https://github.com/TryGhost/Team/issues/3432
This commit is contained in:
Peter Zimon 2023-06-15 20:42:03 +02:00
parent 5559b2234e
commit 5bfbd5a7a0
8 changed files with 97 additions and 53 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs></defs><title>add</title><line x1="0.75" y1="12" x2="23.25" y2="12" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5px"></line><line x1="12" y1="0.75" x2="12" y2="23.25" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5px"></line></svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs></defs><title>navigation-menu</title><line x1="2.25" y1="18.003" x2="21.75" y2="18.003" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5px"></line><line x1="2.25" y1="12.003" x2="21.75" y2="12.003" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5px"></line><line x1="2.25" y1="6.003" x2="21.75" y2="6.003" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5px"></line></svg>

After

Width:  |  Height:  |  Size: 587 B

View File

@ -17,6 +17,8 @@ interface TextFieldProps {
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
className?: string;
maxLength?: number;
containerClassName?: string;
unstyled?: boolean;
}
const TextField: React.FC<TextFieldProps> = ({
@ -33,33 +35,43 @@ const TextField: React.FC<TextFieldProps> = ({
onBlur,
className = '',
maxLength,
containerClassName = '',
unstyled = false,
...props
}) => {
const id = useId();
return (
<div className='flex flex-col'>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
<input
ref={inputRef}
className={clsx(
'h-10 border-b py-2',
clearBg ? 'bg-transparent' : 'bg-grey-75 px-[10px]',
error ? `border-red` : `border-grey-500 hover:border-grey-700 focus:border-black`,
(title && !hideTitle && !clearBg) && `mt-2`,
className
)}
id={id}
maxLength={maxLength}
placeholder={placeholder}
type={type}
value={value}
onBlur={onBlur}
onChange={onChange}
{...props} />
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div>
const textFieldClasses = !unstyled && clsx(
'h-10 border-b py-2',
clearBg ? 'bg-transparent' : 'bg-grey-75 px-[10px]',
error ? `border-red` : `border-grey-500 hover:border-grey-700 focus:border-black`,
(title && !hideTitle && !clearBg) && `mt-2`,
className
);
const field = <input
ref={inputRef}
className={textFieldClasses || className}
id={id}
maxLength={maxLength}
placeholder={placeholder}
type={type}
value={value}
onBlur={onBlur}
onChange={onChange}
{...props} />;
if (title || hint) {
return (
<div className={`flex flex-col ${containerClassName}`}>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={value ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{field}
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);
} else {
return field;
}
};
export default TextField;

View File

@ -6,11 +6,11 @@ import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
import useForm from '../../../hooks/useForm';
import {CustomThemeSetting, Post, Setting, SettingValue, SiteData} from '../../../types/api';
import {CustomThemeSetting, Post, Setting, SettingValue} from '../../../types/api';
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
import {ServicesContext} from '../../providers/ServiceProvider';
import {SettingsContext} from '../../providers/SettingsProvider';
import {getSettingValues} from '../../../utils/helpers';
import {getHomepageUrl, getSettingValues} from '../../../utils/helpers';
const Sidebar: React.FC<{
brandSettings: BrandSettingValues
@ -47,13 +47,6 @@ const Sidebar: React.FC<{
);
};
function getHomepageUrl(siteData: SiteData): string {
const url = new URL(siteData.url);
const subdir = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
return `${url.origin}${subdir}`;
}
const DesignModal: React.FC = () => {
const modal = useModal();

View File

@ -1,7 +1,7 @@
import Heading from '../../../admin-x-ds/global/Heading';
import Modal from '../../../admin-x-ds/global/modal/Modal';
import NavigationEditor from './navigation/NavigationEditor';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import TabView from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {NavigationItem} from './navigation/NavigationItemEditor';
import {getSettingValues} from '../../../utils/helpers';
@ -26,7 +26,8 @@ const NavigationModal = NiceModal.create(() => {
<Modal
buttonsDisabled={saveState === 'saving'}
scrolling={true}
size='full'
size='lg'
stickyFooter={true}
title='Navigation'
onCancel={() => modal.remove()}
onOk={async () => {
@ -34,10 +35,22 @@ const NavigationModal = NiceModal.create(() => {
modal.remove();
}}
>
<Heading className="mt-6" level={6}>Primary navigation</Heading>
<NavigationEditor baseUrl={siteData!.url} items={navigation} setItems={items => updateSetting('navigation', JSON.stringify(items))} />
<Heading level={6}>Secondary navigation</Heading>
<NavigationEditor baseUrl={siteData!.url} items={secondaryNavigation} setItems={items => updateSetting('secondary_navigation', JSON.stringify(items))} />
<div className='-mb-8 mt-6'>
<TabView
tabs={[
{
id: 'primary-nav',
title: 'Primary navigation',
contents: <NavigationEditor baseUrl={siteData!.url} items={navigation} setItems={items => updateSetting('navigation', JSON.stringify(items))} />
},
{
id: 'secondary-nav',
title: 'Secondary navigation',
contents: <NavigationEditor baseUrl={siteData!.url} items={secondaryNavigation} setItems={items => updateSetting('secondary_navigation', JSON.stringify(items))} />
}
]}
/>
</div>
</Modal>
);
});

View File

@ -31,7 +31,6 @@ const SortableItem: React.FC<{
<NavigationItemEditor
ref={setNodeRef}
baseUrl={baseUrl}
className="flex gap-4 p-2"
dragHandleProps={{...attributes, ...listeners}}
item={item}
style={style}
@ -49,7 +48,7 @@ const NavigationEditor: React.FC<{
// Copy items to a local state we can reorder without changing IDs, so that drag and drop animations work nicely
const [draggableItems, setLocalDraggableItems] = useState<DraggableItem[]>(items.map((item, index) => ({...item, id: index.toString()})));
const [newItem, setNewItem] = useState<NavigationItem>({label: '', url: ''});
const [newItem, setNewItem] = useState<NavigationItem>({label: '', url: baseUrl});
const [draggingId, setDraggingId] = useState<string | null>(null);
const setDraggableItems = (newItems: DraggableItem[]) => {
@ -62,8 +61,10 @@ const NavigationEditor: React.FC<{
};
const addItem = () => {
setDraggableItems(draggableItems.concat({...newItem, id: draggableItems.length.toString()}));
setNewItem({label: '', url: ''});
if (newItem.label && newItem.url) {
setDraggableItems(draggableItems.concat({...newItem, id: draggableItems.length.toString()}));
setNewItem({label: '', url: baseUrl});
}
};
const removeItem = (id: string) => {
@ -79,7 +80,7 @@ const NavigationEditor: React.FC<{
setDraggingId(null);
};
return <div className="mb-6 mt-4 rounded border border-grey-100 px-4 pb-4 pt-2">
return <div className="w-full">
<DndContext
collisionDetection={closestCenter}
onDragEnd={event => moveItem(event.active.id as string, event.over?.id as string)}
@ -105,10 +106,11 @@ const NavigationEditor: React.FC<{
</DragOverlay>
</DndContext>
<div className="flex gap-4 p-2 pl-14">
<TextField value={newItem.label} onChange={e => setNewItem({...newItem, label: e.target.value})} />
<TextField value={newItem.url} onChange={e => setNewItem({...newItem, url: e.target.value})} />
<Button icon="user-add" onClick={addItem} />
<div className="flex items-center gap-3 p-2">
<span className='inline-block w-8'></span>
<TextField className='grow' placeholder='New item label' value={newItem.label} onChange={e => setNewItem({...newItem, label: e.target.value})} />
<TextField className='ml-2 grow' value={newItem.url} onChange={e => setNewItem({...newItem, url: e.target.value})} />
<Button icon="add" iconColorClass='text-green stroke-2' size='sm' onClick={addItem} />
</div>
</div>;
};

View File

@ -1,4 +1,5 @@
import Button from '../../../../admin-x-ds/global/Button';
import Icon from '../../../../admin-x-ds/global/Icon';
import React, {forwardRef, useEffect, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import clsx from 'clsx';
@ -101,14 +102,28 @@ const NavigationItemEditor = forwardRef<HTMLDivElement, NavigationItemEditorProp
updateItem?.({url: save});
};
const containerClasses = clsx(
'flex w-full items-center gap-3 rounded border-b border-grey-200 bg-white py-4 hover:bg-grey-100',
isDragging && 'opacity-75'
);
const dragHandleClasses = clsx(
'ml-2 cursor-grab pl-2',
isDragging ? 'cursor-grabbing' : 'cursor-grap'
);
const textFieldClasses = clsx(
'grow border-b border-transparent bg-white px-2 py-0.5 hover:border-grey-300 focus:border-grey-600'
);
return (
<div ref={ref} className={clsx('flex gap-4 bg-white p-2', isDragging && 'opacity-75')} {...props}>
<button className="pl-3 pr-2" type="button" {...dragHandleProps}>
<span className="inline-block h-3 w-3 rounded-full bg-black" />
<div ref={ref} className={containerClasses} {...props}>
<button className={dragHandleClasses} type='button' {...dragHandleProps}>
<Icon colorClass='text-grey-500' name='hamburger' size='sm' />
</button>
<TextField value={item.label} onChange={e => updateItem?.({label: e.target.value})} />
<TextField value={urlValue} onBlur={updateUrl} onChange={e => setUrlValue(e.target.value)} />
<Button icon="trash" onClick={onDelete} />
<TextField className={textFieldClasses} value={item.label} unstyled onChange={e => updateItem?.({label: e.target.value})} />
<TextField className={textFieldClasses} value={urlValue} unstyled onBlur={updateUrl} onChange={e => setUrlValue(e.target.value)} />
<Button className='mr-2' icon="trash" size='sm' onClick={onDelete} />
</div>
);
});

View File

@ -1,4 +1,4 @@
import {Setting, SettingValue, User} from '../types/api';
import {Setting, SettingValue, SiteData, User} from '../types/api';
export interface IGhostPaths {
adminRoot: string;
@ -92,3 +92,10 @@ export function downloadFile(url: string) {
iframe.setAttribute('src', url);
}
export function getHomepageUrl(siteData: SiteData): string {
const url = new URL(siteData.url);
const subdir = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
return `${url.origin}${subdir}`;
}