ubiquitous-invention/apps/web/components/views/config/sort-config.tsx
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

198 lines
7.4 KiB
TypeScript

"use client";
import * as Popover from "@radix-ui/react-popover";
import * as Select from "@radix-ui/react-select";
import { ArrowDown, ArrowDownAZ, ArrowUpAZ, Plus, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { ViewSort } from "@/lib/hooks/use-view-data";
import { useViewStore } from "@/lib/stores/view-store";
import { cn } from "@/lib/utils";
const SORT_FIELDS = [
{ value: "title", label: "Title" },
{ value: "status", label: "Status" },
{ value: "sortOrder", label: "Order" },
{ value: "createdAt", label: "Created" },
{ value: "updatedAt", label: "Updated" },
] as const;
const selectTriggerClass = cn(
"flex h-8 min-w-[5.5rem] shrink-0 items-center justify-between gap-1 rounded-md border border-input bg-background px-2 text-xs",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
);
const selectContentClass = cn(
"z-[60] max-h-[min(60vh,20rem)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
);
const selectItemClass =
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
function SortSelect({
value,
options,
onChange,
placeholder,
className,
}: {
value: string;
options: { value: string; label: string }[];
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
return (
<Select.Root value={value} onValueChange={onChange}>
<Select.Trigger className={cn(selectTriggerClass, className)} aria-label={placeholder}>
<Select.Value placeholder={placeholder} />
<Select.Icon className="opacity-50">
<svg width="12" height="12" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content position="popper" className={selectContentClass} sideOffset={4}>
<Select.Viewport className="p-1">
{options.map((o) => (
<Select.Item key={o.value} value={o.value} className={selectItemClass}>
<Select.ItemText>{o.label}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
);
}
function defaultSort(): ViewSort {
return { field: "sortOrder", direction: "asc" };
}
function SortRow({
sort,
index,
onChange,
onRemove,
}: {
sort: ViewSort;
index: number;
onChange: (next: ViewSort) => void;
onRemove: () => void;
}) {
const field = SORT_FIELDS.some((f) => f.value === sort.field) ? sort.field : "sortOrder";
const direction = sort.direction === "desc" ? "desc" : "asc";
return (
<div className="flex flex-col gap-2 rounded-md border border-border/80 bg-muted/30 p-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
<SortSelect
value={field}
onChange={(v) => onChange({ ...sort, field: v })}
options={SORT_FIELDS.map((f) => ({ value: f.value, label: f.label }))}
placeholder="Field"
className="min-w-[7rem] flex-1"
/>
<div className="flex items-center gap-0.5 rounded-md border border-input bg-background p-0.5">
<Button
type="button"
variant={direction === "asc" ? "secondary" : "ghost"}
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => onChange({ ...sort, direction: "asc" })}
aria-pressed={direction === "asc"}
aria-label={`Sort ${index + 1} ascending`}
>
<ArrowUpAZ className="size-3.5" />
Asc
</Button>
<Button
type="button"
variant={direction === "desc" ? "secondary" : "ghost"}
size="sm"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => onChange({ ...sort, direction: "desc" })}
aria-pressed={direction === "desc"}
aria-label={`Sort ${index + 1} descending`}
>
<ArrowDownAZ className="size-3.5" />
Desc
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 self-end sm:self-center"
onClick={onRemove}
aria-label={`Remove sort ${index + 1}`}
>
<X className="size-4" />
</Button>
</div>
);
}
export function SortConfig() {
const sorts = useViewStore((s) => s.config.sorts);
const addSort = useViewStore((s) => s.addSort);
const removeSort = useViewStore((s) => s.removeSort);
const updateSort = useViewStore((s) => s.updateSort);
return (
<Popover.Root>
<Popover.Trigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs">
<ArrowDown className="size-3.5" />
Sort
{sorts.length > 0 ? (
<span className="rounded-full bg-muted px-1.5 text-[10px] font-semibold text-muted-foreground">
{sorts.length}
</span>
) : null}
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-[60] w-[min(calc(100vw-2rem),22rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
sideOffset={6}
align="start"
>
<p className="mb-2 text-xs font-semibold">Sort by</p>
<div className="flex max-h-[min(50vh,18rem)] flex-col gap-2 overflow-y-auto pr-0.5">
{sorts.map((s, i) => (
<SortRow
key={i}
sort={s}
index={i}
onChange={(next) => updateSort(i, next)}
onRemove={() => removeSort(i)}
/>
))}
</div>
<Separator className="my-3" />
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start gap-2 text-xs"
onClick={() => addSort(defaultSort())}
>
<Plus className="size-3.5" />
Add sort
</Button>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}