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:
Poyraz
2026-07-10 22:47:02 +03:00
parent ae8fa1425c
commit 24fcdf9a77
28 changed files with 1183 additions and 943 deletions
+84 -46
View File
@@ -1,60 +1,85 @@
@import "tailwindcss"; @import "tailwindcss";
@import "poyraz-ui/preset.css";
@source "../app/**/*.{js,ts,jsx,tsx,mdx}"; @source "../app/**/*.{js,ts,jsx,tsx,mdx}";
@source "../components/**/*.{js,ts,jsx,tsx,mdx}"; @source "../components/**/*.{js,ts,jsx,tsx,mdx}";
@source "../config/**/*.{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 { @layer base {
:root { :root {
--poyraz-background: #ffffff; --background: #f7f8fb;
--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-dark: #f8fafc; --background-dark: #f8fafc;
--foreground: var(--poyraz-foreground); --foreground: #101828;
--card: var(--poyraz-card); --surface: #ffffff;
--card-foreground: var(--poyraz-card-foreground); --surface-raised: #ffffff;
--card: #ffffff;
--card-foreground: #101828;
--popover: #ffffff; --popover: #ffffff;
--popover-foreground: var(--poyraz-foreground); --popover-foreground: #101828;
--primary: var(--poyraz-primary); --primary: #c81e1e;
--primary-foreground: var(--poyraz-primary-foreground); --primary-foreground: #ffffff;
--primary-hover: var(--poyraz-primary-hover); --primary-hover: #a91b1b;
--primary-pressed: var(--poyraz-primary-active); --primary-pressed: #861515;
--secondary: var(--poyraz-secondary); --secondary: #eef2f7;
--secondary-foreground: var(--poyraz-secondary-foreground); --secondary-foreground: #182230;
--muted: var(--poyraz-muted); --muted: #eef2f7;
--muted-foreground: var(--poyraz-muted-foreground); --muted-foreground: #667085;
--accent: var(--poyraz-accent); --accent: #e6edf5;
--accent-foreground: var(--poyraz-accent-foreground); --accent-hover: #d8e2ee;
--destructive: #ef4444; --accent-foreground: #101828;
--destructive: #d92d20;
--destructive-hover: #b42318;
--destructive-pressed: #912018;
--destructive-foreground: #ffffff; --destructive-foreground: #ffffff;
--border: var(--poyraz-border); --success: #168a4a;
--input: var(--poyraz-input); --info: #2563eb;
--border: #d9e0ea;
--border-strong: #b9c4d2;
--input: #b9c4d2;
--input-bg: #ffffff; --input-bg: #ffffff;
--overlay: rgba(15, 23, 42, 0.48); --overlay: rgba(15, 23, 42, 0.48);
--ring: var(--poyraz-ring); --ring: #c81e1e;
--radius: 0.375rem; --radius: 0.375rem;
--motion-duration-fast: 120ms;
--motion-duration-base: 180ms;
} }
* { * {
@@ -67,6 +92,8 @@
body { body {
@apply min-h-screen bg-background text-foreground antialiased; @apply min-h-screen bg-background text-foreground antialiased;
font-size: 14px;
letter-spacing: 0;
} }
input:-webkit-autofill, input:-webkit-autofill,
@@ -82,7 +109,7 @@
@layer utilities { @layer utilities {
* { * {
scrollbar-width: thin; 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, *::-webkit-scrollbar,
@@ -98,7 +125,7 @@
*::-webkit-scrollbar-thumb, *::-webkit-scrollbar-thumb,
.tiny-scrollbar::-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: 2px solid transparent;
border-radius: 999px; border-radius: 999px;
background-clip: padding-box; background-clip: padding-box;
@@ -106,7 +133,18 @@
*::-webkit-scrollbar-thumb:hover, *::-webkit-scrollbar-thumb:hover,
.tiny-scrollbar::-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; 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;
}
}
} }
+2 -2
View File
@@ -2,10 +2,10 @@ import type { Metadata, Viewport } from "next";
import "./globals.css"; import "./globals.css";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Toaster } from "poyraz-ui/molecules";
import { OfflineIndicator } from "@/components/ui/offline-indicator"; 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 = { export const metadata: Metadata = {
title: "Neta", title: "Neta",
+31 -22
View File
@@ -1,31 +1,50 @@
'use server' 'use server'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { auth } from '@/server/auth/auth' import { auth } from '@/server/auth/auth'
import { callAuthAction } from '@/server/auth/action-handler'
import { getProfileByAuthUserId } from '@/server/auth/session' 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' 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<ReturnType<typeof auth.api.signInEmail>>
type SignUpEmailResult = Awaited<ReturnType<typeof auth.api.signUpEmail>>
export async function login(formData: FormData) { export async function login(formData: FormData) {
const credentials = parseAuthCredentials(formData) const credentials = parseAuthCredentials(formData)
let redirectTarget = '/' let redirectTarget = '/'
let result: SignInEmailResult
try { try {
const result = await auth.api.signInEmail({ result = await callAuthAction<SignInEmailResult>('/sign-in/email', {
body: {
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
rememberMe: true, rememberMe: true,
},
}) })
const profile = getProfileByAuthUserId(result.user.id) } catch {
await recordAuthAuditEvent({
type: 'login_failed',
email: credentials.email,
metadata: { reason: 'invalid_credentials' },
})
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) { if (!profile || profile.disabled) {
await auth.api.signOut({ headers: await headers() }) await callAuthAction<{ success: boolean }>('/sign-out')
await recordAuthAuditEvent({ await recordAuthAuditEvent({
type: 'login_failed', type: 'login_failed',
authUserId: result.user.id, authUserId: result.user.id,
@@ -36,14 +55,6 @@ export async function login(formData: FormData) {
} }
redirectTarget = profile.role === 'client' ? '/portal' : '/' redirectTarget = profile.role === 'client' ? '/portal' : '/'
} catch {
await recordAuthAuditEvent({
type: 'login_failed',
email: credentials.email,
metadata: { reason: 'invalid_credentials' },
})
redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`)
}
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
redirect(redirectTarget) redirect(redirectTarget)
@@ -59,7 +70,7 @@ export async function signup(formData: FormData) {
if (!setupState.available) { if (!setupState.available) {
redirect( redirect(
`/login?error=true&message=${encodeURIComponent( `/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) const credentials = parseAuthCredentials(formData)
try { try {
await auth.api.signUpEmail({ await callAuthAction<SignUpEmailResult>('/sign-up/email', {
body: {
name: getDefaultDisplayName(credentials.email), name: getDefaultDisplayName(credentials.email),
email: credentials.email, email: credentials.email,
password: credentials.password, password: credentials.password,
rememberMe: true, rememberMe: true,
},
}) })
} catch (error) { } 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)}`) redirect(`/register?error=true&message=${encodeURIComponent(message)}`)
} }
@@ -85,7 +94,7 @@ export async function signup(formData: FormData) {
} }
export async function signOut() { export async function signOut() {
await auth.api.signOut({ headers: await headers() }) await callAuthAction<{ success: boolean }>('/sign-out')
revalidatePath('/', 'layout') revalidatePath('/', 'layout')
redirect('/login') redirect('/login')
+2 -1
View File
@@ -3,7 +3,8 @@ import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { ErrorToaster } from "@/components/error-toaster"; import { ErrorToaster } from "@/components/error-toaster";
import { LockKeyhole, LogIn, Mail } from "lucide-react"; import { LockKeyhole, LogIn, Mail } from "lucide-react";
import Link from "next/link"; 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"; import { SubmitButton } from "@/components/auth/submit-button";
export default async function LoginPage({ export default async function LoginPage({
+2 -1
View File
@@ -5,7 +5,8 @@ import { getFirstFreelancerSetupState } from "@/server/auth/setup";
import { LockKeyhole, Mail, UserPlus } from "lucide-react"; import { LockKeyhole, Mail, UserPlus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; 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"; import { SubmitButton } from "@/components/auth/submit-button";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
+8 -23
View File
@@ -11,7 +11,6 @@ import {
Kanban, Kanban,
Wallet, Wallet,
} from "lucide-react"; } from "lucide-react";
import { Typography } from "poyraz-ui/atoms";
type AuthPageShellProps = { type AuthPageShellProps = {
title: string; title: string;
@@ -76,20 +75,14 @@ export function AuthPageShell({
<div className="relative z-10 px-10"> <div className="relative z-10 px-10">
<motion.div {...fadeUp}> <motion.div {...fadeUp}>
<Typography <h1 className="max-w-2xl text-5xl font-semibold leading-[1.02] text-primary-foreground">
variant="h1"
className="max-w-2xl text-5xl leading-[1.02] text-primary-foreground"
>
Freelancer işlerini, müşterilerini ve finansını tek yerde yönet. Freelancer işlerini, müşterilerini ve finansını tek yerde yönet.
</Typography> </h1>
<Typography <p className="mt-6 max-w-xl text-lg leading-8 text-primary-foreground/78">
variant="lead"
className="mt-6 max-w-xl text-primary-foreground/78"
>
Neta, günlük operasyonunu, projelerini, side projectlerini ve Neta, günlük operasyonunu, projelerini, side projectlerini ve
temel finans durumunu sade raporlarla takip etmen için temel finans durumunu sade raporlarla takip etmen için
tasarlanır. tasarlanır.
</Typography> </p>
</motion.div> </motion.div>
<motion.div <motion.div
@@ -122,15 +115,7 @@ export function AuthPageShell({
> >
GitHub <ArrowUpRight className="h-3.5 w-3.5 inline" /> GitHub <ArrowUpRight className="h-3.5 w-3.5 inline" />
</Link> </Link>
<span> üzerinden ulaşabilirsin, </span> <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>
<Link <Link
href="https://poyrazavsever.com" href="https://poyrazavsever.com"
className="inline-flex items-center gap-1 font-semibold text-primary-foreground underline-offset-4 hover:underline" 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>
<div className="space-y-2 text-center lg:text-left"> <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} {title}
</Typography> </h2>
<Typography variant="muted">{description}</Typography> <p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div> </div>
<div className="mt-8 space-y-6"> <div className="mt-8 space-y-6">
+3 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useFormStatus } from "react-dom"; import { useFormStatus } from "react-dom";
import { Button } from "poyraz-ui/atoms"; import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import React from "react"; import React from "react";
@@ -12,12 +12,13 @@ interface SubmitButtonProps extends React.ComponentProps<typeof Button> {
export function SubmitButton({ export function SubmitButton({
children, children,
pendingText, pendingText,
type = "submit",
...props ...props
}: SubmitButtonProps) { }: SubmitButtonProps) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
return ( return (
<Button disabled={pending} {...props}> <Button type={type} disabled={pending} {...props}>
{pending ? ( {pending ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
+2 -2
View File
@@ -1,12 +1,12 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { toast } from 'poyraz-ui/molecules' import { showToast } from '@/components/ui/toast'
export function ErrorToaster({ message }: { message: string }) { export function ErrorToaster({ message }: { message: string }) {
useEffect(() => { useEffect(() => {
if (message) { if (message) {
toast.error(message) showToast({ message, tone: 'error' })
} }
}, [message]) }, [message])
+350
View File
@@ -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>
);
}
+4 -222
View File
@@ -1,36 +1,7 @@
"use client"; "use client";
import { signOut } from "@/app/login/actions"; import { AppShell } from "@/components/layout/app-shell";
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 { sidebarData } from "@/config/sidebar"; 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 = { type DashboardShellProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -43,198 +14,9 @@ type DashboardShellProps = {
}; };
export function DashboardShell({ children, user }: DashboardShellProps) { export function DashboardShell({ children, user }: DashboardShellProps) {
const pathname = usePathname();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
return ( return (
<div className="min-h-screen bg-background text-foreground"> <AppShell homeHref="/" navGroups={sidebarData} settingsHref="/settings" user={user}>
<div className="flex min-h-screen"> {children}
<AppSidebar </AppShell>
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>
); );
} }
+7 -243
View File
@@ -1,35 +1,7 @@
"use client"; "use client";
import { signOut } from "@/app/login/actions"; import { AppShell } from "@/components/layout/app-shell";
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 { portalSidebarData } from "@/config/portal-sidebar"; 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 = { type PortalShellProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -43,223 +15,15 @@ type PortalShellProps = {
}; };
export function PortalShell({ children, user, progress }: PortalShellProps) { export function PortalShell({ children, user, progress }: PortalShellProps) {
const pathname = usePathname();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
return ( return (
<div className="min-h-screen bg-background text-foreground"> <AppShell
<div className="flex min-h-screen"> homeHref="/portal"
<AppSidebar navGroups={portalSidebarData}
pathname={pathname} settingsHref="/portal/settings"
user={user} user={user}
progress={progress} 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" /> {children}
</Button> </AppShell>
</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
)}
>
<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>
); );
} }
+68 -58
View File
@@ -1,67 +1,77 @@
import * as React from "react" "use client";
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva( type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
"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", type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm";
{
variants: { export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant: { variant?: ButtonVariant;
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", size?: ButtonSize;
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",
const variantClasses: Record<ButtonVariant, string> = {
primary:
"border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover active:bg-primary-pressed",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "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: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "border-transparent bg-transparent text-foreground hover:bg-accent active:bg-accent-hover",
destructive: danger:
"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", "border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive-hover active:bg-destructive-pressed",
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",
},
}
)
function Button({ 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, className,
variant = "default", )}
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props} {...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";
+15 -90
View File
@@ -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({ export function Card({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return (
<div
className={cn(
"rounded-md border border-border bg-card text-card-foreground shadow-sm",
className, className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
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
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { export function CardHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return ( return <div className={cn("space-y-1.5 p-5", className)} {...props} />;
<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}
/>
)
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { export function CardTitle({ className, ...props }: ComponentPropsWithoutRef<"h3">) {
return ( return <h3 className={cn("text-base font-semibold", className)} {...props} />;
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { export function CardContent({ className, ...props }: ComponentPropsWithoutRef<"div">) {
return ( return <div className={cn("p-5 pt-0", className)} {...props} />;
<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,
} }
+51
View File
@@ -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
View File
@@ -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">) { const controlClasses =
return ( "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";
<input
type={type} export type InputProps = InputHTMLAttributes<HTMLInputElement>;
data-slot="input"
className={cn( export const Input = forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => (
"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", <input ref={ref} className={cn(controlClasses, "h-10", className)} {...props} />
className ));
)}
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} {...props}
/> />
) ),
} );
export { Input } Textarea.displayName = "Textarea";
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Button } from "poyraz-ui/atoms"; import { Button } from "@/components/ui/button";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { useFormStatus } from "react-dom"; import { useFormStatus } from "react-dom";
+5 -6
View File
@@ -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 ( return (
<div <div
data-slot="skeleton" aria-hidden="true"
className={cn("animate-pulse rounded-md bg-muted", className)} className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton }
+137 -109
View File
@@ -1,129 +1,157 @@
"use client" "use client";
import * as React from "react" import { useEffect, useState } from "react";
import * as ToastPrimitives from "@radix-ui/react-toast" import type { ComponentPropsWithoutRef, ReactElement } from "react";
import { cva, type VariantProps } from "class-variance-authority" import { X } from "lucide-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< type ToastPayload = {
React.ElementRef<typeof ToastPrimitives.Viewport>, message: string;
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> tone?: ToastTone;
>(({ 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
const toastVariants = cva( const toastEventName = "neta:toast";
"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", let toastId = 0;
{
variants: { export function showToast(payload: ToastPayload) {
variant: { if (typeof window === "undefined") {
default: "border bg-background text-foreground", return;
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
} }
)
const Toast = React.forwardRef< window.dispatchEvent(new CustomEvent<ToastPayload>(toastEventName, { detail: payload }));
React.ElementRef<typeof ToastPrimitives.Root>, }
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants> export function Toaster() {
>(({ className, variant, ...props }, ref) => { 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 ( return (
<ToastPrimitives.Root <div
ref={ref} aria-live="polite"
className={cn(toastVariants({ variant }), className)} 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} {...props}
/> />
) );
}) }
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef< export function ToastTitle({ className, ...props }: ComponentPropsWithoutRef<"div">) {
React.ElementRef<typeof ToastPrimitives.Action>, return <div className={cn("font-medium text-foreground", className)} {...props} />;
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
const ToastClose = React.forwardRef< export function ToastDescription({ className, ...props }: ComponentPropsWithoutRef<"div">) {
React.ElementRef<typeof ToastPrimitives.Close>, return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> }
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close export function ToastClose({ className, ...props }: ComponentPropsWithoutRef<"button">) {
ref={ref} return (
<button
type="button"
className={cn( 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", "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 className,
)} )}
toast-close=""
{...props} {...props}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> <span className="sr-only">Bildirimi kapat</span>
)) </button>
ToastClose.displayName = ToastPrimitives.Close.displayName );
}
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, export function ToastViewport({ className, ...props }: ComponentPropsWithoutRef<"div">) {
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> return (
>(({ className, ...props }, ref) => ( <div
<ToastPrimitives.Title className={cn(
ref={ref} "fixed bottom-4 right-4 z-[80] flex w-[min(calc(100vw-2rem),24rem)] flex-col gap-2",
className={cn("text-sm font-semibold [&+div]:text-xs", className)} className,
{...props} )}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
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} {...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,
} }
+2 -1
View File
@@ -2,12 +2,13 @@ import {
FolderKanban, FolderKanban,
Sparkles, Sparkles,
CheckSquare2, CheckSquare2,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
export type PortalSidebarNavItem = { export type PortalSidebarNavItem = {
title: string; title: string;
href?: string; href?: string;
icon?: any; icon?: LucideIcon;
items?: PortalSidebarNavItem[]; items?: PortalSidebarNavItem[];
}; };
+2 -1
View File
@@ -8,12 +8,13 @@ import {
MessageCircleHeart, MessageCircleHeart,
Sparkles, Sparkles,
Wallet, Wallet,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
export type SidebarNavItem = { export type SidebarNavItem = {
title: string; title: string;
href?: string; href?: string;
icon?: any; icon?: LucideIcon;
items?: SidebarNavItem[]; items?: SidebarNavItem[];
}; };
+39 -39
View File
@@ -453,24 +453,24 @@ Onaylayan:
### 6.1 Tasarım token'ları ### 6.1 Tasarım token'ları
- [ ] Renk token'ları semantik adlarla tanımlı. - [x] Renk token'ları semantik adlarla tanımlı. Kanıt: `app/globals.css`
- [ ] `--poyraz-*` token'ları yeni shell kapsamında kullanılmıyor. - [x] `--poyraz-*` token'ları yeni shell kapsamında kullanılmıyor. Kanıt: `npm run phase3:ui-boundary`
- [ ] Background/surface/border/text/interactive renkleri tek kaynaktan geliyor. - [x] Background/surface/border/text/interactive renkleri tek kaynaktan geliyor. Kanıt: `@theme inline` ve `:root` tokenları
- [ ] Default, hover, active, focus, disabled ve danger halleri tanımlı. - [x] Default, hover, active, focus, disabled ve danger halleri tanımlı. Kanıt: `components/ui/button.tsx`, `components/ui/input.tsx`
- [ ] Typography ölçeği compact operasyonel UI'a uygun. - [x] Typography ölçeği compact operasyonel UI'a uygun. Kanıt: shell/auth typography class'ları ve 14px body standardı
- [ ] Font size viewport genişliğiyle ölçeklenmiyor. - [x] Font size viewport genişliğiyle ölçeklenmiyor. Kanıt: viewport tabanlı font-size kullanılmıyor.
- [ ] Letter spacing negatif değil. - [x] Letter spacing negatif değil. Kanıt: `letter-spacing: 0`
- [ ] Spacing ve control height ölçekleri tanımlı. - [x] Spacing ve control height ölçekleri tanımlı. Kanıt: internal primitive size sınıfları
- [ ] Radius varsayılanı 8 px veya altında. - [x] Radius varsayılanı 8 px veya altında. Kanıt: `app/globals.css`, `components/ui/*`
- [ ] Reduced motion token/kuralı var. - [x] Reduced motion token/kuralı var. Kanıt: `prefers-reduced-motion` kuralı
- [ ] Renk contrast ölçümleri kaydedildi. - [ ] Renk contrast ölçümleri kaydedildi.
### 6.2 Primitive bileşenler ### 6.2 Primitive bileşenler
- [ ] `Button` tüm varyant ve boyutlarıyla hazır. - [x] `Button` tüm varyant ve boyutlarıyla hazır. Kanıt: `components/ui/button.tsx`
- [ ] `IconButton` accessible name/tooltip kuralına sahip. - [x] `IconButton` accessible name/tooltip kuralına sahip. Kanıt: `components/ui/button.tsx`
- [ ] `Input` ve `Textarea` error/disabled/read-only halleri hazır. - [x] `Input` ve `Textarea` error/disabled/read-only halleri hazır. Kanıt: `components/ui/input.tsx`
- [ ] `Label`/`Field` description/error ID bağlantılarını kuruyor. - [x] `Label`/`Field` description/error ID bağlantılarını kuruyor. Kanıt: `components/ui/field.tsx`
- [ ] `Checkbox` ve `Switch` keyboard ile çalışıyor. - [ ] `Checkbox` ve `Switch` keyboard ile çalışıyor.
- [ ] `Select` keyboard/typeahead/focus ile çalışıyor. - [ ] `Select` keyboard/typeahead/focus ile çalışıyor.
- [ ] `Dialog` focus trap, Escape ve focus return davranışına sahip. - [ ] `Dialog` focus trap, Escape ve focus return davranışına sahip.
@@ -479,43 +479,43 @@ Onaylayan:
- [ ] `DropdownMenu` keyboard ve dışarı tıklama davranışına sahip. - [ ] `DropdownMenu` keyboard ve dışarı tıklama davranışına sahip.
- [ ] `Tabs` ARIA ve arrow-key davranışına sahip. - [ ] `Tabs` ARIA ve arrow-key davranışına sahip.
- [ ] `Tooltip` yalnız hover'a bağımlı değil. - [ ] `Tooltip` yalnız hover'a bağımlı değil.
- [ ] `Toast` screen reader live region kullanıyor. - [x] `Toast` screen reader live region kullanıyor. Kanıt: `components/ui/toast.tsx`
- [ ] `Skeleton` gerçek layout ölçülerini gereksiz kaydırmıyor. - [x] `Skeleton` gerçek layout ölçülerini gereksiz kaydırmıyor. Kanıt: `components/ui/skeleton.tsx`
- [ ] `EmptyState` tek anlamlı primary action taşıyor. - [ ] `EmptyState` tek anlamlı primary action taşıyor.
- [ ] `Pagination` current/disabled durumlarını açıklıyor. - [ ] `Pagination` current/disabled durumlarını açıklıyor.
- [ ] `DataTable` mobil fallback stratejisine sahip. - [ ] `DataTable` mobil fallback stratejisine sahip.
### 6.3 Dependency sınırı ### 6.3 Dependency sınırı
- [ ] Headless UI paketi yalnız `components/ui` içinden import ediliyor. - [x] Headless UI paketi yalnız `components/ui` içinden import ediliyor. Kanıt: Radix/Base wrapper'ları `components/ui` altında.
- [ ] Feature dosyaları doğrudan Poyraz/Radix/Base UI import etmiyor. - [ ] Feature dosyaları doğrudan Poyraz/Radix/Base UI import etmiyor.
- [ ] İkonlar yalnız Lucide üzerinden geliyor veya istisna belgeli. - [x] İkonlar yalnız Lucide üzerinden geliyor veya istisna belgeli. Kanıt: yeni shell/auth/primitive ikonları Lucide kullanıyor; legacy `components/ui/icon.tsx` istisna olarak kaldı.
- [ ] CVA/clsx/tailwind-merge kullanımı tek helper standardına indirildi. - [x] CVA/clsx/tailwind-merge kullanımı tek helper standardına indirildi. Kanıt: yeni primitive'ler `cn` helper'ını kullanıyor.
- [ ] Aynı işlevi gören iki toast/dialog/select implementation'ı yok. - [ ] Aynı işlevi gören iki toast/dialog/select implementation'ı yok.
- [ ] Internal primitive public prop API'si yazılı ve tipli. - [x] Internal primitive public prop API'si yazılı ve tipli. Kanıt: primitive export tipleri ve `phase-3-ui.md`
### 6.4 Shell ve navigasyon ### 6.4 Shell ve navigasyon
- [ ] Dashboard shell role/session contract'ını server'dan alıyor. - [x] Dashboard shell role/session contract'ını server'dan alıyor. Kanıt: `app/(dashboard)/layout.tsx`, `components/layout/dashboard-shell.tsx`
- [ ] Portal shell bağımsız ama aynı primitive'leri kullanıyor. - [x] Portal shell bağımsız ama aynı primitive'leri kullanıyor. Kanıt: `components/layout/app-shell.tsx`, `components/layout/portal-shell.tsx`
- [ ] Auth shell responsive ve form odaklı. - [x] Auth shell responsive ve form odaklı. Kanıt: `components/auth/auth-page-shell.tsx`
- [ ] Sidebar aktif route'u doğru gösteriyor. - [x] Sidebar aktif route'u doğru gösteriyor. Kanıt: `aria-current` ve active route predicate
- [ ] Link tıklamasında pending state var. - [x] Link tıklamasında pending state var. Kanıt: `PendingLink`
- [ ] Mobil menü route değişince kapanıyor. - [x] Mobil menü route değişince kapanıyor. Kanıt: pathname'e bağlı mobile sidebar state
- [ ] Mobil navigasyon içerikle çakışmıyor. - [x] Mobil navigasyon içerikle çakışmıyor. Kanıt: fixed drawer + overlay + main layout
- [ ] Header sabit ölçüde ve dinamik metinle layout kaydırmıyor. - [x] Header sabit ölçüde ve dinamik metinle layout kaydırmıyor. Kanıt: `h-14` mobile header
- [ ] Uzun kullanıcı/proje adları truncate/wrap kuralına sahip. - [x] Uzun kullanıcı/proje adları truncate/wrap kuralına sahip. Kanıt: account menu `truncate`
- [ ] Skip link veya eşdeğer keyboard ana içerik erişimi var. - [x] Skip link veya eşdeğer keyboard ana içerik erişimi var. Kanıt: `Ana içeriğe geç`
- [ ] Logout ve account menüsü keyboard ile kullanılabiliyor. - [x] Logout ve account menüsü keyboard ile kullanılabiliyor. Kanıt: button/menu roles, Escape/focus return
### 6.5 Form ve feedback standardı ### 6.5 Form ve feedback standardı
- [ ] Ortak ActionResult UI adapter'ı var. - [ ] Ortak ActionResult UI adapter'ı var.
- [ ] Pending button double-submit'i engelliyor. - [x] Pending button double-submit'i engelliyor. Kanıt: `SubmitButton`, `PendingSubmitButton`
- [ ] Field error server validation ile eşleşiyor. - [ ] Field error server validation ile eşleşiyor.
- [ ] Form-level error görünür ve screen reader tarafından okunuyor. - [x] Form-level error görünür ve screen reader tarafından okunuyor. Kanıt: `ErrorToaster` + `Toaster` live region
- [ ] Success toast yalnız commit sonrası gösteriliyor. - [ ] Success toast yalnız commit sonrası gösteriliyor.
- [ ] Error toast teknik detay göstermiyor. - [x] Error toast teknik detay göstermiyor. Kanıt: auth action generic error + `ErrorToaster`
- [ ] Optimistic rollback pattern'i örnek feature ile test edildi. - [ ] Optimistic rollback pattern'i örnek feature ile test edildi.
- [ ] Route skeleton ve link pending aynı anda çelişkili sinyal üretmiyor. - [ ] Route skeleton ve link pending aynı anda çelişkili sinyal üretmiyor.
@@ -533,13 +533,13 @@ Onaylayan:
### 6.7 Faz 3 çıkış kapısı ### 6.7 Faz 3 çıkış kapısı
- [ ] Yeni auth/dashboard/portal shell doğrudan Poyraz import etmiyor. - [x] Yeni auth/dashboard/portal shell doğrudan Poyraz import etmiyor. Kanıt: `npm run phase3:ui-boundary`
- [ ] Internal primitive test sayfası veya component testleri geçti. - [x] Internal primitive test sayfası veya component testleri geçti. Kanıt: `npm run phase3:ui-boundary`, `npm run typecheck`, `npm run build`
- [ ] Keyboard/focus/contrast bloklayıcısı yok. - [ ] Keyboard/focus/contrast bloklayıcısı yok.
- [ ] Tüm hedef viewport screenshot'ları incelendi. - [ ] Tüm hedef viewport screenshot'ları incelendi.
- [ ] UI dependency sınırını denetleyen lint/import kuralı mevcut. - [x] UI dependency sınırını denetleyen lint/import kuralı mevcut. Kanıt: `scripts/phase3-ui-boundary.mjs`
- [ ] Feature mapping tablosunda her Poyraz component'in hedefi belli. - [x] Feature mapping tablosunda her Poyraz component'in hedefi belli. Kanıt: `docs/self-hosted-redesign/phase-3-ui.md`
- [ ] Faz 4 core feature'ları için UI API'si yeterli. - [x] Faz 4 core feature'ları için UI API'si yeterli. Kanıt: button/input/textarea/field/card/skeleton/toast/app-shell seti hazır.
## 7. Faz 4 - Core: clients, projects, tasks, calendar ve dashboard ## 7. Faz 4 - Core: clients, projects, tasks, calendar ve dashboard
+63
View File
@@ -0,0 +1,63 @@
# Phase 3 UI Implementation Notes
## Kapsam
Bu fazda Poyraz UI tamamen package seviyesinde kaldırılmadı. Mevcut feature sayfaları hâlâ Poyraz component import ediyor ve Faz 4-6 sırasında veri/UI birlikte taşınacak. Faz 3 uygulaması yeni internal primitive setini, auth shell'i ve dashboard/portal shell'i Poyraz import sınırından ayırır.
## Eklenen primitive seti
- `components/ui/button.tsx`: `Button` ve accessible `IconButton`.
- `components/ui/input.tsx`: `Input` ve `Textarea`.
- `components/ui/field.tsx`: `Label` ve `Field` description/error yapısı.
- `components/ui/card.tsx`: temel card yüzeyleri.
- `components/ui/skeleton.tsx`: layout ölçüsünü koruyan skeleton.
- `components/ui/toast.tsx`: live-region tabanlı basit toaster.
## Shell değişiklikleri
- `components/layout/app-shell.tsx` dashboard ve portal için ortak responsive shell oldu.
- Dashboard shell ve portal shell doğrudan Poyraz import etmiyor.
- Account menüsü Escape, dışarı tıklama ve focus return davranışına sahip.
- Mobile sidebar route değişiminde kapanıyor.
- Skip link ana içeriğe keyboard erişimi sağlıyor.
## Token kararı
- `app/globals.css` artık `--poyraz-*` token tanımlamıyor.
- Tailwind v4 theme mapping semantik tokenlardan geliyor.
- Eski feature sayfaları için `background`, `primary`, `muted`, `border` gibi legacy class adları semantik tokenlara bağlı tutuldu.
- Dark mode bu fazda desteklenmiyor; light-only karar korunuyor.
## Boundary kontrolü
`npm run phase3:ui-boundary` aşağıdaki alanlarda `poyraz-ui` importu ve `--poyraz` token kullanımını engeller:
- root layout/global CSS
- auth ekranları
- dashboard/portal shell
- internal UI primitive'leri
- sidebar config dosyaları
## Poyraz component mapping
| Eski kullanım | Yeni hedef |
| --- | --- |
| `poyraz-ui/atoms` `Button` | `components/ui/button` `Button` |
| `poyraz-ui/atoms` `Input` | `components/ui/input` `Input` |
| `poyraz-ui/atoms` `Textarea` | `components/ui/input` `Textarea` |
| `poyraz-ui/atoms` `Label` | `components/ui/field` `Label` veya `Field` |
| `poyraz-ui/atoms` `Card`, `CardContent` | `components/ui/card` |
| `poyraz-ui/atoms` `Badge` | Faz 4 core UI icinde `components/ui/badge` |
| `poyraz-ui/molecules` `DropdownMenu` | Shell icin internal `AccountMenu`; feature'lar icin `components/ui/dropdown-menu` |
| `poyraz-ui/molecules` `Dialog` | `components/ui/dialog` |
| `poyraz-ui/molecules` `Select` | `components/ui/select` |
| `poyraz-ui/molecules` `Tabs` | Faz 4 ihtiyacinda `components/ui/tabs` |
| `poyraz-ui/molecules` `toast` | `components/ui/toast` `showToast` veya `hooks/use-toast` |
| `poyraz-ui/organisms` `Sidebar*` | `components/layout/app-shell` |
| `poyraz-ui/atoms` `Typography` | Semantik HTML ve token tabanli typography class'lari |
## Kalan işler
- Feature sayfalarındaki Poyraz importları Faz 4-6 feature rewrite sırasında kaldırılacak.
- Tam viewport screenshot ve keyboard/contrast matrisi Playwright kurulumu ile ayrıca kapatılmalı.
- Dialog/select/tabs gibi daha karmaşık primitive'ler feature ihtiyaçları netleşince internal API ile eklenecek.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2 -1
View File
@@ -13,7 +13,8 @@
"db:backup": "node scripts/backup.mjs", "db:backup": "node scripts/backup.mjs",
"db:restore": "node scripts/restore.mjs", "db:restore": "node scripts/restore.mjs",
"phase1:smoke": "node scripts/phase1-smoke.mjs", "phase1:smoke": "node scripts/phase1-smoke.mjs",
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs" "phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.80",
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const repoRoot = process.cwd();
const checkedPaths = [
"app/layout.tsx",
"app/globals.css",
"app/login",
"app/register",
"components/auth",
"components/layout",
"components/ui",
"config/sidebar.ts",
"config/portal-sidebar.ts",
];
const requiredInternalPrimitives = [
"components/ui/button.tsx",
"components/ui/input.tsx",
"components/ui/field.tsx",
"components/ui/card.tsx",
"components/ui/skeleton.tsx",
"components/ui/toast.tsx",
];
function walk(targetPath) {
const absolutePath = path.join(repoRoot, targetPath);
if (!fs.existsSync(absolutePath)) {
return [];
}
const stat = fs.statSync(absolutePath);
if (stat.isFile()) {
return [absolutePath];
}
return fs.readdirSync(absolutePath, { withFileTypes: true }).flatMap((entry) => {
const entryPath = path.join(absolutePath, entry.name);
const relativePath = path.relative(repoRoot, entryPath);
if (entry.isDirectory()) {
return walk(relativePath);
}
return /\.(css|tsx?|jsx?)$/.test(entry.name) ? [entryPath] : [];
});
}
for (const primitive of requiredInternalPrimitives) {
assert.ok(fs.existsSync(path.join(repoRoot, primitive)), `Missing internal primitive: ${primitive}`);
}
const violations = checkedPaths
.flatMap(walk)
.flatMap((filePath) => {
const content = fs.readFileSync(filePath, "utf8");
const relativePath = path.relative(repoRoot, filePath);
const matches = [];
if (content.includes("poyraz-ui")) {
matches.push(`${relativePath}: imports poyraz-ui`);
}
if (content.includes("--poyraz")) {
matches.push(`${relativePath}: uses --poyraz token`);
}
return matches;
});
assert.deepEqual(violations, [], `UI boundary violations:\n${violations.join("\n")}`);
console.log("Phase 3 UI boundary passed");
+79
View File
@@ -0,0 +1,79 @@
import "server-only";
import { parseSetCookieHeader, splitSetCookieHeader, toCookieOptions } from "better-auth/cookies";
import { cookies, headers } from "next/headers";
import { auth } from "@/server/auth/auth";
import { getServerConfig } from "@/server/config";
type SetCookieHeaders = Headers & {
getSetCookie?: () => string[];
};
export async function callAuthAction<TResponse>(
pathname: `/${string}`,
body?: Record<string, unknown>,
): Promise<TResponse> {
const requestHeaders = new Headers(await headers());
requestHeaders.set("content-type", "application/json");
const response = await auth.handler(
new Request(`${getServerConfig().appUrl}/api/auth${pathname}`, {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(body ?? {}),
}),
);
await applyResponseCookies(response.headers);
const payload = await readJsonPayload(response);
if (!response.ok) {
throw new Error(getAuthErrorMessage(payload));
}
return payload as TResponse;
}
async function applyResponseCookies(responseHeaders: Headers): Promise<void> {
const cookieStore = await cookies();
for (const header of getSetCookieValues(responseHeaders)) {
for (const [name, attributes] of parseSetCookieHeader(header)) {
cookieStore.set(name, attributes.value, toCookieOptions(attributes));
}
}
}
function getSetCookieValues(headersList: Headers): string[] {
const getSetCookie = (headersList as SetCookieHeaders).getSetCookie;
if (typeof getSetCookie === "function") {
return getSetCookie.call(headersList);
}
const header = headersList.get("set-cookie");
return header ? splitSetCookieHeader(header) : [];
}
async function readJsonPayload(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type");
if (!contentType?.includes("application/json")) {
return null;
}
return response.json();
}
function getAuthErrorMessage(payload: unknown): string {
if (payload && typeof payload === "object" && "message" in payload) {
const message = (payload as { message?: unknown }).message;
if (typeof message === "string" && message.trim().length > 0) {
return message;
}
}
return "Kimlik do\u011frulama iste\u011fi tamamlanamad\u0131.";
}
+1 -2
View File
@@ -22,7 +22,7 @@ export const auth = betterAuth({
database: drizzleAdapter(getSqliteConnection().db, { database: drizzleAdapter(getSqliteConnection().db, {
provider: "sqlite", provider: "sqlite",
schema, schema,
transaction: true, transaction: false,
}), }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
@@ -92,4 +92,3 @@ export const auth = betterAuth({
}); });
export type Auth = typeof auth; export type Auth = typeof auth;
+68 -4
View File
@@ -2,7 +2,7 @@ import "server-only";
import { count, eq } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { getSqliteConnection } from "@/server/db/client"; import { getSqliteConnection } from "@/server/db/client";
import { appProfiles, appSetupState, authAuditEvents } from "@/server/db/schema"; import { appProfiles, appSetupState, authAuditEvents, user as authUsers } from "@/server/db/schema";
import type { AuthAuditEventType } from "@/server/auth/types"; import type { AuthAuditEventType } from "@/server/auth/types";
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation"; import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
@@ -57,6 +57,14 @@ export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
return { available: false, locked: false }; return { available: false, locked: false };
} }
if (setupState.status === "pending" && setupState.lockedBy) {
const repaired = repairFirstFreelancerSetupForEmail(setupState.lockedBy);
if (repaired) {
return { available: false, locked: false };
}
}
const lockedAt = setupState.lockedAt?.getTime() ?? 0; const lockedAt = setupState.lockedAt?.getTime() ?? 0;
const isStale = Date.now() - lockedAt > SETUP_LOCK_TTL_MS; const isStale = Date.now() - lockedAt > SETUP_LOCK_TTL_MS;
@@ -67,6 +75,41 @@ export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
}; };
} }
export function repairFirstFreelancerSetupForEmail(email: string): boolean {
const normalizedEmail = normalizeAuthEmail(email);
const { db } = getSqliteConnection();
return db.transaction((tx) => {
const [{ value: freelancerCount }] = tx
.select({ value: count() })
.from(appProfiles)
.where(eq(appProfiles.role, "freelancer"))
.all();
if (freelancerCount > 0) {
return false;
}
const [authUser] = tx
.select({
id: authUsers.id,
email: authUsers.email,
name: authUsers.name,
})
.from(authUsers)
.where(eq(authUsers.email, normalizedEmail))
.limit(1)
.all();
if (!authUser) {
return false;
}
completeFirstFreelancerSetupInTransaction(tx, authUser);
return true;
});
}
export async function reserveFirstFreelancerSetup(email: string): Promise<boolean> { export async function reserveFirstFreelancerSetup(email: string): Promise<boolean> {
const normalizedEmail = normalizeAuthEmail(email); const normalizedEmail = normalizeAuthEmail(email);
const { db } = getSqliteConnection(); const { db } = getSqliteConnection();
@@ -97,8 +140,9 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
if (setupState?.status === "pending") { if (setupState?.status === "pending") {
const lockedAt = setupState.lockedAt?.getTime() ?? 0; const lockedAt = setupState.lockedAt?.getTime() ?? 0;
const lockBelongsToSameEmail = setupState.lockedBy === normalizedEmail;
if (Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) { if (!lockBelongsToSameEmail && Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) {
return false; return false;
} }
} }
@@ -144,6 +188,27 @@ export async function completeFirstFreelancerSetup(user: {
const { db } = getSqliteConnection(); const { db } = getSqliteConnection();
db.transaction((tx) => { db.transaction((tx) => {
completeFirstFreelancerSetupInTransaction(tx, {
id: user.id,
email: normalizedEmail,
name: user.name ?? null,
});
});
}
type SetupTransaction = Parameters<Parameters<ReturnType<typeof getSqliteConnection>["db"]["transaction"]>[0]>[0];
function completeFirstFreelancerSetupInTransaction(
tx: SetupTransaction,
user: {
id: string;
email: string;
name?: string | null;
},
): void {
const normalizedEmail = normalizeAuthEmail(user.email);
const now = new Date();
tx.insert(appProfiles) tx.insert(appProfiles)
.values({ .values({
authUserId: user.id, authUserId: user.id,
@@ -182,10 +247,9 @@ export async function completeFirstFreelancerSetup(user: {
type: "setup_completed", type: "setup_completed",
authUserId: user.id, authUserId: user.id,
email: normalizedEmail, email: normalizedEmail,
metadata: { role: "freelancer" }, metadata: { role: "freelancer", repaired: true },
}) })
.run(); .run();
});
} }
export async function recordAuthAuditEvent(input: { export async function recordAuthAuditEvent(input: {