Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions src/components/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-transparent shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}

export { Checkbox }
54 changes: 40 additions & 14 deletions src/features/group-labels/add-tag-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand All @@ -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 })
Expand All @@ -69,10 +87,11 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Add tag</DialogTitle>
<DialogTitle>{publication ? "Create publication" : "Add tag"}</DialogTitle>
<DialogDescription>
A flat attribute, like a language or campus. Tags aren&apos;t nested — for a browsable category, use
&quot;Add category&quot; 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."}
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-3" onSubmit={(event) => void submit(event)}>
Expand All @@ -82,15 +101,22 @@ 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
/>
</Field>
{!isValidLabelSegment(trimmed) && trimmed && (
<p className="text-xs text-destructive">Use a plain name, without dots or URL separators.</p>
)}
{reservedRelease && (
<p className="text-xs text-destructive">
{publication
? "Enter the name without the release- prefix."
: "The release- prefix is reserved. Use Create publication instead."}
</p>
)}
{reserved && <p className="text-xs text-destructive">This name is reserved for a category.</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
Expand All @@ -107,7 +133,7 @@ export function AddTagDialog({ open, onOpenChange }: { open: boolean; onOpenChan
</Button>
<Button type="submit" disabled={pending || !canSave}>
{pending && <LoaderCircle data-icon="inline-start" className="animate-spin-slow" />}
Add tag
{publication ? "Create publication" : "Add tag"}
</Button>
</DialogFooter>
</form>
Expand Down
30 changes: 23 additions & 7 deletions src/features/group-labels/group-label-card.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<boolean>
onSave: (values: GroupLabelEditValues) => Promise<boolean>
}
Expand All @@ -52,6 +55,7 @@ export function GroupLabelCard({
leading,
allowRename = true,
allowChildren = true,
linkTo,
onDelete,
onSave,
}: GroupLabelCardProps) {
Expand Down Expand Up @@ -131,13 +135,25 @@ export function GroupLabelCard({
</>
) : (
<>
<Badge
className={cn("h-auto max-w-[min(50%,24rem)] min-w-0 shrink py-1 text-sm", swatch.badgeClassName)}
style={swatch.badgeStyle}
title={groupLabel.label}
>
<span className="truncate">{displaySegment}</span>
</Badge>
{linkTo ? (
<Link
to={linkTo}
title={`View the groups tagged "${groupLabel.label}"`}
className="flex max-w-[min(50%,24rem)] min-w-0 shrink rounded-md hover:opacity-80 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
>
<Badge className={cn("h-auto min-w-0 py-1 text-sm", swatch.badgeClassName)} style={swatch.badgeStyle}>
<span className="truncate">{displaySegment}</span>
</Badge>
</Link>
) : (
<Badge
className={cn("h-auto max-w-[min(50%,24rem)] min-w-0 shrink py-1 text-sm", swatch.badgeClassName)}
style={swatch.badgeStyle}
title={groupLabel.label}
>
<span className="truncate">{displaySegment}</span>
</Badge>
)}
<p className="min-w-0 flex-1 truncate text-sm text-foreground/85">
{groupLabel.description || <span className="text-muted-foreground italic">No description</span>}
</p>
Expand Down
65 changes: 57 additions & 8 deletions src/features/group-labels/group-labels-page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -14,6 +14,7 @@ import {
filterFlatLabels,
filterLabelTree,
isCategoryLabel,
hasReleaseLabelPrefix,
type LabelTreeNode,
} from "./label-tree"
import type { GroupLabel } from "./types"
Expand All @@ -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 (
<div className="animate-appear">
<DataToolbar
eyebrow="Web"
title="Group labels"
description="Manage the categories and tags used to organize groups on the PoliNetwork website."
description="Manage permanent categories and attributes separately from publication batches."
count={matchCount}
total={labels.length}
searchPlaceholder="Search categories and tags…"
searchPlaceholder="Search categories, attributes and publications…"
onSearch={setQuery}
action={
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -94,10 +101,10 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou
)}
</section>

<section>
<h2 className="mb-1 text-sm font-semibold text-foreground/85">Tags</h2>
<section className="mb-6">
<h2 className="mb-1 text-sm font-semibold text-foreground/85">Attributes</h2>
<p className="mb-3 text-xs text-muted-foreground">
Flat attributes, like a language or campus, that don&apos;t belong to the category hierarchy.
Permanent tags, like a language or campus. They are preserved when groups are published.
</p>
{filteredTags.length ? (
<div className="flex flex-col gap-2">
Expand All @@ -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)}
/>
Expand All @@ -126,6 +134,47 @@ export function GroupLabelsPage({ loadedGroupLabels }: { loadedGroupLabels: Grou
)}
</section>

<section aria-label="Publications">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="mb-1 text-sm font-semibold text-foreground/85">Publications</h2>
<p className="text-xs text-muted-foreground">
Temporary batches of groups to publish together. Only their release labels are cleared.
</p>
</div>
<Button variant="outline" onClick={() => setAddPublicationOpen(true)}>
<Megaphone data-icon="inline-start" /> Create publication
</Button>
</div>
{filteredReleases.length ? (
<div className="flex flex-col gap-2">
{filteredReleases.map((label) => (
<GroupLabelCard
key={label.label}
groupLabel={label}
allLabels={labels}
allowChildren={false}
allowRename={false}
linkTo={`/dashboard/web/tags/${encodeURIComponent(label.label)}`}
onDelete={() => removeGroupLabel(label)}
onSave={(values) => saveGroupLabel(label, values)}
/>
))}
</div>
) : (
<EmptyState
icon={Megaphone}
title={releaseLabels.length ? "No publications match this search" : "No publications yet"}
text={
releaseLabels.length
? "Try a different name or description."
: "Create a publication, add existing groups, then publish the batch."
}
/>
)}
</section>

<AddTagDialog open={addPublicationOpen} onOpenChange={setAddPublicationOpen} publication />
<AddCategoryDialog open={addCategoryOpen} onOpenChange={setAddCategoryOpen} />
<AddTagDialog open={addTagOpen} onOpenChange={setAddTagOpen} />
</div>
Expand Down
13 changes: 13 additions & 0 deletions src/features/group-labels/group-labels.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { webAdminMiddleware, webWriteAdminMiddleware } from "@/server/auth.middl

import {
createGroupLabelInput,
createReleaseLabelInput,
editGroupLabelInput,
groupLabelIdentifierInput,
renameGroupLabelInput,
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading