Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/collections-studio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@cloudhal/cli": minor
---

Studio: first-class collections (RFC-0040 step 3).
- **Sidebar:** lists every collection of the site, declared ones and those that agents or imports filled. Each shows its name in Studio's language, its icon and its entry count, followed by **Collections**.
- **Collections screen:** shows each collection's address, entry count and whether it is named. It has a **New collection** dialog with plural and singular name, key, icon and address, and the API checks the address for overlaps.
- **Collection list:** each collection gets the Articles list (`/collections/<key>`, where `/articles` now redirects). Category and tag columns and filters appear only where those taxonomies apply.
- **Collection settings:** names in every site language, icon, RSS feed, sitemap, default order, and which categories and tags apply.
- **Editor:** its breadcrumb leads back to the entry's own collection.
- **Elsewhere:** the comments queue, the language review options and the taxonomy dialog cover every collection.
- **Translations:** new strings in all 19 languages; every language except English is an AI draft awaiting review.
2 changes: 2 additions & 0 deletions apps/studio/scripts/design-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export const ROUTE_TEMPLATES: Record<string, Template> = {
"routes/ProjectPage.tsx": "dashboard",
"routes/PagesPage.tsx": "list",
"routes/ArticlesPage.tsx": "list",
"routes/CollectionsPage.tsx": "list",
"routes/CollectionSettingsPage.tsx": "settings",
"routes/SnapshotsPage.tsx": "list",
"routes/ActivityPage.tsx": "list",
"routes/ApprovalsPage.tsx": "queue",
Expand Down
34 changes: 34 additions & 0 deletions apps/studio/src/components/CollectionActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { LinkButton } from "@cloudhal/ui";
import { GearSix } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { useT } from "../i18n/index.tsx";
import { useCan } from "./useMe.ts";

/** A collection list's header actions: its settings (people who may change them), then "New". */
export function CollectionActions({
siteId,
collection,
add,
}: {
siteId: string;
collection: string;
add: ReactNode;
}) {
const t = useT();
const settings = useCan("settings.write");
return (
<div className="flex items-center gap-2">
{settings && (
<LinkButton
href={`/sites/${siteId}/collections/${collection}/settings`}
variant="secondary"
icon={GearSix}
data-testid="collection-settings"
>
{t("collectionList.settings")}
</LinkButton>
)}
{add}
</div>
);
}
61 changes: 61 additions & 0 deletions apps/studio/src/components/CollectionNav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Site } from "@cloudhal/core-contracts";
import { Sidebar } from "@cloudhal/ui";
import { Stack } from "@phosphor-icons/react";
import { useI18n } from "../i18n/index.tsx";
import {
collectionIcon,
collectionNames,
entryTotal,
useCollectionIcons,
useCollections,
} from "../lib/collections.ts";

/**
* The sidebar's collections (RFC-0040 §4): one item per collection of the registry, by its plural
* name in Studio's language, with its entry count, then "Collections", the overview with "New
* collection". `posts` keeps Studio's own "Articles" until someone names it.
*/
export function CollectionNav({
siteId,
site,
is,
}: {
siteId: string;
site: Site | undefined;
is: (path: string, exact?: boolean) => boolean;
}) {
const { t, locale } = useI18n();
const registry = useCollections(siteId);
const icons = useCollectionIcons();
const base = `/sites/${siteId}`;
return (
<>
{(registry.data ?? []).map((c) => {
const href = `${base}/collections/${c.key}`;
const name =
collectionNames(site, c.key, locale)?.plural ??
(c.key === "posts" ? t("nav.articles") : c.key);
return (
<Sidebar.MenuButton
key={c.key}
icon={collectionIcon(c, icons)}
href={href}
active={is(href) || (c.key === "posts" && is(`${base}/articles`))}
tooltip={name}
>
{name}
<Sidebar.MenuBadge>{entryTotal(c)}</Sidebar.MenuBadge>
</Sidebar.MenuButton>
);
})}
<Sidebar.MenuButton
icon={Stack}
href={`${base}/collections`}
active={is(`${base}/collections`, true)}
tooltip={t("nav.collections")}
>
{t("nav.collections")}
</Sidebar.MenuButton>
</>
);
}
24 changes: 17 additions & 7 deletions apps/studio/src/components/LanguageSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useEffect, useState } from "react";
import { useI18n } from "../i18n/index.tsx";
import { ApiRequestError, api } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
import { collectionNames, useCollections } from "../lib/collections.ts";
import { languageName } from "../lib/languages.ts";
import { EditionGuide } from "./EditionGuide.tsx";
import { LanguagePicker } from "./LanguagePicker.tsx";
Expand Down Expand Up @@ -58,8 +59,6 @@ function withForm(site: Site, f: Form): Site {
};
}

