From 24fcdf9a77ccda2105516cfa1157ce67a5016eed Mon Sep 17 00:00:00 2001 From: Poyraz <78071274+poyrazavsever@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:47:02 +0300 Subject: [PATCH] feat: add new UI components and improve existing ones - Introduced `Field` and `Label` components for better form handling. - Refactored `Input` and `Textarea` components to use forward refs and improved styling. - Updated `PendingSubmitButton` to use the new `Button` component. - Enhanced `Skeleton` component for better layout handling. - Revamped `Toast` component to support live regions and improved accessibility. - Updated sidebar configuration to use typed icons. - Added phase 3 UI boundary checks to prevent usage of deprecated imports. - Implemented new authentication action handler for better cookie management. - Improved setup logic for freelancer accounts with repair functionality. --- app/globals.css | 130 +++++--- app/layout.tsx | 4 +- app/login/actions.ts | 75 +++-- app/login/page.tsx | 3 +- app/register/page.tsx | 3 +- components/auth/auth-page-shell.tsx | 31 +- components/auth/submit-button.tsx | 5 +- components/error-toaster.tsx | 4 +- components/layout/app-shell.tsx | 350 ++++++++++++++++++++++ components/layout/dashboard-shell.tsx | 226 +------------- components/layout/portal-shell.tsx | 254 +--------------- components/ui/button.tsx | 130 ++++---- components/ui/card.tsx | 101 +------ components/ui/field.tsx | 51 ++++ components/ui/input.tsx | 39 ++- components/ui/pending-submit-button.tsx | 2 +- components/ui/skeleton.tsx | 11 +- components/ui/toast.tsx | 256 +++++++++------- config/portal-sidebar.ts | 3 +- config/sidebar.ts | 3 +- docs/19-self-hosted-redesign-checklist.md | 78 ++--- docs/self-hosted-redesign/phase-3-ui.md | 63 ++++ next-env.d.ts | 2 +- package.json | 3 +- scripts/phase3-ui-boundary.mjs | 77 +++++ server/auth/action-handler.ts | 79 +++++ server/auth/auth.ts | 3 +- server/auth/setup.ts | 140 ++++++--- 28 files changed, 1183 insertions(+), 943 deletions(-) create mode 100644 components/layout/app-shell.tsx create mode 100644 components/ui/field.tsx create mode 100644 docs/self-hosted-redesign/phase-3-ui.md create mode 100644 scripts/phase3-ui-boundary.mjs create mode 100644 server/auth/action-handler.ts diff --git a/app/globals.css b/app/globals.css index b5d2356..af6394a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,60 +1,85 @@ @import "tailwindcss"; -@import "poyraz-ui/preset.css"; @source "../app/**/*.{js,ts,jsx,tsx,mdx}"; @source "../components/**/*.{js,ts,jsx,tsx,mdx}"; @source "../config/**/*.{js,ts,jsx,tsx,mdx}"; +@theme inline { + --font-sans: var(--font-geist-sans); + --color-background: var(--background); + --color-background-dark: var(--background-dark); + --color-foreground: var(--foreground); + --color-surface: var(--surface); + --color-surface-raised: var(--surface-raised); + --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-primary-hover: var(--primary-hover); + --color-primary-pressed: var(--primary-pressed); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-hover: var(--destructive-hover); + --color-destructive-pressed: var(--destructive-pressed); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-info: var(--info); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-input: var(--input); + --color-input-bg: var(--input-bg); + --color-ring: var(--ring); + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 8px; +} + @layer base { :root { - --poyraz-background: #ffffff; - --poyraz-foreground: #101828; - --poyraz-primary: #dc2626; - --poyraz-primary-foreground: #ffffff; - --poyraz-primary-hover: #b91c1c; - --poyraz-primary-active: #991b1b; - --poyraz-primary-muted: #fef2f2; - --poyraz-primary-muted-foreground: #b91c1c; - --poyraz-secondary: #f8fafc; - --poyraz-secondary-foreground: #101828; - --poyraz-muted: #f8fafc; - --poyraz-muted-foreground: #667085; - --poyraz-accent: #f1f5f9; - --poyraz-accent-hover: #e2e8f0; - --poyraz-accent-foreground: #101828; - --poyraz-border: #e4e7ec; - --poyraz-border-strong: #d0d5dd; - --poyraz-input: #98a2b3; - --poyraz-ring: #dc2626; - --poyraz-card: #ffffff; - --poyraz-card-foreground: #101828; - - /* Legacy aliases kept until all prototype pages move to Poyraz UI. */ - --background: var(--poyraz-background); + --background: #f7f8fb; --background-dark: #f8fafc; - --foreground: var(--poyraz-foreground); - --card: var(--poyraz-card); - --card-foreground: var(--poyraz-card-foreground); + --foreground: #101828; + --surface: #ffffff; + --surface-raised: #ffffff; + --card: #ffffff; + --card-foreground: #101828; --popover: #ffffff; - --popover-foreground: var(--poyraz-foreground); - --primary: var(--poyraz-primary); - --primary-foreground: var(--poyraz-primary-foreground); - --primary-hover: var(--poyraz-primary-hover); - --primary-pressed: var(--poyraz-primary-active); - --secondary: var(--poyraz-secondary); - --secondary-foreground: var(--poyraz-secondary-foreground); - --muted: var(--poyraz-muted); - --muted-foreground: var(--poyraz-muted-foreground); - --accent: var(--poyraz-accent); - --accent-foreground: var(--poyraz-accent-foreground); - --destructive: #ef4444; + --popover-foreground: #101828; + --primary: #c81e1e; + --primary-foreground: #ffffff; + --primary-hover: #a91b1b; + --primary-pressed: #861515; + --secondary: #eef2f7; + --secondary-foreground: #182230; + --muted: #eef2f7; + --muted-foreground: #667085; + --accent: #e6edf5; + --accent-hover: #d8e2ee; + --accent-foreground: #101828; + --destructive: #d92d20; + --destructive-hover: #b42318; + --destructive-pressed: #912018; --destructive-foreground: #ffffff; - --border: var(--poyraz-border); - --input: var(--poyraz-input); + --success: #168a4a; + --info: #2563eb; + --border: #d9e0ea; + --border-strong: #b9c4d2; + --input: #b9c4d2; --input-bg: #ffffff; --overlay: rgba(15, 23, 42, 0.48); - --ring: var(--poyraz-ring); + --ring: #c81e1e; --radius: 0.375rem; + --motion-duration-fast: 120ms; + --motion-duration-base: 180ms; } * { @@ -67,6 +92,8 @@ body { @apply min-h-screen bg-background text-foreground antialiased; + font-size: 14px; + letter-spacing: 0; } input:-webkit-autofill, @@ -82,7 +109,7 @@ @layer utilities { * { scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--poyraz-primary) 45%, transparent) transparent; + scrollbar-color: color-mix(in srgb, var(--primary) 45%, transparent) transparent; } *::-webkit-scrollbar, @@ -98,7 +125,7 @@ *::-webkit-scrollbar-thumb, .tiny-scrollbar::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--poyraz-primary) 36%, transparent); + background: color-mix(in srgb, var(--primary) 36%, transparent); border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; @@ -106,7 +133,18 @@ *::-webkit-scrollbar-thumb:hover, .tiny-scrollbar::-webkit-scrollbar-thumb:hover { - background: color-mix(in srgb, var(--poyraz-primary) 58%, transparent); + background: color-mix(in srgb, var(--primary) 58%, transparent); background-clip: padding-box; } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 1ms !important; + } + } } diff --git a/app/layout.tsx b/app/layout.tsx index 51d5dc1..c58d5eb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,10 +2,10 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { Geist } from "next/font/google"; import { cn } from "@/lib/utils"; -import { Toaster } from "poyraz-ui/molecules"; import { OfflineIndicator } from "@/components/ui/offline-indicator"; +import { Toaster } from "@/components/ui/toast"; -const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); +const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }); export const metadata: Metadata = { title: "Neta", diff --git a/app/login/actions.ts b/app/login/actions.ts index e8d4a97..84acb34 100644 --- a/app/login/actions.ts +++ b/app/login/actions.ts @@ -1,41 +1,32 @@ 'use server' import { revalidatePath } from 'next/cache' -import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { auth } from '@/server/auth/auth' +import { callAuthAction } from '@/server/auth/action-handler' import { getProfileByAuthUserId } from '@/server/auth/session' -import { getFirstFreelancerSetupState, recordAuthAuditEvent } from '@/server/auth/setup' +import { + getFirstFreelancerSetupState, + recordAuthAuditEvent, + repairFirstFreelancerSetupForEmail, +} from '@/server/auth/setup' import { getDefaultDisplayName, parseAuthCredentials } from '@/server/auth/validation' -const genericLoginError = 'E-posta veya şifre hatalı.' +const genericLoginError = 'E-posta veya \u015fifre hatal\u0131.' +type SignInEmailResult = Awaited> +type SignUpEmailResult = Awaited> export async function login(formData: FormData) { const credentials = parseAuthCredentials(formData) let redirectTarget = '/' + let result: SignInEmailResult try { - const result = await auth.api.signInEmail({ - body: { - email: credentials.email, - password: credentials.password, - rememberMe: true, - }, + result = await callAuthAction('/sign-in/email', { + email: credentials.email, + password: credentials.password, + rememberMe: true, }) - const profile = getProfileByAuthUserId(result.user.id) - - if (!profile || profile.disabled) { - await auth.api.signOut({ headers: await headers() }) - await recordAuthAuditEvent({ - type: 'login_failed', - authUserId: result.user.id, - email: credentials.email, - metadata: { reason: 'missing_or_disabled_profile' }, - }) - redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`) - } - - redirectTarget = profile.role === 'client' ? '/portal' : '/' } catch { await recordAuthAuditEvent({ type: 'login_failed', @@ -45,6 +36,26 @@ export async function login(formData: FormData) { redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`) } + let profile = getProfileByAuthUserId(result.user.id) + + if (!profile) { + repairFirstFreelancerSetupForEmail(result.user.email) + profile = getProfileByAuthUserId(result.user.id) + } + + if (!profile || profile.disabled) { + await callAuthAction<{ success: boolean }>('/sign-out') + await recordAuthAuditEvent({ + type: 'login_failed', + authUserId: result.user.id, + email: credentials.email, + metadata: { reason: 'missing_or_disabled_profile' }, + }) + redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`) + } + + redirectTarget = profile.role === 'client' ? '/portal' : '/' + revalidatePath('/', 'layout') redirect(redirectTarget) } @@ -59,7 +70,7 @@ export async function signup(formData: FormData) { if (!setupState.available) { redirect( `/login?error=true&message=${encodeURIComponent( - 'Kayıt kapalı. Bu Neta kurulumunda ilk freelancer hesabı zaten oluşturulmuş.', + 'Kay\u0131t kapal\u0131. Bu Neta kurulumunda ilk freelancer hesab\u0131 zaten olu\u015fturulmu\u015f.', )}`, ) } @@ -67,16 +78,14 @@ export async function signup(formData: FormData) { const credentials = parseAuthCredentials(formData) try { - await auth.api.signUpEmail({ - body: { - name: getDefaultDisplayName(credentials.email), - email: credentials.email, - password: credentials.password, - rememberMe: true, - }, + await callAuthAction('/sign-up/email', { + name: getDefaultDisplayName(credentials.email), + email: credentials.email, + password: credentials.password, + rememberMe: true, }) } catch (error) { - const message = error instanceof Error ? error.message : 'Kullanıcı oluşturulamadı.' + const message = error instanceof Error ? error.message : 'Kullan\u0131c\u0131 olu\u015fturulamad\u0131.' redirect(`/register?error=true&message=${encodeURIComponent(message)}`) } @@ -85,7 +94,7 @@ export async function signup(formData: FormData) { } export async function signOut() { - await auth.api.signOut({ headers: await headers() }) + await callAuthAction<{ success: boolean }>('/sign-out') revalidatePath('/', 'layout') redirect('/login') diff --git a/app/login/page.tsx b/app/login/page.tsx index 9521f25..856c86c 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -3,7 +3,8 @@ import { AuthPageShell } from "@/components/auth/auth-page-shell"; import { ErrorToaster } from "@/components/error-toaster"; import { LockKeyhole, LogIn, Mail } from "lucide-react"; import Link from "next/link"; -import { Input, Label } from "poyraz-ui/atoms"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/field"; import { SubmitButton } from "@/components/auth/submit-button"; export default async function LoginPage({ diff --git a/app/register/page.tsx b/app/register/page.tsx index 43633ae..d343943 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -5,7 +5,8 @@ import { getFirstFreelancerSetupState } from "@/server/auth/setup"; import { LockKeyhole, Mail, UserPlus } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { Input, Label } from "poyraz-ui/atoms"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/field"; import { SubmitButton } from "@/components/auth/submit-button"; export const dynamic = "force-dynamic"; diff --git a/components/auth/auth-page-shell.tsx b/components/auth/auth-page-shell.tsx index e4c30d0..891287b 100644 --- a/components/auth/auth-page-shell.tsx +++ b/components/auth/auth-page-shell.tsx @@ -11,7 +11,6 @@ import { Kanban, Wallet, } from "lucide-react"; -import { Typography } from "poyraz-ui/atoms"; type AuthPageShellProps = { title: string; @@ -76,20 +75,14 @@ export function AuthPageShell({
- +

Freelancer işlerini, müşterilerini ve finansını tek yerde yönet. - - +

+

Neta, günlük operasyonunu, projelerini, side projectlerini ve temel finans durumunu sade raporlarla takip etmen için tasarlanır. - +

GitHub - üzerinden ulaşabilirsin, - - Poyraz UI - - ile tasarlandı, + üzerinden ulaşabilirsin.
- +

{title} - - {description} +

+

{description}

diff --git a/components/auth/submit-button.tsx b/components/auth/submit-button.tsx index 4dba157..1ef403c 100644 --- a/components/auth/submit-button.tsx +++ b/components/auth/submit-button.tsx @@ -1,7 +1,7 @@ "use client"; import { useFormStatus } from "react-dom"; -import { Button } from "poyraz-ui/atoms"; +import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import React from "react"; @@ -12,12 +12,13 @@ interface SubmitButtonProps extends React.ComponentProps { export function SubmitButton({ children, pendingText, + type = "submit", ...props }: SubmitButtonProps) { const { pending } = useFormStatus(); return ( -
+
+ ); +} + +function AppSidebar({ + homeHref, + navGroups, + pathname, + progress, + settingsHref, + user, + onNavigate, + className, +}: { + homeHref: string; + navGroups: AppShellNavGroup[]; + pathname: string; + progress?: number; + settingsHref: string; + user: ShellUser; + onNavigate?: () => void; + className?: string; +}) { + return ( + + ); +} + +function ProgressSummary({ progress }: { progress: number }) { + const normalizedProgress = Math.max(0, Math.min(100, progress)); + + return ( + + +
Proje ilerlemesi
+
+
+ %{normalizedProgress} tamamlandı +
+
+
+
+
+ + + ); +} + +function AccountMenu({ + user, + settingsHref, + onNavigate, +}: { + user: ShellUser; + settingsHref: string; + onNavigate?: () => void; +}) { + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + function handlePointerDown(event: PointerEvent) { + if (!menuRef.current?.contains(event.target as Node)) { + setOpen(false); + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + return ( +
+ + + {open ? ( +
+ { + setOpen(false); + onNavigate?.(); + }} + className="flex h-9 items-center gap-2 rounded-sm px-3 text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + showSpinner + > + + Ayarlar + +
+
+ +
+
+ ) : null} +
+ ); +} + +function UserAvatar({ user }: { user: ShellUser }) { + if (user.avatarUrl) { + return ( + + ); + } + + return ( + + {user.shortName} + + ); +} diff --git a/components/layout/dashboard-shell.tsx b/components/layout/dashboard-shell.tsx index c4feffb..9b8e958 100644 --- a/components/layout/dashboard-shell.tsx +++ b/components/layout/dashboard-shell.tsx @@ -1,36 +1,7 @@ "use client"; -import { signOut } from "@/app/login/actions"; -import { Button } from "poyraz-ui/atoms"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "poyraz-ui/molecules"; -import { - Sidebar, - SidebarBranding, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - SidebarMenuItem, - SidebarSection, - SidebarSeparator, - SidebarTrigger, - SidebarUserProfile, -} from "poyraz-ui/organisms"; +import { AppShell } from "@/components/layout/app-shell"; import { sidebarData } from "@/config/sidebar"; -import { PendingLink } from "@/components/ui/pending-link"; -import { cn } from "@/lib/utils"; -import { ChevronUp, LogOut, Menu, Settings } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; type DashboardShellProps = { children: React.ReactNode; @@ -43,198 +14,9 @@ type DashboardShellProps = { }; export function DashboardShell({ children, user }: DashboardShellProps) { - const pathname = usePathname(); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - return ( -
-
- - - {/* Mobile Sidebar Overlay */} - {isMobileSidebarOpen && ( -
setIsMobileSidebarOpen(false)} - /> - )} - - {/* Mobile Sidebar Drawer */} - setIsMobileSidebarOpen(false)} - className={`fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out lg:hidden ${ - isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full" - }`} - /> - -
-
- - Neta - - -
- -
{children}
-
-
-
- ); -} - -function AppSidebar({ - pathname, - user, - onNavigate, - className, -}: { - pathname: string; - user: DashboardShellProps["user"]; - onNavigate?: () => void; - className?: string; -}) { - return ( - - - - Neta - - - - - - - {sidebarData.map((group, groupIndex) => ( -
- - - {group.items.map((item) => { - const isActive = - item.href === "/" - ? pathname === "/" - : item.href - ? pathname === item.href || pathname.startsWith(item.href + "/") - : false; - const Icon = item.icon; - - return ( - : undefined} - className={cn(isActive && "font-semibold")} - > - - {item.title} - - - ); - })} - - - {groupIndex < sidebarData.length - 1 ? ( - - ) : null} -
- ))} -
- - - - - - - - -
- - {user.displayName} - - - {user.email} - -
-
- - - - - Ayarlar - - - -
- - - -
-
-
-
- - -
+ + {children} + ); } diff --git a/components/layout/portal-shell.tsx b/components/layout/portal-shell.tsx index 6f42185..04a3efa 100644 --- a/components/layout/portal-shell.tsx +++ b/components/layout/portal-shell.tsx @@ -1,35 +1,7 @@ "use client"; -import { signOut } from "@/app/login/actions"; -import { Button, Card, CardContent } from "poyraz-ui/atoms"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "poyraz-ui/molecules"; -import { - Sidebar, - SidebarBranding, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - SidebarMenuItem, - SidebarSection, - SidebarSeparator, - SidebarTrigger, - SidebarUserProfile, -} from "poyraz-ui/organisms"; +import { AppShell } from "@/components/layout/app-shell"; import { portalSidebarData } from "@/config/portal-sidebar"; -import { cn } from "@/lib/utils"; -import { Activity, ChevronUp, LogOut, Menu, Settings } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; type PortalShellProps = { children: React.ReactNode; @@ -43,223 +15,15 @@ type PortalShellProps = { }; export function PortalShell({ children, user, progress }: PortalShellProps) { - const pathname = usePathname(); - const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - return ( -
-
- - - {/* Mobile Sidebar Overlay */} - {isMobileSidebarOpen && ( -
setIsMobileSidebarOpen(false)} - /> - )} - - {/* Mobile Sidebar Drawer */} - setIsMobileSidebarOpen(false)} - className={`fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out lg:hidden ${ - isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full" - }`} - /> - -
-
- - Neta - -
- -
-
- -
{children}
-
-
-
- ); -} - -function AppSidebar({ - pathname, - user, - progress = 0, - onNavigate, - className, -}: { - pathname: string; - user: PortalShellProps["user"]; - progress?: number; - onNavigate?: () => void; - className?: string; -}) { - return ( - - - - Neta - - - - - - - {portalSidebarData.map((group, groupIndex) => ( -
- - - {group.items.map((item) => { - const isActive = - item.href === "/portal" - ? pathname === "/portal" - : item.href - ? pathname === item.href || pathname.startsWith(item.href + "/") - : false; - const Icon = item.icon; - - return ( - : undefined} - className={cn(isActive && "font-semibold")} - > - - {item.title} - - - ); - })} - - - {groupIndex < portalSidebarData.length - 1 ? ( - - ) : null} -
- ))} -
- - - - -
Proje İlerlemesi
-
-
- %{progress} Tamamlandı -
-
-
-
-
-
-
-
- - - - - - - - - -
- - {user.displayName} - - - {user.email} - -
-
- - - - - Ayarlar - - - -
- - - -
-
-
- - - - + {children} + ); } diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 6138844..8fccf59 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,67 +1,77 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { Slot } from "radix-ui" +"use client"; -import { cn } from "@/lib/utils" +import { forwardRef } from "react"; +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { cn } from "@/lib/utils"; -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) +type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger"; +type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm"; -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" +export type ButtonProps = ButtonHTMLAttributes & { + variant?: ButtonVariant; + size?: ButtonSize; +}; - return ( - = { + primary: + "border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover active:bg-primary-pressed", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-accent active:bg-accent-hover", + outline: + "border-border-strong bg-surface text-foreground shadow-sm hover:bg-accent active:bg-accent-hover", + ghost: + "border-transparent bg-transparent text-foreground hover:bg-accent active:bg-accent-hover", + danger: + "border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover active:bg-destructive-pressed", +}; + +const sizeClasses: Record = { + sm: "h-8 gap-1.5 px-3 text-xs", + md: "h-10 gap-2 px-4 text-sm", + lg: "h-11 gap-2.5 px-5 text-sm", + icon: "h-9 w-9 p-0", + "icon-sm": "h-8 w-8 p-0", +}; + +export const Button = forwardRef( + ({ className, variant = "primary", size = "md", type = "button", ...props }, ref) => ( + + ), +); + +IconButton.displayName = "IconButton"; diff --git a/components/ui/card.tsx b/components/ui/card.tsx index 40cac5f..b31391d 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -1,103 +1,28 @@ -import * as React from "react" +"use client"; -import { cn } from "@/lib/utils" +import type { ComponentPropsWithoutRef } from "react"; +import { cn } from "@/lib/utils"; -function Card({ - className, - size = "default", - ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { +export function Card({ className, ...props }: ComponentPropsWithoutRef<"div">) { return (
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", - className + "rounded-md border border-border bg-card text-card-foreground shadow-sm", + className, )} {...props} /> - ) + ); } -function CardHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) { + return
; } -function CardTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) +export function CardTitle({ className, ...props }: ComponentPropsWithoutRef<"h3">) { + return

; } -function CardDescription({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function CardFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardAction, - CardDescription, - CardContent, +export function CardContent({ className, ...props }: ComponentPropsWithoutRef<"div">) { + return
; } diff --git a/components/ui/field.tsx b/components/ui/field.tsx new file mode 100644 index 0000000..08f1027 --- /dev/null +++ b/components/ui/field.tsx @@ -0,0 +1,51 @@ +"use client"; + +import type { ComponentPropsWithoutRef, ReactNode } from "react"; +import { cn } from "@/lib/utils"; + +export function Label({ className, ...props }: ComponentPropsWithoutRef<"label">) { + return ( +