"use client"; import { useState } from "react"; // Its controlled checkboxes and selects sit inside action forms, whose reset they would not // survive alone (forms/Controlled.tsx). import { ControlledCheck, ControlledSelect, } from "../../components/forms/Controlled"; // The channel form's per-site membership picker: every configured site with a // checkbox (member or not) plus a group dropdown, including a "+ New group…" // option that reveals a name input. Serializes the checked sites into the // hidden `siteMembershipsJson` field consumed by lib/siteMemberships.ts — // same hidden-JSON-field pattern as SiteForm's groupsJson/channelsJson. export type SiteMembershipOption = { siteId: string; siteTitle: string; defaultGroupId: string; // Pre-sorted server-side via sortGroups. groups: { id: string; name: string }[]; }; export type InitialMembership = { siteId: string; groupId?: string }; // Sentinel select value for "+ New group…". Can never collide with a real // group id — underscores fail isValidGroupId (same trick as ALL_SITES). const NEW_GROUP = "__new__"; type Row = { groupId: string; newGroupName: string }; type Props = { sites: SiteMembershipOption[]; // Edit mode: the channel's current memberships (pre-checked). initialMemberships?: InitialMembership[]; // Create mode: the active site, which starts checked. ChannelFormClient // resolves it on its first render (a `?site=` link's, else the stored // selection), so it is part of the initial state below. activeSiteId?: string | null; }; export function SiteMembershipsSection({ sites, initialMemberships, activeSiteId, }: Props) { // Presence in the map = checked. groupId "" = the site's default group. // // Create mode starts with the active site checked (mirrors the old hidden // `activeSite` field's behavior). It is INITIAL state, not an effect: the // server renders the box checked, so there is no unchecked first paint, and a // later refresh (the pulse, or a site picked in another tab) cannot re-check a // box the user has cleared. const [selected, setSelected] = useState>(() => { const rows = new Map( (initialMemberships ?? []).map((m) => [ m.siteId, { groupId: m.groupId ?? "", newGroupName: "" }, ]), ); if ( activeSiteId && !rows.has(activeSiteId) && sites.some((s) => s.siteId === activeSiteId) ) { rows.set(activeSiteId, { groupId: "", newGroupName: "" }); } return rows; }); if (sites.length === 0) { // No hidden field at all: the actions skip membership reconciliation. return (

No sites configured — create one under{" "} Sites .

); } const toggle = (siteId: string, on: boolean) => setSelected((prev) => { const next = new Map(prev); if (on) next.set(siteId, prev.get(siteId) ?? { groupId: "", newGroupName: "" }); else next.delete(siteId); return next; }); const setGroup = (siteId: string, groupId: string) => setSelected((prev) => { const next = new Map(prev); const row = prev.get(siteId) ?? { groupId: "", newGroupName: "" }; next.set(siteId, { ...row, groupId }); return next; }); const setNewGroupName = (siteId: string, newGroupName: string) => setSelected((prev) => { const next = new Map(prev); const row = prev.get(siteId) ?? { groupId: "", newGroupName: "" }; next.set(siteId, { ...row, newGroupName }); return next; }); // One entry per CHECKED site; an unchecked site is simply absent (= remove // membership on edit). See MembershipRequest in lib/siteMemberships.ts. const membershipsJson = JSON.stringify( sites .filter((s) => selected.has(s.siteId)) .map((s) => { const row = selected.get(s.siteId)!; if (row.groupId === NEW_GROUP) { const name = row.newGroupName.trim(); // The visible name input is `required`, so an empty name never // submits; falling back to default is belt-and-braces only. return name ? { siteId: s.siteId, newGroupName: name } : { siteId: s.siteId }; } return row.groupId ? { siteId: s.siteId, groupId: row.groupId } : { siteId: s.siteId }; }), ); return (

Pick the sites this channel appears on, and the group it belongs to on each.

{sites.map((s) => { const row = selected.get(s.siteId); const on = !!row; const label = s.siteTitle || s.siteId; return (
{on && ( setGroup(s.siteId, e.target.value)} aria-label={`Group for ${label}`} className="rounded border border-border bg-card px-1.5 py-0.5 text-xs shrink-0" > {s.groups.map((g) => ( ))} )}
{on && row.groupId === NEW_GROUP && ( setNewGroupName(s.siteId, e.target.value)} required placeholder="New group name" aria-label={`New group name for ${label}`} className="ml-6 rounded border border-border bg-card px-2 py-1 text-sm" /> )}
); })}

Full group management (rename, reorder, defaults) lives under{" "} Sites .

); }