@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.
- package/LICENSE +37 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +2936 -0
- package/package.json +41 -0
- package/src/adapters/proxy-asset-store.ts +210 -0
- package/src/adapters/proxy-content-repository.ts +259 -0
- package/src/components/admin-app.tsx +275 -0
- package/src/components/collection-view.tsx +103 -0
- package/src/components/entry-form.tsx +76 -0
- package/src/components/entry-list.tsx +119 -0
- package/src/components/page-builder.tsx +1134 -0
- package/src/components/toast.tsx +48 -0
- package/src/fields/array-field-renderer.tsx +101 -0
- package/src/fields/boolean-field-renderer.tsx +28 -0
- package/src/fields/field-renderer.tsx +60 -0
- package/src/fields/icon-field-renderer.tsx +130 -0
- package/src/fields/image-field-renderer.tsx +266 -0
- package/src/fields/number-field-renderer.tsx +38 -0
- package/src/fields/object-field-renderer.tsx +41 -0
- package/src/fields/override-field-renderer.tsx +48 -0
- package/src/fields/select-field-renderer.tsx +42 -0
- package/src/fields/text-field-renderer.tsx +313 -0
- package/src/hooks/use-field.ts +82 -0
- package/src/hooks/use-save.ts +46 -0
- package/src/index.ts +34 -0
- package/src/providers/setzkasten-provider.tsx +80 -0
- package/src/stores/app-store.ts +61 -0
- package/src/stores/form-store.test.ts +111 -0
- package/src/stores/form-store.ts +298 -0
- package/src/styles/admin.css +2017 -0
|
@@ -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
|
+
}
|