From 0ba1e012d3879daaddb82a82f1379ca6986035e0 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:03:18 +0300 Subject: [PATCH] feat(studio): close RFC-0040's Studio gaps (comments, overview, editor URL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collection settings get a Comments section (accept comments, close threads after). It saves to comment_settings with its rev, like the Comments screen, and keeps nothing on the collection (RFC-0007 §4.5). - Overview: "Recent content" lists the newest entries of every collection in one request, with a Collection column. - The editor moves to /collections/$collection/$entryId. /articles/$id redirects there, and the editor corrects the address to the entry's own collection once it loads. Every link goes through entryHref / entryRoute; the sidebar marks the collection active on its editors. - Command palette: one "Go to {name}" per collection. - i18n: new keys in en, drafts for the other languages (unreviewed). Studio 1215/1215, design lint, size and budget (388.3 kB gzip) pass; screenshots at 1440 and 390 px clean. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/collections-studio-gaps.md | 5 + .../components/CollectionCommentsSection.tsx | 309 ++++++++++++++++++ apps/studio/src/components/CollectionNav.tsx | 9 +- apps/studio/src/components/ReleaseParts.tsx | 3 +- .../src/components/ScheduleRunDialog.tsx | 3 +- .../src/components/SnapshotDiffDialog.tsx | 3 +- .../studio/src/components/TranslationList.tsx | 8 +- apps/studio/src/components/Translations.tsx | 3 +- .../site-overview/RecentContentCard.tsx | 102 ++++++ .../src/components/useCollectionCommands.ts | 26 ++ .../src/components/useCollectionView.ts | 27 +- apps/studio/src/components/useEditorByPath.ts | 3 +- apps/studio/src/components/useSitePalette.ts | 45 +-- apps/studio/src/i18n/ar.ts | 9 +- apps/studio/src/i18n/de.ts | 9 +- apps/studio/src/i18n/en.ts | 14 +- apps/studio/src/i18n/es.ts | 9 +- apps/studio/src/i18n/fa.ts | 9 +- apps/studio/src/i18n/fr.ts | 9 +- apps/studio/src/i18n/generated/params.ts | 1 + apps/studio/src/i18n/he.ts | 9 +- apps/studio/src/i18n/hi.ts | 9 +- apps/studio/src/i18n/id.ts | 9 +- apps/studio/src/i18n/it.ts | 9 +- apps/studio/src/i18n/ja.ts | 9 +- apps/studio/src/i18n/ko.ts | 9 +- apps/studio/src/i18n/lazy/collections.ar.ts | 15 +- apps/studio/src/i18n/lazy/collections.de.ts | 15 +- apps/studio/src/i18n/lazy/collections.en.ts | 13 + apps/studio/src/i18n/lazy/collections.es.ts | 15 +- apps/studio/src/i18n/lazy/collections.fa.ts | 15 +- apps/studio/src/i18n/lazy/collections.fr.ts | 15 +- apps/studio/src/i18n/lazy/collections.he.ts | 15 +- apps/studio/src/i18n/lazy/collections.hi.ts | 15 +- apps/studio/src/i18n/lazy/collections.id.ts | 15 +- apps/studio/src/i18n/lazy/collections.it.ts | 15 +- apps/studio/src/i18n/lazy/collections.ja.ts | 15 +- apps/studio/src/i18n/lazy/collections.ko.ts | 15 +- apps/studio/src/i18n/lazy/collections.nl.ts | 15 +- apps/studio/src/i18n/lazy/collections.pl.ts | 15 +- .../studio/src/i18n/lazy/collections.pt-BR.ts | 15 +- apps/studio/src/i18n/lazy/collections.ru.ts | 15 +- apps/studio/src/i18n/lazy/collections.tr.ts | 15 +- apps/studio/src/i18n/lazy/collections.uk.ts | 15 +- .../src/i18n/lazy/collections.zh-Hans.ts | 13 +- apps/studio/src/i18n/nl.ts | 9 +- apps/studio/src/i18n/pl.ts | 9 +- apps/studio/src/i18n/pt-BR.ts | 9 +- apps/studio/src/i18n/ru.ts | 9 +- apps/studio/src/i18n/tr.ts | 9 +- apps/studio/src/i18n/uk.ts | 9 +- apps/studio/src/i18n/zh-Hans.ts | 9 +- apps/studio/src/lib/analytics.ts | 4 +- apps/studio/src/lib/collections.ts | 15 + apps/studio/src/lib/entry-href.ts | 26 ++ apps/studio/src/lib/palette.ts | 11 +- apps/studio/src/lib/schedules.ts | 3 +- apps/studio/src/router.tsx | 22 +- apps/studio/src/routes/ArticleEditorPage.tsx | 12 +- apps/studio/src/routes/ArticlesPage.tsx | 10 +- apps/studio/src/routes/AssetPage.tsx | 18 +- .../src/routes/CollectionSettingsPage.tsx | 15 +- apps/studio/src/routes/CommentThreadPage.tsx | 8 +- apps/studio/src/routes/RedirectsPage.tsx | 3 +- apps/studio/src/routes/SiteOverviewPage.tsx | 74 +---- apps/studio/src/routes/TaxonomyPage.tsx | 2 +- apps/studio/src/routes/TranslationsPage.tsx | 3 +- apps/studio/test/analytics-lib.test.ts | 2 +- apps/studio/test/analytics-screen.test.tsx | 2 +- apps/studio/test/collections-studio.test.tsx | 243 ++++++++++++++ apps/studio/test/media-screens.test.tsx | 2 +- docs/design/DESIGN.md | 1 + scripts/size-baseline.json | 6 +- 73 files changed, 1260 insertions(+), 226 deletions(-) create mode 100644 .changeset/collections-studio-gaps.md create mode 100644 apps/studio/src/components/CollectionCommentsSection.tsx create mode 100644 apps/studio/src/components/site-overview/RecentContentCard.tsx create mode 100644 apps/studio/src/components/useCollectionCommands.ts create mode 100644 apps/studio/src/lib/entry-href.ts create mode 100644 apps/studio/test/collections-studio.test.tsx diff --git a/.changeset/collections-studio-gaps.md b/.changeset/collections-studio-gaps.md new file mode 100644 index 00000000..dc96626f --- /dev/null +++ b/.changeset/collections-studio-gaps.md @@ -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//collections//` — the old `/articles/` links redirect there. The command palette goes to each collection. diff --git a/apps/studio/src/components/CollectionCommentsSection.tsx b/apps/studio/src/components/CollectionCommentsSection.tsx new file mode 100644 index 00000000..74ed395f --- /dev/null +++ b/apps/studio/src/components/CollectionCommentsSection.tsx @@ -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(); + 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>>( + 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; + +/** 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 ( + + ); + return ( +
+ } + title={t("commentsSettings.conflictTitle")} + description={t("commentsSettings.conflictDescription")} + action={ + } + onClick={() => { + save.reset(); + void settings.refetch(); + }} + loading={settings.isFetching} + > + {t("commentsSettings.loadLatest")} + + } + /> +
+ ); +} + +/** 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 ( +
+ + {!siteEnabled && ( + } + title={t("collections.settings.commentsSiteOff")} + description={t("collections.settings.commentsSiteOffDescription")} + /> + )} + state.setForm({ ...form, enabled })} + disabled={off} + /> +