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
5 changes: 5 additions & 0 deletions .changeset/collections-studio-gaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudhal/cli": patch
---

Studio (RFC-0040 §4): a collection's settings get a Comments section (saved in the site's comment settings), the site overview lists recent content across every collection, and entries open at `/sites/<id>/collections/<collection>/<entry>` — the old `/articles/<entry>` links redirect there. The command palette goes to each collection.
309 changes: 309 additions & 0 deletions apps/studio/src/components/CollectionCommentsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import { Badge, Banner, Button, Link, Select, SkeletonLine } from "@cloudhal/ui";
import { ArrowClockwise, ChatsCircle, LockSimple, Warning } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n/index.tsx";
import { api, type CommentSettings, isNotEnabled } from "../lib/api.ts";
import { apiErrorText, isConflict } from "../lib/api-errors.ts";
import { CLOSE_OPTIONS, daysFrom, daysValue } from "../lib/comment-settings.ts";
import { commentSettingsKey, commentsKey } from "../lib/comments.ts";
import { ErrorBanner } from "./ErrorBanner.tsx";
import { SettingsSection } from "./Settings.tsx";
import { SwitchField } from "./TaxonomySettingsDialog.tsx";
import { useToast } from "./Toast.ts";
import { useGate, useMe } from "./useMe.ts";

/** The section's id in the collection settings' table of contents. */
export const COLLECTION_COMMENTS_SECTION = "comments";

/** One collection's row of `comment_settings.collections` (RFC-0007 §4.5). */
export type CollectionComments = CommentSettings["collections"][string];

const OFF: CollectionComments = { enabled: false };

/** The collection's row, off when the site has none for it yet. */
export function commentsOf(settings: CommentSettings, collection: string): CollectionComments {
return settings.collections[collection] ?? OFF;
}

/** The PUT's patch: every collection as saved, with this one replaced (only `collections`). */
export function collectionCommentsPatch(
settings: CommentSettings,
collection: string,
next: CollectionComments,
) {
return { collections: { ...settings.collections, [collection]: next } };
}

const sameRow = (a: CollectionComments, b: CollectionComments) =>
a.enabled === b.enabled && (a.closeAfterDays ?? "site") === (b.closeAfterDays ?? "site");

/** The section's data: the site's comment settings, this collection's row and its draft. */
function useCollectionComments(siteId: string, collection: string) {
const { t } = useI18n("commentsSettings");
const toast = useToast();
const qc = useQueryClient();
const me = useMe();
const read = useGate("comments.read");
const gate = useGate("comments.settings");
const instance = useQuery({ queryKey: ["instance"], queryFn: api.instance, retry: false });
const feature = instance.data?.features.comments;
const settings = useQuery({
queryKey: commentSettingsKey(siteId),
queryFn: () => api.comments.settings(siteId),
enabled: !me.isPending && read.allowed && feature?.available !== false,
retry: (count, error) => !isNotEnabled(error) && count < 1,
});
const latest = settings.data?.settings;
const saved = latest ? commentsOf(latest, collection) : undefined;
const [form, setForm] = useState<CollectionComments>();
useEffect(() => {
if (saved) setForm(saved);
}, [saved?.enabled, saved?.closeAfterDays]);
const save = useMutation({
mutationFn: ({ base, next }: { base: CommentSettings; next: CollectionComments }) =>
api.comments.updateSettings(
siteId,
base.rev,
collectionCommentsPatch(base, collection, next),
),
onSuccess: async (result) => {
qc.setQueryData<Awaited<ReturnType<typeof api.comments.settings>>>(
commentSettingsKey(siteId),
(prev) => (prev ? { ...prev, settings: result.settings } : prev),
);
toast.success(
t("commentsSettings.saved"),
result.purge !== "none" ? t("commentsSettings.savedPurged") : undefined,
);
await qc.invalidateQueries({
queryKey: commentsKey(siteId),
predicate: (q) => q.queryKey[2] !== "settings",
});
},
});
return {
read,
gate,
settings,
latest,
saved,
form,
setForm,
save,
dirty: !!form && !!saved && !sameRow(form, saved),
unavailable: feature?.available === false || isNotEnabled(settings.error),
};
}
type State = ReturnType<typeof useCollectionComments>;

