From 374088bff555489aad65679fddd9282284999e3e Mon Sep 17 00:00:00 2001 From: Bianca Date: Fri, 4 Sep 2026 21:53:30 +0200 Subject: [PATCH 1/7] feat: implement tagging functionality for WhatsApp groups - Added PublishTagGroupsDialog to manage the visibility and untagging of groups with a specific tag. - Created TagGroupsPage to display groups associated with a flat tag, allowing for search and management. - Introduced useLabelGroupRows hook to streamline the retrieval of group rows based on labels. - Enhanced CreateEditGroupDialog to include an option to hide groups until published. - Updated CombinedGroupsTable to handle visibility toggling for WhatsApp groups. - Implemented setWhatsappGroupVisibility function to manage group visibility state. - Added UI elements for displaying visibility status and handling user interactions. - Updated routing to include a dedicated page for managing tags. --- src/components/ui/checkbox.tsx | 26 ++++ .../group-labels/group-label-card.tsx | 30 ++++- .../group-labels/group-labels-page.tsx | 1 + .../group-labels/label-tree-selector.tsx | 78 ++++++----- .../add-group-to-label-dialog.tsx | 88 ++++++++++--- .../groups-by-label/combined-groups-table.tsx | 61 ++++++++- .../groups-by-label/groups-by-label-page.tsx | 62 ++------- .../publish-tag-groups-dialog.tsx | 123 ++++++++++++++++++ .../groups-by-label/tag-groups-page.tsx | 95 ++++++++++++++ .../groups-by-label/use-label-group-rows.ts | 78 +++++++++++ .../whatsapp/create-edit-group-dialog.tsx | 15 ++- src/features/whatsapp/groups.functions.ts | 10 ++ .../whatsapp/whatsapp-group-fields.tsx | 18 ++- .../whatsapp/whatsapp-groups-page.tsx | 119 ++++++++++++++--- src/routeTree.gen.ts | 21 +++ src/routes/dashboard/web/tags/$tag.tsx | 46 +++++++ 16 files changed, 735 insertions(+), 136 deletions(-) create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/features/groups-by-label/publish-tag-groups-dialog.tsx create mode 100644 src/features/groups-by-label/tag-groups-page.tsx create mode 100644 src/features/groups-by-label/use-label-group-rows.ts create mode 100644 src/routes/dashboard/web/tags/$tag.tsx diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..74a8cc9 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/src/features/group-labels/group-label-card.tsx b/src/features/group-labels/group-label-card.tsx index b59c311..191498e 100644 --- a/src/features/group-labels/group-label-card.tsx +++ b/src/features/group-labels/group-label-card.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router" import { FolderPlus, LoaderCircle, MoreVertical, Pencil, PencilLine, Save, Trash2, X } from "lucide-react" import { useState } from "react" @@ -42,6 +43,8 @@ type GroupLabelCardProps = { allowRename?: boolean /** Off for flat tags, which never nest. */ allowChildren?: boolean + /** When set, the name badge links here — a distinct target, so the card's own edit and delete controls stay clickable. */ + linkTo?: string onDelete: () => Promise onSave: (values: GroupLabelEditValues) => Promise } @@ -52,6 +55,7 @@ export function GroupLabelCard({ leading, allowRename = true, allowChildren = true, + linkTo, onDelete, onSave, }: GroupLabelCardProps) { @@ -131,13 +135,25 @@ export function GroupLabelCard({ ) : ( <> - - {displaySegment} - + {linkTo ? ( + + + {displaySegment} + + + ) : ( + + {displaySegment} + + )}

{groupLabel.description || No description}

