x
This commit is contained in:
1 parent
78b722e120
commit
75770ca122
18 files changed
+1119
-318
No files matched your search
@@ -27,6 +27,7 @@ import MainNavigationPage from './pages/menu-manager/MainNavigationPage'
|
||||
import FooterExplorePage from './pages/menu-manager/FooterExplorePage'
|
||||
import FooterSupportPage from './pages/menu-manager/FooterSupportPage'
|
||||
import FooterBrandPage from './pages/menu-manager/FooterBrandPage'
|
||||
import FooterLayoutPage from './pages/menu-manager/FooterLayoutPage'
|
||||
import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage'
|
||||
import ArticlesPage from './pages/ArticlesPage'
|
||||
import ArticleEditPage from './pages/ArticleEditPage'
|
||||
@@ -125,6 +126,7 @@ function App() {
|
||||
<Route path="/menu-manager/footer-explore" element={<FooterExplorePage />} />
|
||||
<Route path="/menu-manager/footer-support" element={<FooterSupportPage />} />
|
||||
<Route path="/menu-manager/footer-brand" element={<FooterBrandPage />} />
|
||||
<Route path="/menu-manager/footer-layout" element={<FooterLayoutPage />} />
|
||||
<Route path="/subscribers" element={<NewsletterSubscribersPage />} />
|
||||
<Route path="/articles" element={<ArticlesPage />} />
|
||||
<Route path="/articles/:id" element={<ArticleEditPage />} />
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Map,
|
||||
Target,
|
||||
BrainCircuit,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
import { clearAdminAuth } from '../utils/auth'
|
||||
@@ -160,6 +161,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
{ icon: FileText, label: t('nav.footerExplore', 'Footer Explore'), to: '/menu-manager/footer-explore' },
|
||||
{ icon: MessageSquare, label: t('nav.footerSupport', 'Footer Support'), to: '/menu-manager/footer-support' },
|
||||
{ icon: Store, label: t('nav.footerBrand', 'Footer Brand'), to: '/menu-manager/footer-brand' },
|
||||
{ icon: LayoutTemplate, label: t('nav.footerLayout', 'Footer Layout & Presentation'), to: '/menu-manager/footer-layout' },
|
||||
]
|
||||
|
||||
const BACKUP_MENU = [
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import {
|
||||
API_BASE,
|
||||
DEFAULT_FOOTER_LAYOUT_SETTINGS,
|
||||
FooterLayoutSettingsSchema,
|
||||
type FooterLayoutSettings,
|
||||
} from '@homaroma/types'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
Save,
|
||||
} from 'lucide-react'
|
||||
import { getAuthHeaders } from '../../utils/auth'
|
||||
|
||||
type FooterColumn = FooterLayoutSettings['columns'][number]
|
||||
type SaveState = { type: 'success' | 'error'; message: string } | null
|
||||
|
||||
const COLUMN_OPTIONS: Array<{ value: FooterColumn; label: string; description: string }> = [
|
||||
{ value: 'brand', label: 'Brand', description: 'Logo, description and social links' },
|
||||
{ value: 'explore', label: 'Explore', description: 'Links from Footer Explore' },
|
||||
{ value: 'collections', label: 'Collections', description: 'Automatic category links' },
|
||||
{ value: 'support', label: 'Support', description: 'Links from Footer Support' },
|
||||
]
|
||||
|
||||
const SPACING_OPTIONS = ['sm', 'md', 'lg', 'xl'] as const
|
||||
const TEXT_SIZE_OPTIONS = ['sm', 'md', 'lg'] as const
|
||||
const CONTAINER_WIDTH_OPTIONS = ['narrow', 'standard', 'wide', 'full'] as const
|
||||
const FINALE_MODE_OPTIONS = ['static', 'curtain'] as const
|
||||
const MOBILE_HEIGHT_OPTIONS = ['compact', 'medium', 'large'] as const
|
||||
const DESKTOP_HEIGHT_OPTIONS = ['compact', 'half', 'screen'] as const
|
||||
|
||||
function selectedOption<Options extends readonly string[]>(
|
||||
value: string,
|
||||
options: Options,
|
||||
fallback: Options[number],
|
||||
): Options[number] {
|
||||
return options.find(option => option === value) ?? fallback
|
||||
}
|
||||
|
||||
function getErrorMessage(payload: unknown, fallback: string): string {
|
||||
if (!payload || typeof payload !== 'object') return fallback
|
||||
const record = payload as Record<string, unknown>
|
||||
const message = typeof record.error === 'string'
|
||||
? record.error
|
||||
: typeof record.message === 'string'
|
||||
? record.message
|
||||
: fallback
|
||||
if (!Array.isArray(record.issues)) return message
|
||||
|
||||
const details = record.issues.flatMap(issue => {
|
||||
if (!issue || typeof issue !== 'object') return []
|
||||
const issueRecord = issue as Record<string, unknown>
|
||||
const issueMessage = typeof issueRecord.message === 'string' ? issueRecord.message : null
|
||||
const path = Array.isArray(issueRecord.path) ? issueRecord.path.join('.') : ''
|
||||
return issueMessage ? [`${path ? `${path}: ` : ''}${issueMessage}`] : []
|
||||
})
|
||||
return details.length > 0 ? `${message} ${details.join('; ')}` : message
|
||||
}
|
||||
|
||||
function selectClassName(): string {
|
||||
return 'admin-input w-full cursor-pointer'
|
||||
}
|
||||
|
||||
function Preview({ layout, mobile }: { layout: FooterLayoutSettings; mobile: boolean }) {
|
||||
const visibleColumns = layout.columns
|
||||
const columnCount = mobile ? layout.mobileColumns : layout.desktopColumns
|
||||
const gridClass = columnCount === 1
|
||||
? 'grid-cols-1'
|
||||
: columnCount === 2
|
||||
? 'grid-cols-2'
|
||||
: columnCount === 3
|
||||
? 'grid-cols-3'
|
||||
: 'grid-cols-4'
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-white/10 bg-night-900 p-4 ${mobile ? 'max-w-64' : 'w-full'}`}>
|
||||
<div className="mb-3 flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-slate-500">
|
||||
<span>{mobile ? 'Mobile' : 'Desktop'}</span>
|
||||
<span>{columnCount} column{columnCount === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className={`grid gap-2 ${gridClass}`}>
|
||||
{visibleColumns.map(column => (
|
||||
<div key={column} className="min-h-14 rounded-lg border border-cyan-glow/20 bg-cyan-glow/5 p-2">
|
||||
<div className="h-1.5 w-2/3 rounded-full bg-cyan-glow/40" />
|
||||
<div className="mt-2 h-1 w-full rounded-full bg-white/15" />
|
||||
<div className="mt-1 h-1 w-4/5 rounded-full bg-white/10" />
|
||||
<span className="mt-2 block truncate text-[9px] capitalize text-slate-400">{column}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{layout.finale.enabled && (
|
||||
<div className="mt-3 flex h-8 items-center justify-center rounded-lg border border-purple-400/20 bg-purple-400/5 text-[9px] font-bold uppercase tracking-widest text-purple-200">
|
||||
{layout.finale.mode} finale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FooterLayoutPage() {
|
||||
const [layout, setLayout] = useState<FooterLayoutSettings>(() => structuredClone(DEFAULT_FOOTER_LAYOUT_SETTINGS))
|
||||
const [version, setVersion] = useState<string | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveState, setSaveState] = useState<SaveState>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
fetch(`${API_BASE}/settings/footer-layout`, { headers: getAuthHeaders() })
|
||||
.then(async response => {
|
||||
const payload: unknown = await response.json()
|
||||
if (!response.ok) throw new Error(getErrorMessage(payload, `Unable to load footer layout (${response.status})`))
|
||||
if (!payload || typeof payload !== 'object') throw new Error('Footer layout response is invalid.')
|
||||
const record = payload as Record<string, unknown>
|
||||
const parsed = FooterLayoutSettingsSchema.safeParse(record.layout)
|
||||
if (!parsed.success) throw new Error('Footer layout response does not match the shared contract.')
|
||||
if (typeof record.version !== 'string') throw new Error('Footer layout response is missing its version.')
|
||||
return {
|
||||
layout: parsed.data,
|
||||
version: record.version,
|
||||
}
|
||||
})
|
||||
.then(result => {
|
||||
if (cancelled) return
|
||||
setLayout(result.layout)
|
||||
setVersion(result.version)
|
||||
})
|
||||
.catch(error => {
|
||||
if (!cancelled) setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to load footer layout.' })
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const orderedColumns = useMemo(() => {
|
||||
const active = layout.columns
|
||||
.map(column => COLUMN_OPTIONS.find(option => option.value === column))
|
||||
.filter((option): option is (typeof COLUMN_OPTIONS)[number] => Boolean(option))
|
||||
const inactive = COLUMN_OPTIONS.filter(option => !layout.columns.includes(option.value))
|
||||
return [...active, ...inactive]
|
||||
}, [layout.columns])
|
||||
|
||||
const updateLayout = <Key extends keyof FooterLayoutSettings>(key: Key, value: FooterLayoutSettings[Key]) => {
|
||||
setLayout(current => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const updateFinale = <Key extends keyof FooterLayoutSettings['finale']>(
|
||||
key: Key,
|
||||
value: FooterLayoutSettings['finale'][Key],
|
||||
) => {
|
||||
setLayout(current => ({ ...current, finale: { ...current.finale, [key]: value } }))
|
||||
}
|
||||
|
||||
const toggleColumn = (column: FooterColumn) => {
|
||||
setLayout(current => {
|
||||
if (current.columns.includes(column)) {
|
||||
if (current.columns.length === 1) return current
|
||||
return { ...current, columns: current.columns.filter(item => item !== column) }
|
||||
}
|
||||
return { ...current, columns: [...current.columns, column] }
|
||||
})
|
||||
}
|
||||
|
||||
const moveColumn = (column: FooterColumn, direction: -1 | 1) => {
|
||||
setLayout(current => {
|
||||
const index = current.columns.indexOf(column)
|
||||
const destination = index + direction
|
||||
if (index < 0 || destination < 0 || destination >= current.columns.length) return current
|
||||
const columns = [...current.columns]
|
||||
columns.splice(index, 1)
|
||||
columns.splice(destination, 0, column)
|
||||
return { ...current, columns }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setSaveState(null)
|
||||
|
||||
const parsed = FooterLayoutSettingsSchema.safeParse(layout)
|
||||
if (!parsed.success) {
|
||||
setSaveState({ type: 'error', message: parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ') })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/settings/footer-layout`, {
|
||||
method: 'PATCH',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ ...parsed.data, expectedVersion: version }),
|
||||
})
|
||||
const payload: unknown = await response.json()
|
||||
if (!response.ok) {
|
||||
if (response.status === 409) throw new Error('Footer layout changed in another session. Reload this page before saving again.')
|
||||
throw new Error(getErrorMessage(payload, `Unable to save footer layout (${response.status})`))
|
||||
}
|
||||
if (!payload || typeof payload !== 'object') throw new Error('Footer layout save response is invalid.')
|
||||
const record = payload as Record<string, unknown>
|
||||
const savedLayout = FooterLayoutSettingsSchema.safeParse(record.layout)
|
||||
if (!savedLayout.success) throw new Error('Saved footer layout does not match the shared contract.')
|
||||
setLayout(savedLayout.data)
|
||||
setVersion(typeof record.version === 'string' ? record.version : version)
|
||||
setSaveState({ type: 'success', message: 'Footer layout saved successfully.' })
|
||||
} catch (error) {
|
||||
setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to save footer layout.' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center p-20" role="status" aria-label="Loading footer layout"><Loader2 className="h-8 w-8 animate-spin text-cyan-glow" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl space-y-8 animate-fade-in">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">Footer Layout & Presentation</h1>
|
||||
<p className="text-sm text-slate-400">Control responsive columns, spacing, typography and the brand finale independently from footer content.</p>
|
||||
</div>
|
||||
|
||||
{saveState && (
|
||||
<div role="status" className={`flex items-start gap-2 rounded-lg border px-4 py-3 text-sm ${saveState.type === 'success' ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200' : 'border-red-400/30 bg-red-400/10 text-red-200'}`}>
|
||||
{saveState.type === 'success' ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" /> : <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />}
|
||||
<span>{saveState.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSave} className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<div className="space-y-8">
|
||||
<section className="admin-card space-y-6 p-8">
|
||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||
<div className="rounded-lg bg-cyan-glow/10 p-2"><LayoutTemplate className="h-5 w-5 text-cyan-glow" /></div>
|
||||
<div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Column composition</h2><p className="text-xs text-slate-500">Choose which columns appear and their left-to-right order.</p></div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{orderedColumns.map(option => {
|
||||
const activeIndex = layout.columns.indexOf(option.value)
|
||||
const isVisible = activeIndex >= 0
|
||||
return (
|
||||
<div key={option.value} className={`flex items-center gap-4 rounded-xl border p-4 ${isVisible ? 'border-cyan-glow/20 bg-cyan-glow/5' : 'border-white/5 bg-white/[0.02]'}`}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button type="button" disabled={!isVisible || activeIndex === 0} onClick={() => moveColumn(option.value, -1)} className="rounded-md p-1 text-slate-400 hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-20" aria-label={`Move ${option.label} up`}><ChevronUp className="h-4 w-4" /></button>
|
||||
<button type="button" disabled={!isVisible || activeIndex === layout.columns.length - 1} onClick={() => moveColumn(option.value, 1)} className="rounded-md p-1 text-slate-400 hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-20" aria-label={`Move ${option.label} down`}><ChevronDown className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1"><p className="font-bold text-white">{option.label}</p><p className="text-xs text-slate-500">{option.description}</p></div>
|
||||
<button type="button" onClick={() => toggleColumn(option.value)} disabled={isVisible && layout.columns.length === 1} aria-pressed={isVisible} className="inline-flex min-w-24 items-center justify-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs font-bold text-slate-300 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{isVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}{isVisible ? 'Visible' : 'Hidden'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Mobile columns<select className={selectClassName()} value={layout.mobileColumns} onChange={event => updateLayout('mobileColumns', event.target.value === '2' ? 2 : 1)}><option value={1}>1 column</option><option value={2}>2 columns</option></select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Desktop columns<select className={selectClassName()} value={layout.desktopColumns} onChange={event => updateLayout('desktopColumns', event.target.value === '2' ? 2 : event.target.value === '3' ? 3 : 4)}><option value={2}>2 columns</option><option value={3}>3 columns</option><option value={4}>4 columns</option></select></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card space-y-6 p-8">
|
||||
<div className="border-b border-white/10 pb-4"><h2 className="text-lg font-bold uppercase tracking-widest text-white">Sizing & spacing</h2><p className="text-xs text-slate-500">Shared presentation scales keep SPA and Website rendering consistent.</p></div>
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Container width<select className={selectClassName()} value={layout.containerWidth} onChange={event => updateLayout('containerWidth', selectedOption(event.target.value, CONTAINER_WIDTH_OPTIONS, 'standard'))}>{CONTAINER_WIDTH_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Vertical padding<select className={selectClassName()} value={layout.verticalPadding} onChange={event => updateLayout('verticalPadding', selectedOption(event.target.value, SPACING_OPTIONS, 'lg'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Column gap<select className={selectClassName()} value={layout.columnGap} onChange={event => updateLayout('columnGap', selectedOption(event.target.value, SPACING_OPTIONS, 'lg'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Heading size<select className={selectClassName()} value={layout.headingSize} onChange={event => updateLayout('headingSize', selectedOption(event.target.value, TEXT_SIZE_OPTIONS, 'sm'))}>{TEXT_SIZE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Link size<select className={selectClassName()} value={layout.linkSize} onChange={event => updateLayout('linkSize', selectedOption(event.target.value, TEXT_SIZE_OPTIONS, 'md'))}>{TEXT_SIZE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Brand logo size<select className={selectClassName()} value={layout.brandLogoSize} onChange={event => updateLayout('brandLogoSize', selectedOption(event.target.value, SPACING_OPTIONS, 'md'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Social button size<select className={selectClassName()} value={layout.socialSize} onChange={event => updateLayout('socialSize', selectedOption(event.target.value, TEXT_SIZE_OPTIONS, 'md'))}>{TEXT_SIZE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card space-y-6 p-8">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-white/10 pb-4">
|
||||
<div><h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand finale</h2><p className="text-xs text-slate-500">Configure the visual brand moment above the footer.</p></div>
|
||||
<button type="button" onClick={() => updateFinale('enabled', !layout.finale.enabled)} aria-pressed={layout.finale.enabled} className={`rounded-full px-4 py-2 text-xs font-bold ${layout.finale.enabled ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400'}`}>{layout.finale.enabled ? 'Enabled' : 'Disabled'}</button>
|
||||
</div>
|
||||
<fieldset disabled={!layout.finale.enabled} className="grid gap-5 disabled:opacity-40 md:grid-cols-2">
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Mode<select className={selectClassName()} value={layout.finale.mode} onChange={event => updateFinale('mode', selectedOption(event.target.value, FINALE_MODE_OPTIONS, 'curtain'))}>{FINALE_MODE_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Finale logo size<select className={selectClassName()} value={layout.finale.logoSize} onChange={event => updateFinale('logoSize', selectedOption(event.target.value, SPACING_OPTIONS, 'xl'))}>{SPACING_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Mobile height<select className={selectClassName()} value={layout.finale.mobileHeight} onChange={event => updateFinale('mobileHeight', selectedOption(event.target.value, MOBILE_HEIGHT_OPTIONS, 'medium'))}>{MOBILE_HEIGHT_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
<label className="space-y-2 text-xs font-bold uppercase tracking-widest text-slate-400">Desktop height<select className={selectClassName()} value={layout.finale.desktopHeight} onChange={event => updateFinale('desktopHeight', selectedOption(event.target.value, DESKTOP_HEIGHT_OPTIONS, 'screen'))}>{DESKTOP_HEIGHT_OPTIONS.map(value => <option key={value} value={value}>{value}</option>)}</select></label>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<button type="submit" disabled={saving || !version} className="btn-primary ml-auto flex items-center gap-2 px-12 py-4 text-sm">{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}{saving ? 'Saving…' : 'Save footer layout'}</button>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4 xl:sticky xl:top-8 xl:self-start">
|
||||
<div className="admin-card space-y-5 p-6">
|
||||
<div><h2 className="text-sm font-bold uppercase tracking-widest text-white">Responsive preview</h2><p className="mt-1 text-xs text-slate-500">Structural preview; storefront theme and content are applied at render time.</p></div>
|
||||
<Preview layout={layout} mobile={false} />
|
||||
<Preview layout={layout} mobile />
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { API_BASE, type Menu } from '@homaroma/types'
|
||||
import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react'
|
||||
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react'
|
||||
import { ICON_REGISTRY } from '../../utils/iconRegistry'
|
||||
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
|
||||
|
||||
@@ -447,10 +447,25 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
}
|
||||
|
||||
const handleRemoveItem = (idx: number) => {
|
||||
const newItems = currentMenu.items.filter((_, i) => i !== idx)
|
||||
const newItems = currentMenu.items
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((item, order) => ({ ...item, order }))
|
||||
setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m))
|
||||
}
|
||||
|
||||
const handleMoveItem = (idx: number, direction: -1 | 1) => {
|
||||
const destination = idx + direction
|
||||
if (destination < 0 || destination >= currentMenu.items.length) return
|
||||
|
||||
const newItems = [...currentMenu.items]
|
||||
const [movedItem] = newItems.splice(idx, 1)
|
||||
if (!movedItem) return
|
||||
newItems.splice(destination, 0, movedItem)
|
||||
|
||||
const reorderedItems = newItems.map((item, order) => ({ ...item, order }))
|
||||
setMenus(menus.map(m => m.name === menuName ? { ...m, items: reorderedItems } : m))
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(currentMenu, null, 2)
|
||||
const blob = new Blob([dataStr], { type: 'application/json' })
|
||||
@@ -644,7 +659,28 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
) : (
|
||||
currentMenu.items.map((item, idx) => (
|
||||
<div key={item.id} className="flex gap-4 items-center bg-white/5 p-4 rounded-xl border border-white/5 group">
|
||||
<GripVertical className="w-5 h-5 text-slate-600 cursor-move" />
|
||||
<div className="flex shrink-0 flex-col gap-1" aria-label={`Reorder ${item.label || `item ${idx + 1}`}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveItem(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-25"
|
||||
title="Move item up"
|
||||
aria-label={`Move ${item.label || `item ${idx + 1}`} up`}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveItem(idx, 1)}
|
||||
disabled={idx === currentMenu.items.length - 1}
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-25"
|
||||
title="Move item down"
|
||||
aria-label={`Move ${item.label || `item ${idx + 1}`} down`}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
Reference in new issue
Block a user