diff --git a/app/globals.css b/app/globals.css index 46745cc..76aab3c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,9 +1,15 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@custom-variant dark (&:is(.dark *)); + :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); @@ -12,14 +18,25 @@ --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); + --color-1: oklch(66.2% 0.225 25.9); + --color-2: oklch(60.4% 0.26 302); + --color-3: oklch(69.6% 0.165 251); + --color-4: oklch(80.2% 0.134 225); + --color-5: oklch(90.7% 0.231 133); --radius: 0.625rem; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); @@ -28,12 +45,25 @@ --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); --color-border: var(--border); + --color-input: var(--input); --color-ring: var(--ring); + --color-color-1: var(--color-1); + --color-color-2: var(--color-2); + --color-color-3: var(--color-3); + --color-color-4: var(--color-4); + --color-color-5: var(--color-5); + --animate-rainbow: rainbow var(--speed, 2s) infinite linear; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --font-sans: Arial, Helvetica, sans-serif; + + @keyframes rainbow { + 0% { background-position: 0%; } + 100% { background-position: 200%; } + } } @layer base { diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..5863962 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,203 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { cn } from '@/lib/utils'; + +interface AccordionContextType { + value: string | string[]; + onValueChange: (value: string) => void; + type: 'single' | 'multiple'; +} + +const AccordionContext = createContext( + undefined +); + +interface AccordionItemContextType { + value: string; +} + +const AccordionItemContext = createContext< + AccordionItemContextType | undefined +>(undefined); + +function useAccordion() { + const context = useContext(AccordionContext); + if (!context) { + throw new Error('Accordion components must be used within an Accordion'); + } + return context; +} + +function useAccordionItem() { + const context = useContext(AccordionItemContext); + if (!context) { + throw new Error( + 'AccordionTrigger and AccordionContent must be used within an AccordionItem' + ); + } + return context; +} + +interface AccordionProps { + type?: 'single' | 'multiple'; + value?: string | string[]; + defaultValue?: string | string[]; + onValueChange?: (value: string | string[]) => void; + collapsible?: boolean; + className?: string; + children: React.ReactNode; +} + +function Accordion({ + type = 'single', + value: controlledValue, + defaultValue, + onValueChange, + collapsible = true, + className, + children, +}: AccordionProps) { + const [internalValue, setInternalValue] = useState( + defaultValue ?? (type === 'single' ? '' : []) + ); + + const isControlled = controlledValue !== undefined; + const value = isControlled ? controlledValue : internalValue; + + const handleValueChange = useCallback( + (itemValue: string) => { + if (type === 'single') { + const newValue = value === itemValue && collapsible ? '' : itemValue; + if (!isControlled) { + setInternalValue(newValue); + } + onValueChange?.(newValue); + } else { + const valueArray = Array.isArray(value) ? value : []; + const newValue = valueArray.includes(itemValue) + ? valueArray.filter((v) => v !== itemValue) + : [...valueArray, itemValue]; + if (!isControlled) { + setInternalValue(newValue); + } + onValueChange?.(newValue); + } + }, + [value, type, isControlled, onValueChange, collapsible] + ); + + return ( + +
{children}
+
+ ); +} + +interface AccordionItemProps { + value: string; + children: React.ReactNode; + className?: string; +} + +function AccordionItem({ value, children, className }: AccordionItemProps) { + return ( + +
+ {children} +
+
+ ); +} + +interface AccordionTriggerProps extends React.ButtonHTMLAttributes { + children: React.ReactNode; +} + +function AccordionTrigger({ + className, + children, + ...props +}: AccordionTriggerProps) { + const accordion = useAccordion(); + const item = useAccordionItem(); + + const handleClick = () => { + accordion.onValueChange(item.value); + }; + + const isOpen = + accordion.type === 'single' + ? accordion.value === item.value + : Array.isArray(accordion.value) && accordion.value.includes(item.value); + + return ( +
+ +
+ ); +} + +interface AccordionContentProps extends React.HTMLAttributes { + children: React.ReactNode; +} + +function AccordionContent({ + className, + children, + ...props +}: AccordionContentProps) { + const accordion = useAccordion(); + const item = useAccordionItem(); + + const isOpen = + accordion.type === 'single' + ? accordion.value === item.value + : Array.isArray(accordion.value) && accordion.value.includes(item.value); + + return ( +
+
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..3c5a3e4 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,60 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +type AlertVariant = "default" | "destructive" + +const baseClasses = + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current" + +const variantClasses: Record = { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", +} + +function Alert({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & { variant?: AlertVariant }) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..6cd2e95 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface AvatarProps extends React.ComponentProps<'div'> { + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +const sizeClasses = { + sm: 'size-6', + md: 'size-8', + lg: 'size-10', + xl: 'size-12', +}; + +function Avatar({ className, size = 'md', ...props }: AvatarProps) { + const [imageError, setImageError] = React.useState(false); + + return ( +
+ ); +} + +function AvatarImage({ + className, + onError, + ...props +}: React.ComponentProps<'img'>) { + const [hasError, setHasError] = React.useState(false); + + const handleError = (e: React.SyntheticEvent) => { + setHasError(true); + onError?.(e as any); + }; + + if (hasError) { + return null; + } + + return ( + + ); +} + +interface AvatarFallbackProps extends React.ComponentProps<'div'> { + children: React.ReactNode; +} + +function AvatarFallback({ + className, + children, + ...props +}: AvatarFallbackProps) { + return ( +
+ {children} +
+ ); +} + +export { Avatar, AvatarImage, AvatarFallback }; +export type { AvatarProps }; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..6eb2a05 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..9313497 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return