const REVIEWABLE = ["pages", "posts"] as const;

export function LanguageSettingsSection({ siteId }: { siteId: string }) {
const { t, locale } = useI18n();
const qc = useQueryClient();
Expand Down Expand Up @@ -127,7 +126,7 @@ export function LanguageSettingsSection({ siteId }: { siteId: string }) {
)}
<LocalesEditor form={form} set={set} def={def} name={name} disabled={disabled} />
<EditionGuide siteId={siteId} />
<LocalizationRows form={form} set={set} disabled={disabled} />
<LocalizationRows form={form} set={set} disabled={disabled} site={site.data} />
</SettingsSection>
);
}
Expand Down Expand Up @@ -197,8 +196,15 @@ function LocalesEditor({
}

/** Addresses, missing translations, review before publishing, the reader's notice. */
function LocalizationRows({ form, set, disabled }: EditorProps) {
const { t } = useI18n();
function LocalizationRows({ form, set, disabled, site }: EditorProps & { site: Site | undefined }) {
const { t, locale } = useI18n();
// Pages, then every collection of the registry (RFC-0040), `posts` first.
const registry = useCollections(site?.id ?? "");
const reviewable = [
"pages",
"posts",
...(registry.data ?? []).map((c) => c.key).filter((k) => k !== "posts"),
];
return (
<dl className="-mb-3 divide-y divide-kumo-hairline border-kumo-hairline border-t">
<SettingRow label={t("languages.routing")} hint={t("languages.routingHint")}>
Expand Down Expand Up @@ -227,10 +233,14 @@ function LocalizationRows({ form, set, disabled }: EditorProps) {
</SettingRow>
<SettingRow label={t("languages.review")} hint={t("languages.reviewHint")}>
<span className="grid gap-2">
{REVIEWABLE.map((c) => (
{reviewable.map((c) => (
<Checkbox
key={c}
label={t(`languages.reviewOptions.${c}`)}
label={
c === "pages" || c === "posts"
? t(`languages.reviewOptions.${c as "pages" | "posts"}`)
: (collectionNames(site, c, locale)?.plural ?? c)
}
checked={form.review.includes(c)}
disabled={disabled}
onCheckedChange={(on) =>
Expand Down
216 changes: 216 additions & 0 deletions apps/studio/src/components/NewCollectionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import type { CollectionSettings, Site } from "@cloudhal/core-contracts";
import { Banner, Input, LayerDialog, Select } from "@cloudhal/ui";
import { WarningCircle } from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n/index.tsx";
import { api } from "../lib/api.ts";
import { apiErrorText } from "../lib/api-errors.ts";
import { validatePrefix } from "../lib/collection-permalinks.ts";
import { COLLECTION_ICON_NAMES } from "../lib/collections.ts";
import { slugify } from "../lib/workflow.ts";
import { useToast } from "./Toast.ts";

const KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

/** What the form makes: the declared settings, the permalink only when not the default. */
export function newCollectionSettings(input: {
key: string;
plural: string;
singular: string;
icon: string;
prefix: string;
locale: string;
}): CollectionSettings {
const prefix = input.prefix.trim();
return {
labels: { [input.locale]: { singular: input.singular.trim(), plural: input.plural.trim() } },
icon: input.icon,
...(prefix && prefix !== `/${input.key}` ? { permalink: `${prefix}/{slug}` } : {}),
};
}

/**
* "New collection" (RFC-0040 §4): plural and singular name in the site's default language, the
* key (derived from the name until edited, immutable afterwards), an icon and the address prefix.
* The API checks the prefix against other collections, archives and reserved paths (409).
*/
export function NewCollectionDialog({
open,
onOpenChange,
site,
taken,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
site: Site | undefined;
taken: readonly string[];
}) {
const { t } = useI18n("collections");
const [plural, setPlural] = useState("");
const [singular, setSingular] = useState("");
const [key, setKey] = useState("");
const [keyEdited, setKeyEdited] = useState(false);
const [icon, setIcon] = useState("files");
const [prefix, setPrefix] = useState("");
const [submitted, setSubmitted] = useState(false);
const create = useCreateCollection(site, () => onOpenChange(false), {
key,
settings: () =>
newCollectionSettings({
key,
plural,
singular,
icon,
prefix,
locale: site?.defaultLocale ?? "en",
}),
name: () => plural.trim(),
});
// Every opening starts clean.
useEffect(() => {
if (!open) return;
setPlural("");
setSingular("");
setKey("");
setKeyEdited(false);
setIcon("files");
setPrefix("");
setSubmitted(false);
create.reset();
}, [open]);

const required = (v: string) =>
submitted && !v.trim() ? t("collections.create.required") : undefined;
const keyError =
submitted && (!KEY.test(key) || taken.includes(key))
? t("collections.create.keyInvalid")
: undefined;
const prefixProblem = prefix.trim() ? validatePrefix(prefix) : undefined;
const prefixError = prefixProblem
? t(`siteSettings.collections.errors.${prefixProblem}`)
: undefined;
const valid =
plural.trim() && singular.trim() && KEY.test(key) && !taken.includes(key) && !prefixProblem;
const submit = () => {
setSubmitted(true);
if (create.isPending || !valid) return;
create.mutate();
};
const icons = Object.fromEntries(
COLLECTION_ICON_NAMES.map((name) => [name, t(`collections.icons.${name}`)]),
);
return (
<LayerDialog.Root open={open} onOpenChange={onOpenChange} dismissDisabled={create.isPending}>
<LayerDialog.Content size="base" closeLabel={t("common.close")}>
<LayerDialog.Title>{t("collections.create.title")}</LayerDialog.Title>
<LayerDialog.Description>{t("collections.create.description")}</LayerDialog.Description>
<LayerDialog.Body>
<div className="grid gap-5" data-testid="new-collection-form">
{create.error && (
<Banner
variant="error"
size="sm"
icon={<WarningCircle weight="fill" />}
title={t("collections.create.failed")}
description={apiErrorText(t, create.error)}
/>
)}
<Input
className="w-full"
label={t("collections.create.plural")}
description={t("collections.create.pluralHint")}
value={plural}
onValueChange={(v) => {
setPlural(v);
// "ı" has no decomposition: map it, or "Etkinlikları" would lose letters.
if (!keyEdited) setKey(v.trim() ? slugify(v.replace(/ı/g, "i")).slice(0, 64) : "");
}}
error={required(plural)}
maxLength={100}
autoFocus
/>
<Input
className="w-full"
label={t("collections.create.singular")}
description={t("collections.create.singularHint")}
value={singular}
onValueChange={setSingular}
error={required(singular)}
maxLength={100}
/>
<Input
className="w-full"
label={t("collections.create.key")}
description={t("collections.create.keyHint")}
value={key}
onValueChange={(v) => {
setKey(v);
setKeyEdited(true);
}}
error={keyError}
maxLength={64}
/>
<Select
className="w-full"
label={t("collections.create.icon")}
value={icon}
onValueChange={(v) => setIcon(v as string)}
items={icons}
/>
<Input
className="w-full"
label={t("collections.create.address")}
description={t("collections.create.addressHint")}
placeholder={key ? `/${key}` : undefined}
value={prefix}
onValueChange={setPrefix}
error={prefixError}
maxLength={200}
/>
</div>
</LayerDialog.Body>
<LayerDialog.Actions dismissLabel={t("common.cancel")}>
<LayerDialog.Actions.Primary onClick={submit} loading={create.isPending}>
{t("collections.create.submit")}
</LayerDialog.Actions.Primary>
</LayerDialog.Actions>
</LayerDialog.Content>
</LayerDialog.Root>
);
}

/** Saves the declaration, then shows the new collection's list. */
function useCreateCollection(
site: Site | undefined,
close: () => void,
input: { key: string; settings: () => CollectionSettings; name: () => string },
) {
const { t } = useI18n("collections");
const qc = useQueryClient();
const toast = useToast();
const navigate = useNavigate();
return useMutation({
mutationFn: async () => {
if (!site) throw new Error("site not loaded");
return api.collections.save(site, input.key, input.settings());
},
onSuccess: async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: ["collections", site?.id] }),
qc.invalidateQueries({ queryKey: ["site", site?.id] }),
]);
close();
toast.success(
t("collections.create.created"),
t("collections.create.createdDescription", { name: input.name() }),
);
if (site)
await navigate({
to: "/sites/$siteId/collections/$collection",
params: { siteId: site.id, collection: input.key },
});
},
});
}
Loading
Loading