Skip to content

Commit b5e1c47

Browse files
committed
refactor(components): restructure PageManager into directory-based architecture
1 parent 78ab585 commit b5e1c47

10 files changed

Lines changed: 1252 additions & 1131 deletions

File tree

src/components/PageManager.jsx

Lines changed: 0 additions & 1130 deletions
This file was deleted.
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { useMemo, useState } from 'react'
2+
import PropTypes from 'prop-types'
3+
import { AlertCircle, FilePlus, RefreshCw, X } from 'lucide-react'
4+
import { normalizeTitle } from '../../utils/postUtils'
5+
import { sanitizeSlug, isValidSlug } from '../../utils/slug'
6+
import { parseJsonField, sanitizeInteger } from './formUtils'
7+
8+
const defaultHeroJson = JSON.stringify(
9+
{
10+
badge: 'Neue Seite',
11+
title: 'Titel der Seite',
12+
subtitle: 'Kurzbeschreibung deiner Seite',
13+
backgroundGradient: 'from-primary-600 to-primary-700',
14+
},
15+
null,
16+
2,
17+
)
18+
const defaultHeroTitle = normalizeTitle(JSON.parse(defaultHeroJson).title, '')
19+
const defaultLayoutConfig = {
20+
aboutSection: {
21+
title: '�ober diese Seite',
22+
},
23+
postsSection: {
24+
title: 'Beitr��ge',
25+
emptyTitle: 'Keine Beitr��ge vorhanden',
26+
emptyMessage: 'Sobald fǬr diese Seite Beitr��ge ver��ffentlicht werden, erscheinen sie hier.',
27+
countLabelSingular: '{count} ver��ffentlichter Beitrag',
28+
countLabelPlural: '{count} ver��ffentlichte Beitr��ge',
29+
},
30+
}
31+
const defaultLayoutJson = JSON.stringify(defaultLayoutConfig, null, 2)
32+
33+
const PageForm = ({ mode, initialData, onSubmit, onCancel, submitting }) => {
34+
const [title, setTitle] = useState(initialData?.title ?? '')
35+
const [slug, setSlug] = useState(initialData?.slug ?? '')
36+
const [description, setDescription] = useState(initialData?.description ?? '')
37+
const [navLabel, setNavLabel] = useState(initialData?.nav_label ?? '')
38+
const [showInNav, setShowInNav] = useState(Boolean(initialData?.show_in_nav))
39+
const [isPublished, setIsPublished] = useState(Boolean(initialData?.is_published))
40+
const [orderIndex, setOrderIndex] = useState(initialData?.order_index ?? 0)
41+
const [hero, setHero] = useState(
42+
initialData?.hero ? JSON.stringify(initialData.hero, null, 2) : defaultHeroJson,
43+
)
44+
const [layout, setLayout] = useState(
45+
initialData?.layout ? JSON.stringify(initialData.layout, null, 2) : defaultLayoutJson,
46+
)
47+
const [heroTitle, setHeroTitle] = useState(() => {
48+
if (initialData?.hero) {
49+
return normalizeTitle(initialData.hero.title ?? initialData.hero, initialData?.title ?? '')
50+
}
51+
return initialData?.title ?? defaultHeroTitle
52+
})
53+
const [error, setError] = useState(null)
54+
const formSanitizedSlug = useMemo(() => sanitizeSlug(slug), [slug])
55+
const slugHasInput = slug.trim().length > 0
56+
const slugHasInvalidCharacters = slugHasInput && !formSanitizedSlug
57+
const slugDiffersAfterSanitize =
58+
slugHasInput && formSanitizedSlug && formSanitizedSlug !== slug.trim()
59+
const handleSubmit = async (event) => {
60+
event.preventDefault()
61+
setError(null)
62+
try {
63+
const trimmedTitle = title.trim()
64+
const trimmedDescription = description.trim()
65+
const trimmedNavLabel = navLabel.trim()
66+
const heroPayloadRaw = parseJsonField(hero, 'Hero JSON')
67+
const heroPayload =
68+
typeof heroPayloadRaw === 'object' && heroPayloadRaw !== null ? { ...heroPayloadRaw } : {}
69+
const trimmedHeroTitle = heroTitle.trim()
70+
const trimmedSlug = slug.trim()
71+
const sanitizedSlug = sanitizeSlug(trimmedSlug)
72+
if (trimmedHeroTitle) {
73+
heroPayload.title = trimmedHeroTitle
74+
} else if (!heroPayload.title) {
75+
heroPayload.title = trimmedTitle
76+
}
77+
if (!trimmedTitle) {
78+
throw new Error('Titel darf nicht leer sein.')
79+
}
80+
if (!sanitizedSlug) {
81+
throw new Error('Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.')
82+
}
83+
if (!isValidSlug(sanitizedSlug)) {
84+
throw new Error('Slug ist ungültig.')
85+
}
86+
const payload = {
87+
title: trimmedTitle,
88+
slug: sanitizedSlug,
89+
description: trimmedDescription,
90+
nav_label: trimmedNavLabel ? trimmedNavLabel : null,
91+
show_in_nav: showInNav,
92+
is_published: isPublished,
93+
order_index: sanitizeInteger(orderIndex),
94+
hero: heroPayload,
95+
layout: parseJsonField(layout, 'Layout JSON'),
96+
}
97+
await onSubmit(payload)
98+
setSlug(sanitizedSlug)
99+
} catch (err) {
100+
setError(err)
101+
}
102+
}
103+
return (
104+
<div className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto dark:bg-slate-900">
105+
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-slate-800">
106+
<div>
107+
<h3 className="text-xl font-semibold text-gray-900 dark:text-slate-100">
108+
{mode === 'edit' ? 'Seite bearbeiten' : 'Neue Seite erstellen'}
109+
</h3>
110+
<p className="text-sm text-gray-500 dark:text-slate-400">
111+
Slug und JSON-Konfiguration beeinflussen die Darstellung der Seite.
112+
</p>
113+
</div>
114+
<button
115+
type="button"
116+
onClick={onCancel}
117+
className="p-2 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800"
118+
>
119+
<X className="w-5 h-5" />
120+
</button>
121+
</div>
122+
<form onSubmit={handleSubmit} className="space-y-6 px-6 py-6">
123+
{error && (
124+
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-300">
125+
<AlertCircle className="w-4 h-4 mt-0.5" />
126+
<div>
127+
<p className="font-medium">Speichern fehlgeschlagen</p>
128+
<p>{error.message}</p>
129+
</div>
130+
</div>
131+
)}
132+
<div className="grid gap-4 md:grid-cols-2">
133+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
134+
Titel
135+
<input
136+
type="text"
137+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
138+
value={title}
139+
onChange={(event) => setTitle(event.target.value)}
140+
required
141+
/>
142+
</label>
143+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
144+
Slug
145+
<input
146+
type="text"
147+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
148+
value={slug}
149+
onChange={(event) => setSlug(event.target.value)}
150+
onBlur={() => setSlug(formSanitizedSlug)}
151+
required
152+
/>
153+
{slugHasInvalidCharacters && (
154+
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
155+
Nur Kleinbuchstaben, Zahlen und Bindestriche erlaubt.
156+
</p>
157+
)}
158+
{slugDiffersAfterSanitize && !slugHasInvalidCharacters && (
159+
<p className="mt-1 text-xs text-gray-500 dark:text-slate-400">
160+
Gespeicherter Slug:{' '}
161+
<code className="rounded bg-gray-100 px-1 py-0.5 text-[11px] dark:bg-slate-800 dark:text-slate-200">{formSanitizedSlug}</code>
162+
</p>
163+
)}
164+
</label>
165+
</div>
166+
<div className="grid gap-4 md:grid-cols-2">
167+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
168+
Navigationstitel
169+
<input
170+
type="text"
171+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
172+
value={navLabel}
173+
onChange={(event) => setNavLabel(event.target.value)}
174+
/>
175+
</label>
176+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
177+
Reihenfolge (Navigation)
178+
<input
179+
type="number"
180+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
181+
value={orderIndex}
182+
onChange={(event) => setOrderIndex(event.target.value)}
183+
/>
184+
</label>
185+
</div>
186+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
187+
Beschreibung
188+
<textarea
189+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
190+
rows={3}
191+
value={description}
192+
onChange={(event) => setDescription(event.target.value)}
193+
placeholder="Kurzbeschreibung der Seite"
194+
/>
195+
</label>
196+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
197+
Hero-Titel
198+
<input
199+
type="text"
200+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
201+
value={heroTitle}
202+
onChange={(event) => {
203+
const { value } = event.target
204+
setHeroTitle(value)
205+
const trimmedValue = value.trim()
206+
setHero((currentHero) => {
207+
try {
208+
const parsed = parseJsonField(currentHero, 'Hero JSON')
209+
const nextHero =
210+
typeof parsed === 'object' && parsed !== null ? { ...parsed } : {}
211+
if (trimmedValue) {
212+
nextHero.title = trimmedValue
213+
} else {
214+
delete nextHero.title
215+
}
216+
return JSON.stringify(nextHero, null, 2)
217+
} catch (err) {
218+
return currentHero
219+
}
220+
})
221+
}}
222+
placeholder="Titel im oberen Bereich der Seite"
223+
/>
224+
<span className="mt-1 block text-xs text-gray-500 dark:text-slate-400">
225+
Wird beim Speichern automatisch in das Hero JSON übernommen.
226+
</span>
227+
</label>
228+
<div className="flex flex-wrap items-center gap-4">
229+
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-slate-200">
230+
<input
231+
type="checkbox"
232+
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-slate-600 dark:bg-slate-900"
233+
checked={showInNav}
234+
onChange={(event) => setShowInNav(event.target.checked)}
235+
/>
236+
In Navigation anzeigen
237+
</label>
238+
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-slate-200">
239+
<input
240+
type="checkbox"
241+
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-slate-600 dark:bg-slate-900"
242+
checked={isPublished}
243+
onChange={(event) => setIsPublished(event.target.checked)}
244+
/>
245+
Veröffentlicht
246+
</label>
247+
</div>
248+
<div className="grid gap-4 lg:grid-cols-2">
249+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
250+
Hero JSON
251+
<textarea
252+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
253+
rows={8}
254+
value={hero}
255+
onChange={(event) => {
256+
const { value } = event.target
257+
setHero(value)
258+
try {
259+
const parsed = JSON.parse(value)
260+
const derivedTitle = normalizeTitle(parsed?.title ?? parsed, '').trim()
261+
setHeroTitle((previous) =>
262+
derivedTitle !== previous ? derivedTitle : previous,
263+
)
264+
} catch (err) {
265+
}
266+
}}
267+
/>
268+
</label>
269+
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200">
270+
Layout JSON
271+
<textarea
272+
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-primary-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
273+
rows={8}
274+
value={layout}
275+
onChange={(event) => setLayout(event.target.value)}
276+
/>
277+
<span className="mt-1 block text-xs text-gray-500 dark:text-slate-400">
278+
Verwendet u.a. <code>aboutSection.title</code>, <code>postsSection.title</code>,{' '}
279+
<code>postsSection.emptyTitle</code>, <code>postsSection.emptyMessage</code>.
280+
</span>
281+
</label>
282+
</div>
283+
<div className="flex justify-end gap-3 pt-2">
284+
<button
285+
type="button"
286+
onClick={onCancel}
287+
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
288+
>
289+
Abbrechen
290+
</button>
291+
<button
292+
type="submit"
293+
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-primary-600 to-primary-700 px-5 py-2.5 text-sm font-semibold text-white shadow-lg hover:from-primary-700 hover:to-primary-800"
294+
disabled={submitting}
295+
>
296+
{submitting ? (
297+
<RefreshCw className="h-4 w-4 animate-spin" />
298+
) : (
299+
<FilePlus className="h-4 w-4" />
300+
)}
301+
<span>{mode === 'edit' ? 'Änderungen speichern' : 'Seite erstellen'}</span>
302+
</button>
303+
</div>
304+
</form>
305+
</div>
306+
)
307+
}
308+
PageForm.propTypes = {
309+
mode: PropTypes.oneOf(['create', 'edit']).isRequired,
310+
initialData: PropTypes.object,
311+
onSubmit: PropTypes.func.isRequired,
312+
onCancel: PropTypes.func.isRequired,
313+
submitting: PropTypes.bool,
314+
}
315+
316+
export default PageForm

0 commit comments

Comments
 (0)