@setzkasten-cms/ui 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ import { useState, useEffect } from 'react'
2
+ import type { SetzKastenConfig } from '@setzkasten-cms/core'
3
+ import { PageBuilder } from './page-builder'
4
+ import { ToastProvider } from './toast'
5
+ import { useConfig } from '../providers/setzkasten-provider'
6
+ import { Layers, Globe, Settings } from 'lucide-react'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ interface UserInfo {
13
+ name?: string
14
+ email: string
15
+ avatarUrl?: string
16
+ }
17
+
18
+ interface PageInfo {
19
+ path: string
20
+ pageKey: string
21
+ label: string
22
+ hasConfig: boolean
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Admin App
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export function AdminApp() {
30
+ const config = useConfig()
31
+ const [user, setUser] = useState<UserInfo | null>(null)
32
+ const [checking, setChecking] = useState(true)
33
+
34
+ useEffect(() => {
35
+ checkSession()
36
+ }, [])
37
+
38
+ async function checkSession() {
39
+ try {
40
+ const response = await fetch('/api/setzkasten/auth/session')
41
+ const session = await response.json()
42
+ if (session.authenticated) {
43
+ setUser(session.user)
44
+ }
45
+ } catch {
46
+ // Not authenticated
47
+ } finally {
48
+ setChecking(false)
49
+ }
50
+ }
51
+
52
+ if (checking) {
53
+ return <LoadingScreen />
54
+ }
55
+
56
+ if (!user) {
57
+ return <LoginScreen config={config} />
58
+ }
59
+
60
+ return (
61
+ <ToastProvider>
62
+ <AdminShell config={config} user={user} />
63
+ </ToastProvider>
64
+ )
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Loading
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function LoadingScreen() {
72
+ return (
73
+ <div className="sk-admin-loading">
74
+ <div className="sk-admin-loading__spinner" />
75
+ <p className="sk-admin-loading__text">Lade Setzkasten...</p>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Login
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function LoginScreen({ config }: { config: SetzKastenConfig }) {
85
+ const providers = config.auth?.providers ?? ['github']
86
+
87
+ return (
88
+ <div className="sk-login">
89
+ <div className="sk-login__card">
90
+ <div className="sk-login__logo">S</div>
91
+ <h1 className="sk-login__title">
92
+ {config.theme?.brandName ?? 'Setzkasten'}
93
+ </h1>
94
+ <p className="sk-login__subtitle">Melde dich an, um Inhalte zu bearbeiten.</p>
95
+
96
+ {providers.includes('github') && (
97
+ <a href="/api/setzkasten/auth/login?provider=github" className="sk-login__btn">
98
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
99
+ <path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844a9.59 9.59 0 012.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0022 12.017C22 6.484 17.522 2 12 2Z" />
100
+ </svg>
101
+ Mit GitHub anmelden
102
+ </a>
103
+ )}
104
+
105
+ {providers.includes('google') && (
106
+ <a href="/api/setzkasten/auth/login?provider=google" className="sk-login__btn">
107
+ <svg viewBox="0 0 24 24" width="20" height="20">
108
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
109
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
110
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
111
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
112
+ </svg>
113
+ Mit Google anmelden
114
+ </a>
115
+ )}
116
+ </div>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Admin Shell – Dashboard or Page Builder
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function AdminShell({ config, user }: { config: SetzKastenConfig; user: UserInfo }) {
126
+ const [view, setView] = useState<'dashboard' | 'page-builder'>('dashboard')
127
+ const [pages, setPages] = useState<PageInfo[]>([])
128
+ const [selectedPageKey, setSelectedPageKey] = useState('index')
129
+ const [loadingPages, setLoadingPages] = useState(true)
130
+
131
+ useEffect(() => {
132
+ fetch('/api/setzkasten/pages')
133
+ .then(r => r.json())
134
+ .then(data => {
135
+ setPages(data.pages ?? [])
136
+ })
137
+ .catch(() => {})
138
+ .finally(() => setLoadingPages(false))
139
+ }, [])
140
+
141
+ if (view === 'page-builder') {
142
+ return (
143
+ <PageBuilder
144
+ pageKey={selectedPageKey}
145
+ pages={pages}
146
+ onPageChange={setSelectedPageKey}
147
+ onExit={() => setView('dashboard')}
148
+ />
149
+ )
150
+ }
151
+
152
+ return (
153
+ <Dashboard
154
+ config={config}
155
+ user={user}
156
+ pages={pages}
157
+ loadingPages={loadingPages}
158
+ selectedPageKey={selectedPageKey}
159
+ onSelectPage={setSelectedPageKey}
160
+ onOpenPageBuilder={() => setView('page-builder')}
161
+ />
162
+ )
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Dashboard
167
+ // ---------------------------------------------------------------------------
168
+
169
+ interface DashboardProps {
170
+ config: SetzKastenConfig
171
+ user: UserInfo
172
+ pages: PageInfo[]
173
+ loadingPages: boolean
174
+ selectedPageKey: string
175
+ onSelectPage: (key: string) => void
176
+ onOpenPageBuilder: () => void
177
+ }
178
+
179
+ function Dashboard({
180
+ config,
181
+ user,
182
+ pages,
183
+ loadingPages,
184
+ selectedPageKey,
185
+ onSelectPage,
186
+ onOpenPageBuilder,
187
+ }: DashboardProps) {
188
+ const brandName = config.theme?.brandName ?? 'Setzkasten'
189
+
190
+ return (
191
+ <div className="sk-dashboard">
192
+ <header className="sk-dashboard__topbar">
193
+ <div className="sk-dashboard__brand">
194
+ <span className="sk-dashboard__logo">S</span>
195
+ <span className="sk-dashboard__brand-name">Setzkasten</span>
196
+ </div>
197
+ <div className="sk-dashboard__user">
198
+ {user.avatarUrl ? (
199
+ <img src={user.avatarUrl} className="sk-dashboard__avatar" alt="" />
200
+ ) : (
201
+ <span className="sk-dashboard__avatar sk-dashboard__avatar--placeholder">
202
+ {(user.name ?? user.email ?? '?')[0]!.toUpperCase()}
203
+ </span>
204
+ )}
205
+ <span className="sk-dashboard__user-name">{user.name || user.email}</span>
206
+ <a href="/api/setzkasten/auth/logout" className="sk-dashboard__logout">
207
+ Abmelden
208
+ </a>
209
+ </div>
210
+ </header>
211
+
212
+ <main className="sk-dashboard__content">
213
+ {/* Primary card: open Page Builder */}
214
+ <div className="sk-dashboard__card sk-dashboard__card--primary">
215
+ <div className="sk-dashboard__card-icon">
216
+ <Layers size={28} />
217
+ </div>
218
+ <h2 className="sk-dashboard__card-title">Zum Setzkasten für</h2>
219
+ <div className="sk-dashboard__card-controls">
220
+ <select
221
+ className="sk-dashboard__select"
222
+ disabled
223
+ value={brandName}
224
+ >
225
+ <option>{brandName}</option>
226
+ </select>
227
+ <select
228
+ className="sk-dashboard__select"
229
+ value={selectedPageKey}
230
+ onChange={e => onSelectPage(e.target.value)}
231
+ disabled={loadingPages || pages.length === 0}
232
+ >
233
+ {loadingPages ? (
234
+ <option>Lade...</option>
235
+ ) : pages.length === 0 ? (
236
+ <option value="index">Startseite</option>
237
+ ) : (
238
+ pages.map(p => (
239
+ <option key={p.pageKey} value={p.pageKey}>
240
+ {p.label}
241
+ </option>
242
+ ))
243
+ )}
244
+ </select>
245
+ <button
246
+ type="button"
247
+ className="sk-dashboard__btn"
248
+ onClick={onOpenPageBuilder}
249
+ >
250
+ Öffnen
251
+ </button>
252
+ </div>
253
+ </div>
254
+
255
+ {/* Secondary cards */}
256
+ <div className="sk-dashboard__cards-row">
257
+ <div className="sk-dashboard__card sk-dashboard__card--disabled">
258
+ <div className="sk-dashboard__card-icon">
259
+ <Globe size={22} />
260
+ </div>
261
+ <h3 className="sk-dashboard__card-subtitle">Websites verwalten</h3>
262
+ <span className="sk-dashboard__badge">Kommt bald</span>
263
+ </div>
264
+ <div className="sk-dashboard__card sk-dashboard__card--disabled">
265
+ <div className="sk-dashboard__card-icon">
266
+ <Settings size={22} />
267
+ </div>
268
+ <h3 className="sk-dashboard__card-subtitle">Globale Konfiguration</h3>
269
+ <span className="sk-dashboard__badge">Kommt bald</span>
270
+ </div>
271
+ </div>
272
+ </main>
273
+ </div>
274
+ )
275
+ }
@@ -0,0 +1,103 @@
1
+ import { useState, useCallback, useRef } from 'react'
2
+ import type { CollectionDefinition } from '@setzkasten-cms/core'
3
+ import { createFormStore } from '../stores/form-store'
4
+ import { useRepository } from '../providers/setzkasten-provider'
5
+ import { EntryForm } from './entry-form'
6
+ import { EntryList } from './entry-list'
7
+
8
+ interface CollectionViewProps {
9
+ collectionKey: string
10
+ collection: CollectionDefinition
11
+ }
12
+
13
+ /**
14
+ * Full CRUD view for a collection: list on the left, form on the right.
15
+ */
16
+ export function CollectionView({ collectionKey, collection }: CollectionViewProps) {
17
+ const repository = useRepository()
18
+ const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
19
+ const [entryData, setEntryData] = useState<Record<string, unknown> | null>(null)
20
+ const [loading, setLoading] = useState(false)
21
+ const [creating, setCreating] = useState(false)
22
+ const formStoreRef = useRef<ReturnType<typeof createFormStore> | null>(null)
23
+
24
+ const loadEntry = useCallback(
25
+ async (slug: string) => {
26
+ setLoading(true)
27
+ setCreating(false)
28
+ const result = await repository.getEntry(collectionKey, slug)
29
+ if (result.ok && result.value) {
30
+ setSelectedSlug(slug)
31
+ setEntryData(result.value.content)
32
+ }
33
+ setLoading(false)
34
+ },
35
+ [repository, collectionKey],
36
+ )
37
+
38
+ const handleCreate = useCallback(() => {
39
+ setSelectedSlug(null)
40
+ setEntryData({})
41
+ setCreating(true)
42
+ }, [])
43
+
44
+ const handleSave = useCallback(
45
+ async (values: Record<string, unknown>) => {
46
+ const slug = creating
47
+ ? prompt('Slug für den neuen Eintrag:')
48
+ : selectedSlug
49
+
50
+ if (!slug) return
51
+
52
+ const result = await repository.saveEntry(collectionKey, slug, {
53
+ content: values,
54
+ })
55
+
56
+ if (result.ok) {
57
+ setSelectedSlug(slug)
58
+ setCreating(false)
59
+ console.log(`[CollectionView] Saved ${slug}`)
60
+ }
61
+ },
62
+ [repository, collectionKey, selectedSlug, creating],
63
+ )
64
+
65
+ return (
66
+ <div className="sk-collection-view">
67
+ <div className="sk-collection-view__list">
68
+ <EntryList
69
+ collection={collectionKey}
70
+ label={collection.label}
71
+ onSelect={loadEntry}
72
+ onCreate={handleCreate}
73
+ selectedSlug={selectedSlug ?? undefined}
74
+ />
75
+ </div>
76
+
77
+ <div className="sk-collection-view__editor">
78
+ {loading ? (
79
+ <div className="sk-empty">
80
+ <p>Lade Eintrag...</p>
81
+ </div>
82
+ ) : entryData !== null ? (
83
+ <div>
84
+ <div className="sk-collection-view__editor-header">
85
+ <h3>{creating ? 'Neuer Eintrag' : selectedSlug}</h3>
86
+ </div>
87
+ <EntryForm
88
+ key={selectedSlug ?? 'new'}
89
+ schema={collection.fields}
90
+ initialValues={entryData}
91
+ onSave={handleSave}
92
+ storeRef={formStoreRef}
93
+ />
94
+ </div>
95
+ ) : (
96
+ <div className="sk-empty">
97
+ <p>Wähle einen Eintrag aus der Liste oder erstelle einen neuen.</p>
98
+ </div>
99
+ )}
100
+ </div>
101
+ </div>
102
+ )
103
+ }
@@ -0,0 +1,76 @@
1
+ import { memo, useEffect, useMemo, type MutableRefObject } from 'react'
2
+ import type { AnyFieldDef, FieldRecord } from '@setzkasten-cms/core'
3
+ import { createFormStore } from '../stores/form-store'
4
+ import type { FormStore } from '../stores/form-store'
5
+ import { FieldRenderer } from '../fields/field-renderer'
6
+
7
+ interface EntryFormProps {
8
+ schema: FieldRecord
9
+ initialValues: Record<string, unknown>
10
+ /** If provided, form starts with these values but initialValues stays as the saved baseline (for dirty detection). */
11
+ draftValues?: Record<string, unknown>
12
+ onSave?: (values: Record<string, unknown>) => void
13
+ /**
14
+ * Optional ref that receives the internal FormStore instance.
15
+ * Useful for external subscribers (e.g. live preview sync).
16
+ */
17
+ storeRef?: MutableRefObject<ReturnType<typeof createFormStore> | null>
18
+ }
19
+
20
+ /**
21
+ * Generates a complete form from a schema definition.
22
+ * Each instance creates its own FormStore (one store per entry).
23
+ */
24
+ export const EntryForm = memo(function EntryForm({
25
+ schema,
26
+ initialValues,
27
+ draftValues,
28
+ onSave,
29
+ storeRef,
30
+ }: EntryFormProps) {
31
+ const store = useMemo(() => createFormStore(), [])
32
+
33
+ useEffect(() => {
34
+ if (storeRef) {
35
+ storeRef.current = store
36
+ }
37
+ return () => {
38
+ if (storeRef) {
39
+ storeRef.current = null
40
+ }
41
+ }
42
+ }, [store, storeRef])
43
+
44
+ useEffect(() => {
45
+ store.getState().init(schema, initialValues, draftValues)
46
+ }, [store, schema, initialValues, draftValues])
47
+
48
+ const handleSave = () => {
49
+ const { values, isDirty } = store.getState()
50
+ if (isDirty() && onSave) {
51
+ onSave(values)
52
+ }
53
+ }
54
+
55
+ return (
56
+ <div className="sk-entry-form">
57
+ <div className="sk-entry-form__fields">
58
+ {Object.entries(schema).map(([key, field]) => (
59
+ <FieldRenderer
60
+ key={key}
61
+ field={field as AnyFieldDef}
62
+ path={[key]}
63
+ store={store}
64
+ />
65
+ ))}
66
+ </div>
67
+ {onSave && (
68
+ <div className="sk-entry-form__actions">
69
+ <button type="button" className="sk-button sk-button--primary" onClick={handleSave}>
70
+ Speichern
71
+ </button>
72
+ </div>
73
+ )}
74
+ </div>
75
+ )
76
+ })
@@ -0,0 +1,119 @@
1
+ import { useEffect, useState, useCallback } from 'react'
2
+ import type { EntryListItem } from '@setzkasten-cms/core'
3
+ import { useRepository } from '../providers/setzkasten-provider'
4
+
5
+ interface EntryListProps {
6
+ collection: string
7
+ label: string
8
+ onSelect: (slug: string) => void
9
+ onCreate: () => void
10
+ selectedSlug?: string
11
+ }
12
+
13
+ /**
14
+ * List of entries in a collection with create/delete actions.
15
+ */
16
+ export function EntryList({
17
+ collection,
18
+ label,
19
+ onSelect,
20
+ onCreate,
21
+ selectedSlug,
22
+ }: EntryListProps) {
23
+ const repository = useRepository()
24
+ const [entries, setEntries] = useState<EntryListItem[]>([])
25
+ const [loading, setLoading] = useState(true)
26
+
27
+ const loadEntries = useCallback(async () => {
28
+ setLoading(true)
29
+ const result = await repository.listEntries(collection)
30
+ if (result.ok) {
31
+ setEntries(result.value)
32
+ }
33
+ setLoading(false)
34
+ }, [repository, collection])
35
+
36
+ useEffect(() => {
37
+ loadEntries()
38
+ }, [loadEntries])
39
+
40
+ const handleDelete = useCallback(
41
+ async (slug: string) => {
42
+ if (!confirm(`"${slug}" wirklich löschen?`)) return
43
+ const result = await repository.deleteEntry(collection, slug)
44
+ if (result.ok) {
45
+ loadEntries()
46
+ }
47
+ },
48
+ [repository, collection, loadEntries],
49
+ )
50
+
51
+ if (loading) {
52
+ return (
53
+ <div className="sk-entry-list sk-entry-list--loading">
54
+ <div className="sk-entry-list__header">
55
+ <h2 className="sk-entry-list__title">{label}</h2>
56
+ </div>
57
+ <div className="sk-entry-list__empty">Lade...</div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <div className="sk-entry-list">
64
+ <div className="sk-entry-list__header">
65
+ <h2 className="sk-entry-list__title">{label}</h2>
66
+ <span className="sk-entry-list__count">{entries.length}</span>
67
+ <button
68
+ type="button"
69
+ className="sk-button sk-button--primary sk-button--sm"
70
+ onClick={onCreate}
71
+ >
72
+ + Neu
73
+ </button>
74
+ </div>
75
+ {entries.length === 0 ? (
76
+ <div className="sk-entry-list__empty">
77
+ <p>Noch keine Einträge.</p>
78
+ <button
79
+ type="button"
80
+ className="sk-button sk-button--primary"
81
+ onClick={onCreate}
82
+ >
83
+ Ersten Eintrag erstellen
84
+ </button>
85
+ </div>
86
+ ) : (
87
+ <ul className="sk-entry-list__items" role="list">
88
+ {entries.map((entry) => (
89
+ <li
90
+ key={entry.slug}
91
+ className={`sk-entry-list__item ${entry.slug === selectedSlug ? 'sk-entry-list__item--active' : ''}`}
92
+ >
93
+ <button
94
+ type="button"
95
+ className="sk-entry-list__item-btn"
96
+ onClick={() => onSelect(entry.slug)}
97
+ >
98
+ <span className="sk-entry-list__item-name">{entry.name}</span>
99
+ <span className="sk-entry-list__item-slug">{entry.slug}</span>
100
+ </button>
101
+ <button
102
+ type="button"
103
+ className="sk-entry-list__item-delete"
104
+ onClick={(e) => {
105
+ e.stopPropagation()
106
+ handleDelete(entry.slug)
107
+ }}
108
+ title="Löschen"
109
+ aria-label={`${entry.name} löschen`}
110
+ >
111
+ ×
112
+ </button>
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ )}
117
+ </div>
118
+ )
119
+ }