@pramen/cms-editor 0.0.56 → 0.0.57
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/dist/editor.js +114 -114
- package/package.json +1 -1
- package/src/api.ts +17 -4
- package/src/app-context.tsx +43 -8
- package/src/components.tsx +128 -17
- package/src/routes/_layout.tsx +44 -13
- package/src/routes/collection-item.tsx +4 -5
- package/src/routes/collection.tsx +4 -5
- package/src/routes/home.tsx +30 -31
- package/src/routes/page.tsx +23 -15
- package/src/routes/type.tsx +21 -28
- package/src/types.ts +7 -1
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -37,6 +37,10 @@ export function isTokenExpired(token: string): boolean {
|
|
|
37
37
|
|
|
38
38
|
const LS = "pramen.cmsEditor";
|
|
39
39
|
|
|
40
|
+
/** The columns the page LIST renders — id (to open the row), title, slug, locale, status.
|
|
41
|
+
* `typeId` rides along so a caller can tell which type a row belongs to without a join. */
|
|
42
|
+
export const PAGE_LIST_COLUMNS = ["id", "typeId", "title", "slug", "locale", "status"] as const;
|
|
43
|
+
|
|
40
44
|
/**
|
|
41
45
|
* The session for this page load.
|
|
42
46
|
*
|
|
@@ -132,10 +136,19 @@ export class Api {
|
|
|
132
136
|
listBlockTypes = () => this.call<BlockType[]>("listBlockTypes");
|
|
133
137
|
listContentTypes = () => this.call<ContentType[]>("listContentTypes");
|
|
134
138
|
getContentType = (id: string) => this.call<ContentType | null>("getContentType", { id });
|
|
135
|
-
/** All pages, or just one content type's (by SLUG)
|
|
136
|
-
* `listPages` in @pramen/cms; it caps
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
+
/** All pages, or just one content type's (by SLUG), one page of them at a time. The server
|
|
140
|
+
* does the filtering AND the paging — see `listPages` in @pramen/cms; it caps its result,
|
|
141
|
+
* so narrowing or counting client-side would be narrowing an already-truncated list.
|
|
142
|
+
*
|
|
143
|
+
* `PAGE_LIST_COLUMNS` is the projection the list screen actually renders. The full page row
|
|
144
|
+
* carries every SEO column, the schedule stamps and the whole `fields` bag; asking for all
|
|
145
|
+
* of it to draw five columns is the D1-over-RPC shape that made lists hang (GitHub #22).
|
|
146
|
+
* The editor fetches a whole page by id (`getPageById`) when it needs the wide row. */
|
|
147
|
+
listPages = (opts: { contentType?: string; limit?: number; offset?: number } = {}) =>
|
|
148
|
+
this.call<Page[]>("listPages", { ...opts, select: [...PAGE_LIST_COLUMNS] });
|
|
149
|
+
/** One page, wide, by id — what the page editor opens. Resolving an id against `listPages`
|
|
150
|
+
* instead can miss: that list is capped, and the editor lists one content type per tab. */
|
|
151
|
+
getPageById = (pageId: string) => this.call<Page | null>("getPageById", { pageId });
|
|
139
152
|
getPagePreview = (slug: string, locale?: string) => this.call<AssembledPage>("getPage", { slug, locale, preview: true });
|
|
140
153
|
listPageAudit = (pageId: string) => this.call<AuditEntry[]>("listPageAudit", { pageId });
|
|
141
154
|
|
package/src/app-context.tsx
CHANGED
|
@@ -95,8 +95,21 @@ interface AppContextValue {
|
|
|
95
95
|
collections: CollectionMeta[];
|
|
96
96
|
/** Content types registered on the server (from `listContentTypes`). More than one ⇒ each
|
|
97
97
|
* gets its own nav tab and its own list route, instead of one pooled "Pages" list where a
|
|
98
|
-
* page and an article sit in the same column with nothing to tell them apart.
|
|
99
|
-
|
|
98
|
+
* page and an article sit in the same column with nothing to tell them apart.
|
|
99
|
+
*
|
|
100
|
+
* `null` means NOT ANSWERED YET, which is a different thing from "this deployment has
|
|
101
|
+
* none" and has to be told apart from it: the whole nav shape is decided by the count, so
|
|
102
|
+
* an empty array standing in for "still loading" makes every screen paint the single-type
|
|
103
|
+
* layout first and correct itself a round trip later. It also swallowed failure — a 5xx,
|
|
104
|
+
* or a session whose role cannot call `listContentTypes` — into the same value, leaving a
|
|
105
|
+
* type route showing "Loading…" over data it already had, forever. */
|
|
106
|
+
contentTypes: ContentType[] | null;
|
|
107
|
+
/** The `listContentTypes` call FAILED (as opposed to answering with none). Screens that
|
|
108
|
+
* cannot render without it say so and offer `refreshContentTypes` rather than pretending
|
|
109
|
+
* to still be loading. */
|
|
110
|
+
contentTypesFailed: boolean;
|
|
111
|
+
/** Retry `listContentTypes` — wired to the retry button on those screens. */
|
|
112
|
+
refreshContentTypes: () => void;
|
|
100
113
|
/** What the SERVER says this deployment supports (from `listCmsCapabilities`) — today,
|
|
101
114
|
* its declared locales. The editor renders its i18n surface off this rather than a local
|
|
102
115
|
* flag, so the UI and the data can never disagree about whether the site is multilingual. */
|
|
@@ -127,7 +140,10 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
127
140
|
const [cfg, setCfg] = useState<Config>(() => loadConfig(BACKEND));
|
|
128
141
|
const [me, setMe] = useState<Me | null>(null);
|
|
129
142
|
const [collections, setCollections] = useState<CollectionMeta[]>([]);
|
|
130
|
-
const [contentTypes, setContentTypes] = useState<ContentType[]>(
|
|
143
|
+
const [contentTypes, setContentTypes] = useState<ContentType[] | null>(null);
|
|
144
|
+
const [contentTypesFailed, setContentTypesFailed] = useState(false);
|
|
145
|
+
const [contentTypesNonce, setContentTypesNonce] = useState(0);
|
|
146
|
+
const refreshContentTypes = useCallback(() => setContentTypesNonce((n) => n + 1), []);
|
|
131
147
|
const [cms, setCms] = useState<CmsCapabilities>(DEFAULT_CAPABILITIES);
|
|
132
148
|
const [error, setError] = useState("");
|
|
133
149
|
// A usable session = somewhere to call + a token that is NOT expired. An expired token
|
|
@@ -185,14 +201,31 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
185
201
|
// Collections drive the nav + list/edit routes. An app that registers none (or an older
|
|
186
202
|
// server without the handler) just leaves the nav as-is — a failure is non-fatal.
|
|
187
203
|
api.call<CollectionMeta[]>("listCollections").then(setCollections).catch(() => setCollections([]));
|
|
188
|
-
// Drives the per-type nav + list routes. A failure leaves it empty, which falls back to
|
|
189
|
-
// the single pooled "Pages" tab — the layout before types had tabs of their own.
|
|
190
|
-
api.listContentTypes().then(setContentTypes).catch(() => setContentTypes([]));
|
|
191
204
|
// A server older than this handler leaves the monolingual default, which is the safe
|
|
192
|
-
// way round: the i18n surface stays hidden rather than half-rendered.
|
|
193
|
-
|
|
205
|
+
// way round: the i18n surface stays hidden rather than half-rendered. Merged OVER the
|
|
206
|
+
// defaults, not substituted for them, so a capability the server does not know about
|
|
207
|
+
// reads as its (closed) default rather than `undefined`.
|
|
208
|
+
api
|
|
209
|
+
.call<Partial<CmsCapabilities>>("listCmsCapabilities")
|
|
210
|
+
.then((r) => setCms({ ...DEFAULT_CAPABILITIES, ...r }))
|
|
211
|
+
.catch(() => setCms(DEFAULT_CAPABILITIES));
|
|
194
212
|
}, [api, authValid]);
|
|
195
213
|
|
|
214
|
+
// Drives the per-type nav + list routes. A failure falls back to the single pooled "Pages"
|
|
215
|
+
// tab — the layout before types had tabs of their own — but is RECORDED, so a screen that
|
|
216
|
+
// genuinely cannot render without the list (the per-type route) can say the call failed and
|
|
217
|
+
// offer a retry instead of claiming to still be loading. Its own effect so that retry
|
|
218
|
+
// re-runs THIS call and not `me`/`listCollections`/the capability probe alongside it.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!authValid) return;
|
|
221
|
+
let live = true;
|
|
222
|
+
api
|
|
223
|
+
.listContentTypes()
|
|
224
|
+
.then((r) => { if (live) { setContentTypes(r); setContentTypesFailed(false); } })
|
|
225
|
+
.catch(() => { if (live) { setContentTypes([]); setContentTypesFailed(true); } });
|
|
226
|
+
return () => { live = false; };
|
|
227
|
+
}, [api, authValid, contentTypesNonce]);
|
|
228
|
+
|
|
196
229
|
if (!authValid) {
|
|
197
230
|
// Configured external sign-in → hand off (the boot effect above navigates). Otherwise
|
|
198
231
|
// fall back to the built-in Setup screen (paste a JWT).
|
|
@@ -206,6 +239,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
|
206
239
|
isAdmin: (me?.roles ?? []).includes("admin"),
|
|
207
240
|
collections,
|
|
208
241
|
contentTypes,
|
|
242
|
+
contentTypesFailed,
|
|
243
|
+
refreshContentTypes,
|
|
209
244
|
cms,
|
|
210
245
|
error,
|
|
211
246
|
setError,
|
package/src/components.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { CONTROL, FieldForm, formatWhen, fromLocalInput, slugify, toLocalInput }
|
|
|
9
9
|
import type { Config } from "./api";
|
|
10
10
|
import { useApp, type Me } from "./app-context";
|
|
11
11
|
import { isRichTextDoc, richTextToPlainText } from "./rich-text";
|
|
12
|
-
import type { AssembledPage, AuditEntry, BlockType, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
12
|
+
import type { AssembledPage, AuditEntry, BlockType, CmsCapabilities, CollectionMeta, ContentType, FieldDefinition, FieldValue, FieldValues, Media, Page, RegionDefinition, RenderedBlock } from "./types";
|
|
13
13
|
|
|
14
14
|
export type InspectorTab = "settings" | "seo" | "workflow" | "i18n" | "audit";
|
|
15
15
|
export const INSPECTOR_TABS: InspectorTab[] = ["settings", "seo", "workflow", "i18n", "audit"];
|
|
@@ -21,6 +21,31 @@ export function visibleTabs(multilingual: boolean): InspectorTab[] {
|
|
|
21
21
|
return multilingual ? INSPECTOR_TABS : INSPECTOR_TABS.filter((t) => t !== "i18n");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Collections-only deployments hide the block/page builder entirely. Read in one place so
|
|
25
|
+
* the nav, the landing redirect and the per-type route cannot disagree about it — a deep
|
|
26
|
+
* link to `/types/:slug` used to render the very builder this flag exists to hide. */
|
|
27
|
+
export function pagesHidden(): boolean {
|
|
28
|
+
return typeof window !== "undefined" && window.PRAMEN_CMS_EDITOR?.hidePages === true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Does this deployment get one tab and one list PER CONTENT TYPE?
|
|
33
|
+
*
|
|
34
|
+
* ONE definition, used by the tab bar, the landing redirect and the page editor's back
|
|
35
|
+
* target — the same reason `visibleTabs` exists. Three places re-deriving the whole nav
|
|
36
|
+
* shape is three places that can disagree about which screen `/` is.
|
|
37
|
+
*
|
|
38
|
+
* - `null` content types mean NOT ANSWERED YET, and answer `false` without committing: the
|
|
39
|
+
* pooled list is what a single-type deployment keeps, so painting it before the count
|
|
40
|
+
* arrives is a flash of the exact screen the split exists to retire.
|
|
41
|
+
* - `pagesByType` is the SERVER's declaration that `listPages` understands `contentType`
|
|
42
|
+
* (see `CmsCapabilities`). Without it the split fails open: N tabs, each showing every
|
|
43
|
+
* type's pages under a heading naming one.
|
|
44
|
+
*/
|
|
45
|
+
export function splitsByType(contentTypes: ContentType[] | null, cms: CmsCapabilities, hidePages = pagesHidden()): boolean {
|
|
46
|
+
return !hidePages && cms.pagesByType && contentTypes !== null && contentTypes.length > 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
// --- presentational primitives (podoba tokens; replaces styles.ts classes) ---
|
|
25
50
|
|
|
26
51
|
const ROW = "flex items-center gap-3 rounded-[14px] border border-transparent bg-surface-card px-[18px] py-3.5";
|
|
@@ -106,19 +131,88 @@ function Banner({ ok, children }: { ok?: boolean; children: ReactNode }) {
|
|
|
106
131
|
|
|
107
132
|
const Dim = ({ children }: { children: ReactNode }) => <span className="text-fg-subtle">{children}</span>;
|
|
108
133
|
|
|
134
|
+
/** A one-line neutral panel with an optional way out — the loading / not-found / unknown-slug
|
|
135
|
+
* state every route needs before (or instead of) its real screen. Four routes had it written
|
|
136
|
+
* out verbatim; the copies had already drifted on which of them offered a way back. */
|
|
137
|
+
export function Notice({ children, action }: { children: ReactNode; action?: ReactNode }) {
|
|
138
|
+
return (
|
|
139
|
+
<div className="mx-auto flex max-w-[1200px] items-center gap-2 px-7 pt-8">
|
|
140
|
+
<p className="text-fg-subtle">{children}</p>
|
|
141
|
+
{action}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
109
146
|
// --- pages list --------------------------------------------------------------
|
|
110
147
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
148
|
+
/** Page size for the page list. `listPages` caps server-side, so a request without an
|
|
149
|
+
* explicit limit silently truncates — and the header then reports the truncated count as if
|
|
150
|
+
* it were the total, which reads as "that is all there is". Same reason, same answer, as
|
|
151
|
+
* `COLLECTION_PAGE_SIZE` below. */
|
|
152
|
+
const PAGE_LIST_SIZE = 50;
|
|
153
|
+
|
|
154
|
+
/** The page list, loading its own rows. `type` scopes it to one content type: the fetch asks
|
|
155
|
+
* the SERVER for that type (narrowing a capped list here would drop the tail of every type),
|
|
156
|
+
* the heading is the type's name, and the create modal opens on that type instead of asking
|
|
157
|
+
* again. Omitted ⇒ the pooled list over every type, which is what a single-type deployment
|
|
158
|
+
* should keep seeing — byte for byte, including the wording.
|
|
159
|
+
*
|
|
160
|
+
* It owns the fetching (like `CollectionList`) rather than taking rows as a prop, because
|
|
161
|
+
* the two things that go wrong when a route owns them both go wrong invisibly: rows left
|
|
162
|
+
* standing from the previous type while the heading has already flipped to the new one, and
|
|
163
|
+
* a count that is really the server's cap. */
|
|
164
|
+
export function PageList({ api, type, onOpen, onError }: { api: Api; type?: ContentType; onOpen: (p: Page) => void; onError: (s: string) => void }) {
|
|
116
165
|
// From the SERVER (listCmsCapabilities), not a local flag — see `CmsCapabilities`.
|
|
117
166
|
const { cms: { multilingual } } = useApp();
|
|
167
|
+
const [pages, setPages] = useState<Page[]>([]);
|
|
168
|
+
const [offset, setOffset] = useState(0);
|
|
169
|
+
const [hasMore, setHasMore] = useState(false);
|
|
170
|
+
const [loading, setLoading] = useState(true);
|
|
171
|
+
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
118
172
|
const [creating, setCreating] = useState(false);
|
|
173
|
+
const contentType = type?.slug;
|
|
174
|
+
|
|
175
|
+
const load = useCallback(
|
|
176
|
+
(off: number) => {
|
|
177
|
+
setLoading(true);
|
|
178
|
+
return api
|
|
179
|
+
.listPages({ contentType, limit: PAGE_LIST_SIZE, offset: off })
|
|
180
|
+
.then((r) => {
|
|
181
|
+
setPages((prev) => (off === 0 ? r : [...prev, ...r]));
|
|
182
|
+
// A full page means there is probably more; a short one is definitely the end.
|
|
183
|
+
setHasMore(r.length === PAGE_LIST_SIZE);
|
|
184
|
+
setOffset(off + r.length);
|
|
185
|
+
})
|
|
186
|
+
.catch((e) => onError(errMsg(e)))
|
|
187
|
+
.finally(() => setLoading(false));
|
|
188
|
+
},
|
|
189
|
+
[api, contentType, onError],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Clearing first is the point: buzola renders the same component instance across a
|
|
193
|
+
// params-only change (`/types/a` → `/types/b`), so without this the previous type's rows
|
|
194
|
+
// sit under the new type's heading until the fetch lands — and permanently if it fails.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
setPages([]);
|
|
197
|
+
setOffset(0);
|
|
198
|
+
setHasMore(false);
|
|
199
|
+
void load(0);
|
|
200
|
+
}, [load]);
|
|
201
|
+
|
|
202
|
+
// Only for the empty state's hint, and it is a GLOBAL list — keyed on `api` alone so
|
|
203
|
+
// switching type tabs doesn't refetch it.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
api.listBlockTypes().then(setBlockTypes).catch((e) => onError(errMsg(e)));
|
|
206
|
+
}, [api, onError]);
|
|
207
|
+
|
|
208
|
+
// "pages", not "entries": these are pages of a content type, and a single-type deployment
|
|
209
|
+
// must read exactly as it did before types had tabs. `type.name` is a label a host writes
|
|
210
|
+
// (often plural, it labels the tab), so it heads the screen and is never bent into a noun
|
|
211
|
+
// phrase — "+ New Articles" is what guessing at grammar produces.
|
|
212
|
+
const count = pages.length === 0 ? "None yet" : pages.length === 1 ? "1 page total" : `${pages.length}${hasMore ? "+" : ""} pages total`;
|
|
119
213
|
return (
|
|
120
214
|
<>
|
|
121
|
-
<Hero lead={type?.name ?? "Pages"} em={
|
|
215
|
+
<Hero lead={type?.name ?? "Pages"} em={loading && pages.length === 0 ? "Loading…" : count}>
|
|
122
216
|
<Cta text="Let's" em="create something">
|
|
123
217
|
<Button className="shrink-0" onPress={() => setCreating(true)}>+ New page</Button>
|
|
124
218
|
</Cta>
|
|
@@ -133,26 +227,37 @@ export function PageList({ api, pages, blockTypes, type, onOpen, onCreated, onEr
|
|
|
133
227
|
<Pill status={p.status}>{p.status}</Pill>
|
|
134
228
|
</div>
|
|
135
229
|
))}
|
|
136
|
-
{pages.length === 0 ? <p className="text-fg-subtle">No
|
|
230
|
+
{!loading && pages.length === 0 ? <p className="text-fg-subtle">No pages yet. {blockTypes.length === 0 ? "Define block types + a content type first (via the API/admin)." : "Create one."}</p> : null}
|
|
137
231
|
</div>
|
|
232
|
+
{hasMore ? (
|
|
233
|
+
<div className="mt-4 flex justify-center">
|
|
234
|
+
<Button variant="ghost" onPress={() => void load(offset)} isDisabled={loading}>{loading ? "Loading…" : "Load more"}</Button>
|
|
235
|
+
</div>
|
|
236
|
+
) : null}
|
|
138
237
|
</div>
|
|
139
|
-
{creating ? <CreatePage api={api} type={type} onClose={() => setCreating(false)} onCreated={() => { setCreating(false);
|
|
238
|
+
{creating ? <CreatePage api={api} type={type} onClose={() => setCreating(false)} onCreated={() => { setCreating(false); void load(0); }} onError={onError} /> : null}
|
|
140
239
|
</>
|
|
141
240
|
);
|
|
142
241
|
}
|
|
143
242
|
|
|
144
243
|
function CreatePage({ api, type, onClose, onCreated, onError }: { api: Api; type?: ContentType; onClose: () => void; onCreated: () => void; onError: (s: string) => void }) {
|
|
145
|
-
|
|
244
|
+
// The app context already holds this list — a second fetch per modal open is a second
|
|
245
|
+
// cache of one list in one tree, with its own error policy.
|
|
246
|
+
const { contentTypes } = useApp();
|
|
247
|
+
const cts = contentTypes ?? [];
|
|
146
248
|
const [typeId, setTypeId] = useState(type?.id ?? "");
|
|
147
249
|
const [title, setTitle] = useState("");
|
|
148
250
|
const [slug, setSlug] = useState("");
|
|
251
|
+
// Re-sync, not seed-once. On a type-scoped list the type is decided by the screen you are
|
|
252
|
+
// on — and that screen can change UNDER an open modal: history navigation isn't blocked by
|
|
253
|
+
// the overlay the way a topbar click is, and buzola keeps this component instance across
|
|
254
|
+
// it. Seeded once, the modal kept filing the new screen's page under the old screen's type,
|
|
255
|
+
// with the picker hidden so nothing on screen said so and `createPage` trusting the id.
|
|
149
256
|
useEffect(() => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
api.listContentTypes().then((r) => { setCts(r); if (r[0]) setTypeId(r[0].id); }).catch((e) => onError(errMsg(e)));
|
|
155
|
-
}, [api, type, onError]);
|
|
257
|
+
if (type) { setTypeId(type.id); return; }
|
|
258
|
+
const list = contentTypes ?? [];
|
|
259
|
+
setTypeId((cur) => (cur && list.some((c) => c.id === cur) ? cur : list[0]?.id ?? ""));
|
|
260
|
+
}, [type, contentTypes]);
|
|
156
261
|
const create = async () => {
|
|
157
262
|
try {
|
|
158
263
|
await api.call("createPage", { typeId, title, slug: slug || slugify(title) });
|
|
@@ -163,7 +268,13 @@ function CreatePage({ api, type, onClose, onCreated, onError }: { api: Api; type
|
|
|
163
268
|
};
|
|
164
269
|
return (
|
|
165
270
|
<Modal onClose={onClose}>
|
|
166
|
-
|
|
271
|
+
{/* Name the type when the picker is hidden. Otherwise the whole modal says "page" and
|
|
272
|
+
nothing on it says WHICH type the page is being filed under — on a per-type list
|
|
273
|
+
that is the one fact the screen is supposed to be carrying. */}
|
|
274
|
+
<ModalTitle>
|
|
275
|
+
Create a <Dim>new page</Dim>
|
|
276
|
+
{type ? <> in <Dim>{type.name}</Dim></> : null} and define the essentials<Dim>.</Dim>
|
|
277
|
+
</ModalTitle>
|
|
167
278
|
<div className="flex flex-col gap-4">
|
|
168
279
|
<div className={`w-full flex-col gap-2 ${type ? "hidden" : "flex"}`}>
|
|
169
280
|
<span className="text-sm font-medium text-fg">Content type</span>
|
package/src/routes/_layout.tsx
CHANGED
|
@@ -7,12 +7,35 @@ import { Button, Card, MoonIcon, SunIcon, Text, Topbar } from "@podoba/react";
|
|
|
7
7
|
import { useEffect, useState } from "react";
|
|
8
8
|
import { useApp } from "../app-context";
|
|
9
9
|
import { BRAND } from "../brand";
|
|
10
|
+
import { pagesHidden, splitsByType } from "../components";
|
|
10
11
|
import { opensInSameTab } from "../mount";
|
|
11
12
|
|
|
12
13
|
const THEME_KEY = "pramen.cms.theme";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The slug segment under `prefix`, DECODED.
|
|
17
|
+
*
|
|
18
|
+
* `useRoute().pathname` comes off a `URL`, so it is percent-encoded; the slugs it is compared
|
|
19
|
+
* against are the raw values the server stored. buzola encodes when it builds an href and
|
|
20
|
+
* decodes into `params` when it matches, so routing is unaffected — only this comparison was,
|
|
21
|
+
* and a content type called `články` navigated correctly to a tab bar with nothing lit.
|
|
22
|
+
*
|
|
23
|
+
* A malformed sequence (`%zz`) throws in `decodeURIComponent`; that cannot match any slug
|
|
24
|
+
* either way, so it degrades to no highlight rather than tearing down the chrome.
|
|
25
|
+
*/
|
|
26
|
+
export function segmentAt(pathname: string, prefix: string): string | undefined {
|
|
27
|
+
if (!pathname.startsWith(prefix)) return undefined;
|
|
28
|
+
const raw = pathname.slice(prefix.length).split("/")[0];
|
|
29
|
+
if (!raw) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
return decodeURIComponent(raw);
|
|
32
|
+
} catch {
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
export default function RootLayout() {
|
|
15
|
-
const { isAdmin, collections, contentTypes, error, reconfigure, confirmNavigation } = useApp();
|
|
38
|
+
const { isAdmin, collections, contentTypes, cms, error, reconfigure, confirmNavigation } = useApp();
|
|
16
39
|
const navigate = useNavigate();
|
|
17
40
|
const { pathname } = useRoute();
|
|
18
41
|
|
|
@@ -34,9 +57,9 @@ export default function RootLayout() {
|
|
|
34
57
|
}, [theme]);
|
|
35
58
|
|
|
36
59
|
// The active collection slug, if we're under /collections/:slug(/...).
|
|
37
|
-
const collectionSlug = pathname
|
|
60
|
+
const collectionSlug = segmentAt(pathname, "/collections/");
|
|
38
61
|
// …and the active content type, under /types/:slug.
|
|
39
|
-
const typeSlug = pathname
|
|
62
|
+
const typeSlug = segmentAt(pathname, "/types/");
|
|
40
63
|
|
|
41
64
|
// "Pages" stays lit while editing a page (/pages/:id) too — but only on a deployment that
|
|
42
65
|
// still HAS a pooled Pages tab. Split by type, the page editor lights nothing: the route
|
|
@@ -50,12 +73,20 @@ export default function RootLayout() {
|
|
|
50
73
|
: pathname.startsWith("/settings") ? "settings"
|
|
51
74
|
: "";
|
|
52
75
|
|
|
53
|
-
|
|
76
|
+
// `aria-current` alongside the class: split by type the nav is N mutually-exclusive tabs
|
|
77
|
+
// whose only "you are here" cue is a background tint, which is invisible to a screen reader
|
|
78
|
+
// and marginal for anyone who cannot see the tint.
|
|
79
|
+
const tabProps = (key: string) => ({
|
|
80
|
+
className: active === key ? "bg-surface-muted text-fg" : "text-fg-muted",
|
|
81
|
+
...(active === key ? { "aria-current": "page" as const } : {}),
|
|
82
|
+
});
|
|
54
83
|
|
|
55
84
|
// Host-configured links to companion tools (e.g. a curation page), from /config.js.
|
|
56
85
|
const extraNav = typeof window !== "undefined" ? window.PRAMEN_CMS_EDITOR?.extraNav ?? [] : [];
|
|
57
86
|
// Collections-only deployments hide the block/page builder entirely.
|
|
58
|
-
const hidePages =
|
|
87
|
+
const hidePages = pagesHidden();
|
|
88
|
+
// Same rule as the landing redirect and the page editor's back target — see `splitsByType`.
|
|
89
|
+
const splitByType = splitsByType(contentTypes, cms, hidePages);
|
|
59
90
|
|
|
60
91
|
// See the extraNav comment below. The rules live in `mount.ts` beside the containment they
|
|
61
92
|
// depend on; what this supplies is the URL the BROWSER will resolve a relative href
|
|
@@ -89,38 +120,38 @@ export default function RootLayout() {
|
|
|
89
120
|
the plain "Pages" tab — there is nothing there to separate, and splitting it
|
|
90
121
|
would put the deployment's own type name where a generic label reads better.
|
|
91
122
|
The label is the type's `name`, so a host that wants a plural tab writes one. */}
|
|
92
|
-
{hidePages ? null :
|
|
93
|
-
contentTypes.map((t) => (
|
|
123
|
+
{hidePages ? null : splitByType ? (
|
|
124
|
+
(contentTypes ?? []).map((t) => (
|
|
94
125
|
<Button
|
|
95
126
|
key={t.slug}
|
|
96
127
|
variant="ghost"
|
|
97
128
|
size="sm"
|
|
98
|
-
|
|
129
|
+
{...tabProps(`type:${t.slug}`)}
|
|
99
130
|
onPress={guarded(() => navigate("type", { params: { slug: t.slug } }))}
|
|
100
131
|
>
|
|
101
132
|
{t.name}
|
|
102
133
|
</Button>
|
|
103
134
|
))
|
|
104
135
|
) : (
|
|
105
|
-
<Button variant="ghost" size="sm"
|
|
136
|
+
<Button variant="ghost" size="sm" {...tabProps("pages")} onPress={guarded(() => navigate("home"))}>
|
|
106
137
|
Pages
|
|
107
138
|
</Button>
|
|
108
139
|
)}
|
|
109
140
|
{collections.map((c) => (
|
|
110
|
-
<Button key={c.slug} variant="ghost" size="sm"
|
|
141
|
+
<Button key={c.slug} variant="ghost" size="sm" {...tabProps(`col:${c.slug}`)} onPress={guarded(() => navigate("collection", { params: { slug: c.slug } }))}>
|
|
111
142
|
{c.icon ? `${c.icon} ` : ""}
|
|
112
143
|
{c.pluralLabel}
|
|
113
144
|
</Button>
|
|
114
145
|
))}
|
|
115
|
-
<Button variant="ghost" size="sm"
|
|
146
|
+
<Button variant="ghost" size="sm" {...tabProps("media")} onPress={guarded(() => navigate("media"))}>
|
|
116
147
|
Media
|
|
117
148
|
</Button>
|
|
118
149
|
{isAdmin ? (
|
|
119
|
-
<Button variant="ghost" size="sm"
|
|
150
|
+
<Button variant="ghost" size="sm" {...tabProps("users")} onPress={guarded(() => navigate("users"))}>
|
|
120
151
|
Users
|
|
121
152
|
</Button>
|
|
122
153
|
) : null}
|
|
123
|
-
<Button variant="ghost" size="sm"
|
|
154
|
+
<Button variant="ghost" size="sm" {...tabProps("settings")} onPress={guarded(() => navigate("settings"))}>
|
|
124
155
|
Settings
|
|
125
156
|
</Button>
|
|
126
157
|
{extraNav.map((l) => (
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createPage, useNavigate } from "@buzola/router";
|
|
6
6
|
import { Button } from "@podoba/react";
|
|
7
7
|
import { useApp } from "../app-context";
|
|
8
|
-
import { CollectionEditor } from "../components";
|
|
8
|
+
import { CollectionEditor, Notice } from "../components";
|
|
9
9
|
|
|
10
10
|
export default createPage()
|
|
11
11
|
.params({ slug: "string", id: "string" })
|
|
@@ -18,10 +18,9 @@ export default createPage()
|
|
|
18
18
|
|
|
19
19
|
if (!def) {
|
|
20
20
|
return (
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</div>
|
|
21
|
+
<Notice action={collections.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : undefined}>
|
|
22
|
+
{collections.length === 0 ? "Loading…" : `Unknown collection: ${params.slug}`}
|
|
23
|
+
</Notice>
|
|
25
24
|
);
|
|
26
25
|
}
|
|
27
26
|
return (
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createPage, useNavigate } from "@buzola/router";
|
|
6
6
|
import { Button } from "@podoba/react";
|
|
7
7
|
import { useApp } from "../app-context";
|
|
8
|
-
import { CollectionList } from "../components";
|
|
8
|
+
import { CollectionList, Notice } from "../components";
|
|
9
9
|
|
|
10
10
|
export default createPage()
|
|
11
11
|
.params({ slug: "string" })
|
|
@@ -18,10 +18,9 @@ export default createPage()
|
|
|
18
18
|
if (!def) {
|
|
19
19
|
// Collections load async; before they arrive (or for a bad slug) show a neutral state.
|
|
20
20
|
return (
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
</div>
|
|
21
|
+
<Notice action={collections.length > 0 ? <Button variant="ghost" size="sm" onPress={() => navigate("home")}>← Pages</Button> : undefined}>
|
|
22
|
+
{collections.length === 0 ? "Loading…" : `Unknown collection: ${params.slug}`}
|
|
23
|
+
</Notice>
|
|
25
24
|
);
|
|
26
25
|
}
|
|
27
26
|
return (
|
package/src/routes/home.tsx
CHANGED
|
@@ -1,56 +1,55 @@
|
|
|
1
1
|
// Home route (`/`): the page list. Opening a page navigates to /pages/:pageId.
|
|
2
2
|
|
|
3
3
|
import { createPage, useNavigate } from "@buzola/router";
|
|
4
|
-
import { useEffect
|
|
4
|
+
import { useEffect } from "react";
|
|
5
5
|
import { useApp } from "../app-context";
|
|
6
|
-
import { PageList,
|
|
7
|
-
import type { BlockType, Page } from "../types";
|
|
6
|
+
import { PageList, pagesHidden, splitsByType } from "../components";
|
|
8
7
|
|
|
9
8
|
export default createPage()
|
|
10
9
|
.route("/")
|
|
11
10
|
.render(function Home() {
|
|
12
|
-
const { api, setError, collections, contentTypes } = useApp();
|
|
11
|
+
const { api, setError, collections, contentTypes, cms } = useApp();
|
|
13
12
|
const navigate = useNavigate();
|
|
14
|
-
const [pages, setPages] = useState<Page[]>([]);
|
|
15
|
-
const [blockTypes, setBlockTypes] = useState<BlockType[]>([]);
|
|
16
13
|
|
|
17
14
|
// Collections-only deployment: `/` is the Pages list, so land on the first
|
|
18
15
|
// collection instead. Without this, hiding the tab still leaves the landing page
|
|
19
16
|
// showing an empty page list — and still fetching pages, which errors outright on a
|
|
20
17
|
// deployment that never spread cmsSchema.
|
|
21
|
-
const hidePages =
|
|
18
|
+
const hidePages = pagesHidden();
|
|
22
19
|
const firstCollection = collections[0]?.slug;
|
|
23
20
|
// With more than one content type each gets its own tab and its own list (`/types/:slug`),
|
|
24
21
|
// so the pooled list here has no tab of its own to be reached from and would just be a
|
|
25
22
|
// fourth way to see the same rows. Land on the first type instead. One type (or none, on a
|
|
26
23
|
// server too old to answer) keeps the pooled list — that IS the whole CMS there.
|
|
27
|
-
const splitByType =
|
|
28
|
-
|
|
24
|
+
const splitByType = splitsByType(contentTypes, cms, hidePages);
|
|
25
|
+
// A content type must have a non-empty slug (the server refuses one), but this is data
|
|
26
|
+
// from a server this build does not control, and an empty slug builds `/types/` — a path
|
|
27
|
+
// the router drops the empty segment from, so it matches nothing. Skip such a type rather
|
|
28
|
+
// than redirect into a route that cannot match.
|
|
29
|
+
const firstType = (contentTypes ?? []).find((t) => t.slug !== "")?.slug;
|
|
30
|
+
// ONE condition for the redirect and for the bail below. Gated differently, a split
|
|
31
|
+
// deployment whose first type has no usable slug redirected nowhere and rendered nothing:
|
|
32
|
+
// a permanently blank `/`, which the wordmark leads straight back to.
|
|
33
|
+
const toType = splitByType && firstType !== undefined ? firstType : undefined;
|
|
29
34
|
|
|
30
35
|
useEffect(() => {
|
|
31
36
|
if (hidePages && firstCollection) navigate("collection", { params: { slug: firstCollection }, replace: true });
|
|
32
|
-
else if (
|
|
33
|
-
}, [hidePages, firstCollection,
|
|
37
|
+
else if (toType !== undefined) navigate("type", { params: { slug: toType }, replace: true });
|
|
38
|
+
}, [hidePages, firstCollection, toType, navigate]);
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
|
|
40
|
+
// `hidePages` means this deployment has no block/page builder at all, so `/` never falls
|
|
41
|
+
// through to the page list here — fetching pages is exactly what the flag says not to do
|
|
42
|
+
// (on a deployment that never spread `cmsSchema` it errors outright). It hands off to the
|
|
43
|
+
// first collection, or says so when there is none to hand off to.
|
|
44
|
+
// (Nothing rendered when there is no collection either: `collections` is empty both while
|
|
45
|
+
// it loads and when a deployment registers none, so any message here would be wrong half
|
|
46
|
+
// the time. The layout's chrome is still on screen — the tabs are the way out.)
|
|
47
|
+
if (hidePages) return null;
|
|
48
|
+
if (toType !== undefined) return null;
|
|
49
|
+
// Not answered yet ≠ "this deployment has one type". Rendering the pooled list before
|
|
50
|
+
// `listContentTypes` lands paints — and fetches — the very screen the split exists to
|
|
51
|
+
// retire, then redirects away from it a round trip later.
|
|
52
|
+
if (contentTypes === null) return null;
|
|
45
53
|
|
|
46
|
-
return (
|
|
47
|
-
<PageList
|
|
48
|
-
api={api}
|
|
49
|
-
pages={pages}
|
|
50
|
-
blockTypes={blockTypes}
|
|
51
|
-
onOpen={(p) => navigate("page", { params: { pageId: p.id } })}
|
|
52
|
-
onCreated={refreshPages}
|
|
53
|
-
onError={setError}
|
|
54
|
-
/>
|
|
55
|
-
);
|
|
54
|
+
return <PageList api={api} onOpen={(p) => navigate("page", { params: { pageId: p.id } })} onError={setError} />;
|
|
56
55
|
});
|