diff --git a/src/features/group-labels/group-labels-page.tsx b/src/features/group-labels/group-labels-page.tsx index d6f8c00..e630f74 100644 --- a/src/features/group-labels/group-labels-page.tsx +++ b/src/features/group-labels/group-labels-page.tsx @@ -107,6 +107,7 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou groupLabel={label} allLabels={labels} allowChildren={false} + linkTo={`/dashboard/web/tags/${encodeURIComponent(label.label)}`} onDelete={() => removeGroupLabel(label)} onSave={(values) => saveGroupLabel(label, values)} /> diff --git a/src/features/group-labels/label-tree-selector.tsx b/src/features/group-labels/label-tree-selector.tsx index 406bf00..598bf9f 100644 --- a/src/features/group-labels/label-tree-selector.tsx +++ b/src/features/group-labels/label-tree-selector.tsx @@ -114,15 +114,18 @@ function CategorySearchResult({ ) } -/** Lets an admin pick labels for a group: a browsable category tree, plus flat tag chips — no dotted paths shown. */ +/** Lets an admin pick labels for a group: a browsable category tree plus flat tag chips, or just the tags. */ export function LabelTreeSelector({ allLabels, selected, onToggleMany, + tagsOnly = false, }: { allLabels: GroupLabel[] selected: GroupLabel[] onToggleMany: (labels: GroupLabel[], select: boolean) => void + /** Limit the picker to flat tags, omitting the category tree and category search results. */ + tagsOnly?: boolean }) { const [query, setQuery] = useState("") const categoryLabels = useMemo(() => allLabels.filter((label) => isCategoryLabel(label.label)), [allLabels]) @@ -141,7 +144,7 @@ export function LabelTreeSelector({ return (
setQuery(event.target.value)} className="h-9" @@ -170,38 +173,39 @@ export function LabelTreeSelector({
)}
- {isSearching - ? matchingCategories.length > 0 && ( -
-

- Categories -

- {matchingCategories.map((label) => ( - - ))} -
- ) - : tree.length > 0 && ( -
-

- Categories -

- {tree.map((node) => ( - - ))} -
- )} + {!tagsOnly && + (isSearching + ? matchingCategories.length > 0 && ( +
+

+ Categories +

+ {matchingCategories.map((label) => ( + + ))} +
+ ) + : tree.length > 0 && ( +
+

+ Categories +

+ {tree.map((node) => ( + + ))} +
+ ))} {visibleTags.length > 0 && (

Tags

@@ -229,8 +233,10 @@ export function LabelTreeSelector({
)} - {(isSearching ? !matchingCategories.length : !tree.length) && !visibleTags.length && ( -

No matching labels

+ {(tagsOnly || (isSearching ? !matchingCategories.length : !tree.length)) && !visibleTags.length && ( +

+ {isSearching ? `No matching ${tagsOnly ? "tags" : "labels"}` : "No tags"} +

)} diff --git a/src/features/groups-by-label/add-group-to-label-dialog.tsx b/src/features/groups-by-label/add-group-to-label-dialog.tsx index 79b41aa..c33ff2f 100644 --- a/src/features/groups-by-label/add-group-to-label-dialog.tsx +++ b/src/features/groups-by-label/add-group-to-label-dialog.tsx @@ -15,9 +15,11 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" -import { DEFAULT_GROUP_LABEL_COLOR } from "@/features/group-labels/group-labels.constants" +import { DEFAULT_GROUP_LABEL_COLOR, isSameGroupLabel } from "@/features/group-labels/group-labels.constants" import { createGroupLabel, tagGroup } from "@/features/group-labels/group-labels.functions" import { formatLabelBreadcrumb } from "@/features/group-labels/label-tree" +import { LabelTreeSelector } from "@/features/group-labels/label-tree-selector" +import type { GroupLabel } from "@/features/group-labels/types" import { createWhatsappGroup } from "@/features/whatsapp/groups.functions" import { WhatsappGroupFields } from "@/features/whatsapp/whatsapp-group-fields" import { isValidWhatsappInviteLink } from "@/features/whatsapp/whatsapp.validation" @@ -31,6 +33,8 @@ type Platform = "telegram" | "whatsapp" export function AddGroupToLabelDialog({ path, labelExists, + allowCreate = true, + allLabels, tgGroups, waGroups, tgLabelsByGroupId, @@ -38,6 +42,9 @@ export function AddGroupToLabelDialog({ }: { path: string labelExists: boolean + /** Off where a brand new group would end up filed under this label alone — the dialog then only labels existing groups. */ + allowCreate?: boolean + allLabels: GroupLabel[] tgGroups: TgGroup[] waGroups: WaGroup[] tgLabelsByGroupId: Map @@ -48,14 +55,19 @@ export function AddGroupToLabelDialog({ const createWhatsappGroupFn = useServerFn(createWhatsappGroup) const tagGroupFn = useServerFn(tagGroup) + // With creation off there's nothing to choose between, so the dialog opens straight on the existing-groups step. + const initialStep: Step = allowCreate ? "choose" : "existing" + const [open, setOpen] = useState(false) - const [step, setStep] = useState("choose") + const [step, setStep] = useState(initialStep) const [platform, setPlatform] = useState(null) const [groupQuery, setGroupQuery] = useState("") const [selectedTgGroups, setSelectedTgGroups] = useState([]) const [selectedWaGroups, setSelectedWaGroups] = useState([]) const [title, setTitle] = useState("") const [link, setLink] = useState("") + const [hide, setHide] = useState(false) + const [selectedTags, setSelectedTags] = useState([]) const [pending, setPending] = useState(false) const [error, setError] = useState("") @@ -72,13 +84,15 @@ export function AddGroupToLabelDialog({ : availableWaGroups function reset() { - setStep("choose") + setStep(initialStep) setPlatform(null) setGroupQuery("") setSelectedTgGroups([]) setSelectedWaGroups([]) setTitle("") setLink("") + setHide(false) + setSelectedTags([]) setError("") } @@ -101,6 +115,16 @@ export function AddGroupToLabelDialog({ ) } + function toggleTags(labels: GroupLabel[], select: boolean) { + setSelectedTags((current) => { + if (select) { + const toAdd = labels.filter((label) => !current.some((existing) => isSameGroupLabel(existing, label))) + return [...current, ...toAdd] + } + return current.filter((existing) => !labels.some((label) => isSameGroupLabel(existing, label))) + }) + } + async function ensureLabelExists() { if (!labelExists) { await createGroupLabelFn({ data: { label: path, color: DEFAULT_GROUP_LABEL_COLOR, description: "" } }) @@ -114,13 +138,19 @@ export function AddGroupToLabelDialog({ setError("") try { await ensureLabelExists() - const created = await createWhatsappGroupFn({ data: { title: title.trim(), link: link.trim() } }) - try { - await tagGroupFn({ data: { groupId: created.id, type: "wa", label: path } }) - } catch (tagCause) { - console.error(tagCause) + const created = await createWhatsappGroupFn({ data: { title: title.trim(), link: link.trim(), hide } }) + const labelsToApply = [{ label: path }, ...selectedTags] + const tagResults = await Promise.allSettled( + labelsToApply.map((label) => tagGroupFn({ data: { groupId: created.id, type: "wa", label: label.label } })) + ) + const failedLabels = labelsToApply.filter((_, index) => tagResults[index]?.status === "rejected") + if (failedLabels.length > 0) { + for (const result of tagResults) { + if (result.status === "rejected") console.error(result.reason) + } + const failedLabelNames = failedLabels.map((label) => `"${formatLabelBreadcrumb(label.label)}"`).join(", ") toast.warning( - `${title.trim()} was added, but could not be labeled "${formatLabelBreadcrumb(path)}". Assign it manually.` + `${title.trim()} was added, but could not be labeled ${failedLabelNames}. Assign ${failedLabels.length === 1 ? "it" : "them"} manually.` ) closeDialog() try { @@ -131,7 +161,12 @@ export function AddGroupToLabelDialog({ } return } - toast.success(`${title.trim()} added and labeled "${formatLabelBreadcrumb(path)}".`) + const extraTagCount = selectedTags.length + toast.success( + extraTagCount > 0 + ? `${title.trim()} added to "${formatLabelBreadcrumb(path)}" with ${extraTagCount} additional tag${extraTagCount === 1 ? "" : "s"}.` + : `${title.trim()} added and labeled "${formatLabelBreadcrumb(path)}".` + ) closeDialog() try { await router.invalidate({ sync: true }) @@ -254,7 +289,18 @@ export function AddGroupToLabelDialog({ > Back - + +
+

Also tag with

+ +
{error &&

{error}

} + {(allowCreate || platform) && ( + + )} {!platform ? (
diff --git a/src/features/groups-by-label/combined-groups-table.tsx b/src/features/groups-by-label/combined-groups-table.tsx index c41e311..7b14be3 100644 --- a/src/features/groups-by-label/combined-groups-table.tsx +++ b/src/features/groups-by-label/combined-groups-table.tsx @@ -26,6 +26,7 @@ import { setGroupVisibility } from "@/features/telegram/groups.functions" import { LeaveGroupDialog } from "@/features/telegram/leave-group-dialog" import { CreateEditGroupDialog } from "@/features/whatsapp/create-edit-group-dialog" import { DeleteGroupDialog } from "@/features/whatsapp/delete-group-dialog" +import { setWhatsappGroupVisibility } from "@/features/whatsapp/groups.functions" import type { TgGroup, TgGroupLabel, WaGroup } from "@/lib/api/types" import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table" import { cn } from "@/lib/utils" @@ -57,8 +58,13 @@ export function CombinedGroupsTable({ }) { const router = useRouter() const setGroupVisibilityFn = useServerFn(setGroupVisibility) + const setWaGroupVisibilityFn = useServerFn(setWhatsappGroupVisibility) const [visibilityOverrides, setVisibilityOverrides] = useState>({}) const [updatingId, setUpdatingId] = useState(null) + // Kept separate from the Telegram overrides above (not merged into one map) for the same reason as the label + // maps: Telegram and WhatsApp group ids are independent sequences that could otherwise collide. + const [waVisibilityOverrides, setWaVisibilityOverrides] = useState>({}) + const [waUpdatingId, setWaUpdatingId] = useState(null) const [mutationError, setMutationError] = useState("") const [refreshError, setRefreshError] = useState("") const [editingKey, setEditingKey] = useState(null) @@ -66,7 +72,7 @@ export function CombinedGroupsTable({ const displayRows = rows.map((row) => row.platform === "telegram" ? { ...row, group: { ...row.group, hide: visibilityOverrides[row.group.telegramId] ?? row.group.hide } } - : row + : { ...row, group: { ...row.group, hide: waVisibilityOverrides[row.group.id] ?? row.group.hide } } ) const editingRow = editingKey ? (displayRows.find((row) => row.key === editingKey) ?? null) : null @@ -103,6 +109,39 @@ export function CombinedGroupsTable({ } } + async function toggleWaVisibility(group: WaGroup) { + if (waUpdatingId !== null) return + const hide = !group.hide + setWaUpdatingId(group.id) + setMutationError("") + setWaVisibilityOverrides((current) => ({ ...current, [group.id]: hide })) + + try { + await setWaGroupVisibilityFn({ data: { id: group.id, hide } }) + toast.success(`${group.title} is now ${hide ? "hidden" : "visible"}.`) + try { + await router.invalidate({ sync: true }) + setRefreshError("") + setWaVisibilityOverrides((current) => { + const { [group.id]: _removed, ...remaining } = current + return remaining + }) + } catch (error) { + console.error(error) + setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") + } + } catch (error) { + console.error(error) + setWaVisibilityOverrides((current) => { + const { [group.id]: _removed, ...remaining } = current + return remaining + }) + setMutationError("The visibility setting could not be updated. Check your permissions and try again.") + } finally { + setWaUpdatingId(null) + } + } + const columns = useMemo(() => { const sortableHeader = ( label: string, @@ -180,8 +219,26 @@ export function CombinedGroupsTable({ cell: ({ row }) => { if (row.original.platform === "whatsapp") { const group = row.original.group + const pending = waUpdatingId === group.id + const visible = !group.hide return (
event.stopPropagation()} className="flex items-center gap-1.5"> +
@@ -215,7 +272,7 @@ export function CombinedGroupsTable({ }, }), ]) - }, [updatingId]) + }, [updatingId, waUpdatingId]) const table = useAppTable({ key: "groups-by-label-combined", diff --git a/src/features/groups-by-label/groups-by-label-page.tsx b/src/features/groups-by-label/groups-by-label-page.tsx index 5a9704e..3bda6d9 100644 --- a/src/features/groups-by-label/groups-by-label-page.tsx +++ b/src/features/groups-by-label/groups-by-label-page.tsx @@ -6,17 +6,16 @@ import { DataToolbar } from "@/components/data-toolbar" import { Button } from "@/components/ui/button" import { buildCategoryRootTree, - buildLabelsByGroupId, findLabelTreeNode, formatLabelBreadcrumb, formatLabelSegment, - hasExactLabel, isCategoryLabel, labelPathToUrlSegments, } from "@/features/group-labels/label-tree" import { AddChildLabelDialog } from "@/features/groups-by-label/add-child-label-dialog" import { AddGroupToLabelDialog } from "@/features/groups-by-label/add-group-to-label-dialog" -import { CombinedGroupsTable, type CombinedGroupRow } from "@/features/groups-by-label/combined-groups-table" +import { CombinedGroupsTable } from "@/features/groups-by-label/combined-groups-table" +import { useLabelGroupRows } from "@/features/groups-by-label/use-label-group-rows" import type { GroupWithLabels, TgGroup, TgGroupLabel, WaGroup } from "@/lib/api/types" export function GroupsByLabelPage({ @@ -34,54 +33,14 @@ export function GroupsByLabelPage({ }) { const [query, setQuery] = useState("") - // Kept as two separate maps (not merged into one) since Telegram and WhatsApp group ids are independent - // sequences that could otherwise collide. - const tgLabelsByGroupId = useMemo( - () => buildLabelsByGroupId(loadedGroupLabels, loadedGroupsWithLabels, "tg"), - [loadedGroupLabels, loadedGroupsWithLabels] - ) - const waLabelsByGroupId = useMemo( - () => buildLabelsByGroupId(loadedGroupLabels, loadedGroupsWithLabels, "wa"), - [loadedGroupLabels, loadedGroupsWithLabels] - ) - - // Only groups tagged with this exact category — a level below shows up as a sub-category to click into, not - // mixed into this list, so the admin always knows precisely where a group is filed. - const branchRows = useMemo(() => { - const tgRows: CombinedGroupRow[] = loadedTgGroups - .filter((group) => hasExactLabel(tgLabelsByGroupId.get(group.telegramId) ?? [], path)) - .map((group) => ({ - key: `telegram-${group.telegramId}`, - platform: "telegram", - title: group.title, - tag: group.tag, - link: group.link, - labels: tgLabelsByGroupId.get(group.telegramId) ?? [], - group, - })) - - const waRows: CombinedGroupRow[] = loadedWaGroups - .filter((group) => hasExactLabel(waLabelsByGroupId.get(group.id) ?? [], path)) - .map((group) => ({ - key: `whatsapp-${group.id}`, - platform: "whatsapp", - title: group.title, - tag: null, - link: group.link, - labels: waLabelsByGroupId.get(group.id) ?? [], - group, - })) - - return [...tgRows, ...waRows] - }, [loadedTgGroups, loadedWaGroups, tgLabelsByGroupId, waLabelsByGroupId, path]) - - const visibleRows = useMemo(() => { - const normalizedQuery = query.trim().toLocaleLowerCase().replace(/^@/, "") - if (!normalizedQuery) return branchRows - return branchRows.filter((row) => - [row.title, row.tag].filter(Boolean).join(" ").toLocaleLowerCase().includes(normalizedQuery) - ) - }, [branchRows, query]) + const { tgLabelsByGroupId, waLabelsByGroupId, branchRows, visibleRows } = useLabelGroupRows({ + path, + query, + loadedTgGroups, + loadedWaGroups, + loadedGroupLabels, + loadedGroupsWithLabels, + }) // Guarantees Didattica and Extra always appear, even with zero labels yet — otherwise an empty root would // have no card to click and no way back into it once it's the only path left to reach it. @@ -144,6 +103,7 @@ export function GroupsByLabelPage({ row.group.hide).length + + async function publishOne(row: CombinedGroupRow) { + // Unhide first, then untag: if the untag fails the group is visible but still carries the tag, so it stays + // listed on this page and a retry finishes the job. The reverse order would clear the tag and leave no handle + // on a group that is still hidden. Already-visible rows are included for the same reason — they are how a + // half-finished run gets cleaned up. + if (row.platform === "telegram") { + if (row.group.hide) await setGroupVisibilityFn({ data: { telegramId: row.group.telegramId, hide: false } }) + await untagGroupFn({ data: { groupId: row.group.telegramId, type: "tg", label: tag } }) + } else { + if (row.group.hide) await setWaGroupVisibilityFn({ data: { id: row.group.id, hide: false } }) + await untagGroupFn({ data: { groupId: row.group.id, type: "wa", label: tag } }) + } + } + + async function publish() { + if (pending) return + setPending(true) + setError("") + try { + const results = await Promise.allSettled(rows.map((row) => publishOne(row))) + const failed = results.filter((result) => result.status === "rejected").length + if (failed > 0) { + // A row's unhide may have succeeded even if its untag (or another row entirely) failed — refresh so the + // underlying data reflects what's actually saved, instead of silently implying nothing happened. + await router.invalidate({ sync: true }) + setError( + failed === rows.length + ? "The groups could not be published. Check your permissions and try again." + : `${failed} of ${rows.length} group(s) couldn't be published — the rest were. They're still listed here, so you can try again.` + ) + return + } + toast.success( + `Published "${tagName}" — ${rows.length} group${rows.length === 1 ? "" : "s"} visible, tag cleared.` + ) + setOpen(false) + await router.invalidate({ sync: true }) + } catch (cause) { + console.error(cause) + setError(errorMessage(cause, "The groups could not be published. Check your permissions and try again.")) + } finally { + setPending(false) + } + } + + return ( + { + if (pending) return + setOpen(nextOpen) + if (!nextOpen) setError("") + }} + > + }> + + Publish {rows.length} group{rows.length === 1 ? "" : "s"} ({hiddenCount || "none"} hidden) + + + + Publish "{tagName}"? + + {hiddenCount > 0 + ? `${hiddenCount} of the ${rows.length} group(s) tagged "${tagName}" are still hidden and will become visible on the site.` + : `None of the ${rows.length} group(s) tagged "${tagName}" are hidden, so this only clears the tag.`}{" "} + The "{tagName}" tag is then removed from all of them. Their categories are left untouched. + + + {error &&

{error}

} + + Cancel + void publish()}> + {pending && } Confirm publish + + +
+
+ ) +} diff --git a/src/features/groups-by-label/tag-groups-page.tsx b/src/features/groups-by-label/tag-groups-page.tsx new file mode 100644 index 0000000..a9b21ea --- /dev/null +++ b/src/features/groups-by-label/tag-groups-page.tsx @@ -0,0 +1,95 @@ +import { Link } from "@tanstack/react-router" +import { ArrowLeft } from "lucide-react" +import { useState } from "react" + +import { DataToolbar } from "@/components/data-toolbar" +import { Button } from "@/components/ui/button" +import { formatLabelSegment } from "@/features/group-labels/label-tree" +import { AddGroupToLabelDialog } from "@/features/groups-by-label/add-group-to-label-dialog" +import { CombinedGroupsTable } from "@/features/groups-by-label/combined-groups-table" +import { PublishTagGroupsDialog } from "@/features/groups-by-label/publish-tag-groups-dialog" +import { useLabelGroupRows } from "@/features/groups-by-label/use-label-group-rows" +import type { GroupWithLabels, TgGroup, TgGroupLabel, WaGroup } from "@/lib/api/types" + +/** + * One flat tag's groups. Tags sit outside the category hierarchy (see CATEGORY_ROOTS in label-tree.ts), so they + * get a flat page of their own rather than a node in the browsable tree: no sub-categories to drill into, and no + * "add sub-category", which the label validator would reject under a tag anyway. + */ +export function TagGroupsPage({ + tag, + loadedTgGroups, + loadedGroupLabels, + loadedGroupsWithLabels, + loadedWaGroups, +}: { + tag: string + loadedTgGroups: TgGroup[] + loadedGroupLabels: TgGroupLabel[] + loadedGroupsWithLabels: GroupWithLabels[] + loadedWaGroups: WaGroup[] +}) { + const [query, setQuery] = useState("") + + const { tgLabelsByGroupId, waLabelsByGroupId, branchRows, visibleRows } = useLabelGroupRows({ + path: tag, + query, + loadedTgGroups, + loadedWaGroups, + loadedGroupLabels, + loadedGroupsWithLabels, + }) + + const hasSearch = Boolean(query.trim()) + const labelExists = loadedGroupLabels.some((label) => label.label === tag) + const title = formatLabelSegment(tag) + const backUrl: string = "/dashboard/web/group-labels" + + return ( +
+ + + {/* Publishing acts on the whole tag, not on what the search box currently shows. */} + + +
+ } + /> + + +
+ ) +} diff --git a/src/features/groups-by-label/use-label-group-rows.ts b/src/features/groups-by-label/use-label-group-rows.ts new file mode 100644 index 0000000..f4d15e3 --- /dev/null +++ b/src/features/groups-by-label/use-label-group-rows.ts @@ -0,0 +1,78 @@ +import { useMemo } from "react" + +import { buildLabelsByGroupId, hasExactLabel } from "@/features/group-labels/label-tree" +import type { CombinedGroupRow } from "@/features/groups-by-label/combined-groups-table" +import type { GroupWithLabels, TgGroup, TgGroupLabel, WaGroup } from "@/lib/api/types" + +/** + * The rows for one label's page, shared by the category browser and by a flat tag's page — the two differ in + * chrome (sub-categories, which dialogs are offered) but derive their groups identically, so an exact-label + * page always lists exactly what its publish/label actions will act on. + */ +export function useLabelGroupRows({ + path, + query, + loadedTgGroups, + loadedWaGroups, + loadedGroupLabels, + loadedGroupsWithLabels, +}: { + path: string + query: string + loadedTgGroups: TgGroup[] + loadedWaGroups: WaGroup[] + loadedGroupLabels: TgGroupLabel[] + loadedGroupsWithLabels: GroupWithLabels[] +}) { + // Kept as two separate maps (not merged into one) since Telegram and WhatsApp group ids are independent + // sequences that could otherwise collide. + const tgLabelsByGroupId = useMemo( + () => buildLabelsByGroupId(loadedGroupLabels, loadedGroupsWithLabels, "tg"), + [loadedGroupLabels, loadedGroupsWithLabels] + ) + const waLabelsByGroupId = useMemo( + () => buildLabelsByGroupId(loadedGroupLabels, loadedGroupsWithLabels, "wa"), + [loadedGroupLabels, loadedGroupsWithLabels] + ) + + // Only groups tagged with this exact label — for a category, a level below shows up as a sub-category to click + // into, not mixed into this list, so the admin always knows precisely where a group is filed. Flat tags never + // nest, so for them exact matching is the only meaning available. + const branchRows = useMemo(() => { + const tgRows: CombinedGroupRow[] = loadedTgGroups + .filter((group) => hasExactLabel(tgLabelsByGroupId.get(group.telegramId) ?? [], path)) + .map((group) => ({ + key: `telegram-${group.telegramId}`, + platform: "telegram", + title: group.title, + tag: group.tag, + link: group.link, + labels: tgLabelsByGroupId.get(group.telegramId) ?? [], + group, + })) + + const waRows: CombinedGroupRow[] = loadedWaGroups + .filter((group) => hasExactLabel(waLabelsByGroupId.get(group.id) ?? [], path)) + .map((group) => ({ + key: `whatsapp-${group.id}`, + platform: "whatsapp", + title: group.title, + tag: null, + link: group.link, + labels: waLabelsByGroupId.get(group.id) ?? [], + group, + })) + + return [...tgRows, ...waRows] + }, [loadedTgGroups, loadedWaGroups, tgLabelsByGroupId, waLabelsByGroupId, path]) + + const visibleRows = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase().replace(/^@/, "") + if (!normalizedQuery) return branchRows + return branchRows.filter((row) => + [row.title, row.tag].filter(Boolean).join(" ").toLocaleLowerCase().includes(normalizedQuery) + ) + }, [branchRows, query]) + + return { tgLabelsByGroupId, waLabelsByGroupId, branchRows, visibleRows } +} diff --git a/src/features/whatsapp/create-edit-group-dialog.tsx b/src/features/whatsapp/create-edit-group-dialog.tsx index fd137d0..5a28b13 100644 --- a/src/features/whatsapp/create-edit-group-dialog.tsx +++ b/src/features/whatsapp/create-edit-group-dialog.tsx @@ -41,6 +41,7 @@ export function CreateEditGroupDialog({ const [open, setOpen] = useState(false) const [title, setTitle] = useState(group?.title ?? "") const [link, setLink] = useState(group?.link ?? "") + const [hide, setHide] = useState(false) const [pending, setPending] = useState(false) const [error, setError] = useState("") @@ -49,6 +50,7 @@ export function CreateEditGroupDialog({ function reset() { setTitle(group?.title ?? "") setLink(group?.link ?? "") + setHide(false) setError("") } @@ -58,7 +60,9 @@ export function CreateEditGroupDialog({ setPending(true) setError("") try { - const values = { title: title.trim(), link: link.trim() } + const values = group + ? { title: title.trim(), link: link.trim() } + : { title: title.trim(), link: link.trim(), hide } if (group) { await editGroupFn({ data: { id: group.id, ...values } }) toast.success(`${values.title} updated.`) @@ -135,7 +139,14 @@ export function CreateEditGroupDialog({
void submit(event)}> - + {error &&

{error}

} + + + + ) + }, }), ]) - }, [labelsByGroupId]) + }, [labelsByGroupId, updatingId]) const table = useAppTable({ key: "whatsapp-groups", @@ -156,6 +231,16 @@ export function WhatsappGroupsPage({ return (
+ {mutationError && ( + + {mutationError} + + )} + {refreshError && ( + + {refreshError} + + )} DashboardRoute, } as any) +const DashboardWebTagsTagRoute = DashboardWebTagsTagRouteImport.update({ + id: '/tags/$tag', + path: '/tags/$tag', + getParentRoute: () => DashboardWebRoute, +} as any) const DashboardWebGroupsByLabelSplatRoute = DashboardWebGroupsByLabelSplatRouteImport.update({ id: '/groups-by-label/$', @@ -176,6 +182,7 @@ export interface FileRoutesByFullPath { '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute + '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label/': typeof DashboardWebGroupsByLabelIndexRoute } @@ -200,6 +207,7 @@ export interface FileRoutesByTo { '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute + '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute '/dashboard/telegram/users': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label': typeof DashboardWebGroupsByLabelIndexRoute } @@ -226,6 +234,7 @@ export interface FileRoutesById { '/dashboard/whatsapp/groups': typeof DashboardWhatsappGroupsRoute '/dashboard/telegram/users/$userId': typeof DashboardTelegramUsersUserIdRoute '/dashboard/web/groups-by-label/$': typeof DashboardWebGroupsByLabelSplatRoute + '/dashboard/web/tags/$tag': typeof DashboardWebTagsTagRoute '/dashboard/telegram/users/': typeof DashboardTelegramUsersIndexRoute '/dashboard/web/groups-by-label/': typeof DashboardWebGroupsByLabelIndexRoute } @@ -253,6 +262,7 @@ export interface FileRouteTypes { | '/dashboard/whatsapp/groups' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' + | '/dashboard/web/tags/$tag' | '/dashboard/telegram/users/' | '/dashboard/web/groups-by-label/' fileRoutesByTo: FileRoutesByTo @@ -277,6 +287,7 @@ export interface FileRouteTypes { | '/dashboard/whatsapp/groups' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' + | '/dashboard/web/tags/$tag' | '/dashboard/telegram/users' | '/dashboard/web/groups-by-label' id: @@ -302,6 +313,7 @@ export interface FileRouteTypes { | '/dashboard/whatsapp/groups' | '/dashboard/telegram/users/$userId' | '/dashboard/web/groups-by-label/$' + | '/dashboard/web/tags/$tag' | '/dashboard/telegram/users/' | '/dashboard/web/groups-by-label/' fileRoutesById: FileRoutesById @@ -464,6 +476,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardTelegramUsersIndexRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/web/tags/$tag': { + id: '/dashboard/web/tags/$tag' + path: '/tags/$tag' + fullPath: '/dashboard/web/tags/$tag' + preLoaderRoute: typeof DashboardWebTagsTagRouteImport + parentRoute: typeof DashboardWebRoute + } '/dashboard/web/groups-by-label/$': { id: '/dashboard/web/groups-by-label/$' path: '/groups-by-label/$' @@ -488,6 +507,7 @@ interface DashboardWebRouteChildren { DashboardWebGuidesRoute: typeof DashboardWebGuidesRoute DashboardWebProjectsRoute: typeof DashboardWebProjectsRoute DashboardWebGroupsByLabelSplatRoute: typeof DashboardWebGroupsByLabelSplatRoute + DashboardWebTagsTagRoute: typeof DashboardWebTagsTagRoute DashboardWebGroupsByLabelIndexRoute: typeof DashboardWebGroupsByLabelIndexRoute } @@ -498,6 +518,7 @@ const DashboardWebRouteChildren: DashboardWebRouteChildren = { DashboardWebGuidesRoute: DashboardWebGuidesRoute, DashboardWebProjectsRoute: DashboardWebProjectsRoute, DashboardWebGroupsByLabelSplatRoute: DashboardWebGroupsByLabelSplatRoute, + DashboardWebTagsTagRoute: DashboardWebTagsTagRoute, DashboardWebGroupsByLabelIndexRoute: DashboardWebGroupsByLabelIndexRoute, } diff --git a/src/routes/dashboard/web/tags/$tag.tsx b/src/routes/dashboard/web/tags/$tag.tsx new file mode 100644 index 0000000..8e21ff8 --- /dev/null +++ b/src/routes/dashboard/web/tags/$tag.tsx @@ -0,0 +1,46 @@ +import { createFileRoute, redirect } from "@tanstack/react-router" + +import { DataPageSkeleton } from "@/components/loading-skeleton" +import { + listGroupLabels, + listGroupsForLabels, + listGroupsWithLabels, +} from "@/features/group-labels/group-labels.functions" +import { isCategoryLabel, labelPathToUrlSegments } from "@/features/group-labels/label-tree" +import { TagGroupsPage } from "@/features/groups-by-label/tag-groups-page" + +export const Route = createFileRoute("/dashboard/web/tags/$tag")({ + beforeLoad: ({ params }) => { + // A category already has a browsable page of its own — routing it here too would be a second, competing view + // of the same label, reachable by hand-typing a URL. + if (isCategoryLabel(params.tag)) { + const to: string = `/dashboard/web/groups-by-label/${labelPathToUrlSegments(params.tag).join("/")}` + throw redirect({ to }) + } + }, + loader: async () => { + const [groups, groupLabels, groupsWithLabels] = await Promise.all([ + listGroupsForLabels(), + listGroupLabels(), + listGroupsWithLabels(), + ]) + return { ...groups, groupLabels, groupsWithLabels } + }, + pendingComponent: () => , + component: TagGroupsRoute, +}) + +function TagGroupsRoute() { + const { tag } = Route.useParams() + const { tgGroups, groupLabels, groupsWithLabels, waGroups } = Route.useLoaderData() + + return ( + + ) +} From 27de3460555756a2228bc512de18cdbb0eeab5da Mon Sep 17 00:00:00 2001 From: Bianca Date: Sun, 6 Sep 2026 13:47:12 +0200 Subject: [PATCH 2/7] feat: add publication label functionality and UI enhancements for group labels --- src/features/group-labels/add-tag-dialog.tsx | 54 ++++++++---- .../group-labels/group-labels-page.tsx | 64 ++++++++++++-- .../group-labels/group-labels.functions.ts | 13 +++ .../group-labels/group-labels.validation.ts | 27 +++++- .../group-labels/label-tree-selector.tsx | 84 +++++++++++-------- src/features/group-labels/label-tree.ts | 17 ++++ .../group-labels/rename-label-dialog.tsx | 10 ++- .../add-group-to-label-dialog.tsx | 2 +- .../publish-tag-groups-dialog.tsx | 21 ++--- .../groups-by-label/tag-groups-page.tsx | 13 ++- 10 files changed, 230 insertions(+), 75 deletions(-) diff --git a/src/features/group-labels/add-tag-dialog.tsx b/src/features/group-labels/add-tag-dialog.tsx index 928bdc4..5d7c549 100644 --- a/src/features/group-labels/add-tag-dialog.tsx +++ b/src/features/group-labels/add-tag-dialog.tsx @@ -18,21 +18,35 @@ import { Input } from "@/components/ui/input" import { errorMessage } from "@/lib/errors" import { DEFAULT_GROUP_LABEL_COLOR, GROUP_LABEL_MAX } from "./group-labels.constants" -import { createGroupLabel } from "./group-labels.functions" -import { isReservedCategoryRoot, isValidLabelSegment } from "./label-tree" +import { createGroupLabel, createReleaseLabel } from "./group-labels.functions" +import { hasReleaseLabelPrefix, isReservedCategoryRoot, isValidLabelSegment, RELEASE_LABEL_PREFIX } from "./label-tree" -/** Creates a flat attribute tag (e.g. a language or campus facet). Tags never nest, unlike a category. */ -export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { +/** Separate entry points for persistent attributes and temporary publication batches. */ +export function AddTagDialog({ + open, + onOpenChange, + publication = false, +}: { + open: boolean + onOpenChange: (open: boolean) => void + publication?: boolean +}) { const router = useRouter() const createGroupLabelFn = useServerFn(createGroupLabel) + const createReleaseLabelFn = useServerFn(createReleaseLabel) const [name, setName] = useState("") const [pending, setPending] = useState(false) const [error, setError] = useState("") const nameId = useId() const trimmed = name.trim() - const reserved = isReservedCategoryRoot(trimmed) - const canSave = isValidLabelSegment(trimmed) && !reserved + const reserved = !publication && isReservedCategoryRoot(trimmed) + const reservedRelease = hasReleaseLabelPrefix(trimmed) + const canSave = + isValidLabelSegment(trimmed) && + !reserved && + !reservedRelease && + trimmed.length <= GROUP_LABEL_MAX - (publication ? RELEASE_LABEL_PREFIX.length : 0) function reset() { setName("") @@ -45,8 +59,12 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan setPending(true) setError("") try { - await createGroupLabelFn({ data: { label: trimmed, color: DEFAULT_GROUP_LABEL_COLOR, description: "" } }) - toast.success(`Tag "${trimmed}" created.`) + if (publication) { + await createReleaseLabelFn({ data: { name: trimmed, color: DEFAULT_GROUP_LABEL_COLOR, description: "" } }) + } else { + await createGroupLabelFn({ data: { label: trimmed, color: DEFAULT_GROUP_LABEL_COLOR, description: "" } }) + } + toast.success(`${publication ? "Publication" : "Tag"} "${trimmed}" created.`) onOpenChange(false) reset() await router.invalidate({ sync: true }) @@ -69,10 +87,11 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan > - Add tag + {publication ? "Create publication" : "Add tag"} - A flat attribute, like a language or campus. Tags aren't nested — for a browsable category, use - "Add category" instead. + {publication + ? "Create a batch of groups to publish together. Publishing makes its groups visible and clears only the batch label; categories and attributes remain." + : "A permanent attribute, like a language or campus. Use Publications to prepare a batch for publishing."} void submit(event)}> @@ -82,8 +101,8 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan id={nameId} value={name} onChange={(event) => setName(event.target.value)} - placeholder="e.g. Italian, Bovisa" - maxLength={GROUP_LABEL_MAX} + placeholder={publication ? "e.g. 2026-27" : "e.g. Italian, Bovisa"} + maxLength={GROUP_LABEL_MAX - (publication ? RELEASE_LABEL_PREFIX.length : 0)} autoFocus required /> @@ -91,6 +110,13 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan {!isValidLabelSegment(trimmed) && trimmed && (

Use a plain name, without dots or URL separators.

)} + {reservedRelease && ( +

+ {publication + ? "Enter the name without the release- prefix." + : "The release- prefix is reserved. Use Create publication instead."} +

+ )} {reserved &&

This name is reserved for a category.

} {error &&

{error}

} @@ -107,7 +133,7 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan diff --git a/src/features/group-labels/group-labels-page.tsx b/src/features/group-labels/group-labels-page.tsx index e630f74..e4cffc6 100644 --- a/src/features/group-labels/group-labels-page.tsx +++ b/src/features/group-labels/group-labels-page.tsx @@ -1,4 +1,4 @@ -import { FolderTree, Plus, Tags } from "lucide-react" +import { FolderTree, Megaphone, Plus, Tags } from "lucide-react" import { useMemo, useState } from "react" import { DataToolbar } from "@/components/data-toolbar" @@ -14,6 +14,7 @@ import { filterFlatLabels, filterLabelTree, isCategoryLabel, + hasReleaseLabelPrefix, type LabelTreeNode, } from "./label-tree" import type { GroupLabel } from "./types" @@ -33,26 +34,32 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou const [query, setQuery] = useState("") const [addCategoryOpen, setAddCategoryOpen] = useState(false) const [addTagOpen, setAddTagOpen] = useState(false) + const [addPublicationOpen, setAddPublicationOpen] = useState(false) const categoryLabels = useMemo(() => labels.filter((label) => isCategoryLabel(label.label)), [labels]) - const tagLabels = useMemo(() => labels.filter((label) => !isCategoryLabel(label.label)), [labels]) + const tagLabels = useMemo( + () => labels.filter((label) => !isCategoryLabel(label.label) && !hasReleaseLabelPrefix(label.label)), + [labels] + ) + const releaseLabels = useMemo(() => labels.filter((label) => hasReleaseLabelPrefix(label.label)), [labels]) + const filteredReleases = useMemo(() => filterFlatLabels(releaseLabels, query), [releaseLabels, query]) const categoryTree = useMemo(() => buildCategoryRootTree(categoryLabels), [categoryLabels]) const filteredCategoryTree = useMemo(() => filterLabelTree(categoryTree, query), [categoryTree, query]) const filteredTags = useMemo(() => filterFlatLabels(tagLabels, query), [tagLabels, query]) const isSearching = Boolean(query.trim()) - const matchCount = countRealLabels(filteredCategoryTree) + filteredTags.length + const matchCount = countRealLabels(filteredCategoryTree) + filteredTags.length + filteredReleases.length return (
@@ -94,10 +101,10 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou )} -
-

Tags

+
+

Attributes

- Flat attributes, like a language or campus, that don't belong to the category hierarchy. + Permanent tags, like a language or campus. They are preserved when groups are published.

{filteredTags.length ? (
@@ -127,6 +134,47 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou )}
+
+
+
+

Publications

+

+ Temporary batches of groups to publish together. Only their release labels are cleared. +

+
+ +
+ {filteredReleases.length ? ( +
+ {filteredReleases.map((label) => ( + removeGroupLabel(label)} + onSave={(values) => saveGroupLabel(label, values)} + /> + ))} +
+ ) : ( + + )} +
+ +
diff --git a/src/features/group-labels/group-labels.functions.ts b/src/features/group-labels/group-labels.functions.ts index 58a5bff..d175649 100644 --- a/src/features/group-labels/group-labels.functions.ts +++ b/src/features/group-labels/group-labels.functions.ts @@ -5,6 +5,7 @@ import { webAdminMiddleware, webWriteAdminMiddleware } from "@/server/auth.middl import { createGroupLabelInput, + createReleaseLabelInput, editGroupLabelInput, groupLabelIdentifierInput, renameGroupLabelInput, @@ -61,6 +62,18 @@ export const createGroupLabel = createServerFn({ method: "POST" }) return created }) +export const createReleaseLabel = createServerFn({ method: "POST" }) + .middleware([webWriteAdminMiddleware]) + .validator(createReleaseLabelInput) + .handler(async ({ data, context }) => { + const [created] = await context.backend.tg.groupLabels.create.mutate({ + ...data, + createdBy: context.telegramId, + }) + if (!created) throw new Error("The publication could not be created.") + return created + }) + export const editGroupLabel = createServerFn({ method: "POST" }) .middleware([webWriteAdminMiddleware]) .validator(editGroupLabelInput) diff --git a/src/features/group-labels/group-labels.validation.ts b/src/features/group-labels/group-labels.validation.ts index 17ad048..d12d15d 100644 --- a/src/features/group-labels/group-labels.validation.ts +++ b/src/features/group-labels/group-labels.validation.ts @@ -3,7 +3,7 @@ import { z } from "zod" import { errorHasZodField, errorMessage } from "@/lib/errors" import { GROUP_LABEL_DESCRIPTION_MAX, GROUP_LABEL_MAX } from "./group-labels.constants" -import { isCategoryLabel, isValidLabelSegment } from "./label-tree" +import { hasReleaseLabelPrefix, isCategoryLabel, isValidLabelSegment, RELEASE_LABEL_PREFIX } from "./label-tree" const label = z.string().trim().min(1).max(GROUP_LABEL_MAX) const color = z.string().regex(/^#[0-9A-Fa-f]{6}$/) @@ -31,6 +31,7 @@ function categoryRoot(value: string): string | null { */ const newLabelPath = label.refine( (value) => { + if (hasReleaseLabelPrefix(value)) return false const segments = value.split(".") if (segments.some((segment) => !isValidLabelSegment(segment))) return false return segments.length === 1 || isCategoryLabel(value) @@ -39,9 +40,29 @@ const newLabelPath = label.refine( ) export const createGroupLabelInput = z.object({ label: newLabelPath, color, description }) +/** Release creation accepts a name, never an arbitrary existing label to promote. */ +export const createReleaseLabelInput = z + .object({ + name: z + .string() + .trim() + .min(1) + .max(GROUP_LABEL_MAX - RELEASE_LABEL_PREFIX.length) + .refine((name) => isValidLabelSegment(name) && !hasReleaseLabelPrefix(name), { + message: "Enter a publication name without the reserved release- prefix or URL separators.", + }), + color, + description, + }) + .transform(({ name, ...fields }) => ({ ...fields, label: `${RELEASE_LABEL_PREFIX}${name}` })) + export const editGroupLabelInput = z.object({ label, color, description }) export const renameGroupLabelInput = z .object({ label, newLabel: newLabelPath, color, description }) + .refine((data) => !hasReleaseLabelPrefix(data.label), { + message: "Publication labels cannot be renamed with the category and tag editor.", + path: ["newLabel"], + }) .refine((data) => categoryRoot(data.label) === categoryRoot(data.newLabel), { message: "A rename can't move a label to a different category root, or turn a tag into a category (or back).", path: ["newLabel"], @@ -50,10 +71,10 @@ export const groupLabelIdentifierInput = z.object({ label }) export function groupLabelSaveErrorMessage(cause: unknown) { if (errorHasZodField(cause, "newLabel")) { - return "A rename can't move a label to a different category root, or turn a tag into a category (or back)." + return "Keep the same category root and label type. The release- prefix is reserved for publications." } if (errorHasZodField(cause, "label")) { - return `Enter a valid label: a plain tag name, a category root, or a category nested under an existing root (max ${GROUP_LABEL_MAX} characters).` + return `Enter a valid label: a plain tag name, a category root, or a category nested under an existing root (max ${GROUP_LABEL_MAX} characters). The release- prefix is reserved for publications.` } if (errorHasZodField(cause, "color")) return "Choose a valid color." if (errorHasZodField(cause, "description")) { diff --git a/src/features/group-labels/label-tree-selector.tsx b/src/features/group-labels/label-tree-selector.tsx index 598bf9f..abeb5ea 100644 --- a/src/features/group-labels/label-tree-selector.tsx +++ b/src/features/group-labels/label-tree-selector.tsx @@ -15,6 +15,7 @@ import { formatLabelChip, formatLabelSegment, isCategoryLabel, + hasReleaseLabelPrefix, type LabelTreeNode, } from "./label-tree" import type { GroupLabel } from "./types" @@ -129,7 +130,12 @@ export function LabelTreeSelector({ }) { const [query, setQuery] = useState("") const categoryLabels = useMemo(() => allLabels.filter((label) => isCategoryLabel(label.label)), [allLabels]) - const tagLabels = useMemo(() => allLabels.filter((label) => !isCategoryLabel(label.label)), [allLabels]) + const tagLabels = useMemo( + () => allLabels.filter((label) => !isCategoryLabel(label.label) && !hasReleaseLabelPrefix(label.label)), + [allLabels] + ) + const releaseLabels = useMemo(() => allLabels.filter((label) => hasReleaseLabelPrefix(label.label)), [allLabels]) + const visibleReleases = useMemo(() => filterFlatLabels(releaseLabels, query), [releaseLabels, query]) const tree = useMemo(() => buildLabelTree(categoryLabels), [categoryLabels]) const isSearching = Boolean(query.trim()) // Searching a hierarchy by expanding one branch at a time is slow, unlike picking a tag — so a search instead @@ -144,7 +150,7 @@ export function LabelTreeSelector({ return (
setQuery(event.target.value)} className="h-9" @@ -206,38 +212,50 @@ export function LabelTreeSelector({ ))}
))} - {visibleTags.length > 0 && ( -
-

Tags

-
- {visibleTags.map((label) => { - const checked = isSelected(label) - const swatch = getGroupLabelColor(label.color) - return ( - - ) - })} -
-
- )} - {(tagsOnly || (isSearching ? !matchingCategories.length : !tree.length)) && !visibleTags.length && ( -

- {isSearching ? `No matching ${tagsOnly ? "tags" : "labels"}` : "No tags"} -

+ {[ + { title: "Attributes", labels: visibleTags }, + { title: "Publications", labels: visibleReleases }, + ].map( + (section) => + section.labels.length > 0 && ( +
+

+ {section.title} +

+
+ {section.labels.map((label) => { + const checked = isSelected(label) + const swatch = getGroupLabelColor(label.color) + return ( + + ) + })} +
+
+ ) )} + {(tagsOnly || (isSearching ? !matchingCategories.length : !tree.length)) && + !visibleTags.length && + !visibleReleases.length && ( +

+ {isSearching + ? `No matching ${tagsOnly ? "attributes or publications" : "labels"}` + : "No attributes or publications"} +

+ )}
) diff --git a/src/features/group-labels/label-tree.ts b/src/features/group-labels/label-tree.ts index e1bdadb..a9eb0c7 100644 --- a/src/features/group-labels/label-tree.ts +++ b/src/features/group-labels/label-tree.ts @@ -10,6 +10,23 @@ import type { GroupLabel } from "./types" */ export const CATEGORY_ROOTS = ["didattica", "extra"] +export const RELEASE_LABEL_PREFIX = "release-" + +/** Reserve case variants too, so an attribute cannot accidentally become a release label. */ +export function hasReleaseLabelPrefix(label: string): boolean { + return label.trim().toLowerCase().startsWith(RELEASE_LABEL_PREFIX) +} + +/** Only a nonempty, flat release label is eligible for publication. */ +export function isReleaseLabel(label: string): boolean { + return ( + label === label.trim() && + hasReleaseLabelPrefix(label) && + label.slice(RELEASE_LABEL_PREFIX.length).trim().length > 0 && + isValidLabelSegment(label) + ) +} + export function isCategoryLabel(label: string): boolean { return CATEGORY_ROOTS.some((root) => label === root || label.startsWith(`${root}.`)) } diff --git a/src/features/group-labels/rename-label-dialog.tsx b/src/features/group-labels/rename-label-dialog.tsx index 7d6a737..e7d9922 100644 --- a/src/features/group-labels/rename-label-dialog.tsx +++ b/src/features/group-labels/rename-label-dialog.tsx @@ -19,7 +19,7 @@ import { Input } from "@/components/ui/input" import { GROUP_LABEL_MAX } from "./group-labels.constants" import { renameGroupLabel } from "./group-labels.functions" import { groupLabelSaveErrorMessage } from "./group-labels.validation" -import { formatLabelBreadcrumb, isReservedCategoryRoot, isValidLabelSegment } from "./label-tree" +import { formatLabelBreadcrumb, hasReleaseLabelPrefix, isReservedCategoryRoot, isValidLabelSegment } from "./label-tree" import type { GroupLabel } from "./types" export function RenameLabelDialog({ @@ -58,7 +58,8 @@ export function RenameLabelDialog({ // Only a bare top-level rename (a tag, since the two category roots never reach this dialog) could collide // with a reserved root name — a nested rename can't, since it'd still be dotted. const reserved = !parentPrefix && isReservedCategoryRoot(trimmed) - const canSave = isValidLabelSegment(trimmed) && trimmed !== segment && !reserved + const reservedRelease = hasReleaseLabelPrefix(path) || (!parentPrefix && hasReleaseLabelPrefix(trimmed)) + const canSave = isValidLabelSegment(trimmed) && trimmed !== segment && !reserved && !reservedRelease const newPath = parentPrefix ? `${parentPrefix}.${trimmed}` : trimmed async function submit(event: React.FormEvent) { @@ -146,6 +147,11 @@ export function RenameLabelDialog({ {!isValidLabelSegment(trimmed) && trimmed && (

Use a plain name, without dots or URL separators.

)} + {reservedRelease && ( +

+ The release- prefix is reserved for publications. This editor cannot rename publication labels. +

+ )} {reserved &&

This name is reserved for a category.

} {trimmed && !trimmed.includes(".") && trimmed !== segment && (

diff --git a/src/features/groups-by-label/add-group-to-label-dialog.tsx b/src/features/groups-by-label/add-group-to-label-dialog.tsx index c33ff2f..76edeed 100644 --- a/src/features/groups-by-label/add-group-to-label-dialog.tsx +++ b/src/features/groups-by-label/add-group-to-label-dialog.tsx @@ -298,7 +298,7 @@ export function AddGroupToLabelDialog({ onHideChange={setHide} />

-

Also tag with

+

Attributes and publications

{error &&

{error}

} diff --git a/src/features/groups-by-label/publish-tag-groups-dialog.tsx b/src/features/groups-by-label/publish-tag-groups-dialog.tsx index 8502ffc..0e6e6af 100644 --- a/src/features/groups-by-label/publish-tag-groups-dialog.tsx +++ b/src/features/groups-by-label/publish-tag-groups-dialog.tsx @@ -17,16 +17,15 @@ import { } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { untagGroup } from "@/features/group-labels/group-labels.functions" -import { formatLabelSegment } from "@/features/group-labels/label-tree" +import { formatLabelSegment, isReleaseLabel } from "@/features/group-labels/label-tree" import type { CombinedGroupRow } from "@/features/groups-by-label/combined-groups-table" import { setGroupVisibility } from "@/features/telegram/groups.functions" import { setWhatsappGroupVisibility } from "@/features/whatsapp/groups.functions" import { errorMessage } from "@/lib/errors" /** - * Releases a batch: every group carrying this flat tag becomes visible and then loses the tag. Only ever offered - * for a flat tag, never a category — a tag is a throwaway grouping, so clearing it is the intended cleanup, - * while a category is the group's real place on the site and must survive. + * Publishes only reserved release labels. Persistent attributes and categories are never consumed. + * The backend still stores all of these as labels; the distinction belongs to this admin workflow. */ export function PublishTagGroupsDialog({ tag, rows }: { tag: string; rows: CombinedGroupRow[] }) { const router = useRouter() @@ -37,21 +36,23 @@ export function PublishTagGroupsDialog({ tag, rows }: { tag: string; rows: Combi const [pending, setPending] = useState(false) const [error, setError] = useState("") - if (!rows.length) return null + if (!rows.length || !isReleaseLabel(tag)) return null const tagName = formatLabelSegment(tag) const hiddenCount = rows.filter((row) => row.group.hide).length async function publishOne(row: CombinedGroupRow) { + if (!isReleaseLabel(tag)) throw new Error("Only publication labels can be published.") // Unhide first, then untag: if the untag fails the group is visible but still carries the tag, so it stays // listed on this page and a retry finishes the job. The reverse order would clear the tag and leave no handle // on a group that is still hidden. Already-visible rows are included for the same reason — they are how a - // half-finished run gets cleaned up. + // half-finished run gets cleaned up. Always establish visibility: another tab may have hidden a group + // since these rows were loaded. if (row.platform === "telegram") { - if (row.group.hide) await setGroupVisibilityFn({ data: { telegramId: row.group.telegramId, hide: false } }) + await setGroupVisibilityFn({ data: { telegramId: row.group.telegramId, hide: false } }) await untagGroupFn({ data: { groupId: row.group.telegramId, type: "tg", label: tag } }) } else { - if (row.group.hide) await setWaGroupVisibilityFn({ data: { id: row.group.id, hide: false } }) + await setWaGroupVisibilityFn({ data: { id: row.group.id, hide: false } }) await untagGroupFn({ data: { groupId: row.group.id, type: "wa", label: tag } }) } } @@ -106,8 +107,8 @@ export function PublishTagGroupsDialog({ tag, rows }: { tag: string; rows: Combi {hiddenCount > 0 ? `${hiddenCount} of the ${rows.length} group(s) tagged "${tagName}" are still hidden and will become visible on the site.` - : `None of the ${rows.length} group(s) tagged "${tagName}" are hidden, so this only clears the tag.`}{" "} - The "{tagName}" tag is then removed from all of them. Their categories are left untouched. + : `All ${rows.length} group(s) tagged "${tagName}" currently appear visible. Publishing makes them visible again before clearing the tag.`}{" "} + The "{tagName}" tag is then removed from all of them. Their categories and attributes are left untouched. {error &&

{error}

} diff --git a/src/features/groups-by-label/tag-groups-page.tsx b/src/features/groups-by-label/tag-groups-page.tsx index a9b21ea..a3ccc37 100644 --- a/src/features/groups-by-label/tag-groups-page.tsx +++ b/src/features/groups-by-label/tag-groups-page.tsx @@ -4,7 +4,7 @@ import { useState } from "react" import { DataToolbar } from "@/components/data-toolbar" import { Button } from "@/components/ui/button" -import { formatLabelSegment } from "@/features/group-labels/label-tree" +import { formatLabelSegment, isReleaseLabel } from "@/features/group-labels/label-tree" import { AddGroupToLabelDialog } from "@/features/groups-by-label/add-group-to-label-dialog" import { CombinedGroupsTable } from "@/features/groups-by-label/combined-groups-table" import { PublishTagGroupsDialog } from "@/features/groups-by-label/publish-tag-groups-dialog" @@ -42,6 +42,7 @@ export function TagGroupsPage({ const hasSearch = Boolean(query.trim()) const labelExists = loadedGroupLabels.some((label) => label.label === tag) + const publication = isReleaseLabel(tag) const title = formatLabelSegment(tag) const backUrl: string = "/dashboard/web/group-labels" @@ -57,9 +58,13 @@ export function TagGroupsPage({ Back to Group labels {/* Publishing acts on the whole tag, not on what the search box currently shows. */} - + {publication && } Date: Sun, 6 Sep 2026 14:11:34 +0200 Subject: [PATCH 3/7] feat: implement group visibility toggle functionality across Telegram and WhatsApp groups --- .../groups-by-label/combined-groups-table.tsx | 103 +++++------------- .../publish-tag-groups-dialog.tsx | 11 +- src/features/telegram/groups-table.tsx | 45 +------- .../whatsapp/whatsapp-groups-page.tsx | 47 ++------ src/hooks/use-group-visibility-toggle.ts | 58 ++++++++++ 5 files changed, 105 insertions(+), 159 deletions(-) create mode 100644 src/hooks/use-group-visibility-toggle.ts diff --git a/src/features/groups-by-label/combined-groups-table.tsx b/src/features/groups-by-label/combined-groups-table.tsx index 7b14be3..27e4392 100644 --- a/src/features/groups-by-label/combined-groups-table.tsx +++ b/src/features/groups-by-label/combined-groups-table.tsx @@ -27,6 +27,7 @@ import { LeaveGroupDialog } from "@/features/telegram/leave-group-dialog" import { CreateEditGroupDialog } from "@/features/whatsapp/create-edit-group-dialog" import { DeleteGroupDialog } from "@/features/whatsapp/delete-group-dialog" import { setWhatsappGroupVisibility } from "@/features/whatsapp/groups.functions" +import { useGroupVisibilityToggle } from "@/hooks/use-group-visibility-toggle" import type { TgGroup, TgGroupLabel, WaGroup } from "@/lib/api/types" import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table" import { cn } from "@/lib/utils" @@ -59,89 +60,35 @@ export function CombinedGroupsTable({ const router = useRouter() const setGroupVisibilityFn = useServerFn(setGroupVisibility) const setWaGroupVisibilityFn = useServerFn(setWhatsappGroupVisibility) - const [visibilityOverrides, setVisibilityOverrides] = useState>({}) - const [updatingId, setUpdatingId] = useState(null) - // Kept separate from the Telegram overrides above (not merged into one map) for the same reason as the label - // maps: Telegram and WhatsApp group ids are independent sequences that could otherwise collide. - const [waVisibilityOverrides, setWaVisibilityOverrides] = useState>({}) - const [waUpdatingId, setWaUpdatingId] = useState(null) - const [mutationError, setMutationError] = useState("") - const [refreshError, setRefreshError] = useState("") + const { + updatingId, + mutationError: tgMutationError, + refreshError: tgRefreshError, + resolveHide: resolveTgHide, + toggleVisibility, + } = useGroupVisibilityToggle((telegramId: number, hide: boolean) => + setGroupVisibilityFn({ data: { telegramId, hide } }) + ) + // Kept as a separate hook instance (not merged into one map) for the same reason as the label maps: Telegram + // and WhatsApp group ids are independent sequences that could otherwise collide. + const { + updatingId: waUpdatingId, + mutationError: waMutationError, + refreshError: waRefreshError, + resolveHide: resolveWaHide, + toggleVisibility: toggleWaVisibility, + } = useGroupVisibilityToggle((id: number, hide: boolean) => setWaGroupVisibilityFn({ data: { id, hide } })) + const mutationError = tgMutationError || waMutationError + const refreshError = tgRefreshError || waRefreshError const [editingKey, setEditingKey] = useState(null) const displayRows = rows.map((row) => row.platform === "telegram" - ? { ...row, group: { ...row.group, hide: visibilityOverrides[row.group.telegramId] ?? row.group.hide } } - : { ...row, group: { ...row.group, hide: waVisibilityOverrides[row.group.id] ?? row.group.hide } } + ? { ...row, group: { ...row.group, hide: resolveTgHide(row.group.telegramId, row.group.hide) } } + : { ...row, group: { ...row.group, hide: resolveWaHide(row.group.id, row.group.hide) } } ) const editingRow = editingKey ? (displayRows.find((row) => row.key === editingKey) ?? null) : null - async function toggleVisibility(group: TgGroup) { - if (updatingId !== null) return - const hide = !group.hide - setUpdatingId(group.telegramId) - setMutationError("") - setVisibilityOverrides((current) => ({ ...current, [group.telegramId]: hide })) - - try { - await setGroupVisibilityFn({ data: { telegramId: group.telegramId, hide } }) - toast.success(`${group.title} is now ${hide ? "hidden" : "visible"}.`) - try { - await router.invalidate({ sync: true }) - setRefreshError("") - setVisibilityOverrides((current) => { - const { [group.telegramId]: _removed, ...remaining } = current - return remaining - }) - } catch (error) { - console.error(error) - setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") - } - } catch (error) { - console.error(error) - setVisibilityOverrides((current) => { - const { [group.telegramId]: _removed, ...remaining } = current - return remaining - }) - setMutationError("The visibility setting could not be updated. Check your permissions and try again.") - } finally { - setUpdatingId(null) - } - } - - async function toggleWaVisibility(group: WaGroup) { - if (waUpdatingId !== null) return - const hide = !group.hide - setWaUpdatingId(group.id) - setMutationError("") - setWaVisibilityOverrides((current) => ({ ...current, [group.id]: hide })) - - try { - await setWaGroupVisibilityFn({ data: { id: group.id, hide } }) - toast.success(`${group.title} is now ${hide ? "hidden" : "visible"}.`) - try { - await router.invalidate({ sync: true }) - setRefreshError("") - setWaVisibilityOverrides((current) => { - const { [group.id]: _removed, ...remaining } = current - return remaining - }) - } catch (error) { - console.error(error) - setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") - } - } catch (error) { - console.error(error) - setWaVisibilityOverrides((current) => { - const { [group.id]: _removed, ...remaining } = current - return remaining - }) - setMutationError("The visibility setting could not be updated. Check your permissions and try again.") - } finally { - setWaUpdatingId(null) - } - } - const columns = useMemo(() => { const sortableHeader = ( label: string, @@ -234,7 +181,7 @@ export function CombinedGroupsTable({ aria-busy={pending} aria-pressed={visible} aria-label={`${group.title} is ${visible ? "visible" : "hidden"}. Change visibility`} - onClick={() => void toggleWaVisibility(group)} + onClick={() => void toggleWaVisibility(group.id, group.title, group.hide)} > {pending ? : visible ? : } {visible ? "Visible" : "Hidden"} @@ -261,7 +208,7 @@ export function CombinedGroupsTable({ aria-busy={pending} aria-pressed={visible} aria-label={`${group.title} is ${visible ? "visible" : "hidden"}. Change visibility`} - onClick={() => void toggleVisibility(group)} + onClick={() => void toggleVisibility(group.telegramId, group.title, group.hide)} > {pending ? : visible ? : } {visible ? "Visible" : "Hidden"} diff --git a/src/features/groups-by-label/publish-tag-groups-dialog.tsx b/src/features/groups-by-label/publish-tag-groups-dialog.tsx index 0e6e6af..262e66d 100644 --- a/src/features/groups-by-label/publish-tag-groups-dialog.tsx +++ b/src/features/groups-by-label/publish-tag-groups-dialog.tsx @@ -78,8 +78,15 @@ export function PublishTagGroupsDialog({ tag, rows }: { tag: string; rows: Combi toast.success( `Published "${tagName}" — ${rows.length} group${rows.length === 1 ? "" : "s"} visible, tag cleared.` ) - setOpen(false) - await router.invalidate({ sync: true }) + try { + await router.invalidate({ sync: true }) + setOpen(false) + } catch (cause) { + console.error(cause) + // Keep the dialog open (instead of closing it and setting an error nobody can see) so the admin + // knows a refresh is still needed even though the publish itself already succeeded. + setError("The groups were published, but the list could not be refreshed. Refresh the page to see the change.") + } } catch (cause) { console.error(cause) setError(errorMessage(cause, "The groups could not be published. Check your permissions and try again.")) diff --git a/src/features/telegram/groups-table.tsx b/src/features/telegram/groups-table.tsx index 4e0b126..08d487f 100644 --- a/src/features/telegram/groups-table.tsx +++ b/src/features/telegram/groups-table.tsx @@ -25,6 +25,7 @@ import { GroupLabelBadges } from "@/features/group-labels/group-label-badges" import { GroupLabelsDialog } from "@/features/group-labels/group-labels-dialog" import { setGroupVisibility } from "@/features/telegram/groups.functions" import { LeaveGroupDialog } from "@/features/telegram/leave-group-dialog" +import { useGroupVisibilityToggle } from "@/hooks/use-group-visibility-toggle" import type { TgGroup, TgGroupLabel } from "@/lib/api/types" import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table" import { cn } from "@/lib/utils" @@ -49,50 +50,16 @@ export function GroupsTable({ }) { const router = useRouter() const setGroupVisibilityFn = useServerFn(setGroupVisibility) - const [visibilityOverrides, setVisibilityOverrides] = useState>({}) - const [updatingId, setUpdatingId] = useState(null) - const [mutationError, setMutationError] = useState("") - const [refreshError, setRefreshError] = useState("") + const { updatingId, mutationError, refreshError, resolveHide, toggleVisibility } = useGroupVisibilityToggle( + (telegramId: number, hide: boolean) => setGroupVisibilityFn({ data: { telegramId, hide } }) + ) const [editingGroup, setEditingGroup] = useState(null) const groups = loadedGroups.map((group) => ({ ...group, - hide: visibilityOverrides[group.telegramId] ?? group.hide, + hide: resolveHide(group.telegramId, group.hide), })) - async function toggleVisibility(group: TgGroup) { - if (updatingId !== null) return - const hide = !group.hide - setUpdatingId(group.telegramId) - setMutationError("") - setVisibilityOverrides((current) => ({ ...current, [group.telegramId]: hide })) - - try { - await setGroupVisibilityFn({ data: { telegramId: group.telegramId, hide } }) - toast.success(`${group.title} is now ${hide ? "hidden" : "visible"}.`) - try { - await router.invalidate({ sync: true }) - setRefreshError("") - setVisibilityOverrides((current) => { - const { [group.telegramId]: _removed, ...remaining } = current - return remaining - }) - } catch (error) { - console.error(error) - setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") - } - } catch (error) { - console.error(error) - setVisibilityOverrides((current) => { - const { [group.telegramId]: _removed, ...remaining } = current - return remaining - }) - setMutationError("The visibility setting could not be updated. Check your permissions and try again.") - } finally { - setUpdatingId(null) - } - } - const columns = useMemo(() => { const sortableHeader = ( label: string, @@ -166,7 +133,7 @@ export function GroupsTable({ aria-busy={pending} aria-pressed={visible} aria-label={`${group.title} is ${visible ? "visible" : "hidden"}. Change visibility`} - onClick={() => void toggleVisibility(group)} + onClick={() => void toggleVisibility(group.telegramId, group.title, group.hide)} > {pending ? : visible ? : } {visible ? "Visible" : "Hidden"} diff --git a/src/features/whatsapp/whatsapp-groups-page.tsx b/src/features/whatsapp/whatsapp-groups-page.tsx index c7e5e89..cc48ff3 100644 --- a/src/features/whatsapp/whatsapp-groups-page.tsx +++ b/src/features/whatsapp/whatsapp-groups-page.tsx @@ -38,6 +38,7 @@ import { LabelTreeSelector } from "@/features/group-labels/label-tree-selector" import { CreateEditGroupDialog } from "@/features/whatsapp/create-edit-group-dialog" import { DeleteGroupDialog } from "@/features/whatsapp/delete-group-dialog" import { setWhatsappGroupVisibility } from "@/features/whatsapp/groups.functions" +import { useGroupVisibilityToggle } from "@/hooks/use-group-visibility-toggle" import type { GroupWithLabels, TgGroupLabel, WaGroup } from "@/lib/api/types" import { createAppColumnHelper, type dashboardFeatures, useAppTable } from "@/lib/table" import { cn } from "@/lib/utils" @@ -67,10 +68,9 @@ export function WhatsappGroupsPage({ const [requiredLabels, setRequiredLabels] = useState([]) const [excludedLabels, setExcludedLabels] = useState([]) const [editingLabelsGroup, setEditingLabelsGroup] = useState(null) - const [visibilityOverrides, setVisibilityOverrides] = useState>({}) - const [updatingId, setUpdatingId] = useState(null) - const [mutationError, setMutationError] = useState("") - const [refreshError, setRefreshError] = useState("") + const { updatingId, mutationError, refreshError, resolveHide, toggleVisibility } = useGroupVisibilityToggle( + (id: number, hide: boolean) => setGroupVisibilityFn({ data: { id, hide } }) + ) const labelsByGroupId = useMemo( () => buildLabelsByGroupId(loadedGroupLabels, loadedGroupsWithLabels, "wa"), @@ -89,45 +89,12 @@ export function WhatsappGroupsPage({ const matchesExcluded = excludedLabels.every((label) => !groupLabels.some((gl) => gl.label === label.label)) return matchesRequired && matchesExcluded }) - .map((group) => ({ ...group, hide: visibilityOverrides[group.id] ?? group.hide })) - }, [loadedGroups, query, requiredLabels, excludedLabels, labelsByGroupId, visibilityOverrides]) + .map((group) => ({ ...group, hide: resolveHide(group.id, group.hide) })) + }, [loadedGroups, query, requiredLabels, excludedLabels, labelsByGroupId, resolveHide]) const activeLabelFilterCount = requiredLabels.length + excludedLabels.length const hasFilters = Boolean(query.trim()) || activeLabelFilterCount > 0 - async function toggleVisibility(group: WaGroup) { - if (updatingId !== null) return - const hide = !group.hide - setUpdatingId(group.id) - setMutationError("") - setVisibilityOverrides((current) => ({ ...current, [group.id]: hide })) - - try { - await setGroupVisibilityFn({ data: { id: group.id, hide } }) - toast.success(`${group.title} is now ${hide ? "hidden" : "visible"}.`) - try { - await router.invalidate({ sync: true }) - setRefreshError("") - setVisibilityOverrides((current) => { - const { [group.id]: _removed, ...remaining } = current - return remaining - }) - } catch (error) { - console.error(error) - setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") - } - } catch (error) { - console.error(error) - setVisibilityOverrides((current) => { - const { [group.id]: _removed, ...remaining } = current - return remaining - }) - setMutationError("The visibility setting could not be updated. Check your permissions and try again.") - } finally { - setUpdatingId(null) - } - } - const columns = useMemo(() => { const sortableHeader = ( label: string, @@ -207,7 +174,7 @@ export function WhatsappGroupsPage({ aria-busy={pending} aria-pressed={visible} aria-label={`${group.title} is ${visible ? "visible" : "hidden"}. Change visibility`} - onClick={() => void toggleVisibility(group)} + onClick={() => void toggleVisibility(group.id, group.title, group.hide)} > {pending ? : visible ? : } {visible ? "Visible" : "Hidden"} diff --git a/src/hooks/use-group-visibility-toggle.ts b/src/hooks/use-group-visibility-toggle.ts new file mode 100644 index 0000000..5ae7c08 --- /dev/null +++ b/src/hooks/use-group-visibility-toggle.ts @@ -0,0 +1,58 @@ +import { useRouter } from "@tanstack/react-router" +import { useCallback, useState } from "react" +import { toast } from "sonner" + +/** + * Shared optimistic-update/rollback/refresh flow behind every group visibility toggle (Telegram, WhatsApp, + * and the combined groups-by-label table). `mutate` performs the actual server call for a given group id; + * callers adapt their own server fn's argument shape (e.g. `telegramId` vs `id`) to it. + */ +export function useGroupVisibilityToggle( + mutate: (id: TId, hide: boolean) => Promise<{ updated: boolean }> +) { + const router = useRouter() + const [overrides, setOverrides] = useState>({}) + const [updatingId, setUpdatingId] = useState(null) + const [mutationError, setMutationError] = useState("") + const [refreshError, setRefreshError] = useState("") + + const resolveHide = useCallback((id: TId, actualHide: boolean): boolean => overrides[id] ?? actualHide, [overrides]) + + const toggleVisibility = useCallback( + async (id: TId, title: string, currentHide: boolean) => { + if (updatingId !== null) return + const hide = !currentHide + setUpdatingId(id) + setMutationError("") + setOverrides((current) => ({ ...current, [id]: hide })) + + try { + await mutate(id, hide) + toast.success(`${title} is now ${hide ? "hidden" : "visible"}.`) + try { + await router.invalidate({ sync: true }) + setRefreshError("") + setOverrides((current) => { + const { [id]: _removed, ...remaining } = current + return remaining + }) + } catch (error) { + console.error(error) + setRefreshError("The visibility was updated, but the latest group data could not be refreshed.") + } + } catch (error) { + console.error(error) + setOverrides((current) => { + const { [id]: _removed, ...remaining } = current + return remaining + }) + setMutationError("The visibility setting could not be updated. Check your permissions and try again.") + } finally { + setUpdatingId(null) + } + }, + [updatingId, mutate, router] + ) + + return { updatingId, mutationError, refreshError, resolveHide, toggleVisibility } +} From b69ead44c86b534b4f4a207be843d3c19f3fb011 Mon Sep 17 00:00:00 2001 From: Bianca Roberta Ianosel Date: Mon, 7 Sep 2026 00:43:48 +0200 Subject: [PATCH 4/7] Update redirection to encode category label in URL Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/routes/dashboard/web/tags/$tag.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/dashboard/web/tags/$tag.tsx b/src/routes/dashboard/web/tags/$tag.tsx index 8e21ff8..3b24d4a 100644 --- a/src/routes/dashboard/web/tags/$tag.tsx +++ b/src/routes/dashboard/web/tags/$tag.tsx @@ -14,7 +14,7 @@ export const Route = createFileRoute("/dashboard/web/tags/$tag")({ // A category already has a browsable page of its own — routing it here too would be a second, competing view // of the same label, reachable by hand-typing a URL. if (isCategoryLabel(params.tag)) { - const to: string = `/dashboard/web/groups-by-label/${labelPathToUrlSegments(params.tag).join("/")}` +const to: string = `/dashboard/web/groups-by-label/${labelPathToUrlSegments(params.tag).map(encodeURIComponent).join("/")}` throw redirect({ to }) } }, From 3bffe39a0c9746780b5fc7264c09c34f52b53df8 Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 7 Sep 2026 00:49:25 +0200 Subject: [PATCH 5/7] fix: update @polinetwork/backend dependency to version 0.18.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index efb4d89..f8f5976 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@base-ui/react": "^1.6.0", "@better-auth/passkey": "^1.5.5", "@dnd-kit/react": "^0.4.0", - "@polinetwork/backend": "^0.18.0", + "@polinetwork/backend": "^0.18.1", "@t3-oss/env-core": "^0.13.10", "@tanstack/react-router": "1.170.17", "@tanstack/react-start": "1.168.27", From b7a699ba89e1076b7593436cebbb0595657223a0 Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 7 Sep 2026 00:51:26 +0200 Subject: [PATCH 6/7] fix: biome --- src/features/whatsapp/whatsapp-groups-page.tsx | 2 +- src/routes/dashboard/web/tags/$tag.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/whatsapp/whatsapp-groups-page.tsx b/src/features/whatsapp/whatsapp-groups-page.tsx index bacfe9d..df2fd20 100644 --- a/src/features/whatsapp/whatsapp-groups-page.tsx +++ b/src/features/whatsapp/whatsapp-groups-page.tsx @@ -11,7 +11,7 @@ import { LoaderCircle, MessageCircleMore, Tag, - X + X, } from "lucide-react" import { useMemo, useState } from "react" import { toast } from "sonner" diff --git a/src/routes/dashboard/web/tags/$tag.tsx b/src/routes/dashboard/web/tags/$tag.tsx index 3b24d4a..fffc494 100644 --- a/src/routes/dashboard/web/tags/$tag.tsx +++ b/src/routes/dashboard/web/tags/$tag.tsx @@ -14,7 +14,7 @@ export const Route = createFileRoute("/dashboard/web/tags/$tag")({ // A category already has a browsable page of its own — routing it here too would be a second, competing view // of the same label, reachable by hand-typing a URL. if (isCategoryLabel(params.tag)) { -const to: string = `/dashboard/web/groups-by-label/${labelPathToUrlSegments(params.tag).map(encodeURIComponent).join("/")}` + const to: string = `/dashboard/web/groups-by-label/${labelPathToUrlSegments(params.tag).map(encodeURIComponent).join("/")}` throw redirect({ to }) } }, From 2fd07b7ed9988c9275c7f4e399b5bdb0d02c04ad Mon Sep 17 00:00:00 2001 From: Bianca Date: Mon, 7 Sep 2026 00:55:42 +0200 Subject: [PATCH 7/7] fix: update @polinetwork/backend dependency to version 0.18.1 --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 327c900..1f7c9cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: ^0.4.0 version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@polinetwork/backend': - specifier: ^0.18.0 - version: 0.18.0 + specifier: ^0.18.1 + version: 0.18.1 '@t3-oss/env-core': specifier: ^0.13.10 version: 0.13.11(typescript@6.0.3)(zod@4.3.5) @@ -1278,8 +1278,8 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} - '@polinetwork/backend@0.18.0': - resolution: {integrity: sha512-B/4JNI9RB0bsPXDFHm0jkCLDEzqoydvZOz/G7bsR/9qti5cpFIUNaIbGPIRIjDGmVB5bO3DFVU6xM3iLc5eQ9A==} + '@polinetwork/backend@0.18.1': + resolution: {integrity: sha512-JMH+twKn7WvjX0y7VK5u5FgBLRa1Z651nlR3BRZW/P8xMlNAgLhuin5wP8UWZ6++QdzvMui9XnXEsojuCxt8wg==} '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -6363,7 +6363,7 @@ snapshots: '@pkgr/core@0.3.6': {} - '@polinetwork/backend@0.18.0': {} + '@polinetwork/backend@0.18.1': {} '@polka/url@1.0.0-next.29': {}