create-kywi-app 0.1.0 → 0.2.0
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/README.md +21 -15
- package/lib/templates.mjs +123 -677
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,9 +47,11 @@ Set via `--mode` (and written into the generated `kywi.config.ts`'s
|
|
|
47
47
|
## What gets generated
|
|
48
48
|
|
|
49
49
|
A generated project **boots and works with zero manual edits**:
|
|
50
|
-
`pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives you a working
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
`pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives you a working
|
|
51
|
+
`/admin` — the **full** Kywi admin (dashboard, site tree, media, users, forms,
|
|
52
|
+
audiences, settings, and every other core surface, sign in and create +
|
|
53
|
+
publish a page) — and, in `coupled` mode, a public site that renders published
|
|
54
|
+
pages at their slug.
|
|
53
55
|
|
|
54
56
|
`buildFileSet()` writes (`lib/templates.mjs`):
|
|
55
57
|
|
|
@@ -57,31 +59,35 @@ site that renders published pages at their slug.
|
|
|
57
59
|
package.json scripts (dev/build/start/migrate/seed) + deps incl. drizzle-kit + tsx
|
|
58
60
|
kywi.config.ts defineKywiConfig/Site/Theme + a default "Page" content type
|
|
59
61
|
tsconfig.json
|
|
60
|
-
next.config.mjs the FULL config to consume core (
|
|
61
|
-
|
|
62
|
+
next.config.mjs the FULL config to consume core (transpilePackages, extensionAlias,
|
|
63
|
+
serverExternalPackages) — every line is load-bearing
|
|
62
64
|
.env.example DATABASE_URL + AUTH_SECRET
|
|
63
65
|
.gitignore
|
|
64
|
-
README.md project-specific quick start (createdb, migrate, seed, dev, /admin)
|
|
65
|
-
lib/dompurify-stub.js webpack stub referenced by next.config
|
|
66
|
+
README.md project-specific quick start (createdb, migrate, seed, dev, /admin, admin.features)
|
|
66
67
|
lib/kywi.ts server runtime: DB, API handler, content scope (memoised)
|
|
67
68
|
lib/config.ts re-export of kywi.config
|
|
68
|
-
lib/admin-auth.ts client admin helpers, re-exported from @kywi-software/core/host-client
|
|
69
|
-
lib/content-types.ts config + built-in (page/folder/link) content-type resolver
|
|
70
69
|
middleware.ts auth gate + session refresh + cookie→bearer bridge (over core/host)
|
|
71
70
|
app/api/v1/[...kywi]/route.ts the versioned API (delegates to core; lifts tokens into httpOnly cookies)
|
|
72
71
|
app/layout.tsx root <html>/<body>
|
|
73
|
-
|
|
74
|
-
app/admin/
|
|
75
|
-
app/admin/page.tsx redirect → /admin/content
|
|
76
|
-
app/admin/login/page.tsx sign-in form
|
|
77
|
-
app/admin/content/page.tsx content-type picker
|
|
78
|
-
app/admin/content/[type]/… list / new / edit + publish (core's ContentEditForm)
|
|
72
|
+
app/icon.svg favicon
|
|
73
|
+
app/admin/[[...admin]]/page.tsx mounts the FULL core admin (KywiAdminApp, every surface) at /admin
|
|
79
74
|
```
|
|
80
75
|
|
|
81
76
|
**coupled** additionally gets `app/(site)/layout.tsx` + `app/(site)/[[...slug]]/page.tsx`
|
|
82
77
|
(the public site — renders "/" and every published page at its slug). **headless**
|
|
83
78
|
and **decoupled** instead get `app/page.tsx` (returns 404 — no public rendering).
|
|
84
79
|
|
|
80
|
+
Note what's *not* in this list: there is no `components/admin-shell.tsx`, no
|
|
81
|
+
per-route `app/admin/content/**`, no `lib/admin-auth.ts`. Since 0.2.0 the whole
|
|
82
|
+
admin is one component (`KywiAdminApp`) shipped by `@kywi-software/core` — the
|
|
83
|
+
generated project mounts it with the single catch-all page above instead of
|
|
84
|
+
hand-rolling admin pages. See [`docs/hosting.md`](../../docs/hosting.md) §3
|
|
85
|
+
for exactly what that mount does, and
|
|
86
|
+
[`docs/admin-configuration.md`](../../docs/admin-configuration.md) for
|
|
87
|
+
disabling surfaces you don't want (per deployment via `admin.features` in
|
|
88
|
+
`kywi.config.ts`, or per site at runtime via the superAdmin "Admin Features"
|
|
89
|
+
surface — no redeploy).
|
|
90
|
+
|
|
85
91
|
The security-critical session plumbing (JWT verify, the httpOnly cookie contract,
|
|
86
92
|
the cookie→bearer bridge) is NOT copied into every project. It lives once in
|
|
87
93
|
`@kywi-software/core/host` (+ `/host-client`); the generated `middleware.ts` and
|
package/lib/templates.mjs
CHANGED
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
* CLI writes, which is what the tests assert against.
|
|
8
8
|
*
|
|
9
9
|
* A generated project boots and works as documented with ZERO manual edits:
|
|
10
|
-
* `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives
|
|
11
|
-
* at /admin (log in with the seeded credentials
|
|
12
|
-
*
|
|
10
|
+
* `pnpm install && pnpm migrate && pnpm seed && pnpm dev` gives the COMPLETE
|
|
11
|
+
* admin at /admin (log in with the seeded credentials — every surface is present:
|
|
12
|
+
* dashboard, site tree, media, users, forms, categories, tags, audiences,
|
|
13
|
+
* settings, …) and, in coupled mode, a public site that renders published pages
|
|
14
|
+
* at their slug.
|
|
15
|
+
*
|
|
16
|
+
* The whole admin is core's `KywiAdminApp`, mounted by ONE optional-catch-all
|
|
17
|
+
* host page (app/admin/[[...admin]]/page.tsx). The scaffold does NOT hand-write
|
|
18
|
+
* per-surface admin pages, a bespoke shell, or a login page — core provides all
|
|
19
|
+
* of them, so `npm update @kywi-software/core` upgrades the entire admin.
|
|
13
20
|
*
|
|
14
21
|
* The three deployment modes differ only in the public surface:
|
|
15
22
|
* - coupled: renders the public site (app/(site)) AND serves the admin + API.
|
|
@@ -73,6 +80,12 @@ function packageJson(a) {
|
|
|
73
80
|
// interactive approval prompt (no-op for npm/yarn).
|
|
74
81
|
pnpm: {
|
|
75
82
|
onlyBuiltDependencies: ['esbuild', 'sharp'],
|
|
83
|
+
// @auth/core lists nodemailer as an OPTIONAL peer capped at ^7; core ships
|
|
84
|
+
// nodemailer 8 (its createTransport/sendMail usage is identical). Tell pnpm
|
|
85
|
+
// 8 is fine so a fresh install prints no unmet-peer warning.
|
|
86
|
+
peerDependencyRules: {
|
|
87
|
+
allowedVersions: { nodemailer: '8' },
|
|
88
|
+
},
|
|
76
89
|
},
|
|
77
90
|
},
|
|
78
91
|
null,
|
|
@@ -182,12 +195,7 @@ function tsconfig() {
|
|
|
182
195
|
// ── next.config.mjs ───────────────────────────────────────────────────────────
|
|
183
196
|
|
|
184
197
|
function nextConfig() {
|
|
185
|
-
return
|
|
186
|
-
|
|
187
|
-
// require() is needed for require.resolve() of the local dompurify stub below.
|
|
188
|
-
const require = createRequire(import.meta.url)
|
|
189
|
-
|
|
190
|
-
/**
|
|
198
|
+
return `/**
|
|
191
199
|
* Next.js config for a Kywi app. Every setting here is required to consume
|
|
192
200
|
* @kywi-software/core; removing any of them breaks the build:
|
|
193
201
|
*
|
|
@@ -196,10 +204,10 @@ const require = createRequire(import.meta.url)
|
|
|
196
204
|
* must resolve those specifiers to the real \`.ts\`/\`.tsx\`.
|
|
197
205
|
* - serverExternalPackages: core's server deps (sharp, drizzle, postgres, …)
|
|
198
206
|
* stay server-side and out of the client bundle.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
207
|
+
*
|
|
208
|
+
* Note: core sanitizes all CMS HTML itself with a DOM-free sanitizer
|
|
209
|
+
* (sanitize-html), so there is no jsdom-based sanitizer to work around and no
|
|
210
|
+
* client-side sanitizer stub to wire up here.
|
|
203
211
|
*/
|
|
204
212
|
/** @type {import('next').NextConfig} */
|
|
205
213
|
const nextConfig = {
|
|
@@ -211,8 +219,6 @@ const nextConfig = {
|
|
|
211
219
|
'nodemailer',
|
|
212
220
|
'bcryptjs',
|
|
213
221
|
'@aws-sdk/client-s3',
|
|
214
|
-
'jsdom',
|
|
215
|
-
'isomorphic-dompurify',
|
|
216
222
|
],
|
|
217
223
|
webpack(config) {
|
|
218
224
|
config.resolve.extensionAlias = {
|
|
@@ -220,10 +226,6 @@ const nextConfig = {
|
|
|
220
226
|
'.js': ['.ts', '.tsx', '.js'],
|
|
221
227
|
'.jsx': ['.tsx', '.jsx'],
|
|
222
228
|
}
|
|
223
|
-
config.resolve.alias = {
|
|
224
|
-
...config.resolve.alias,
|
|
225
|
-
'isomorphic-dompurify': require.resolve('./lib/dompurify-stub.js'),
|
|
226
|
-
}
|
|
227
229
|
return config
|
|
228
230
|
},
|
|
229
231
|
}
|
|
@@ -232,30 +234,6 @@ export default nextConfig
|
|
|
232
234
|
`
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
function dompurifyStub() {
|
|
236
|
-
return `/**
|
|
237
|
-
* Stub for isomorphic-dompurify used in both server and client webpack bundles.
|
|
238
|
-
* Server: isomorphic-dompurify uses jsdom, which fails when webpack bundles it.
|
|
239
|
-
* Client: its CJS/ESM interop breaks when @kywi-software/core is transpiled.
|
|
240
|
-
* This stub passes content through unchanged — DOMPurify sanitisation is
|
|
241
|
-
* defense-in-depth; CMS-authored content is trusted.
|
|
242
|
-
*/
|
|
243
|
-
const DOMPurify = {
|
|
244
|
-
sanitize: (dirty) => dirty,
|
|
245
|
-
addHook: () => {},
|
|
246
|
-
removeHook: () => {},
|
|
247
|
-
removeHooks: () => {},
|
|
248
|
-
removeAllHooks: () => {},
|
|
249
|
-
isValidAttribute: () => true,
|
|
250
|
-
setConfig: () => {},
|
|
251
|
-
clearConfig: () => {},
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export default DOMPurify
|
|
255
|
-
export const sanitize = DOMPurify.sanitize
|
|
256
|
-
`
|
|
257
|
-
}
|
|
258
|
-
|
|
259
237
|
// ── env / gitignore ───────────────────────────────────────────────────────────
|
|
260
238
|
|
|
261
239
|
function envExample() {
|
|
@@ -271,6 +249,17 @@ AUTH_SECRET=CHANGE_ME
|
|
|
271
249
|
`
|
|
272
250
|
}
|
|
273
251
|
|
|
252
|
+
// A simple, neutral favicon. Next's App Router auto-serves app/icon.svg and
|
|
253
|
+
// injects <link rel="icon">, so the browser stops requesting /favicon.ico (no
|
|
254
|
+
// more 404 in the console on a fresh boot).
|
|
255
|
+
function faviconSvg() {
|
|
256
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
257
|
+
<rect width="32" height="32" rx="7" fill="#0f172a"/>
|
|
258
|
+
<path d="M11 8v16M11 16l8-8M11 16l8 8" fill="none" stroke="#e2e8f0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
259
|
+
</svg>
|
|
260
|
+
`
|
|
261
|
+
}
|
|
262
|
+
|
|
274
263
|
function gitignore() {
|
|
275
264
|
return `node_modules
|
|
276
265
|
.next
|
|
@@ -345,26 +334,6 @@ export default config
|
|
|
345
334
|
`
|
|
346
335
|
}
|
|
347
336
|
|
|
348
|
-
function libAdminAuth() {
|
|
349
|
-
return `/**
|
|
350
|
-
* Client-side admin helpers. Re-exported from @kywi-software/core/host-client so
|
|
351
|
-
* every admin surface imports them from one local path — swap the source here if
|
|
352
|
-
* you ever want to customise them.
|
|
353
|
-
*/
|
|
354
|
-
export {
|
|
355
|
-
adminFetch,
|
|
356
|
-
getAdminUser,
|
|
357
|
-
setAuth,
|
|
358
|
-
clearAuth,
|
|
359
|
-
signOut,
|
|
360
|
-
getActiveSiteId,
|
|
361
|
-
setActiveSiteId,
|
|
362
|
-
clearActiveSiteId,
|
|
363
|
-
type AdminUser,
|
|
364
|
-
} from '@kywi-software/core/host-client'
|
|
365
|
-
`
|
|
366
|
-
}
|
|
367
|
-
|
|
368
337
|
// ── middleware.ts (thin framework wiring over @kywi-software/core/host) ───────
|
|
369
338
|
|
|
370
339
|
function middleware() {
|
|
@@ -638,603 +607,70 @@ export const dynamic = 'force-dynamic'
|
|
|
638
607
|
`
|
|
639
608
|
}
|
|
640
609
|
|
|
641
|
-
// ── admin
|
|
642
|
-
|
|
643
|
-
function adminLayout() {
|
|
644
|
-
return `import React from 'react'
|
|
645
|
-
|
|
646
|
-
// Pass-through: the root layout provides <html>/<body>; admin pages bring their
|
|
647
|
-
// own chrome (AdminShell for authed pages, nothing extra for the login page).
|
|
648
|
-
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
|
649
|
-
return <>{children}</>
|
|
650
|
-
}
|
|
651
|
-
`
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function adminIndexPage() {
|
|
655
|
-
return `import { redirect } from 'next/navigation'
|
|
656
|
-
|
|
657
|
-
export default function AdminIndexPage() {
|
|
658
|
-
redirect('/admin/content')
|
|
659
|
-
}
|
|
660
|
-
`
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function adminShellComponent() {
|
|
664
|
-
return `'use client'
|
|
665
|
-
|
|
666
|
-
import React, { useEffect, useState } from 'react'
|
|
667
|
-
import { usePathname } from 'next/navigation'
|
|
668
|
-
import '@kywi-software/core/admin/styles.css'
|
|
669
|
-
import { getAdminUser, signOut, type AdminUser } from '../lib/admin-auth'
|
|
610
|
+
// ── admin (the full core admin, mounted at /admin) ─────────────────────────────
|
|
670
611
|
|
|
671
612
|
/**
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
613
|
+
* The single optional-catch-all host page that mounts the ENTIRE core admin
|
|
614
|
+
* (all surfaces) at /admin. Modeled on apps/reference — the whole scaffolded
|
|
615
|
+
* admin is `KywiAdminApp`, upgraded via `npm update @kywi-software/core`, not
|
|
616
|
+
* hand-written per-surface pages.
|
|
617
|
+
*/
|
|
618
|
+
function adminCatchAllPage() {
|
|
619
|
+
return `/**
|
|
620
|
+
* Host page for the core-mounted admin (\`KywiAdminApp\`).
|
|
621
|
+
*
|
|
622
|
+
* This ONE optional-catch-all serves your entire admin at /admin — dashboard,
|
|
623
|
+
* site tree, media, users, forms, categories, tags, audiences, settings, and
|
|
624
|
+
* every other surface. It is a server component: it projects the full,
|
|
625
|
+
* server-side kywi.config.ts down to the secret-free AdminRuntimeConfig
|
|
626
|
+
* (toAdminRuntimeConfig), extracts ?next= for the login surface, and renders the
|
|
627
|
+
* one client KywiAdminApp, which maps the URL segments to a surface and mounts
|
|
628
|
+
* it inside the core admin chrome.
|
|
676
629
|
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
630
|
+
* The @kywi-software/core/admin/styles.css import is LOAD-BEARING: the admin
|
|
631
|
+
* design-system tokens/inputs live there, so without it every surface (and the
|
|
632
|
+
* login page) renders unstyled. Do not remove it.
|
|
633
|
+
*
|
|
634
|
+
* Server-side 404: before rendering, the segments are checked against the config
|
|
635
|
+
* ceiling with the pure resolveSurface helper, calling Next's notFound() for a
|
|
636
|
+
* true HTTP 404 on an unknown or config-disabled path. The \`login\` segment is
|
|
637
|
+
* exempt (it is not a registry surface — the middleware's auth exception renders
|
|
638
|
+
* it chrome-less through KywiAdminApp); the bare /admin index (segments === [])
|
|
639
|
+
* resolves to the dashboard, so it never 404s.
|
|
640
|
+
*
|
|
641
|
+
* You should not need to edit this file. To disable surfaces per deployment, set
|
|
642
|
+
* \`admin.features\` in kywi.config.ts; superAdmins can also toggle surfaces at
|
|
643
|
+
* runtime from the "Admin Features" surface.
|
|
679
644
|
*/
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
export function AdminShell({ children }: { children: React.ReactNode }) {
|
|
685
|
-
const pathname = usePathname()
|
|
686
|
-
const [user, setUser] = useState<AdminUser | null>(null)
|
|
687
|
-
const [checked, setChecked] = useState(false)
|
|
688
|
-
|
|
689
|
-
useEffect(() => {
|
|
690
|
-
const u = getAdminUser()
|
|
691
|
-
if (!u) {
|
|
692
|
-
window.location.href = '/admin/login'
|
|
693
|
-
return
|
|
694
|
-
}
|
|
695
|
-
setUser(u)
|
|
696
|
-
setChecked(true)
|
|
697
|
-
}, [])
|
|
698
|
-
|
|
699
|
-
async function handleSignOut() {
|
|
700
|
-
await signOut()
|
|
701
|
-
window.location.href = '/admin/login'
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
if (!checked) {
|
|
705
|
-
return (
|
|
706
|
-
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
|
707
|
-
Loading…
|
|
708
|
-
</div>
|
|
709
|
-
)
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
return (
|
|
713
|
-
<div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
|
|
714
|
-
<aside style={{ width: 220, background: '#0f172a', color: '#e2e8f0', padding: '1.25rem 0', flexShrink: 0 }}>
|
|
715
|
-
<div style={{ padding: '0 1.25rem 1rem', fontWeight: 700, fontSize: '1.0625rem' }}>Kywi Admin</div>
|
|
716
|
-
<nav>
|
|
717
|
-
{NAV.map((item) => {
|
|
718
|
-
const active = pathname === item.href || pathname.startsWith(item.href + '/')
|
|
719
|
-
return (
|
|
720
|
-
<a
|
|
721
|
-
key={item.href}
|
|
722
|
-
href={item.href}
|
|
723
|
-
style={{
|
|
724
|
-
display: 'block',
|
|
725
|
-
padding: '0.5rem 1.25rem',
|
|
726
|
-
color: active ? '#fff' : '#cbd5e1',
|
|
727
|
-
background: active ? '#1e293b' : 'transparent',
|
|
728
|
-
textDecoration: 'none',
|
|
729
|
-
fontSize: '0.9375rem',
|
|
730
|
-
}}
|
|
731
|
-
>
|
|
732
|
-
{item.label}
|
|
733
|
-
</a>
|
|
734
|
-
)
|
|
735
|
-
})}
|
|
736
|
-
</nav>
|
|
737
|
-
</aside>
|
|
738
|
-
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
|
739
|
-
<header
|
|
740
|
-
style={{
|
|
741
|
-
display: 'flex',
|
|
742
|
-
justifyContent: 'flex-end',
|
|
743
|
-
alignItems: 'center',
|
|
744
|
-
gap: '1rem',
|
|
745
|
-
padding: '0.75rem 1.5rem',
|
|
746
|
-
borderBottom: '1px solid #e2e8f0',
|
|
747
|
-
background: '#fff',
|
|
748
|
-
}}
|
|
749
|
-
>
|
|
750
|
-
<a href="/" style={{ color: '#64748b', fontSize: '0.875rem', textDecoration: 'none' }}>View site ↗</a>
|
|
751
|
-
<span style={{ color: '#64748b', fontSize: '0.875rem' }}>{user?.role}</span>
|
|
752
|
-
<button type="button" onClick={handleSignOut} className="kywi-btn kywi-btn-secondary kywi-btn-sm">
|
|
753
|
-
Sign out
|
|
754
|
-
</button>
|
|
755
|
-
</header>
|
|
756
|
-
<main style={{ padding: '1.5rem', flex: 1 }}>{children}</main>
|
|
757
|
-
</div>
|
|
758
|
-
</div>
|
|
759
|
-
)
|
|
760
|
-
}
|
|
761
|
-
`
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
function adminLoginPage() {
|
|
765
|
-
return `'use client'
|
|
766
|
-
|
|
767
|
-
import React, { useState } from 'react'
|
|
768
|
-
import { setAuth } from '../../../lib/admin-auth'
|
|
645
|
+
import { notFound } from 'next/navigation'
|
|
646
|
+
import { KywiAdminApp } from '@kywi-software/core/admin/app'
|
|
647
|
+
import { toAdminRuntimeConfig, resolveSurface } from '@kywi-software/core/admin/server'
|
|
648
|
+
// CRITICAL: load the admin design system once for the whole mount.
|
|
769
649
|
import '@kywi-software/core/admin/styles.css'
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
function
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
setLoading(true)
|
|
792
|
-
try {
|
|
793
|
-
const res = await fetch('/api/v1/auth/login', {
|
|
794
|
-
method: 'POST',
|
|
795
|
-
headers: { 'Content-Type': 'application/json' },
|
|
796
|
-
body: JSON.stringify({ email, password }),
|
|
797
|
-
})
|
|
798
|
-
const body = await res.json()
|
|
799
|
-
if (!res.ok) {
|
|
800
|
-
setError(body.error?.message ?? 'Login failed')
|
|
801
|
-
return
|
|
802
|
-
}
|
|
803
|
-
if (!body.data?.user || !body.data?.accessToken) {
|
|
804
|
-
setError('This account requires an unsupported login step.')
|
|
805
|
-
return
|
|
806
|
-
}
|
|
807
|
-
// The token is set by the server as an httpOnly cookie; we keep only the
|
|
808
|
-
// (non-sensitive) user descriptor for rendering the chrome.
|
|
809
|
-
setAuth(body.data.user)
|
|
810
|
-
window.location.href = safeNext()
|
|
811
|
-
} catch {
|
|
812
|
-
setError('Network error — is the server running?')
|
|
813
|
-
} finally {
|
|
814
|
-
setLoading(false)
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
return (
|
|
819
|
-
<div className="kywi-admin-shell kywi-admin-login">
|
|
820
|
-
<form onSubmit={handleSubmit} className="kywi-admin-login-card">
|
|
821
|
-
<div className="kywi-admin-login-brand">
|
|
822
|
-
<span aria-hidden="true" className="kywi-admin-login-dot" />
|
|
823
|
-
<h1>Kywi Admin</h1>
|
|
824
|
-
</div>
|
|
825
|
-
<p className="kywi-admin-login-subtitle">Sign in to your workspace</p>
|
|
826
|
-
{error && (
|
|
827
|
-
<div className="kywi-admin-callout kywi-admin-callout-danger" role="alert">
|
|
828
|
-
{error}
|
|
829
|
-
</div>
|
|
830
|
-
)}
|
|
831
|
-
<label className="kywi-admin-login-field">
|
|
832
|
-
<span className="kywi-admin-login-label">Email</span>
|
|
833
|
-
<input type="email" className="kywi-input" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
|
834
|
-
</label>
|
|
835
|
-
<label className="kywi-admin-login-field">
|
|
836
|
-
<span className="kywi-admin-login-label">Password</span>
|
|
837
|
-
<input type="password" className="kywi-input" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
|
838
|
-
</label>
|
|
839
|
-
<button type="submit" disabled={loading} className="kywi-btn kywi-btn-primary kywi-admin-login-submit">
|
|
840
|
-
{loading ? 'Signing in…' : 'Sign in'}
|
|
841
|
-
</button>
|
|
842
|
-
</form>
|
|
843
|
-
</div>
|
|
844
|
-
)
|
|
845
|
-
}
|
|
846
|
-
`
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
/** Shared client helper (bundled into pages that import it): resolve a content
|
|
850
|
-
* type by name from config + the built-ins. Emitted as a lib file. */
|
|
851
|
-
function libContentTypes() {
|
|
852
|
-
return `import type { ContentTypeConfig } from '@kywi-software/core/config'
|
|
853
|
-
import config from './config'
|
|
854
|
-
|
|
855
|
-
/** Built-in types always available in the editor even if not in kywi.config.ts. */
|
|
856
|
-
export const BUILT_IN_CONTENT_TYPES: ContentTypeConfig[] = [
|
|
857
|
-
{ name: 'page', label: 'Page', baseType: 'content', fields: [] },
|
|
858
|
-
{ name: 'folder', label: 'Folder', baseType: 'folder', fields: [] },
|
|
859
|
-
{ name: 'link', label: 'Link', baseType: 'link', fields: [] },
|
|
860
|
-
]
|
|
861
|
-
|
|
862
|
-
/** All content types the admin can author: config-defined win over built-ins by name. */
|
|
863
|
-
export function allContentTypes(): ContentTypeConfig[] {
|
|
864
|
-
const byName = new Map<string, ContentTypeConfig>()
|
|
865
|
-
for (const ct of BUILT_IN_CONTENT_TYPES) byName.set(ct.name, ct)
|
|
866
|
-
for (const ct of config.contentTypes) byName.set(ct.name, ct)
|
|
867
|
-
return [...byName.values()]
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
export function resolveContentType(name: string): ContentTypeConfig | null {
|
|
871
|
-
return allContentTypes().find((ct) => ct.name === name) ?? null
|
|
872
|
-
}
|
|
873
|
-
`
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function adminContentLandingPage() {
|
|
877
|
-
return `'use client'
|
|
878
|
-
|
|
879
|
-
import React from 'react'
|
|
880
|
-
import { useRouter } from 'next/navigation'
|
|
881
|
-
import { AdminShell } from '../../../components/admin-shell'
|
|
882
|
-
import { allContentTypes } from '../../../lib/content-types'
|
|
883
|
-
|
|
884
|
-
// Pick a content type to browse/author. Types come from kywi.config.ts plus the
|
|
885
|
-
// always-available built-ins (page/folder/link).
|
|
886
|
-
export default function ContentLandingPage() {
|
|
887
|
-
const router = useRouter()
|
|
888
|
-
const types = allContentTypes()
|
|
889
|
-
|
|
890
|
-
return (
|
|
891
|
-
<AdminShell>
|
|
892
|
-
<h1 style={{ margin: '0 0 1.5rem', fontSize: '1.25rem', fontWeight: 600 }}>Content</h1>
|
|
893
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '1rem' }}>
|
|
894
|
-
{types.map((ct) => (
|
|
895
|
-
<button
|
|
896
|
-
key={ct.name}
|
|
897
|
-
onClick={() => router.push('/admin/content/' + ct.name)}
|
|
898
|
-
style={{
|
|
899
|
-
display: 'flex',
|
|
900
|
-
flexDirection: 'column',
|
|
901
|
-
alignItems: 'flex-start',
|
|
902
|
-
padding: '1.25rem',
|
|
903
|
-
background: '#fff',
|
|
904
|
-
border: '1px solid #e2e8f0',
|
|
905
|
-
borderRadius: 8,
|
|
906
|
-
cursor: 'pointer',
|
|
907
|
-
textAlign: 'left',
|
|
908
|
-
}}
|
|
909
|
-
>
|
|
910
|
-
<span style={{ fontSize: '1rem', fontWeight: 600, color: '#1e293b' }}>{ct.label}</span>
|
|
911
|
-
<span style={{ fontSize: '0.8125rem', color: '#64748b', marginTop: '0.25rem' }}>
|
|
912
|
-
{ct.fields.length} custom field{ct.fields.length !== 1 ? 's' : ''}
|
|
913
|
-
</span>
|
|
914
|
-
</button>
|
|
915
|
-
))}
|
|
916
|
-
</div>
|
|
917
|
-
</AdminShell>
|
|
918
|
-
)
|
|
919
|
-
}
|
|
920
|
-
`
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
function adminContentListPage() {
|
|
924
|
-
return `'use client'
|
|
925
|
-
|
|
926
|
-
import React, { useEffect, useState } from 'react'
|
|
927
|
-
import { useParams, useRouter } from 'next/navigation'
|
|
928
|
-
import { AdminShell } from '../../../../components/admin-shell'
|
|
929
|
-
import { adminFetch } from '../../../../lib/admin-auth'
|
|
930
|
-
import { resolveContentType } from '../../../../lib/content-types'
|
|
931
|
-
|
|
932
|
-
interface Row {
|
|
933
|
-
id: string
|
|
934
|
-
title?: string
|
|
935
|
-
slug?: string
|
|
936
|
-
path?: string
|
|
937
|
-
status?: string
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
export default function ContentListPage() {
|
|
941
|
-
const params = useParams<{ type: string }>()
|
|
942
|
-
const router = useRouter()
|
|
943
|
-
const typeName = params.type
|
|
944
|
-
const contentType = resolveContentType(typeName)
|
|
945
|
-
const [rows, setRows] = useState<Row[] | null>(null)
|
|
946
|
-
|
|
947
|
-
useEffect(() => {
|
|
948
|
-
let cancelled = false
|
|
949
|
-
adminFetch('/api/v1/content/' + typeName)
|
|
950
|
-
.then((res) => (res.ok ? res.json() : { data: [] }))
|
|
951
|
-
.then((body) => {
|
|
952
|
-
if (!cancelled) setRows((body?.data ?? []) as Row[])
|
|
953
|
-
})
|
|
954
|
-
.catch(() => {
|
|
955
|
-
if (!cancelled) setRows([])
|
|
956
|
-
})
|
|
957
|
-
return () => {
|
|
958
|
-
cancelled = true
|
|
959
|
-
}
|
|
960
|
-
}, [typeName])
|
|
961
|
-
|
|
962
|
-
return (
|
|
963
|
-
<AdminShell>
|
|
964
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
|
965
|
-
<h1 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 600 }}>{contentType?.label ?? typeName}</h1>
|
|
966
|
-
<button className="kywi-btn kywi-btn-primary" onClick={() => router.push('/admin/content/' + typeName + '/new')}>
|
|
967
|
-
+ New {contentType?.label ?? typeName}
|
|
968
|
-
</button>
|
|
969
|
-
</div>
|
|
970
|
-
{rows === null ? (
|
|
971
|
-
<p style={{ color: '#64748b' }}>Loading…</p>
|
|
972
|
-
) : rows.length === 0 ? (
|
|
973
|
-
<p style={{ color: '#64748b' }}>Nothing here yet. Create your first {contentType?.label ?? typeName}.</p>
|
|
974
|
-
) : (
|
|
975
|
-
<div className="kywi-table-scroll" style={{ background: '#fff', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
|
976
|
-
<table className="kywi-table" style={{ width: '100%' }}>
|
|
977
|
-
<thead>
|
|
978
|
-
<tr>
|
|
979
|
-
<th>Title</th>
|
|
980
|
-
<th>Slug</th>
|
|
981
|
-
<th>Status</th>
|
|
982
|
-
<th />
|
|
983
|
-
</tr>
|
|
984
|
-
</thead>
|
|
985
|
-
<tbody>
|
|
986
|
-
{rows.map((r) => (
|
|
987
|
-
<tr key={r.id}>
|
|
988
|
-
<td>
|
|
989
|
-
<a href={'/admin/content/' + typeName + '/' + r.id}>{r.title || '(untitled)'}</a>
|
|
990
|
-
</td>
|
|
991
|
-
<td>{r.slug}</td>
|
|
992
|
-
<td>
|
|
993
|
-
{r.status && <span className={'kywi-status-badge kywi-status-' + r.status}>{r.status}</span>}
|
|
994
|
-
</td>
|
|
995
|
-
<td style={{ textAlign: 'right' }}>
|
|
996
|
-
{r.status === 'published' && r.path && (
|
|
997
|
-
<a href={r.path} target="_blank" rel="noreferrer" style={{ fontSize: '0.875rem' }}>
|
|
998
|
-
View ↗
|
|
999
|
-
</a>
|
|
1000
|
-
)}
|
|
1001
|
-
</td>
|
|
1002
|
-
</tr>
|
|
1003
|
-
))}
|
|
1004
|
-
</tbody>
|
|
1005
|
-
</table>
|
|
1006
|
-
</div>
|
|
1007
|
-
)}
|
|
1008
|
-
</AdminShell>
|
|
1009
|
-
)
|
|
1010
|
-
}
|
|
1011
|
-
`
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function adminContentNewPage() {
|
|
1015
|
-
return `'use client'
|
|
1016
|
-
|
|
1017
|
-
import React, { useState } from 'react'
|
|
1018
|
-
import { useParams, useRouter } from 'next/navigation'
|
|
1019
|
-
import { AdminShell } from '../../../../../components/admin-shell'
|
|
1020
|
-
import { ContentEditForm, buildWritablePayload } from '@kywi-software/core/admin'
|
|
1021
|
-
import { adminFetch } from '../../../../../lib/admin-auth'
|
|
1022
|
-
import { resolveContentType } from '../../../../../lib/content-types'
|
|
1023
|
-
|
|
1024
|
-
export default function ContentNewPage() {
|
|
1025
|
-
const params = useParams<{ type: string }>()
|
|
1026
|
-
const router = useRouter()
|
|
1027
|
-
const typeName = params.type
|
|
1028
|
-
const contentType = resolveContentType(typeName)
|
|
1029
|
-
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
1030
|
-
const [error, setError] = useState<string | null>(null)
|
|
1031
|
-
|
|
1032
|
-
if (!contentType) {
|
|
1033
|
-
return (
|
|
1034
|
-
<AdminShell>
|
|
1035
|
-
<h1>Content type "{typeName}" not found</h1>
|
|
1036
|
-
<p>It is not defined in kywi.config.ts.</p>
|
|
1037
|
-
</AdminShell>
|
|
1038
|
-
)
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
const fieldNames = contentType.fields.map((f) => f.name)
|
|
1042
|
-
|
|
1043
|
-
async function handleSubmit(data: Record<string, unknown>) {
|
|
1044
|
-
setIsSubmitting(true)
|
|
1045
|
-
setError(null)
|
|
1046
|
-
try {
|
|
1047
|
-
const payload = buildWritablePayload(data, fieldNames)
|
|
1048
|
-
const res = await adminFetch('/api/v1/content/' + typeName, {
|
|
1049
|
-
method: 'POST',
|
|
1050
|
-
body: JSON.stringify(payload),
|
|
1051
|
-
})
|
|
1052
|
-
if (!res.ok) {
|
|
1053
|
-
const body = await res.json().catch(() => ({}))
|
|
1054
|
-
throw new Error(body.error?.message ?? 'HTTP ' + res.status)
|
|
1055
|
-
}
|
|
1056
|
-
const created = await res.json()
|
|
1057
|
-
const id = created.data?.id ?? created.id
|
|
1058
|
-
router.push(id ? '/admin/content/' + typeName + '/' + id : '/admin/content/' + typeName)
|
|
1059
|
-
} catch (err) {
|
|
1060
|
-
setError(err instanceof Error ? err.message : 'Failed to create content')
|
|
1061
|
-
setIsSubmitting(false)
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
return (
|
|
1066
|
-
<AdminShell>
|
|
1067
|
-
<div style={{ marginBottom: '1rem' }}>
|
|
1068
|
-
<button className="kywi-btn kywi-btn-ghost kywi-btn-sm" onClick={() => router.push('/admin/content/' + typeName)}>
|
|
1069
|
-
← {contentType.label}
|
|
1070
|
-
</button>
|
|
1071
|
-
</div>
|
|
1072
|
-
{error && (
|
|
1073
|
-
<div className="kywi-admin-callout kywi-admin-callout-danger" style={{ marginBottom: '1rem' }}>
|
|
1074
|
-
{error}
|
|
1075
|
-
</div>
|
|
1076
|
-
)}
|
|
1077
|
-
<ContentEditForm
|
|
1078
|
-
contentType={contentType}
|
|
1079
|
-
mode="create"
|
|
1080
|
-
isSubmitting={isSubmitting}
|
|
1081
|
-
onSubmit={handleSubmit}
|
|
1082
|
-
onCancel={() => router.push('/admin/content/' + typeName)}
|
|
1083
|
-
/>
|
|
1084
|
-
</AdminShell>
|
|
1085
|
-
)
|
|
1086
|
-
}
|
|
1087
|
-
`
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
function adminContentEditPage() {
|
|
1091
|
-
return `'use client'
|
|
1092
|
-
|
|
1093
|
-
import React, { useEffect, useState } from 'react'
|
|
1094
|
-
import { useParams, useRouter } from 'next/navigation'
|
|
1095
|
-
import { AdminShell } from '../../../../../components/admin-shell'
|
|
1096
|
-
import { ContentEditForm, buildWritablePayload, mapContentToInitialValues } from '@kywi-software/core/admin'
|
|
1097
|
-
import { adminFetch } from '../../../../../lib/admin-auth'
|
|
1098
|
-
import { resolveContentType } from '../../../../../lib/content-types'
|
|
1099
|
-
|
|
1100
|
-
export default function ContentEditPage() {
|
|
1101
|
-
const params = useParams<{ type: string; id: string }>()
|
|
1102
|
-
const router = useRouter()
|
|
1103
|
-
const typeName = params.type
|
|
1104
|
-
const id = params.id
|
|
1105
|
-
const contentType = resolveContentType(typeName)
|
|
1106
|
-
const [node, setNode] = useState<Record<string, unknown> | null>(null)
|
|
1107
|
-
const [loaded, setLoaded] = useState(false)
|
|
1108
|
-
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
1109
|
-
const [error, setError] = useState<string | null>(null)
|
|
1110
|
-
const [notice, setNotice] = useState<string | null>(null)
|
|
1111
|
-
|
|
1112
|
-
useEffect(() => {
|
|
1113
|
-
let cancelled = false
|
|
1114
|
-
adminFetch('/api/v1/content/' + typeName + '/' + id)
|
|
1115
|
-
.then((res) => (res.ok ? res.json() : null))
|
|
1116
|
-
.then((body) => {
|
|
1117
|
-
if (cancelled) return
|
|
1118
|
-
setNode((body?.data ?? null) as Record<string, unknown> | null)
|
|
1119
|
-
setLoaded(true)
|
|
1120
|
-
})
|
|
1121
|
-
.catch(() => {
|
|
1122
|
-
if (!cancelled) setLoaded(true)
|
|
1123
|
-
})
|
|
1124
|
-
return () => {
|
|
1125
|
-
cancelled = true
|
|
1126
|
-
}
|
|
1127
|
-
}, [typeName, id])
|
|
1128
|
-
|
|
1129
|
-
if (!contentType) {
|
|
1130
|
-
return (
|
|
1131
|
-
<AdminShell>
|
|
1132
|
-
<h1>Content type "{typeName}" not found</h1>
|
|
1133
|
-
</AdminShell>
|
|
1134
|
-
)
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
if (!loaded) {
|
|
1138
|
-
return (
|
|
1139
|
-
<AdminShell>
|
|
1140
|
-
<p style={{ color: '#64748b' }}>Loading…</p>
|
|
1141
|
-
</AdminShell>
|
|
1142
|
-
)
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
if (!node) {
|
|
1146
|
-
return (
|
|
1147
|
-
<AdminShell>
|
|
1148
|
-
<h1>Not found</h1>
|
|
1149
|
-
<p>This content no longer exists.</p>
|
|
1150
|
-
</AdminShell>
|
|
1151
|
-
)
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
const fieldNames = contentType.fields.map((f) => f.name)
|
|
1155
|
-
const status = String(node['status'] ?? 'draft')
|
|
1156
|
-
const path = node['path'] as string | undefined
|
|
1157
|
-
|
|
1158
|
-
async function patch(payload: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
|
1159
|
-
const res = await adminFetch('/api/v1/content/' + typeName + '/' + id, {
|
|
1160
|
-
method: 'PATCH',
|
|
1161
|
-
body: JSON.stringify(payload),
|
|
1162
|
-
})
|
|
1163
|
-
if (!res.ok) {
|
|
1164
|
-
const body = await res.json().catch(() => ({}))
|
|
1165
|
-
throw new Error(body.error?.message ?? 'HTTP ' + res.status)
|
|
1166
|
-
}
|
|
1167
|
-
const body = await res.json().catch(() => ({}))
|
|
1168
|
-
return (body?.data ?? null) as Record<string, unknown> | null
|
|
650
|
+
import kywiConfig from '../../../lib/config'
|
|
651
|
+
|
|
652
|
+
export default async function AdminCatchAll({
|
|
653
|
+
params,
|
|
654
|
+
searchParams,
|
|
655
|
+
}: {
|
|
656
|
+
params: Promise<{ admin?: string[] }>
|
|
657
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>
|
|
658
|
+
}) {
|
|
659
|
+
const { admin = [] } = await params
|
|
660
|
+
const sp = await searchParams
|
|
661
|
+
const next = typeof sp.next === 'string' ? sp.next : null
|
|
662
|
+
|
|
663
|
+
const runtime = toAdminRuntimeConfig(kywiConfig)
|
|
664
|
+
|
|
665
|
+
// Hard, server-side config ceiling → real HTTP 404. The login segment is not a
|
|
666
|
+
// surface (chrome-less middleware exception); the bare index resolves to the
|
|
667
|
+
// dashboard, so both skip the 404 pre-check.
|
|
668
|
+
const isLogin = admin.length === 1 && admin[0] === 'login'
|
|
669
|
+
if (!isLogin && resolveSurface(admin, runtime.admin) === null) {
|
|
670
|
+
notFound()
|
|
1169
671
|
}
|
|
1170
672
|
|
|
1171
|
-
|
|
1172
|
-
setIsSubmitting(true)
|
|
1173
|
-
setError(null)
|
|
1174
|
-
setNotice(null)
|
|
1175
|
-
try {
|
|
1176
|
-
const updated = await patch(buildWritablePayload(data, fieldNames))
|
|
1177
|
-
if (updated) setNode(updated)
|
|
1178
|
-
setNotice('Saved.')
|
|
1179
|
-
} catch (err) {
|
|
1180
|
-
setError(err instanceof Error ? err.message : 'Failed to save')
|
|
1181
|
-
} finally {
|
|
1182
|
-
setIsSubmitting(false)
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
// Publish/unpublish directly, so it works regardless of how you fill the form.
|
|
1187
|
-
async function togglePublish() {
|
|
1188
|
-
setError(null)
|
|
1189
|
-
setNotice(null)
|
|
1190
|
-
try {
|
|
1191
|
-
const next = status === 'published' ? 'draft' : 'published'
|
|
1192
|
-
const updated = await patch({ status: next })
|
|
1193
|
-
if (updated) setNode(updated)
|
|
1194
|
-
setNotice(next === 'published' ? 'Published.' : 'Unpublished.')
|
|
1195
|
-
} catch (err) {
|
|
1196
|
-
setError(err instanceof Error ? err.message : 'Failed to change status')
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
return (
|
|
1201
|
-
<AdminShell>
|
|
1202
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', gap: '1rem' }}>
|
|
1203
|
-
<button className="kywi-btn kywi-btn-ghost kywi-btn-sm" onClick={() => router.push('/admin/content/' + typeName)}>
|
|
1204
|
-
← {contentType.label}
|
|
1205
|
-
</button>
|
|
1206
|
-
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
|
1207
|
-
<span className={'kywi-status-badge kywi-status-' + status}>{status}</span>
|
|
1208
|
-
{status === 'published' && path && (
|
|
1209
|
-
<a href={path} target="_blank" rel="noreferrer" className="kywi-btn kywi-btn-secondary kywi-btn-sm">
|
|
1210
|
-
View ↗
|
|
1211
|
-
</a>
|
|
1212
|
-
)}
|
|
1213
|
-
<button className="kywi-btn kywi-btn-primary kywi-btn-sm" onClick={togglePublish}>
|
|
1214
|
-
{status === 'published' ? 'Unpublish' : 'Publish'}
|
|
1215
|
-
</button>
|
|
1216
|
-
</div>
|
|
1217
|
-
</div>
|
|
1218
|
-
{error && (
|
|
1219
|
-
<div className="kywi-admin-callout kywi-admin-callout-danger" style={{ marginBottom: '1rem' }}>
|
|
1220
|
-
{error}
|
|
1221
|
-
</div>
|
|
1222
|
-
)}
|
|
1223
|
-
{notice && (
|
|
1224
|
-
<div className="kywi-admin-callout" style={{ marginBottom: '1rem' }}>
|
|
1225
|
-
{notice}
|
|
1226
|
-
</div>
|
|
1227
|
-
)}
|
|
1228
|
-
<ContentEditForm
|
|
1229
|
-
contentType={contentType}
|
|
1230
|
-
mode="edit"
|
|
1231
|
-
initialValues={mapContentToInitialValues(node)}
|
|
1232
|
-
isSubmitting={isSubmitting}
|
|
1233
|
-
onSubmit={handleSubmit}
|
|
1234
|
-
onCancel={() => router.push('/admin/content/' + typeName)}
|
|
1235
|
-
/>
|
|
1236
|
-
</AdminShell>
|
|
1237
|
-
)
|
|
673
|
+
return <KywiAdminApp runtime={runtime} segments={admin} loginNext={next} />
|
|
1238
674
|
}
|
|
1239
675
|
`
|
|
1240
676
|
}
|
|
@@ -1279,18 +715,38 @@ pnpm seed # prints the generated superadmin credentials
|
|
|
1279
715
|
|
|
1280
716
|
# 4. Start the dev server
|
|
1281
717
|
pnpm dev # http://localhost:3000
|
|
718
|
+
# # use another port with: pnpm dev -p <port>
|
|
1282
719
|
\`\`\`
|
|
1283
720
|
|
|
1284
721
|
Then open:
|
|
1285
|
-
- \`http://localhost:3000/admin\` — the admin UI
|
|
1286
|
-
\`pnpm seed\` printed (default \`admin@kywi.dev\` / \`admin123\`
|
|
1287
|
-
\`KYWI_ADMIN_EMAIL\` / \`KYWI_ADMIN_PASSWORD\`).
|
|
722
|
+
- \`http://localhost:3000/admin\` — the **full admin UI**. Sign in with the
|
|
723
|
+
credentials \`pnpm seed\` printed (default \`admin@kywi.dev\` / \`admin123\`
|
|
724
|
+
unless you set \`KYWI_ADMIN_EMAIL\` / \`KYWI_ADMIN_PASSWORD\`). Every surface is
|
|
725
|
+
there out of the box — dashboard, site tree, media library, users, forms,
|
|
726
|
+
categories, tags, audiences, settings, and more.
|
|
1288
727
|
${publicLine}
|
|
1289
728
|
${decoupledBlock}
|
|
729
|
+
### The admin
|
|
730
|
+
|
|
731
|
+
The entire admin at \`/admin\` is **Kywi's own admin app** (\`KywiAdminApp\`),
|
|
732
|
+
mounted by one host page (\`app/admin/[[...admin]]/page.tsx\`). You did not have to
|
|
733
|
+
build any of it, and you upgrade the whole thing — new surfaces, fixes, polish —
|
|
734
|
+
with:
|
|
735
|
+
|
|
736
|
+
\`\`\`bash
|
|
737
|
+
npm update @kywi-software/core # (or pnpm/yarn up)
|
|
738
|
+
\`\`\`
|
|
739
|
+
|
|
740
|
+
**Turn surfaces off per deployment.** Add \`admin.features\` to \`kywi.config.ts\`
|
|
741
|
+
to disable surfaces you don't want in this deployment (e.g. hide Forms or
|
|
742
|
+
Audiences). SuperAdmins can also toggle surfaces at runtime from the
|
|
743
|
+
**Admin Features** surface inside the admin — no redeploy needed.
|
|
744
|
+
|
|
1290
745
|
### Create your first page
|
|
1291
746
|
|
|
1292
|
-
In the admin
|
|
1293
|
-
|
|
747
|
+
In the admin, open **Site Tree** (or **Content**), create a new **Page**, enter a
|
|
748
|
+
title (the slug is auto-generated from it — or type your own), then click
|
|
749
|
+
**Create**. Open the new page and click **Publish**.${a.mode === 'coupled' ? ' The published page lives at `/<slug>` (e.g. a page with slug `about` is served at `/about`) — open it to see it live.' : ' Fetch it from `/api/v1/content/page`.'}
|
|
1294
750
|
|
|
1295
751
|
## Notes
|
|
1296
752
|
|
|
@@ -1298,20 +754,19 @@ then **Publish**.${a.mode === 'coupled' ? ' Open its slug (e.g. `/about`) to see
|
|
|
1298
754
|
\`NOTICE: identifier "..." will be truncated\` lines for a few long foreign-key
|
|
1299
755
|
names. They are informational — the migration still applies cleanly.
|
|
1300
756
|
- **Config lives in \`kywi.config.ts\`** — sites, themes, content types, auth
|
|
1301
|
-
providers
|
|
1302
|
-
re-run \`pnpm migrate\`, then restart the dev server.
|
|
757
|
+
providers, the deployment mode, and \`admin.features\`. Edit it (add content
|
|
758
|
+
types, etc.) and re-run \`pnpm migrate\`, then restart the dev server.
|
|
1303
759
|
|
|
1304
760
|
## Project layout
|
|
1305
761
|
|
|
1306
762
|
\`\`\`
|
|
1307
|
-
kywi.config.ts your config: sites, themes, content types, auth, mode
|
|
763
|
+
kywi.config.ts your config: sites, themes, content types, auth, mode, admin.features
|
|
1308
764
|
middleware.ts auth gate + session refresh + cookie→bearer bridge
|
|
1309
765
|
next.config.mjs required Next config to consume @kywi-software/core
|
|
1310
766
|
lib/kywi.ts server runtime (DB, API handler, content scope)
|
|
1311
|
-
lib/
|
|
767
|
+
lib/config.ts single import path for kywi.config.ts
|
|
1312
768
|
app/api/v1/[...kywi]/route.ts the versioned API (delegates to core)
|
|
1313
|
-
app/admin
|
|
1314
|
-
components/admin-shell.tsx your admin chrome${a.mode === 'coupled' ? '\napp/(site)/… your public site (renders published pages)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
|
|
769
|
+
app/admin/[[...admin]]/page.tsx mounts the FULL core admin (all surfaces) at /admin${a.mode === 'coupled' ? '\napp/(site)/… your public site (renders published pages)' : '\napp/page.tsx returns 404 (no public rendering in this mode)'}
|
|
1315
770
|
\`\`\`
|
|
1316
771
|
`
|
|
1317
772
|
}
|
|
@@ -1346,26 +801,17 @@ export function buildFileSet(answers) {
|
|
|
1346
801
|
'.env.example': envExample(),
|
|
1347
802
|
'.gitignore': gitignore(),
|
|
1348
803
|
'README.md': readme(answers),
|
|
1349
|
-
|
|
1350
|
-
// server runtime + client helpers
|
|
804
|
+
// server runtime + config
|
|
1351
805
|
'lib/kywi.ts': libKywi(),
|
|
1352
806
|
'lib/config.ts': libConfig(),
|
|
1353
|
-
'lib/admin-auth.ts': libAdminAuth(),
|
|
1354
|
-
'lib/content-types.ts': libContentTypes(),
|
|
1355
807
|
// host wiring (thin, over @kywi-software/core/host)
|
|
1356
808
|
'middleware.ts': middleware(),
|
|
1357
809
|
'app/api/v1/[...kywi]/route.ts': apiRoute(),
|
|
1358
810
|
// root
|
|
1359
811
|
'app/layout.tsx': rootLayout(),
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
'app/admin/
|
|
1363
|
-
'app/admin/page.tsx': adminIndexPage(),
|
|
1364
|
-
'app/admin/login/page.tsx': adminLoginPage(),
|
|
1365
|
-
'app/admin/content/page.tsx': adminContentLandingPage(),
|
|
1366
|
-
'app/admin/content/[type]/page.tsx': adminContentListPage(),
|
|
1367
|
-
'app/admin/content/[type]/new/page.tsx': adminContentNewPage(),
|
|
1368
|
-
'app/admin/content/[type]/[id]/page.tsx': adminContentEditPage(),
|
|
812
|
+
'app/icon.svg': faviconSvg(),
|
|
813
|
+
// admin (all modes): the FULL core admin (all surfaces) via one catch-all.
|
|
814
|
+
'app/admin/[[...admin]]/page.tsx': adminCatchAllPage(),
|
|
1369
815
|
}
|
|
1370
816
|
|
|
1371
817
|
if (answers.mode === 'coupled') {
|
package/package.json
CHANGED