+ 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 ? (
@@ -107,6 +114,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)}
/>
@@ -126,6 +134,47 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou
)}
+
+
+
+
Publications
+
+ Temporary batches of groups to publish together. Only their release labels are cleared.
+
+
+
setAddPublicationOpen(true)}>
+ Create publication
+
+
+ {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 406bf00..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"
@@ -114,19 +115,27 @@ 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])
- 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
@@ -141,7 +150,7 @@ export function LabelTreeSelector({
return (
setQuery(event.target.value)}
className="h-9"
@@ -170,68 +179,83 @@ export function LabelTreeSelector({
)}
- {isSearching
- ? matchingCategories.length > 0 && (
-
+ {!tagsOnly &&
+ (isSearching
+ ? matchingCategories.length > 0 && (
+
+
+ Categories
+
+ {matchingCategories.map((label) => (
+
+ ))}
+
+ )
+ : tree.length > 0 && (
+
+
+ Categories
+
+ {tree.map((node) => (
+
+ ))}
+
+ ))}
+ {[
+ { title: "Attributes", labels: visibleTags },
+ { title: "Publications", labels: visibleReleases },
+ ].map(
+ (section) =>
+ section.labels.length > 0 && (
+
- Categories
+ {section.title}
- {matchingCategories.map((label) => (
-
- ))}
+
+ {section.labels.map((label) => {
+ const checked = isSelected(label)
+ const swatch = getGroupLabelColor(label.color)
+ return (
+ onToggleMany([label], !checked)}
+ className={cn(
+ "flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium",
+ checked ? swatch.badgeClassName : "border-border bg-transparent text-muted-foreground"
+ )}
+ style={checked ? swatch.badgeStyle : undefined}
+ >
+
+ {formatLabelSegment(label.label)}
+
+ )
+ })}
+
)
- : tree.length > 0 && (
-
-
- Categories
-
- {tree.map((node) => (
-
- ))}
-
- )}
- {visibleTags.length > 0 && (
-
-
Tags
-
- {visibleTags.map((label) => {
- const checked = isSelected(label)
- const swatch = getGroupLabelColor(label.color)
- return (
- onToggleMany([label], !checked)}
- className={cn(
- "flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium",
- checked ? swatch.badgeClassName : "border-border bg-transparent text-muted-foreground"
- )}
- style={checked ? swatch.badgeStyle : undefined}
- >
-
- {formatLabelSegment(label.label)}
-
- )
- })}
-
-
- )}
- {(isSearching ? !matchingCategories.length : !tree.length) && !visibleTags.length && (
-
No matching labels
)}
+ {(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 79b41aa..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
@@ -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
-
+
+
+
Attributes and publications
+
+
{error && {error}
}
@@ -270,15 +316,17 @@ export function AddGroupToLabelDialog({
{step === "existing" && (
-
(platform ? setPlatform(null) : setStep("choose"))}
- >
- Back
-
+ {(allowCreate || platform) && (
+
(platform ? setPlatform(null) : setStep("choose"))}
+ >
+ Back
+
+ )}
{!platform ? (
diff --git a/src/features/groups-by-label/combined-groups-table.tsx b/src/features/groups-by-label/combined-groups-table.tsx
index 99553f6..e0dd048 100644
--- a/src/features/groups-by-label/combined-groups-table.tsx
+++ b/src/features/groups-by-label/combined-groups-table.tsx
@@ -27,6 +27,8 @@ 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 { 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"
@@ -58,52 +60,36 @@ export function CombinedGroupsTable({
}) {
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 setWaGroupVisibilityFn = useServerFn(setWhatsappGroupVisibility)
+ 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
+ ? { ...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)
- }
- }
-
const columns = useMemo(() => {
const sortableHeader = (
label: string,
@@ -184,8 +170,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">
+ void toggleWaVisibility(group.id, group.title, group.hide)}
+ >
+ {pending ? : visible ? : }
+ {visible ? "Visible" : "Hidden"}
+
@@ -205,7 +209,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 ? : }
@@ -215,7 +219,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) {
+ 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. Always establish visibility: another tab may have hidden a group
+ // since these rows were loaded.
+ if (row.platform === "telegram") {
+ await setGroupVisibilityFn({ data: { telegramId: row.group.telegramId, hide: false } })
+ await untagGroupFn({ data: { groupId: row.group.telegramId, type: "tg", label: tag } })
+ } else {
+ 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.`
+ )
+ 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."))
+ } 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.`
+ : `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}
}
+
+ 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..a3ccc37
--- /dev/null
+++ b/src/features/groups-by-label/tag-groups-page.tsx
@@ -0,0 +1,100 @@
+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, 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"
+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 publication = isReleaseLabel(tag)
+ const title = formatLabelSegment(tag)
+ const backUrl: string = "/dashboard/web/group-labels"
+
+ return (
+
+
}
+ nativeButton={false}
+ className="-ml-2 mb-2 w-fit gap-1 text-muted-foreground"
+ >
+
Back to Group labels
+
+
+ {/* Publishing acts on the whole tag, not on what the search box currently shows. */}
+ {publication && }
+
+
+ }
+ />
+
+
+
+ )
+}
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/telegram/groups-table.tsx b/src/features/telegram/groups-table.tsx
index a78ba00..cd70807 100644
--- a/src/features/telegram/groups-table.tsx
+++ b/src/features/telegram/groups-table.tsx
@@ -26,6 +26,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"
@@ -50,50 +51,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,
@@ -188,7 +155,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 ? : }
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({