/** A failed save: the 409 with "Load latest", anything else as an error. */
function SaveError({ state }: { state: State }) {
const { t } = useI18n("commentsSettings");
const { save, settings } = state;
if (!save.error) return null;
if (!isConflict(save.error))
return (
<Banner
variant="error"
size="sm"
title={t("commentsSettings.failed")}
description={apiErrorText(t, save.error)}
/>
);
return (
<div role="alert">
<Banner
variant="alert"
icon={<Warning weight="fill" />}
title={t("commentsSettings.conflictTitle")}
description={t("commentsSettings.conflictDescription")}
action={
<Banner.Action
variant="secondary"
icon={<ArrowClockwise />}
onClick={() => {
save.reset();
void settings.refetch();
}}
loading={settings.isFetching}
>
{t("commentsSettings.loadLatest")}
</Banner.Action>
}
/>
</div>
);
}

/** The controls: on/off and when threads close, with the site-wide state and a link to the rest. */
function CommentsFields({
siteId,
state,
form,
siteEnabled,
}: {
siteId: string;
state: State;
form: CollectionComments;
siteEnabled: boolean;
}) {
const { t } = useI18n("collections", "commentsSettings");
const off = !state.gate.allowed;
const daysLabel = (days: number | null) =>
days === null
? t("commentsSettings.closeAfter.never")
: days === 365
? t("commentsSettings.closeAfter.year")
: t("commentsSettings.closeAfter.days", { days });
const current = form.closeAfterDays;
const options =
current === undefined || CLOSE_OPTIONS.includes(current)
? CLOSE_OPTIONS
: [...CLOSE_OPTIONS, current];
return (
<div className="grid gap-5">
<SaveError state={state} />
{!siteEnabled && (
<Banner
variant="secondary"
size="sm"
icon={<ChatsCircle weight="fill" />}
title={t("collections.settings.commentsSiteOff")}
description={t("collections.settings.commentsSiteOffDescription")}
/>
)}
<SwitchField
testId="collection-comments"
label={t("collections.settings.commentsEnabled")}
hint={t("collections.settings.commentsEnabledHint")}
checked={form.enabled}
onChange={(enabled) => state.setForm({ ...form, enabled })}
disabled={off}
/>
<Select
className="w-full"
label={t("collections.settings.commentsCloseAfter")}
description={t("collections.settings.commentsCloseAfterHint")}
value={daysValue(current)}
onValueChange={(v) => {
const days = daysFrom(String(v));
const { closeAfterDays: _old, ...rest } = form;
state.setForm(days === undefined ? rest : { ...rest, closeAfterDays: days });
}}
items={[
{ value: "site", label: t("commentsSettings.collections.sameAsSite") },
...options.map((d) => ({ value: daysValue(d), label: daysLabel(d) })),
]}
disabled={off || !form.enabled}
data-testid="collection-comments-close"
/>
<Link href={`/sites/${siteId}/settings#comments`}>
{t("collections.settings.commentsLink")}
</Link>
</div>
);
}

/** What the section shows: no access, not on this server, the error, loading, or the form. */
function CommentsBody({ siteId, state }: { siteId: string; state: State }) {
const { t } = useI18n("commentsSettings");
const { read, settings, form, latest } = state;
if (!read.allowed)
return (
<Banner
variant="secondary"
icon={<LockSimple weight="fill" />}
title={t("commentsSettings.noAccessTitle")}
description={read.reason}
/>
);
if (state.unavailable)
return (
<Banner
variant="secondary"
icon={<ChatsCircle weight="fill" />}
title={t("commentsSettings.unavailableTitle")}
description={t("commentsSettings.unavailableDescription")}
/>
);
if (settings.error && !form)
return (
<ErrorBanner
error={settings.error}
onRetry={() => void settings.refetch()}
retrying={settings.isFetching}
/>
);
if (!form || !latest)
return (
<div className="grid gap-3" role="status">
<span className="sr-only">{t("common.loading")}</span>
<SkeletonLine minWidth={30} maxWidth={45} />
<SkeletonLine minWidth={60} maxWidth={85} />
</div>
);
return <CommentsFields siteId={siteId} state={state} form={form} siteEnabled={latest.enabled} />;
}

