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.
This commit is contained in:
@@ -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({
|
||||
|
||||
<div className="relative z-10 px-10">
|
||||
<motion.div {...fadeUp}>
|
||||
<Typography
|
||||
variant="h1"
|
||||
className="max-w-2xl text-5xl leading-[1.02] text-primary-foreground"
|
||||
>
|
||||
<h1 className="max-w-2xl text-5xl font-semibold leading-[1.02] text-primary-foreground">
|
||||
Freelancer işlerini, müşterilerini ve finansını tek yerde yönet.
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="lead"
|
||||
className="mt-6 max-w-xl text-primary-foreground/78"
|
||||
>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-xl text-lg leading-8 text-primary-foreground/78">
|
||||
Neta, günlük operasyonunu, projelerini, side projectlerini ve
|
||||
temel finans durumunu sade raporlarla takip etmen için
|
||||
tasarlanır.
|
||||
</Typography>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -122,15 +115,7 @@ export function AuthPageShell({
|
||||
>
|
||||
GitHub <ArrowUpRight className="h-3.5 w-3.5 inline" />
|
||||
</Link>
|
||||
<span> üzerinden ulaşabilirsin, </span>
|
||||
<Link
|
||||
href="https://ui.poyrazavsever.com"
|
||||
className="font-semibold text-primary-foreground underline-offset-4 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
Poyraz UI <ArrowUpRight className="h-3.5 w-3.5 inline" />
|
||||
</Link>
|
||||
<span> ile tasarlandı, </span>
|
||||
<span> üzerinden ulaşabilirsin. </span>
|
||||
<Link
|
||||
href="https://poyrazavsever.com"
|
||||
className="inline-flex items-center gap-1 font-semibold text-primary-foreground underline-offset-4 hover:underline"
|
||||
@@ -162,10 +147,10 @@ export function AuthPageShell({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-center lg:text-left">
|
||||
<Typography variant="h2" className="text-3xl">
|
||||
<h2 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="muted">{description}</Typography>
|
||||
</h2>
|
||||
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
|
||||
@@ -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<typeof Button> {
|
||||
export function SubmitButton({
|
||||
children,
|
||||
pendingText,
|
||||
type = "submit",
|
||||
...props
|
||||
}: SubmitButtonProps) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button disabled={pending} {...props}>
|
||||
<Button type={type} disabled={pending} {...props}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { toast } from 'poyraz-ui/molecules'
|
||||
import { showToast } from '@/components/ui/toast'
|
||||
|
||||
export function ErrorToaster({ message }: { message: string }) {
|
||||
useEffect(() => {
|
||||
if (message) {
|
||||
toast.error(message)
|
||||
showToast({ message, tone: 'error' })
|
||||
}
|
||||
}, [message])
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "@/app/login/actions";
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PendingLink } from "@/components/ui/pending-link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronUp, LogOut, Menu, Settings } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type AppShellNavItem = {
|
||||
title: string;
|
||||
href?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export type AppShellNavGroup = {
|
||||
title: string;
|
||||
items: AppShellNavItem[];
|
||||
};
|
||||
|
||||
type ShellUser = {
|
||||
email: string;
|
||||
displayName: string;
|
||||
shortName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
children: React.ReactNode;
|
||||
homeHref: string;
|
||||
navGroups: AppShellNavGroup[];
|
||||
settingsHref: string;
|
||||
user: ShellUser;
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
homeHref,
|
||||
navGroups,
|
||||
settingsHref,
|
||||
user,
|
||||
progress,
|
||||
}: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const [mobileSidebarState, setMobileSidebarState] = useState({
|
||||
open: false,
|
||||
pathname,
|
||||
});
|
||||
const isMobileSidebarOpen =
|
||||
mobileSidebarState.open && mobileSidebarState.pathname === pathname;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[90] focus:rounded-md focus:bg-surface focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
Ana içeriğe geç
|
||||
</a>
|
||||
<div className="flex min-h-screen">
|
||||
<AppSidebar
|
||||
homeHref={homeHref}
|
||||
navGroups={navGroups}
|
||||
pathname={pathname}
|
||||
progress={progress}
|
||||
settingsHref={settingsHref}
|
||||
user={user}
|
||||
className="sticky top-0 hidden h-screen shrink-0 self-stretch lg:flex"
|
||||
/>
|
||||
|
||||
{isMobileSidebarOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Menüyü kapat"
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setMobileSidebarState({ open: false, pathname })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppSidebar
|
||||
homeHref={homeHref}
|
||||
navGroups={navGroups}
|
||||
pathname={pathname}
|
||||
progress={progress}
|
||||
settingsHref={settingsHref}
|
||||
user={user}
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 transition-transform duration-200 ease-out lg:hidden",
|
||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
onNavigate={() => setMobileSidebarState({ open: false, pathname })}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-surface/95 px-4 backdrop-blur lg:hidden">
|
||||
<Link href={homeHref} className="flex items-center gap-2 font-semibold">
|
||||
<Image
|
||||
src="/logo/blackLogoLong.png"
|
||||
alt="Neta"
|
||||
width={120}
|
||||
height={32}
|
||||
className="h-8 w-auto object-contain"
|
||||
style={{ width: "auto" }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<IconButton
|
||||
label="Menüyü aç"
|
||||
variant="outline"
|
||||
onClick={() => setMobileSidebarState({ open: true, pathname })}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<main id="main-content" className="min-w-0 flex-1 p-4 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-dvh w-[280px] max-w-[82vw] flex-col overflow-hidden border-r border-border bg-surface",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 px-6 py-3">
|
||||
<Link href={homeHref} className="flex w-full items-center justify-center">
|
||||
<Image
|
||||
src="/logo/blackLogoLong.png"
|
||||
alt="Neta"
|
||||
width={160}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain"
|
||||
style={{ width: "auto" }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<nav className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
{navGroups.map((group, groupIndex) => (
|
||||
<section key={group.title} className={cn(groupIndex > 0 && "mt-6")}>
|
||||
<h2 className="px-2 text-[11px] font-semibold uppercase leading-6 text-muted-foreground">
|
||||
{group.title}
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
item.href === homeHref
|
||||
? pathname === homeHref
|
||||
: item.href
|
||||
? pathname === item.href || pathname.startsWith(`${item.href}/`)
|
||||
: false;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li key={item.href || item.title}>
|
||||
<PendingLink
|
||||
href={item.href || "#"}
|
||||
onClick={onNavigate}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive && "bg-primary/10 text-primary",
|
||||
)}
|
||||
showSpinner
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4 shrink-0" /> : null}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</PendingLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex shrink-0 flex-col gap-4 border-t border-border px-4 py-4">
|
||||
{typeof progress === "number" ? <ProgressSummary progress={progress} /> : null}
|
||||
<AccountMenu user={user} settingsHref={settingsHref} onNavigate={onNavigate} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressSummary({ progress }: { progress: number }) {
|
||||
const normalizedProgress = Math.max(0, Math.min(100, progress));
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-primary/10 bg-primary/5 shadow-none">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="text-sm font-semibold text-foreground">Proje ilerlemesi</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="text-primary">%{normalizedProgress} tamamlandı</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out"
|
||||
style={{ width: `${normalizedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMenu({
|
||||
user,
|
||||
settingsHref,
|
||||
onNavigate,
|
||||
}: {
|
||||
user: ShellUser;
|
||||
settingsHref: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className="flex w-full items-center gap-3 rounded-md p-2 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<UserAvatar user={user} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{user.displayName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute bottom-[calc(100%+0.5rem)] left-0 z-[70] w-full rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||
>
|
||||
<PendingLink
|
||||
href={settingsHref}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
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
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Ayarlar
|
||||
</PendingLink>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<form action={signOut}>
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
className="flex h-9 w-full items-center gap-2 rounded-sm px-3 text-left text-sm text-destructive hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Çıkış yap
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAvatar({ user }: { user: ShellUser }) {
|
||||
if (user.avatarUrl) {
|
||||
return (
|
||||
<Image
|
||||
src={user.avatarUrl}
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-9 w-9 rounded-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
|
||||
{user.shortName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="flex min-h-screen">
|
||||
<AppSidebar
|
||||
pathname={pathname}
|
||||
user={user}
|
||||
className="sticky top-0 hidden h-screen shrink-0 self-stretch lg:flex"
|
||||
/>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isMobileSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden transition-opacity"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar Drawer */}
|
||||
<AppSidebar
|
||||
pathname={pathname}
|
||||
user={user}
|
||||
onNavigate={() => 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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur lg:hidden">
|
||||
<Link href="/" className="flex justify-center items-center gap-2 font-semibold">
|
||||
<Image
|
||||
src="/logo/blackLogoLong.png"
|
||||
alt="Neta"
|
||||
width={120}
|
||||
height={32}
|
||||
className="h-8 w-auto object-contain"
|
||||
style={{ width: "auto" }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-9 p-0"
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 lg:p-8 min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppSidebar({
|
||||
pathname,
|
||||
user,
|
||||
onNavigate,
|
||||
className,
|
||||
}: {
|
||||
pathname: string;
|
||||
user: DashboardShellProps["user"];
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="bordered"
|
||||
className={cn(
|
||||
"flex h-dvh max-h-dvh flex-col overflow-hidden rounded-none border-y-0 border-l-0 border-r border-border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SidebarHeader className="shrink-0 border-b-0 px-6 py-3">
|
||||
<Link href="/" className="flex w-full items-center justify-center">
|
||||
<Image
|
||||
src="/logo/blackLogoLong.png"
|
||||
alt="Neta"
|
||||
width={160}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain"
|
||||
style={{ width: "auto" }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator className="mx-0 my-0 w-full" />
|
||||
|
||||
<SidebarContent className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{sidebarData.map((group, groupIndex) => (
|
||||
<div key={group.title}>
|
||||
<SidebarSection title={group.title} defaultOpen className="mb-0">
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: item.href
|
||||
? pathname === item.href || pathname.startsWith(item.href + "/")
|
||||
: false;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={item.href || item.title}
|
||||
active={isActive}
|
||||
icon={Icon ? <Icon className="h-4 w-4" /> : undefined}
|
||||
className={cn(isActive && "font-semibold")}
|
||||
>
|
||||
<PendingLink
|
||||
href={item.href || "#"}
|
||||
onClick={onNavigate}
|
||||
className="flex w-full items-center justify-between gap-2"
|
||||
showSpinner
|
||||
>
|
||||
{item.title}
|
||||
</PendingLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarSection>
|
||||
{groupIndex < sidebarData.length - 1 ? (
|
||||
<SidebarSeparator className="-mx-6 my-5 w-[calc(100%+3rem)]" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="mt-auto shrink-0 border-t border-border px-6 py-5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-sm text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarUserProfile
|
||||
name={user.displayName}
|
||||
role={user.email}
|
||||
avatarUrl={user.avatarUrl || undefined}
|
||||
initials={user.shortName}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<ChevronUp className="mr-3 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
|
||||
>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<PendingLink href="/settings" onClick={onNavigate} className="gap-2" showSpinner>
|
||||
<Settings className="h-4 w-4" />
|
||||
Ayarlar
|
||||
</PendingLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<form action={signOut}>
|
||||
<DropdownMenuItem asChild>
|
||||
<button type="submit" className="w-full gap-2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Çıkış yap
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</form>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarTrigger className="hidden" />
|
||||
</Sidebar>
|
||||
<AppShell homeHref="/" navGroups={sidebarData} settingsHref="/settings" user={user}>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="flex min-h-screen">
|
||||
<AppSidebar
|
||||
pathname={pathname}
|
||||
user={user}
|
||||
progress={progress}
|
||||
className="sticky top-0 hidden h-screen shrink-0 self-stretch lg:flex"
|
||||
/>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{isMobileSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden transition-opacity"
|
||||
onClick={() => setIsMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar Drawer */}
|
||||
<AppSidebar
|
||||
pathname={pathname}
|
||||
user={user}
|
||||
progress={progress}
|
||||
onNavigate={() => 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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur lg:hidden">
|
||||
<Link href="/portal" className="flex items-center gap-2 font-semibold">
|
||||
<Image
|
||||
src="/logo/blackLogoLong.png"
|
||||
alt="Neta"
|
||||
width={120}
|
||||
height={32}
|
||||
className="h-8 w-auto object-contain"
|
||||
style={{ width: "auto" }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 w-9 p-0 lg:hidden"
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 p-4 lg:p-8 min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppSidebar({
|
||||
pathname,
|
||||
user,
|
||||
progress = 0,
|
||||
onNavigate,
|
||||
className,
|
||||
}: {
|
||||
pathname: string;
|
||||
user: PortalShellProps["user"];
|
||||
progress?: number;
|
||||
onNavigate?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Sidebar
|
||||
variant="bordered"
|
||||
className={cn(
|
||||
"flex h-dvh max-h-dvh flex-col overflow-hidden rounded-none border-y-0 border-l-0 border-r border-border",
|
||||
className
|
||||
)}
|
||||
<AppShell
|
||||
homeHref="/portal"
|
||||
navGroups={portalSidebarData}
|
||||
settingsHref="/portal/settings"
|
||||
user={user}
|
||||
progress={progress}
|
||||
>
|
||||
<SidebarHeader className="shrink-0 border-b-0 px-6 py-3">
|
||||
<Link href="/portal" className="flex w-full items-center justify-center">
|
||||
<Image
|
||||
src="/logo/blackLogoLong.png"
|
||||
alt="Neta"
|
||||
width={160}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain"
|
||||
style={{ width: "auto" }}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator className="mx-0 my-0 w-full" />
|
||||
|
||||
<SidebarContent className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
{portalSidebarData.map((group, groupIndex) => (
|
||||
<div key={group.title}>
|
||||
<SidebarSection title={group.title} defaultOpen className="mb-0">
|
||||
<SidebarMenu>
|
||||
{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 (
|
||||
<SidebarMenuItem
|
||||
key={item.href || item.title}
|
||||
active={isActive}
|
||||
icon={Icon ? <Icon className="h-4 w-4" /> : undefined}
|
||||
className={cn(isActive && "font-semibold")}
|
||||
>
|
||||
<Link
|
||||
href={item.href || "#"}
|
||||
onClick={onNavigate}
|
||||
className="block w-full"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarSection>
|
||||
{groupIndex < portalSidebarData.length - 1 ? (
|
||||
<SidebarSeparator className="-mx-6 my-5 w-[calc(100%+3rem)]" />
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="mt-auto flex shrink-0 flex-col gap-5 border-t border-border px-6 py-5">
|
||||
<Card className="bg-primary/5 border-primary/10 shadow-none overflow-hidden relative">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="text-sm font-semibold text-foreground relative z-10">Proje İlerlemesi</div>
|
||||
<div className="space-y-1.5 mt-1 relative z-10">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="text-primary">%{progress} Tamamlandı</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-primary/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-700 ease-out relative"
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
<div className="absolute top-0 right-0 bottom-0 left-0 bg-white/20 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-4 -top-4 w-16 h-16 bg-primary/5 rounded-full blur-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-sm text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarUserProfile
|
||||
name={user.displayName}
|
||||
role={user.email}
|
||||
avatarUrl={user.avatarUrl || undefined}
|
||||
initials={user.shortName}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<ChevronUp className="mr-3 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
|
||||
>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/portal/settings" onClick={onNavigate} className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Ayarlar
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<form action={signOut}>
|
||||
<DropdownMenuItem asChild>
|
||||
<button type="submit" className="w-full gap-2">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Çıkış yap
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</form>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarTrigger className="hidden" />
|
||||
</Sidebar>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
+70
-60
@@ -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<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
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<ButtonSize, string> = {
|
||||
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<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "primary", size = "md", type = "button", ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-md border font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
export { Button, buttonVariants }
|
||||
Button.displayName = "Button";
|
||||
|
||||
export type IconButtonProps = Omit<ButtonProps, "children" | "size"> & {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
({ label, tooltip, children, className, ...props }, ref) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
size="icon"
|
||||
aria-label={label}
|
||||
title={tooltip ?? label}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
),
|
||||
);
|
||||
|
||||
IconButton.displayName = "IconButton";
|
||||
|
||||
+13
-88
@@ -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 (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>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 (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function CardHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||
return <div className={cn("space-y-1.5 p-5", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function CardTitle({ className, ...props }: ComponentPropsWithoutRef<"h3">) {
|
||||
return <h3 className={cn("text-base font-semibold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
export function CardContent({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||
return <div className={cn("p-5 pt-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<label
|
||||
className={cn("text-sm font-medium leading-none text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FieldProps = ComponentPropsWithoutRef<"div"> & {
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
error?: ReactNode;
|
||||
htmlFor?: string;
|
||||
};
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const descriptionId = htmlFor && description ? `${htmlFor}-description` : undefined;
|
||||
const errorId = htmlFor && error ? `${htmlFor}-error` : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{label ? <Label htmlFor={htmlFor}>{label}</Label> : null}
|
||||
{children}
|
||||
{description ? (
|
||||
<p id={descriptionId} className="text-xs leading-5 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p id={errorId} role="alert" className="text-xs leading-5 text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+25
-14
@@ -1,19 +1,30 @@
|
||||
import * as React from "react"
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { forwardRef } from "react";
|
||||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-input-bg px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-sm",
|
||||
className
|
||||
)}
|
||||
const controlClasses =
|
||||
"w-full rounded-md border border-input bg-input-bg px-3 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-80 aria-invalid:border-destructive aria-invalid:ring-destructive";
|
||||
|
||||
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => (
|
||||
<input ref={ref} className={cn(controlClasses, "h-10", className)} {...props} />
|
||||
));
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(controlClasses, "min-h-24 py-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
export { Input }
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "poyraz-ui/atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
export function Skeleton({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
aria-hidden="true"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
||||
+142
-114
@@ -1,129 +1,157 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ComponentPropsWithoutRef, ReactElement } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
type ToastTone = "success" | "error" | "info";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
type ToastItem = {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: ToastTone;
|
||||
};
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
type ToastPayload = {
|
||||
message: string;
|
||||
tone?: ToastTone;
|
||||
};
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
const toastEventName = "neta:toast";
|
||||
let toastId = 0;
|
||||
|
||||
export function showToast(payload: ToastPayload) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
window.dispatchEvent(new CustomEvent<ToastPayload>(toastEventName, { detail: payload }));
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleToast(event: Event) {
|
||||
const customEvent = event as CustomEvent<ToastPayload>;
|
||||
const item = {
|
||||
id: ++toastId,
|
||||
message: customEvent.detail.message,
|
||||
tone: customEvent.detail.tone ?? "info",
|
||||
};
|
||||
|
||||
setItems((current) => [...current.slice(-2), item]);
|
||||
window.setTimeout(() => {
|
||||
setItems((current) => current.filter((toast) => toast.id !== item.id));
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
window.addEventListener(toastEventName, handleToast);
|
||||
return () => window.removeEventListener(toastEventName, handleToast);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-relevant="additions text"
|
||||
className="fixed bottom-4 right-4 z-[80] flex w-[min(calc(100vw-2rem),24rem)] flex-col gap-2"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-md border bg-surface p-4 text-sm shadow-lg",
|
||||
item.tone === "error" && "border-destructive/30",
|
||||
item.tone === "success" && "border-success/30",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 h-2 w-2 shrink-0 rounded-full bg-info",
|
||||
item.tone === "error" && "bg-destructive",
|
||||
item.tone === "success" && "bg-success",
|
||||
)}
|
||||
/>
|
||||
<p className="min-w-0 flex-1 text-foreground">{item.message}</p>
|
||||
<IconButton
|
||||
label="Bildirimi kapat"
|
||||
variant="ghost"
|
||||
className="-mr-2 -mt-2 h-8 w-8"
|
||||
onClick={() => setItems((current) => current.filter((toast) => toast.id !== item.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ToastProps = ComponentPropsWithoutRef<"div"> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
variant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
export type ToastActionElement = ReactElement;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function Toast({ open = true, onOpenChange, variant, className, ...props }: ToastProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={variant === "destructive" ? "alert" : "status"}
|
||||
className={cn(
|
||||
"group pointer-events-auto flex w-full items-start gap-3 rounded-md border bg-surface p-4 text-sm shadow-lg",
|
||||
variant === "destructive" && "border-destructive/30",
|
||||
className,
|
||||
)}
|
||||
data-on-open-change={onOpenChange ? "" : undefined}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
);
|
||||
}
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
export function ToastTitle({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||
return <div className={cn("font-medium text-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
export function ToastDescription({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
export function ToastClose({ className, ...props }: ComponentPropsWithoutRef<"button">) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-auto rounded-sm p-1 text-muted-foreground opacity-80 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Bildirimi kapat</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
export function ToastViewport({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-4 right-4 z-[80] flex w-[min(calc(100vw-2rem),24rem)] flex-col gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user