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} + /> +