feat: add boards and tasks management endpoints
This commit is contained in:
17
frontend/src/components/atoms/BrandMark.tsx
Normal file
17
frontend/src/components/atoms/BrandMark.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function BrandMark() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg border-2 border-gray-200 bg-white text-sm font-bold text-gray-900 shadow-lush">
|
||||
<span className="font-heading tracking-[0.2em]">
|
||||
OC
|
||||
</span>
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="font-heading text-sm uppercase tracking-[0.28em] text-gray-600">
|
||||
OpenClaw
|
||||
</div>
|
||||
<div className="text-[11px] font-medium text-gray-500">Mission Control</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/components/atoms/HeroKicker.tsx
Normal file
9
frontend/src/components/atoms/HeroKicker.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function HeroKicker({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-4 py-1 text-[11px] font-semibold uppercase tracking-[0.35em] text-gray-600">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/atoms/StatusPill.tsx
Normal file
21
frontend/src/components/atoms/StatusPill.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const STATUS_STYLES: Record<string, "default" | "outline" | "ember"> = {
|
||||
inbox: "outline",
|
||||
assigned: "default",
|
||||
in_progress: "ember",
|
||||
testing: "outline",
|
||||
review: "default",
|
||||
done: "default",
|
||||
online: "default",
|
||||
busy: "ember",
|
||||
offline: "outline",
|
||||
};
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge variant={STATUS_STYLES[status] ?? "default"}>
|
||||
{status.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/molecules/HeroCopy.tsx
Normal file
20
frontend/src/components/molecules/HeroCopy.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { HeroKicker } from "@/components/atoms/HeroKicker";
|
||||
|
||||
export function HeroCopy() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<HeroKicker>Mission Control</HeroKicker>
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-heading text-4xl font-bold leading-tight text-gray-900 sm:text-5xl lg:text-6xl">
|
||||
Orchestrate work without
|
||||
<br />
|
||||
the daily status chase.
|
||||
</h1>
|
||||
<p className="max-w-xl text-base text-gray-600 sm:text-lg">
|
||||
OpenClaw keeps every task, agent, and delivery signal in one place so
|
||||
teams can spot momentum shifts fast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/molecules/TaskCard.tsx
Normal file
38
frontend/src/components/molecules/TaskCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { CalendarClock, UserCircle } from "lucide-react";
|
||||
|
||||
import { StatusPill } from "@/components/atoms/StatusPill";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface TaskCardProps {
|
||||
title: string;
|
||||
status: string;
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
}
|
||||
|
||||
export function TaskCard({ title, status, assignee, due }: TaskCardProps) {
|
||||
return (
|
||||
<Card className="border-gray-200 bg-white">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-gray-900">{title}</p>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCircle className="h-4 w-4" />
|
||||
<span>{assignee ?? "Unassigned"}</span>
|
||||
</div>
|
||||
{due ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
<span>{due}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/organisms/DashboardSidebar.tsx
Normal file
42
frontend/src/components/organisms/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-6 shadow-lush">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
|
||||
Work
|
||||
</p>
|
||||
<nav className="space-y-1 text-sm">
|
||||
<Link
|
||||
href="/agents"
|
||||
className={cn(
|
||||
"block rounded-lg border border-transparent px-3 py-2 font-medium text-gray-700 hover:border-gray-200 hover:bg-gray-50",
|
||||
pathname.startsWith("/agents") &&
|
||||
"border-gray-200 bg-gray-50 text-gray-900"
|
||||
)}
|
||||
>
|
||||
Agents
|
||||
</Link>
|
||||
<Link
|
||||
href="/boards"
|
||||
className={cn(
|
||||
"block rounded-lg border border-transparent px-3 py-2 font-medium text-gray-700 hover:border-gray-200 hover:bg-gray-50",
|
||||
pathname.startsWith("/boards") &&
|
||||
"border-gray-200 bg-gray-50 text-gray-900"
|
||||
)}
|
||||
>
|
||||
Boards
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/organisms/LandingHero.tsx
Normal file
88
frontend/src/components/organisms/LandingHero.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
|
||||
|
||||
import { HeroCopy } from "@/components/molecules/HeroCopy";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LandingHero() {
|
||||
return (
|
||||
<section className="grid w-full items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div
|
||||
className="space-y-8 animate-fade-in-up"
|
||||
style={{ animationDelay: "0.05s" }}
|
||||
>
|
||||
<HeroCopy />
|
||||
<div
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center animate-fade-in-up"
|
||||
style={{ animationDelay: "0.12s" }}
|
||||
>
|
||||
<SignedOut>
|
||||
<SignInButton
|
||||
mode="modal"
|
||||
afterSignInUrl="/boards"
|
||||
afterSignUpUrl="/boards"
|
||||
forceRedirectUrl="/boards"
|
||||
signUpForceRedirectUrl="/boards"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto border-2 border-gray-900 bg-gray-900 text-white hover:bg-gray-900/90"
|
||||
>
|
||||
Sign in to open mission control
|
||||
</Button>
|
||||
</SignInButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<div className="text-sm text-gray-600">
|
||||
You're signed in. Open your boards when you're ready.
|
||||
</div>
|
||||
</SignedIn>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs uppercase tracking-[0.3em] text-gray-500 animate-fade-in-up"
|
||||
style={{ animationDelay: "0.18s" }}
|
||||
>
|
||||
One login · clear ownership · faster decisions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative animate-fade-in-up"
|
||||
style={{ animationDelay: "0.3s" }}
|
||||
>
|
||||
<div className="glass-panel rounded-2xl bg-white p-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
|
||||
<span>Status</span>
|
||||
<span className="rounded-full border border-gray-200 px-2 py-1 text-[10px]">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
Tasks claimed automatically
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Agents pick the next task in queue, report progress, and ship
|
||||
deliverables back to you.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{["Assignments", "In review", "Delivered", "Signals"].map(
|
||||
(label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="rounded-xl border-2 border-gray-200 bg-white p-4 text-sm font-semibold text-gray-900 soft-shadow-sm"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/organisms/TaskBoard.tsx
Normal file
99
frontend/src/components/organisms/TaskBoard.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { TaskCard } from "@/components/molecules/TaskCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
due_at?: string | null;
|
||||
};
|
||||
|
||||
type TaskBoardProps = {
|
||||
tasks: Task[];
|
||||
onCreateTask: () => void;
|
||||
isCreateDisabled?: boolean;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "Inbox", status: "inbox" },
|
||||
{ title: "Assigned", status: "assigned" },
|
||||
{ title: "In Progress", status: "in_progress" },
|
||||
{ title: "Testing", status: "testing" },
|
||||
{ title: "Review", status: "review" },
|
||||
{ title: "Done", status: "done" },
|
||||
];
|
||||
|
||||
const formatDueDate = (value?: string | null) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
export function TaskBoard({
|
||||
tasks,
|
||||
onCreateTask,
|
||||
isCreateDisabled = false,
|
||||
}: TaskBoardProps) {
|
||||
const grouped = useMemo(() => {
|
||||
const buckets: Record<string, Task[]> = {};
|
||||
for (const column of columns) {
|
||||
buckets[column.status] = [];
|
||||
}
|
||||
tasks.forEach((task) => {
|
||||
const bucket = buckets[task.status] ?? buckets.inbox;
|
||||
bucket.push(task);
|
||||
});
|
||||
return buckets;
|
||||
}, [tasks]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-6 overflow-x-auto pb-2">
|
||||
{columns.map((column) => {
|
||||
const columnTasks = grouped[column.status] ?? [];
|
||||
return (
|
||||
<div key={column.title} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{column.title}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500">
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{column.status === "inbox" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateTask}
|
||||
disabled={isCreateDisabled}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 px-4 py-6 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500 transition hover:border-gray-300 hover:bg-white",
|
||||
isCreateDisabled && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
New task
|
||||
</button>
|
||||
) : null}
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
title={task.title}
|
||||
status={column.status}
|
||||
due={formatDueDate(task.due_at)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/templates/DashboardShell.tsx
Normal file
31
frontend/src/components/templates/DashboardShell.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||
|
||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||
|
||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative min-h-screen bg-white text-gray-900">
|
||||
<div
|
||||
className="absolute inset-0 bg-landing-grid opacity-[0.35] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative flex min-h-screen w-full flex-col gap-8 px-6 pb-10 pt-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<BrandMark />
|
||||
<SignedIn>
|
||||
<div className="rounded-lg border-2 border-gray-200 bg-white px-2 py-1">
|
||||
<UserButton />
|
||||
</div>
|
||||
</SignedIn>
|
||||
</header>
|
||||
<div className="grid flex-1 gap-6 lg:grid-cols-[320px_1fr]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/templates/LandingShell.tsx
Normal file
40
frontend/src/components/templates/LandingShell.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||
|
||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||
|
||||
export function LandingShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="landing-page bg-white text-gray-900">
|
||||
<section className="relative overflow-hidden pt-24 pb-16 px-4 sm:px-6 lg:px-8">
|
||||
<div
|
||||
className="absolute inset-0 bg-landing-grid opacity-[0.35] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-28 right-0 h-64 w-64 rounded-full bg-gray-100 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-32 left-0 h-72 w-72 rounded-full bg-gray-100 blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative w-full">
|
||||
<header className="flex items-center justify-between pb-12">
|
||||
<BrandMark />
|
||||
<SignedIn>
|
||||
<div className="rounded-lg border-2 border-gray-200 bg-white px-2 py-1">
|
||||
<UserButton />
|
||||
</div>
|
||||
</SignedIn>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
"inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "text-foreground",
|
||||
default: "bg-gray-100 text-gray-800",
|
||||
outline: "border border-gray-300 text-gray-800",
|
||||
ember: "bg-gray-900 text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
primary:
|
||||
"border-2 border-gray-900 bg-gray-900 text-white hover:bg-gray-900/90",
|
||||
secondary:
|
||||
"border-2 border-gray-200 bg-white text-gray-900 hover:border-gray-900 hover:bg-gray-900 hover:text-white",
|
||||
ghost: "bg-transparent text-gray-900 hover:bg-black/5",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
sm: "h-9 px-4",
|
||||
md: "h-11 px-5",
|
||||
lg: "h-12 px-6 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: "primary",
|
||||
size: "md",
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -34,15 +34,13 @@ export interface ButtonProps
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
||||
@@ -6,46 +6,30 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
|
||||
className={cn(
|
||||
"rounded-xl border-2 border-gray-200 bg-white soft-shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("px-6 pt-6", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("px-6 pb-6", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export { Card, CardHeader, CardContent };
|
||||
|
||||
102
frontend/src/components/ui/dialog.tsx
Normal file
102
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 w-[90vw] max-w-2xl translate-x-[-50%] translate-y-[-50%] rounded-2xl border-2 border-gray-200 bg-white p-6 shadow-lush focus:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center justify-end gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-gray-900", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-gray-600", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -2,22 +2,18 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-lg border-2 border-gray-200 bg-white px-4 text-sm text-gray-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
||||
@@ -1,23 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<select
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-11 w-full cursor-pointer items-center justify-between rounded-lg border-2 border-gray-200 bg-white px-4 text-sm text-gray-900 shadow-sm focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = "SelectTrigger";
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-pointer items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = "SelectScrollUpButton";
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-pointer items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = "SelectScrollDownButton";
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border-2 border-gray-200 bg-white shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = "SelectContent";
|
||||
|
||||
export { Select };
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = "SelectLabel";
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-gray-800 outline-none focus:bg-black/5 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = "SelectItem";
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-gray-200", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = "SelectSeparator";
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
|
||||
52
frontend/src/components/ui/tabs.tsx
Normal file
52
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border-2 border-gray-200 bg-white p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-2 text-xs font-semibold text-gray-600 transition data-[state=active]:bg-gray-900 data-[state=active]:text-white",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn("mt-4 focus-visible:outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -2,22 +2,19 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-[120px] w-full rounded-lg border-2 border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
|
||||
28
frontend/src/components/ui/tooltip.tsx
Normal file
28
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"rounded-lg bg-gray-900 px-3 py-2 text-xs font-semibold text-white soft-shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TooltipContent.displayName = "TooltipContent";
|
||||
|
||||
export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };
|
||||
Reference in New Issue
Block a user