/**
* A collection › Settings › Comments (RFC-0040 §4): whether readers can comment on this
* collection's entries and when its threads close. The words and the gates are those of
* Site settings › Comments (`comments.read` to see, `comments.settings` to change); the values stay
* in `comment_settings` (RFC-0007 §4.5), saved with its `rev` — never on the collection.
*/
export function CollectionCommentsSection({
siteId,
collection,
}: {
siteId: string;
collection: string;
}) {
const { t } = useI18n("collections", "commentsSettings");
const state = useCollectionComments(siteId, collection);
const { saved, form, latest, save, dirty, gate } = state;
return (
<SettingsSection
id={COLLECTION_COMMENTS_SECTION}
title={t("collections.settings.comments")}
description={t("collections.settings.commentsDescription")}
aside={
saved ? (
<Badge variant={saved.enabled ? "success" : "neutral"} appearance="dot">
{t(saved.enabled ? "commentsSettings.status.on" : "commentsSettings.status.off")}
</Badge>
) : undefined
}
footer={
form && latest && !state.unavailable && state.read.allowed ? (
<>
{dirty && (
<Button
variant="ghost"
onClick={() => {
state.setForm(saved);
save.reset();
}}
disabled={save.isPending}
>
{t("common.discard")}
</Button>
)}
<Button
variant="primary"
onClick={() => save.mutate({ base: latest, next: form })}
loading={save.isPending}
disabled={!gate.allowed || !dirty}
title={gate.reason}
data-testid="collection-comments-save"
>
{t("common.saveChanges")}
</Button>
</>
) : undefined
}
>
<CommentsBody siteId={siteId} state={state} />
</SettingsSection>
);
}
9 changes: 4 additions & 5 deletions apps/studio/src/components/CollectionNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Stack } from "@phosphor-icons/react";
import { useI18n } from "../i18n/index.tsx";
import {
collectionIcon,
collectionNames,
collectionName,
entryTotal,
useCollectionIcons,
useCollections,
Expand Down Expand Up @@ -32,15 +32,14 @@ export function CollectionNav({
<>
{(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);
const name = collectionName(site, c.key, locale, t("nav.articles"));
return (
<Sidebar.MenuButton
key={c.key}
icon={collectionIcon(c, icons)}
href={href}
active={is(href) || (c.key === "posts" && is(`${base}/articles`))}
// The list, its settings and its entries' editors (`…/$collection/$entryId`).
active={is(href, true) || is(`${href}/`)}
tooltip={name}
>
{name}
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/components/ReleaseParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import type { ReleaseDiff, ReleaseItemView, ReleaseView } from "../lib/api.ts";
import { entryHref } from "../lib/entry-href.ts";
import { schedulesApi } from "../lib/schedules-api.ts";
import { TextChange } from "./BodyChanges.tsx";
import { type ST, scheduleKeys, useScheduleWords } from "./Schedules.tsx";
Expand All @@ -50,7 +51,7 @@ export function itemEditor(siteId: string, item: Pick<ReleaseItemView, "nodeId"
const base = `/sites/${siteId}`;
switch (item.nodeKind) {
case "contentEntry":
return `${base}/articles/${item.nodeId}`;
return entryHref(siteId, item.nodeId);
case "page":
return `${base}/pages/${item.nodeId}`;
case "term":
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/components/ScheduleRunDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { ShieldWarning } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import type { RunFinding } from "../lib/api.ts";
import { entryHref } from "../lib/entry-href.ts";
import { formatWhen } from "../lib/schedules.ts";
import { schedulesApi } from "../lib/schedules-api.ts";
import { type ST, scheduleKeys, useScheduleWords, useSiteZone } from "./Schedules.tsx";
Expand Down Expand Up @@ -54,7 +55,7 @@ const SEVERITY = { error: "error", warning: "warning", info: "secondary" } as co
/** Where a node is edited, from its id's prefix. */
function nodeHref(siteId: string, id: string | undefined): string | undefined {
if (!id) return undefined;
if (id.startsWith("entry_")) return `/sites/${siteId}/articles/${id}`;
if (id.startsWith("entry_")) return entryHref(siteId, id);
if (id.startsWith("page_")) return `/sites/${siteId}/pages/${id}`;
if (id.startsWith("term_")) return `/sites/${siteId}/taxonomy/${id}`;
if (id.startsWith("rel_")) return `/sites/${siteId}/schedules/releases/${id}`;
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/components/SnapshotDiffDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
diffBases,
diffRows,
} from "../lib/diff.ts";
import { entryHref } from "../lib/entry-href.ts";
import { assetTitle, formatSize, typeLabel } from "../lib/media.ts";
import type { MenuDiffSummary } from "../lib/menus.ts";
import { archivePath } from "../lib/taxonomy.ts";
Expand Down Expand Up @@ -197,7 +198,7 @@ function useNodeNames(siteId: string, enabled: boolean) {
? {
title: entry.title,
detail: entryPath(site.data, entry),
href: `/sites/${siteId}/articles/${entry.id}`,
href: entryHref(siteId, entry.id, entry.collection),
}
: {};
};
Expand Down
Loading
Loading