Skip to content
Open
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: 2 additions & 0 deletions src/components/dashboard-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type LucideIcon,
Settings,
ShieldCheck,
Tags,
Users,
UsersRound,
} from "lucide-react"
Expand Down Expand Up @@ -63,6 +64,7 @@ export const dashboardNavigation = [
{ title: "Associations", url: "/dashboard/web/associations", icon: Users },
{ title: "Guides", url: "/dashboard/web/guides", icon: BookOpen },
{ title: "FAQs", url: "/dashboard/web/faqs", icon: CircleQuestionMark },
{ title: "Group labels", url: "/dashboard/web/group-labels", icon: Tags },
],
},
] as const satisfies readonly DashboardNavigationCategory[]
Expand Down
81 changes: 80 additions & 1 deletion src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,4 +109,83 @@ function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
)
}

export { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList }
const ComboboxValue = ComboboxPrimitive.Value

function ComboboxChipsGroup({ className, ...props }: ComboboxPrimitive.InputGroup.Props) {
return (
<ComboboxPrimitive.InputGroup
data-slot="combobox-chips-group"
className={cn(
"flex min-h-8 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1.5 py-1 transition-colors outline-none focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-disabled:bg-input/50 has-disabled:opacity-50 dark:bg-input/30",
className
)}
{...props}
/>
)
}

function ComboboxChips({ className, ...props }: ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn("flex w-full flex-wrap items-center gap-1", className)}
{...props}
/>
)
}

function ComboboxChip({ className, ...props }: ComboboxPrimitive.Chip.Props) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"group/combobox-chip flex h-6 shrink-0 cursor-default items-center gap-1 rounded-md bg-accent py-0 pr-1 pl-2 text-xs font-medium text-accent-foreground outline-hidden data-highlighted:bg-primary data-highlighted:text-primary-foreground",
className
)}
{...props}
/>
)
}

function ComboboxChipRemove({ className, ...props }: ComboboxPrimitive.ChipRemove.Props) {
return (
<ComboboxPrimitive.ChipRemove
data-slot="combobox-chip-remove"
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-sm text-inherit hover:bg-foreground/10 group-data-highlighted/combobox-chip:hover:bg-primary-foreground/20",
className
)}
{...props}
>
<XIcon className="pointer-events-none size-3" />
</ComboboxPrimitive.ChipRemove>
)
}

function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chips-input"
className={cn(
"h-6 min-w-16 flex-1 border-0 bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground",
className
)}
{...props}
/>
)
}

export {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipRemove,
ComboboxChipsGroup,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxValue,
}
225 changes: 225 additions & 0 deletions src/features/group-labels/group-label-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { CircleDashed, LoaderCircle, Pencil, Save, Trash2, X } from "lucide-react"
import { useState } from "react"

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"

import { GroupLabelColorPicker } from "./group-label-color-picker"
import { GROUP_LABEL_DESCRIPTION_MAX, GROUP_LABEL_MAX, getGroupLabelColor } from "./group-labels.constants"
import type { GroupLabel, GroupLabelFormValues } from "./types"

type GroupLabelCardProps = {
groupLabel: GroupLabel
draft: boolean
initialEditActive: boolean
onCancelDraft: () => void
onDelete: () => Promise<boolean>
onSave: (values: GroupLabelFormValues) => Promise<boolean>
}

export function GroupLabelCard({
groupLabel,
draft,
initialEditActive,
onCancelDraft,
onDelete,
onSave,
}: GroupLabelCardProps) {
const [label, setLabel] = useState(groupLabel.label)
const [editing, setEditing] = useState(initialEditActive)
const [color, setColor] = useState(groupLabel.color)
const [description, setDescription] = useState(groupLabel.description ?? "")
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const canSave = Boolean(label.trim())

function resetFields() {
setLabel(groupLabel.label)
setColor(groupLabel.color)
setDescription(groupLabel.description ?? "")
}

function cancelEdit() {
if (draft) {
onCancelDraft()
return
}
resetFields()
setEditing(false)
}

async function save() {
if (saving || !canSave) return
setSaving(true)
try {
const saved = await onSave({ label: label.trim(), color, description: description.trim() })
if (saved) setEditing(false)
} finally {
setSaving(false)
}
}

async function remove() {
setDeleting(true)
try {
if (await onDelete()) setDeleteOpen(false)
} finally {
setDeleting(false)
}
}

const swatch = getGroupLabelColor(groupLabel.color)

return (
<>
<Card
className={cn("h-full", draft && "border-dashed border-primary/60 bg-primary/[0.035] ring-1 ring-primary/10")}
>
<CardHeader
className={cn(
"grid-cols-[1fr_auto] gap-x-4",
editing && "border-b pb-(--card-spacing)",
draft && "border-primary/15"
)}
>
<CardTitle className="flex min-w-0 items-start gap-3 text-lg">
{editing ? (
<div className="min-w-0 flex-1 space-y-2">
{draft && (
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
<CircleDashed data-icon="inline-start" /> Unsaved draft
</Badge>
)}
<div className="flex items-center gap-2">
<GroupLabelColorPicker value={color} onChange={setColor} />
{draft ? (
<Input
aria-label="Label"
value={label}
onChange={(event) => setLabel(event.target.value)}
className="bg-background text-base font-medium"
maxLength={GROUP_LABEL_MAX}
required
autoFocus
/>
) : (
<span className="text-base font-medium" title="The label name can't be changed after creation.">
{label}
</span>
)}
</div>
</div>
) : (
<Badge className={cn("h-auto py-1 text-sm", swatch.badgeClassName)} style={swatch.badgeStyle}>
{groupLabel.label}
</Badge>
)}
</CardTitle>
<CardAction className="flex items-center gap-1.5">
{editing ? (
<>
<Button
type="button"
size="icon-sm"
disabled={saving || !canSave}
onClick={() => void save()}
aria-label={`Save ${label}`}
>
{saving ? <LoaderCircle className="animate-spin-slow" /> : <Save />}
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={saving}
onClick={cancelEdit}
aria-label="Cancel editing"
>
<X />
</Button>
</>
) : (
<>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label={`Edit ${groupLabel.label}`}
onClick={() => {
resetFields()
setEditing(true)
}}
>
<Pencil />
</Button>
<Button
type="button"
variant="destructive"
size="icon-sm"
aria-label={`Delete ${groupLabel.label}`}
onClick={() => setDeleteOpen(true)}
>
<Trash2 />
</Button>
</>
)}
</CardAction>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
{editing ? (
<Textarea
aria-label="Description"
value={description}
onChange={(event) => setDescription(event.target.value)}
className="min-h-24 bg-background"
maxLength={GROUP_LABEL_DESCRIPTION_MAX}
placeholder="What kind of groups does this label apply to?"
/>
) : (
<p className="text-sm leading-6 text-foreground/85">
{groupLabel.description || <span className="text-muted-foreground italic">No description</span>}
</p>
)}
</CardContent>
</Card>

<AlertDialog open={deleteOpen} onOpenChange={(open) => !deleting && setDeleteOpen(open)}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20">
<Trash2 />
</AlertDialogMedia>
<AlertDialogTitle>Delete label</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{groupLabel.label}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting} onClick={() => setDeleteOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={deleting} onClick={() => void remove()}>
{deleting && <LoaderCircle data-icon="inline-start" className="animate-spin-slow" />} Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
36 changes: 36 additions & 0 deletions src/features/group-labels/group-label-color-picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"

import { getGroupLabelColor, GROUP_LABEL_COLORS } from "./group-labels.constants"

export function GroupLabelColorPicker({ value, onChange }: { value: string; onChange: (color: string) => void }) {
const swatch = getGroupLabelColor(value)

return (
<Select value={value} onValueChange={(val) => val && onChange(val)}>
<SelectTrigger aria-label="Label color" className="w-auto shrink-0 px-2">
<SelectValue>
<span
className={cn("size-3.5 rounded-full", swatch.dotClassName)}
style={swatch.dotStyle}
title={swatch.label}
/>
</SelectValue>
</SelectTrigger>
<SelectContent className="w-auto p-2" align="start">
<SelectGroup className="grid grid-cols-5 gap-1 p-1">
{GROUP_LABEL_COLORS.map((color) => (
<SelectItem
key={color.hex}
value={color.hex}
title={color.label}
className="flex size-8 items-center justify-center rounded-md p-0 pr-0 cursor-pointer hover:bg-accent focus:bg-accent data-selected:bg-accent data-selected:ring-2 data-selected:ring-primary/60 [&>span:last-child]:hidden"
>
<span className={cn("size-4 rounded-full", color.dot)} />
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
Loading
Loading