feat(ui): complete phase 4 Poyraz UI foundation
This commit is contained in:
@@ -22,7 +22,7 @@ type AnalyticsClientProps = {
|
|||||||
data: AnalyticsData;
|
data: AnalyticsData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLORS = ["hsl(var(--primary))", "hsl(var(--destructive))", "#eab308", "#3b82f6", "#8b5cf6"];
|
const COLORS = ["var(--poyraz-primary)", "var(--poyraz-destructive)", "#eab308", "#3b82f6", "#8b5cf6"];
|
||||||
|
|
||||||
export function AnalyticsClient({ data }: AnalyticsClientProps) {
|
export function AnalyticsClient({ data }: AnalyticsClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -98,8 +98,8 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => `₺${Number(value ?? 0)}`}
|
formatter={(value) => `₺${Number(value ?? 0)}`}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'hsl(var(--background))',
|
backgroundColor: 'var(--poyraz-background)',
|
||||||
borderColor: 'hsl(var(--border))',
|
borderColor: 'var(--poyraz-border)',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -119,9 +119,9 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
|
|||||||
<div className="h-[300px] w-full">
|
<div className="h-[300px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={taskStatusData}>
|
<BarChart data={taskStatusData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--poyraz-border)" />
|
||||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }} dy={10} />
|
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: 'var(--poyraz-muted-foreground)' }} dy={10} />
|
||||||
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }} dx={-10} />
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: 'var(--poyraz-muted-foreground)' }} dx={-10} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: 'transparent' }}
|
cursor={{ fill: 'transparent' }}
|
||||||
content={({ active, payload, label }) => {
|
content={({ active, payload, label }) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent, Skeleton } from "poyraz-ui/atoms";
|
||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
|
|
||||||
export default function AnalyticsLoading() {
|
export default function AnalyticsLoading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent, Skeleton } from "poyraz-ui/atoms";
|
||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
|
|
||||||
export default function ClientsLoading() {
|
export default function ClientsLoading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -111,18 +111,18 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
{incomeTrendData.length > 0 ? (
|
{incomeTrendData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={incomeTrendData}>
|
<BarChart data={incomeTrendData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--poyraz-border)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 12, fill: 'var(--poyraz-muted-foreground)' }}
|
||||||
dy={10}
|
dy={10}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 12, fill: 'var(--poyraz-muted-foreground)' }}
|
||||||
dx={-10}
|
dx={-10}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -181,26 +181,26 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
{moodTrendData.length > 0 ? (
|
{moodTrendData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={moodTrendData}>
|
<LineChart data={moodTrendData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--poyraz-border)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 12, fill: 'var(--poyraz-muted-foreground)' }}
|
||||||
dy={10}
|
dy={10}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
domain={[0, 5]}
|
domain={[0, 5]}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fontSize: 12, fill: 'var(--poyraz-muted-foreground)' }}
|
||||||
width={30}
|
width={30}
|
||||||
dx={-10}
|
dx={-10}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
backgroundColor: 'hsl(var(--background))',
|
backgroundColor: 'var(--poyraz-background)',
|
||||||
borderColor: 'hsl(var(--border))',
|
borderColor: 'var(--poyraz-border)',
|
||||||
borderRadius: '0.375rem',
|
borderRadius: '0.375rem',
|
||||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
|
||||||
}}
|
}}
|
||||||
@@ -208,9 +208,9 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="mood"
|
dataKey="mood"
|
||||||
stroke="hsl(var(--primary))"
|
stroke="var(--poyraz-primary)"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
dot={{ r: 4, fill: "hsl(var(--primary))", strokeWidth: 2, stroke: "hsl(var(--background))" }}
|
dot={{ r: 4, fill: "var(--poyraz-primary)", strokeWidth: 2, stroke: "var(--poyraz-background)" }}
|
||||||
activeDot={{ r: 6, strokeWidth: 0 }}
|
activeDot={{ r: 6, strokeWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
@@ -218,7 +218,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
dataKey="energy"
|
dataKey="energy"
|
||||||
stroke="#eab308"
|
stroke="#eab308"
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
dot={{ r: 4, fill: "#eab308", strokeWidth: 2, stroke: "hsl(var(--background))" }}
|
dot={{ r: 4, fill: "#eab308", strokeWidth: 2, stroke: "var(--poyraz-background)" }}
|
||||||
activeDot={{ r: 6, strokeWidth: 0 }}
|
activeDot={{ r: 6, strokeWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@@ -138,12 +138,12 @@ export function JournalClient({ logs }: JournalClientProps) {
|
|||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData} margin={{ left: -16, right: 16, top: 12, bottom: 0 }}>
|
<LineChart data={chartData} margin={{ left: -16, right: 16, top: 12, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--poyraz-border)" />
|
||||||
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
|
||||||
<YAxis domain={[1, 5]} tickCount={5} tickLine={false} axisLine={false} fontSize={12} />
|
<YAxis domain={[1, 5]} tickCount={5} tickLine={false} axisLine={false} fontSize={12} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
border: "1px solid hsl(var(--border))",
|
border: "1px solid var(--poyraz-border)",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
boxShadow: "0 10px 30px rgba(15, 23, 42, 0.08)",
|
boxShadow: "0 10px 30px rgba(15, 23, 42, 0.08)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||||
import { requireFreelancer } from "@/server/auth/session";
|
import { requireFreelancer } from "@/server/auth/session";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -7,6 +8,7 @@ export default async function DashboardLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const { user, profile } = await requireFreelancer();
|
const { user, profile } = await requireFreelancer();
|
||||||
|
const branding = getPublicBranding();
|
||||||
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı";
|
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı";
|
||||||
|
|
||||||
const shortName =
|
const shortName =
|
||||||
@@ -20,6 +22,12 @@ export default async function DashboardLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.applicationName,
|
||||||
|
organizationName: branding.organizationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
user={{
|
user={{
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName,
|
displayName,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent, Skeleton } from "poyraz-ui/atoms";
|
||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
|
|
||||||
export default function DashboardLoading() {
|
export default function DashboardLoading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent, Skeleton } from "poyraz-ui/atoms";
|
||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
|
|
||||||
export default function ProjectDetailLoading() {
|
export default function ProjectDetailLoading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent, Skeleton } from "poyraz-ui/atoms";
|
||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
|
|
||||||
export default function ProjectsLoading() {
|
export default function ProjectsLoading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Card, CardContent, Skeleton } from "poyraz-ui/atoms";
|
||||||
import { Card, CardContent } from "poyraz-ui/atoms";
|
|
||||||
|
|
||||||
export default function TasksLoading() {
|
export default function TasksLoading() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+8
-120
@@ -1,85 +1,13 @@
|
|||||||
@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 {
|
||||||
--background: #f7f8fb;
|
--poyraz-font-primary: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--background-dark: #f8fafc;
|
|
||||||
--foreground: #101828;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-raised: #ffffff;
|
|
||||||
--card: #ffffff;
|
|
||||||
--card-foreground: #101828;
|
|
||||||
--popover: #ffffff;
|
|
||||||
--popover-foreground: #101828;
|
|
||||||
--primary: #c81e1e;
|
|
||||||
--primary-foreground: #ffffff;
|
|
||||||
--primary-hover: #a91b1b;
|
|
||||||
--primary-pressed: #861515;
|
|
||||||
--secondary: #eef2f7;
|
|
||||||
--secondary-foreground: #182230;
|
|
||||||
--muted: #eef2f7;
|
|
||||||
--muted-foreground: #667085;
|
|
||||||
--accent: #e6edf5;
|
|
||||||
--accent-hover: #d8e2ee;
|
|
||||||
--accent-foreground: #101828;
|
|
||||||
--destructive: #d92d20;
|
|
||||||
--destructive-hover: #b42318;
|
|
||||||
--destructive-pressed: #912018;
|
|
||||||
--destructive-foreground: #ffffff;
|
|
||||||
--success: #168a4a;
|
|
||||||
--info: #2563eb;
|
|
||||||
--border: #d9e0ea;
|
|
||||||
--border-strong: #b9c4d2;
|
|
||||||
--input: #b9c4d2;
|
|
||||||
--input-bg: #ffffff;
|
|
||||||
--overlay: rgba(15, 23, 42, 0.48);
|
|
||||||
--ring: #c81e1e;
|
|
||||||
--radius: 0.375rem;
|
|
||||||
--motion-duration-fast: 120ms;
|
|
||||||
--motion-duration-base: 180ms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -90,48 +18,8 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-color-mode="dark"] {
|
:root.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: #0b1018;
|
|
||||||
--background-dark: #0b1018;
|
|
||||||
--foreground: #f2f4f7;
|
|
||||||
--surface: #121926;
|
|
||||||
--surface-raised: #182230;
|
|
||||||
--card: #121926;
|
|
||||||
--card-foreground: #f2f4f7;
|
|
||||||
--popover: #182230;
|
|
||||||
--popover-foreground: #f2f4f7;
|
|
||||||
--secondary: #253044;
|
|
||||||
--secondary-foreground: #f2f4f7;
|
|
||||||
--muted: #253044;
|
|
||||||
--muted-foreground: #98a2b3;
|
|
||||||
--border: #344054;
|
|
||||||
--border-strong: #475467;
|
|
||||||
--input: #475467;
|
|
||||||
--input-bg: #121926;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root[data-color-mode="system"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--background: #0b1018;
|
|
||||||
--background-dark: #0b1018;
|
|
||||||
--foreground: #f2f4f7;
|
|
||||||
--surface: #121926;
|
|
||||||
--surface-raised: #182230;
|
|
||||||
--card: #121926;
|
|
||||||
--card-foreground: #f2f4f7;
|
|
||||||
--popover: #182230;
|
|
||||||
--popover-foreground: #f2f4f7;
|
|
||||||
--secondary: #253044;
|
|
||||||
--secondary-foreground: #f2f4f7;
|
|
||||||
--muted: #253044;
|
|
||||||
--muted-foreground: #98a2b3;
|
|
||||||
--border: #344054;
|
|
||||||
--border-strong: #475467;
|
|
||||||
--input: #475467;
|
|
||||||
--input-bg: #121926;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -144,8 +32,8 @@
|
|||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
input:-webkit-autofill:focus,
|
input:-webkit-autofill:focus,
|
||||||
input:-webkit-autofill:active {
|
input:-webkit-autofill:active {
|
||||||
-webkit-box-shadow: 0 0 0 30px var(--input-bg) inset !important;
|
-webkit-box-shadow: 0 0 0 30px var(--poyraz-surface) inset !important;
|
||||||
-webkit-text-fill-color: var(--foreground) !important;
|
-webkit-text-fill-color: var(--poyraz-foreground) !important;
|
||||||
transition: background-color 5000s ease-in-out 0s;
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +41,7 @@
|
|||||||
@layer utilities {
|
@layer utilities {
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: color-mix(in srgb, var(--primary) 45%, transparent) transparent;
|
scrollbar-color: color-mix(in srgb, var(--poyraz-primary) 45%, transparent) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar,
|
*::-webkit-scrollbar,
|
||||||
@@ -169,7 +57,7 @@
|
|||||||
|
|
||||||
*::-webkit-scrollbar-thumb,
|
*::-webkit-scrollbar-thumb,
|
||||||
.tiny-scrollbar::-webkit-scrollbar-thumb {
|
.tiny-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: color-mix(in srgb, var(--primary) 36%, transparent);
|
background: color-mix(in srgb, var(--poyraz-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;
|
||||||
@@ -177,7 +65,7 @@
|
|||||||
|
|
||||||
*::-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(--primary) 58%, transparent);
|
background: color-mix(in srgb, var(--poyraz-primary) 58%, transparent);
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { acceptInvitation } from "./actions";
|
|||||||
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
||||||
import { SubmitButton } from "@/components/auth/submit-button";
|
import { SubmitButton } from "@/components/auth/submit-button";
|
||||||
import { ErrorToaster } from "@/components/error-toaster";
|
import { ErrorToaster } from "@/components/error-toaster";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input, Label } from "poyraz-ui/atoms";
|
||||||
import { Label } from "@/components/ui/field";
|
import { Alert, AlertDescription } from "poyraz-ui/molecules";
|
||||||
import { getPortalInvitationPreview } from "@/server/auth/invitations";
|
import { getPortalInvitationPreview } from "@/server/auth/invitations";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export default async function InvitationPage({
|
|||||||
}) {
|
}) {
|
||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
const invitation = getPortalInvitationPreview(token);
|
const invitation = getPortalInvitationPreview(token);
|
||||||
|
const branding = getPublicBranding();
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -40,6 +42,11 @@ export default async function InvitationPage({
|
|||||||
<>
|
<>
|
||||||
{query.error && query.message ? <ErrorToaster message={query.message} /> : null}
|
{query.error && query.message ? <ErrorToaster message={query.message} /> : null}
|
||||||
<AuthPageShell
|
<AuthPageShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.applicationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
title="Müşteri portalına katıl"
|
title="Müşteri portalına katıl"
|
||||||
description="Davet edilen hesabın için adını ve şifreni belirle."
|
description="Davet edilen hesabın için adını ve şifreni belirle."
|
||||||
form={
|
form={
|
||||||
@@ -75,7 +82,9 @@ export default async function InvitationPage({
|
|||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{unavailableMessage}</p>
|
<Alert variant="warning" appearance="soft">
|
||||||
|
<AlertDescription>{unavailableMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
secondaryAction={null}
|
secondaryAction={null}
|
||||||
|
|||||||
+12
-2
@@ -4,10 +4,17 @@ 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 { OfflineIndicator } from "@/components/ui/offline-indicator";
|
import { OfflineIndicator } from "@/components/ui/offline-indicator";
|
||||||
import { Toaster } from "@/components/ui/toast";
|
import { Toaster } from "poyraz-ui/molecules";
|
||||||
import { getPublicBranding } from "@/server/branding/runtime";
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
|
||||||
const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
|
const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" });
|
||||||
|
const colorModeScript = `(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const apply = () => root.classList.toggle("dark", root.dataset.colorMode === "dark" || (root.dataset.colorMode === "system" && media.matches));
|
||||||
|
apply();
|
||||||
|
if (root.dataset.colorMode === "system") media.addEventListener("change", apply);
|
||||||
|
})();`;
|
||||||
|
|
||||||
export function generateMetadata(): Metadata {
|
export function generateMetadata(): Metadata {
|
||||||
const branding = getPublicBranding();
|
const branding = getPublicBranding();
|
||||||
@@ -42,10 +49,13 @@ export default function RootLayout({
|
|||||||
style={branding.cssVariables as CSSProperties}
|
style={branding.cssVariables as CSSProperties}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: colorModeScript }} />
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
<Toaster />
|
<Toaster closeButton richColors position="top-right" />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-5
@@ -3,9 +3,10 @@ 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 } from "@/components/ui/input";
|
import { Input, Label } from "poyraz-ui/atoms";
|
||||||
import { Label } from "@/components/ui/field";
|
import { Alert, AlertDescription } from "poyraz-ui/molecules";
|
||||||
import { SubmitButton } from "@/components/auth/submit-button";
|
import { SubmitButton } from "@/components/auth/submit-button";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
|
||||||
export default async function LoginPage({
|
export default async function LoginPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -15,19 +16,25 @@ export default async function LoginPage({
|
|||||||
const resolvedParams = await searchParams;
|
const resolvedParams = await searchParams;
|
||||||
const error = resolvedParams?.error;
|
const error = resolvedParams?.error;
|
||||||
const message = resolvedParams?.message;
|
const message = resolvedParams?.message;
|
||||||
|
const branding = getPublicBranding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && message && <ErrorToaster message={String(message)} />}
|
{error && message && <ErrorToaster message={String(message)} />}
|
||||||
<AuthPageShell
|
<AuthPageShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.applicationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
title="Giriş yap"
|
title="Giriş yap"
|
||||||
description="Neta çalışma alanına erişmek için hesabına giriş yap."
|
description="Neta çalışma alanına erişmek için hesabına giriş yap."
|
||||||
form={
|
form={
|
||||||
<form className="space-y-6">
|
<form className="space-y-6">
|
||||||
{!error && message ? (
|
{!error && message ? (
|
||||||
<p className="rounded-md border border-success/30 bg-success/5 p-3 text-sm text-foreground">
|
<Alert variant="success" appearance="soft">
|
||||||
{String(message)}
|
<AlertDescription>{String(message)}</AlertDescription>
|
||||||
</p>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PortalShell } from "@/components/layout/portal-shell";
|
import { PortalShell } from "@/components/layout/portal-shell";
|
||||||
import { requireClientUser } from "@/server/auth/session";
|
import { requireClientUser } from "@/server/auth/session";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
|
||||||
export default async function PortalLayout({
|
export default async function PortalLayout({
|
||||||
children,
|
children,
|
||||||
@@ -7,6 +8,7 @@ export default async function PortalLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const { user, profile } = await requireClientUser();
|
const { user, profile } = await requireClientUser();
|
||||||
|
const branding = getPublicBranding();
|
||||||
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Müşteri";
|
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Müşteri";
|
||||||
|
|
||||||
const shortName =
|
const shortName =
|
||||||
@@ -20,6 +22,12 @@ export default async function PortalLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalShell
|
<PortalShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.applicationName,
|
||||||
|
organizationName: branding.organizationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
user={{
|
user={{
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName,
|
displayName,
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ 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 } from "@/components/ui/input";
|
import { Input, Label } from "poyraz-ui/atoms";
|
||||||
import { Label } from "@/components/ui/field";
|
|
||||||
import { SubmitButton } from "@/components/auth/submit-button";
|
import { SubmitButton } from "@/components/auth/submit-button";
|
||||||
|
import { getPublicBranding } from "@/server/branding/runtime";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -33,11 +33,17 @@ export default async function RegisterPage({
|
|||||||
const resolvedParams = await searchParams;
|
const resolvedParams = await searchParams;
|
||||||
const error = resolvedParams?.error;
|
const error = resolvedParams?.error;
|
||||||
const message = resolvedParams?.message;
|
const message = resolvedParams?.message;
|
||||||
|
const branding = getPublicBranding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && message && <ErrorToaster message={String(message)} />}
|
{error && message && <ErrorToaster message={String(message)} />}
|
||||||
<AuthPageShell
|
<AuthPageShell
|
||||||
|
branding={{
|
||||||
|
applicationName: branding.applicationName,
|
||||||
|
lightLogoUrl: branding.lightLogoUrl,
|
||||||
|
darkLogoUrl: branding.darkLogoUrl,
|
||||||
|
}}
|
||||||
title="İlk admin hesabını oluştur"
|
title="İlk admin hesabını oluştur"
|
||||||
description="Bu Neta çalışma alanının ilk yönetici hesabını oluştur."
|
description="Bu Neta çalışma alanının ilk yönetici hesabını oluştur."
|
||||||
form={
|
form={
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { Typography } from "poyraz-ui/atoms";
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -13,6 +14,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
type AuthPageShellProps = {
|
type AuthPageShellProps = {
|
||||||
|
branding: {
|
||||||
|
applicationName: string;
|
||||||
|
lightLogoUrl: string | null;
|
||||||
|
darkLogoUrl: string | null;
|
||||||
|
};
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
imageSrc?: string;
|
imageSrc?: string;
|
||||||
@@ -31,6 +37,7 @@ const highlights = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function AuthPageShell({
|
export function AuthPageShell({
|
||||||
|
branding,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
form,
|
form,
|
||||||
@@ -63,8 +70,8 @@ export function AuthPageShell({
|
|||||||
<div className="absolute inset-0 opacity-20 bg-[linear-gradient(rgba(255,255,255,.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.18)_1px,transparent_1px)] bg-size-[32px_32px]" />
|
<div className="absolute inset-0 opacity-20 bg-[linear-gradient(rgba(255,255,255,.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.18)_1px,transparent_1px)] bg-size-[32px_32px]" />
|
||||||
<div className="relative z-10 flex items-center gap-4 p-10">
|
<div className="relative z-10 flex items-center gap-4 p-10">
|
||||||
<Image
|
<Image
|
||||||
src="/logo/lightLogoLong.png"
|
src={branding.darkLogoUrl ?? "/logo/lightLogoLong.png"}
|
||||||
alt="Neta"
|
alt={branding.applicationName}
|
||||||
width={240}
|
width={240}
|
||||||
height={64}
|
height={64}
|
||||||
className="h-16 w-auto object-contain"
|
className="h-16 w-auto object-contain"
|
||||||
@@ -75,14 +82,18 @@ export function AuthPageShell({
|
|||||||
|
|
||||||
<div className="relative z-10 px-10">
|
<div className="relative z-10 px-10">
|
||||||
<motion.div {...fadeUp}>
|
<motion.div {...fadeUp}>
|
||||||
<h1 className="max-w-2xl text-5xl font-semibold leading-[1.02] text-primary-foreground">
|
<Typography
|
||||||
|
component="h1"
|
||||||
|
variant="display"
|
||||||
|
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.
|
Freelancer işlerini, müşterilerini ve finansını tek yerde yönet.
|
||||||
</h1>
|
</Typography>
|
||||||
<p className="mt-6 max-w-xl text-lg leading-8 text-primary-foreground/78">
|
<Typography component="p" variant="lead" className="mt-6 max-w-xl text-lg leading-8 text-primary-foreground/78">
|
||||||
Neta, günlük operasyonunu, projelerini, side projectlerini ve
|
{branding.applicationName}, 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.
|
||||||
</p>
|
</Typography>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -136,8 +147,8 @@ export function AuthPageShell({
|
|||||||
>
|
>
|
||||||
<div className="mb-8 flex justify-center lg:hidden">
|
<div className="mb-8 flex justify-center lg:hidden">
|
||||||
<Image
|
<Image
|
||||||
src="/logo/blackLogoLong.png"
|
src={branding.lightLogoUrl ?? "/logo/blackLogoLong.png"}
|
||||||
alt="Neta logo"
|
alt={`${branding.applicationName} logo`}
|
||||||
width={180}
|
width={180}
|
||||||
height={56}
|
height={56}
|
||||||
className="h-14 w-auto object-contain"
|
className="h-14 w-auto object-contain"
|
||||||
@@ -147,10 +158,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">
|
||||||
<h2 className="text-3xl font-semibold tracking-normal text-foreground">
|
<Typography component="h2" variant="h1" className="text-3xl font-semibold tracking-normal text-foreground">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</Typography>
|
||||||
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
|
<Typography component="p" variant="muted" className="text-sm leading-6">{description}</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 space-y-6">
|
<div className="mt-8 space-y-6">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "poyraz-ui/atoms";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface SubmitButtonProps extends React.ComponentProps<typeof Button> {
|
interface SubmitButtonProps extends React.ComponentProps<typeof Button> {
|
||||||
@@ -18,10 +17,9 @@ export function SubmitButton({
|
|||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button type={type} disabled={pending} {...props}>
|
<Button type={type} disabled={pending} loading={pending} aria-busy={pending} {...props}>
|
||||||
{pending ? (
|
{pending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
{pendingText || children}
|
{pendingText || children}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from "react";
|
||||||
import { showToast } from '@/components/ui/toast'
|
import { toast } from "poyraz-ui/molecules";
|
||||||
|
|
||||||
export function ErrorToaster({ message }: { message: string }) {
|
export function ErrorToaster({ message }: { message: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (message) {
|
if (message) {
|
||||||
showToast({ message, tone: 'error' })
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}, [message])
|
}, [message])
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+240
-230
@@ -1,16 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { signOut } from "@/app/login/actions";
|
import { signOut } from "@/app/login/actions";
|
||||||
import { IconButton } from "@/components/ui/button";
|
import {
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
Card,
|
||||||
import { PendingLink } from "@/components/ui/pending-link";
|
CardContent,
|
||||||
import { cn } from "@/lib/utils";
|
Typography,
|
||||||
import { ChevronUp, LogOut, Menu, Settings } from "lucide-react";
|
} from "poyraz-ui/atoms";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "poyraz-ui/molecules";
|
||||||
|
import {
|
||||||
|
SidebarBranding,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarPanel,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarTrigger,
|
||||||
|
SidebarUserProfile,
|
||||||
|
useSidebar,
|
||||||
|
} from "poyraz-ui/organisms";
|
||||||
|
import { ChevronUp, LogOut, Settings } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
export type AppShellNavItem = {
|
export type AppShellNavItem = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -23,6 +50,13 @@ export type AppShellNavGroup = {
|
|||||||
items: AppShellNavItem[];
|
items: AppShellNavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AppShellBranding = {
|
||||||
|
applicationName: string;
|
||||||
|
organizationName: string | null;
|
||||||
|
lightLogoUrl: string | null;
|
||||||
|
darkLogoUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ShellUser = {
|
type ShellUser = {
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -31,6 +65,7 @@ type ShellUser = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
|
branding: AppShellBranding;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
homeHref: string;
|
homeHref: string;
|
||||||
navGroups: AppShellNavGroup[];
|
navGroups: AppShellNavGroup[];
|
||||||
@@ -40,6 +75,7 @@ type AppShellProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell({
|
export function AppShell({
|
||||||
|
branding,
|
||||||
children,
|
children,
|
||||||
homeHref,
|
homeHref,
|
||||||
navGroups,
|
navGroups,
|
||||||
@@ -48,137 +84,135 @@ export function AppShell({
|
|||||||
progress,
|
progress,
|
||||||
}: AppShellProps) {
|
}: AppShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileSidebarState, setMobileSidebarState] = useState({
|
const sidebarProps = { branding, homeHref, navGroups, pathname, progress, settingsHref, user };
|
||||||
open: false,
|
|
||||||
pathname,
|
|
||||||
});
|
|
||||||
const isMobileSidebarOpen =
|
|
||||||
mobileSidebarState.open && mobileSidebarState.pathname === pathname;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
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"
|
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-focus-ring"
|
||||||
>
|
>
|
||||||
Ana içeriğe geç
|
Ana içeriğe geç
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<AppSidebar
|
<DesktopSidebar {...sidebarProps} />
|
||||||
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">
|
<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">
|
<MobileSidebar {...sidebarProps} />
|
||||||
<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">
|
<main id="main-content" className="min-w-0 flex-1 p-4 lg:p-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppSidebar({
|
type SidebarCompositionProps = {
|
||||||
homeHref,
|
branding: AppShellBranding;
|
||||||
navGroups,
|
|
||||||
pathname,
|
|
||||||
progress,
|
|
||||||
settingsHref,
|
|
||||||
user,
|
|
||||||
onNavigate,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
homeHref: string;
|
homeHref: string;
|
||||||
navGroups: AppShellNavGroup[];
|
navGroups: AppShellNavGroup[];
|
||||||
pathname: string;
|
pathname: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
settingsHref: string;
|
settingsHref: string;
|
||||||
user: ShellUser;
|
user: ShellUser;
|
||||||
onNavigate?: () => void;
|
};
|
||||||
className?: string;
|
|
||||||
}) {
|
function DesktopSidebar(props: SidebarCompositionProps) {
|
||||||
return (
|
return (
|
||||||
<aside
|
<SidebarProvider variant="collapsible">
|
||||||
className={cn(
|
<SidebarPanel className="sticky top-0 hidden h-screen shrink-0 self-stretch lg:flex">
|
||||||
"flex h-dvh w-[280px] max-w-[82vw] flex-col overflow-hidden border-r border-border bg-surface",
|
<SidebarComposition {...props} />
|
||||||
className,
|
<SidebarRail aria-label="Kenar çubuğunu daralt veya genişlet" />
|
||||||
)}
|
</SidebarPanel>
|
||||||
>
|
</SidebarProvider>
|
||||||
<div className="shrink-0 px-6 py-3">
|
);
|
||||||
<Link href={homeHref} className="flex w-full items-center justify-center">
|
}
|
||||||
<Image
|
|
||||||
src="/logo/blackLogoLong.png"
|
function MobileSidebar(props: SidebarCompositionProps) {
|
||||||
alt="Neta"
|
return (
|
||||||
width={160}
|
<SidebarProvider variant="floating">
|
||||||
height={48}
|
<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">
|
||||||
className="h-12 w-auto object-contain"
|
<Link href={props.homeHref} className="min-w-0">
|
||||||
style={{ width: "auto" }}
|
<MobileBrand branding={props.branding} />
|
||||||
priority
|
</Link>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SidebarTrigger action="mobile" aria-label="Ana menüyü aç veya kapat" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Menü</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SidebarPanel className="h-dvh max-w-[82vw] lg:hidden">
|
||||||
|
<SidebarComposition {...props} />
|
||||||
|
</SidebarPanel>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarComposition({
|
||||||
|
branding,
|
||||||
|
homeHref,
|
||||||
|
navGroups,
|
||||||
|
pathname,
|
||||||
|
progress,
|
||||||
|
settingsHref,
|
||||||
|
user,
|
||||||
|
}: SidebarCompositionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarHeader>
|
||||||
|
<Link href={homeHref} className="min-w-0 flex-1" aria-label={`${branding.applicationName} ana sayfa`}>
|
||||||
|
<SidebarBranding
|
||||||
|
logo={<BrandMark branding={branding} />}
|
||||||
|
title={branding.applicationName}
|
||||||
|
subtitle={branding.organizationName ?? "Freelancer portalı"}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SidebarTrigger
|
||||||
|
className="hidden lg:inline-flex"
|
||||||
|
aria-label="Kenar çubuğunu daralt veya genişlet"
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Kenar çubuğunu daralt</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
<div className="h-px bg-border" />
|
<SidebarContent scrollMode="fade">
|
||||||
|
<SidebarNavigation
|
||||||
|
homeHref={homeHref}
|
||||||
|
navGroups={navGroups}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
<nav className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
<SidebarFooter className="flex flex-col gap-3">
|
||||||
{navGroups.map((group, groupIndex) => (
|
{typeof progress === "number" ? <ProgressSummary progress={progress} /> : null}
|
||||||
<section key={group.title} className={cn(groupIndex > 0 && "mt-6")}>
|
<AccountMenu user={user} settingsHref={settingsHref} />
|
||||||
<h2 className="px-2 text-[11px] font-semibold uppercase leading-6 text-muted-foreground">
|
</SidebarFooter>
|
||||||
{group.title}
|
</>
|
||||||
</h2>
|
);
|
||||||
<ul className="mt-2 space-y-1">
|
}
|
||||||
|
|
||||||
|
function SidebarNavigation({
|
||||||
|
homeHref,
|
||||||
|
navGroups,
|
||||||
|
pathname,
|
||||||
|
}: Pick<SidebarCompositionProps, "homeHref" | "navGroups" | "pathname">) {
|
||||||
|
const { setMobileOpen, variant } = useSidebar();
|
||||||
|
|
||||||
|
return navGroups.map((group) => (
|
||||||
|
<SidebarGroup key={group.title}>
|
||||||
|
<SidebarGroupLabel>{group.title}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive =
|
const active =
|
||||||
item.href === homeHref
|
item.href === homeHref
|
||||||
? pathname === homeHref
|
? pathname === homeHref
|
||||||
: item.href
|
: item.href
|
||||||
@@ -187,34 +221,69 @@ function AppSidebar({
|
|||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.href || item.title}>
|
<SidebarMenuItem
|
||||||
<PendingLink
|
key={item.href || item.title}
|
||||||
href={item.href || "#"}
|
href={item.href || "#"}
|
||||||
onClick={onNavigate}
|
active={active}
|
||||||
aria-current={isActive ? "page" : undefined}
|
icon={Icon ? <Icon className="h-4 w-4" aria-hidden="true" /> : undefined}
|
||||||
className={cn(
|
onClick={() => {
|
||||||
"flex h-10 items-center gap-3 rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors",
|
if (variant === "floating") setMobileOpen(false);
|
||||||
"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}
|
{item.title}
|
||||||
<span className="truncate">{item.title}</span>
|
</SidebarMenuItem>
|
||||||
</PendingLink>
|
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</SidebarMenu>
|
||||||
</section>
|
</SidebarGroup>
|
||||||
))}
|
));
|
||||||
</nav>
|
}
|
||||||
|
|
||||||
<div className="mt-auto flex shrink-0 flex-col gap-4 border-t border-border px-4 py-4">
|
function BrandMark({ branding }: { branding: AppShellBranding }) {
|
||||||
{typeof progress === "number" ? <ProgressSummary progress={progress} /> : null}
|
const logoUrl = branding.lightLogoUrl ?? branding.darkLogoUrl;
|
||||||
<AccountMenu user={user} settingsHref={settingsHref} onNavigate={onNavigate} />
|
|
||||||
|
if (!logoUrl) {
|
||||||
|
return (
|
||||||
|
<span className="flex h-full w-full items-center justify-center text-sm font-bold text-primary">
|
||||||
|
{branding.applicationName.slice(0, 1).toLocaleUpperCase("tr-TR")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="flex h-full w-full items-center justify-center">
|
||||||
|
<Image
|
||||||
|
src={logoUrl}
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
unoptimized
|
||||||
|
className={branding.darkLogoUrl ? "h-full w-full object-contain dark:hidden" : "h-full w-full object-contain"}
|
||||||
|
/>
|
||||||
|
{branding.darkLogoUrl ? (
|
||||||
|
<Image
|
||||||
|
src={branding.darkLogoUrl}
|
||||||
|
alt=""
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
unoptimized
|
||||||
|
className="hidden h-full w-full object-contain dark:block"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileBrand({ branding }: { branding: AppShellBranding }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-sm border border-border bg-muted">
|
||||||
|
<BrandMark branding={branding} />
|
||||||
|
</span>
|
||||||
|
<Typography component="span" variant="small" className="truncate font-semibold">
|
||||||
|
{branding.applicationName}
|
||||||
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,16 +291,25 @@ function ProgressSummary({ progress }: { progress: number }) {
|
|||||||
const normalizedProgress = Math.max(0, Math.min(100, progress));
|
const normalizedProgress = Math.max(0, Math.min(100, progress));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden border-primary/10 bg-primary/5 shadow-none">
|
<Card variant="soft" className="w-full overflow-hidden border-primary/10 shadow-none">
|
||||||
<CardContent className="space-y-3 p-4">
|
<CardContent className="space-y-3 p-3">
|
||||||
<div className="text-sm font-semibold text-foreground">Proje ilerlemesi</div>
|
<Typography variant="small" className="font-semibold">
|
||||||
|
Proje ilerlemesi
|
||||||
|
</Typography>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-xs font-medium">
|
<Typography variant="caption" className="font-medium text-primary">
|
||||||
<span className="text-primary">%{normalizedProgress} tamamlandı</span>
|
%{normalizedProgress} tamamlandı
|
||||||
</div>
|
</Typography>
|
||||||
<div className="h-2 w-full overflow-hidden rounded-full bg-primary/10">
|
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out"
|
role="progressbar"
|
||||||
|
aria-label="Proje ilerlemesi"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={normalizedProgress}
|
||||||
|
className="h-2 w-full overflow-hidden rounded-full bg-primary-muted"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-[width] duration-700 ease-out motion-reduce:transition-none"
|
||||||
style={{ width: `${normalizedProgress}%` }}
|
style={{ width: `${normalizedProgress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,110 +319,42 @@ function ProgressSummary({ progress }: { progress: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountMenu({
|
function AccountMenu({ user, settingsHref }: { user: ShellUser; settingsHref: string }) {
|
||||||
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 (
|
return (
|
||||||
<div ref={menuRef} className="relative">
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={open}
|
aria-label="Hesap menüsünü aç"
|
||||||
aria-haspopup="menu"
|
className="flex w-full items-center gap-2 rounded-sm p-1 text-left outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
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} />
|
<SidebarUserProfile
|
||||||
<div className="min-w-0 flex-1">
|
className="min-w-0 flex-1"
|
||||||
<div className="truncate text-sm font-medium text-foreground">{user.displayName}</div>
|
name={user.displayName}
|
||||||
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
role={user.email}
|
||||||
</div>
|
avatarUrl={user.avatarUrl ?? undefined}
|
||||||
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
initials={user.shortName}
|
||||||
|
/>
|
||||||
|
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
{open ? (
|
<DropdownMenuContent align="end" side="top" className="min-w-52">
|
||||||
<div
|
<DropdownMenuItem asChild media={<Settings className="h-4 w-4" aria-hidden="true" />}>
|
||||||
role="menu"
|
<Link href={settingsHref}>Ayarlar</Link>
|
||||||
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"
|
</DropdownMenuItem>
|
||||||
>
|
<DropdownMenuSeparator />
|
||||||
<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}>
|
<form action={signOut}>
|
||||||
<button
|
<DropdownMenuItem
|
||||||
type="submit"
|
asChild
|
||||||
role="menuitem"
|
media={<LogOut className="h-4 w-4" aria-hidden="true" />}
|
||||||
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"
|
className="text-destructive"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<button type="submit" className="w-full">
|
||||||
Çıkış yap
|
Çıkış yap
|
||||||
</button>
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DropdownMenuContent>
|
||||||
) : null}
|
</DropdownMenu>
|
||||||
</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,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell";
|
||||||
import { sidebarData } from "@/config/sidebar";
|
import { sidebarData } from "@/config/sidebar";
|
||||||
|
|
||||||
type DashboardShellProps = {
|
type DashboardShellProps = {
|
||||||
|
branding: AppShellBranding;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
user: {
|
user: {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -13,9 +14,9 @@ type DashboardShellProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DashboardShell({ children, user }: DashboardShellProps) {
|
export function DashboardShell({ branding, children, user }: DashboardShellProps) {
|
||||||
return (
|
return (
|
||||||
<AppShell homeHref="/" navGroups={sidebarData} settingsHref="/settings" user={user}>
|
<AppShell branding={branding} homeHref="/" navGroups={sidebarData} settingsHref="/settings" user={user}>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell, type AppShellBranding } from "@/components/layout/app-shell";
|
||||||
import { portalSidebarData } from "@/config/portal-sidebar";
|
import { portalSidebarData } from "@/config/portal-sidebar";
|
||||||
|
|
||||||
type PortalShellProps = {
|
type PortalShellProps = {
|
||||||
|
branding: AppShellBranding;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
user: {
|
user: {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -14,9 +15,10 @@ type PortalShellProps = {
|
|||||||
progress?: number;
|
progress?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PortalShell({ children, user, progress }: PortalShellProps) {
|
export function PortalShell({ branding, children, user, progress }: PortalShellProps) {
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
|
branding={branding}
|
||||||
homeHref="/portal"
|
homeHref="/portal"
|
||||||
navGroups={portalSidebarData}
|
navGroups={portalSidebarData}
|
||||||
settingsHref="/portal/settings"
|
settingsHref="/portal/settings"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "poyraz-ui/atoms";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "poyraz-ui/molecules";
|
||||||
|
|
||||||
|
type DestructiveConfirmationProps = {
|
||||||
|
cancelLabel?: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
description: string;
|
||||||
|
loading?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DestructiveConfirmation({
|
||||||
|
cancelLabel = "Vazgeç",
|
||||||
|
confirmLabel,
|
||||||
|
description,
|
||||||
|
loading = false,
|
||||||
|
onConfirm,
|
||||||
|
onOpenChange,
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
}: DestructiveConfirmationProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent surface="solid" radius="lg" mobile="floating">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={loading}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
loading={loading}
|
||||||
|
aria-busy={loading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Button, Card, CardContent, Skeleton, Typography } from "poyraz-ui/atoms";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "poyraz-ui/molecules";
|
||||||
|
import { Ban, CircleAlert, Inbox } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type FeedbackStateProps = {
|
||||||
|
action?: ReactNode;
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
variant: "empty" | "error" | "forbidden";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FeedbackState({ action, description, title, variant }: FeedbackStateProps) {
|
||||||
|
if (variant === "empty") {
|
||||||
|
return (
|
||||||
|
<Card variant="soft">
|
||||||
|
<CardContent className="flex min-h-56 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-primary-muted text-primary-muted-foreground">
|
||||||
|
<Inbox className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Typography component="h2" variant="h4">{title}</Typography>
|
||||||
|
<Typography component="p" variant="muted" className="max-w-lg">{description}</Typography>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbidden = variant === "forbidden";
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
role="alert"
|
||||||
|
variant={forbidden ? "warning" : "destructive"}
|
||||||
|
appearance="soft"
|
||||||
|
icon={forbidden ? <Ban aria-hidden="true" /> : <CircleAlert aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<AlertTitle>{title}</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<p>{description}</p>
|
||||||
|
{action}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({ label = "İçerik yükleniyor" }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" aria-busy="true" aria-label={label} role="status">
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
<Skeleton className="h-8 w-2/5" />
|
||||||
|
<Skeleton className="h-4 w-3/5" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-36 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetryAction({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onClick}>
|
||||||
|
Yeniden dene
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Typography } from "poyraz-ui/atoms";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type PageHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
primaryAction?: ReactNode;
|
||||||
|
secondaryActions?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
eyebrow,
|
||||||
|
primaryAction,
|
||||||
|
secondaryActions,
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
{eyebrow ? (
|
||||||
|
<Typography component="p" variant="caption" className="font-semibold uppercase tracking-wider text-primary">
|
||||||
|
{eyebrow}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
<Typography component="h1" variant="h1" balance>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{description ? (
|
||||||
|
<Typography component="p" variant="muted" className="max-w-3xl">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{primaryAction || secondaryActions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{secondaryActions}
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Badge } from "poyraz-ui/atoms";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
const statusPresentation = {
|
||||||
|
accepted: { label: "Kabul edildi", variant: "success" },
|
||||||
|
active: { label: "Aktif", variant: "success" },
|
||||||
|
archived: { label: "Arşivlendi", variant: "outline" },
|
||||||
|
cancelled: { label: "İptal edildi", variant: "outline" },
|
||||||
|
completed: { label: "Tamamlandı", variant: "success" },
|
||||||
|
done: { label: "Tamamlandı", variant: "success" },
|
||||||
|
draft: { label: "Taslak", variant: "secondary" },
|
||||||
|
expired: { label: "Süresi doldu", variant: "destructive" },
|
||||||
|
in_progress: { label: "Devam ediyor", variant: "info" },
|
||||||
|
overdue: { label: "Gecikmiş", variant: "destructive" },
|
||||||
|
paid: { label: "Ödendi", variant: "success" },
|
||||||
|
paused: { label: "Duraklatıldı", variant: "warning" },
|
||||||
|
pending: { label: "Bekliyor", variant: "warning" },
|
||||||
|
planned: { label: "Planlandı", variant: "secondary" },
|
||||||
|
planning: { label: "Planlanıyor", variant: "secondary" },
|
||||||
|
rejected: { label: "Reddedildi", variant: "destructive" },
|
||||||
|
revoked: { label: "İptal edildi", variant: "destructive" },
|
||||||
|
sent: { label: "Gönderildi", variant: "info" },
|
||||||
|
todo: { label: "Yapılacak", variant: "secondary" },
|
||||||
|
} as const satisfies Record<string, { label: string; variant: NonNullable<ComponentProps<typeof Badge>["variant"]> }>;
|
||||||
|
|
||||||
|
export type NetaStatus = keyof typeof statusPresentation;
|
||||||
|
|
||||||
|
type StatusBadgeProps = Omit<ComponentProps<typeof Badge>, "children" | "variant"> & {
|
||||||
|
status: NetaStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({ status, ...props }: StatusBadgeProps) {
|
||||||
|
const presentation = statusPresentation[status];
|
||||||
|
return (
|
||||||
|
<Badge variant={presentation.variant} {...props}>
|
||||||
|
{presentation.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { statusPresentation };
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { forwardRef } from "react";
|
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
|
|
||||||
type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm";
|
|
||||||
|
|
||||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
||||||
variant?: ButtonVariant;
|
|
||||||
size?: ButtonSize;
|
|
||||||
};
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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";
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function Card({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-md border border-border bg-card text-card-foreground shadow-sm",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardHeader({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
||||||
return <div className={cn("space-y-1.5 p-5", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardTitle({ className, ...props }: ComponentPropsWithoutRef<"h3">) {
|
|
||||||
return <h3 className={cn("text-base font-semibold", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardContent({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
||||||
return <div className={cn("p-5 pt-0", className)} {...props} />;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { CheckIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Checkbox({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
data-slot="checkbox"
|
|
||||||
className={cn(
|
|
||||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
data-slot="checkbox-indicator"
|
|
||||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
|
||||||
>
|
|
||||||
<CheckIcon
|
|
||||||
/>
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Dialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
data-slot="dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
size="icon-sm"
|
|
||||||
>
|
|
||||||
<XIcon
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({
|
|
||||||
className,
|
|
||||||
showCloseButton = false,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close asChild>
|
|
||||||
<Button variant="outline">Close</Button>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
data-slot="dialog-title"
|
|
||||||
className={cn(
|
|
||||||
"font-heading text-base leading-none font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
data-slot="dialog-description"
|
|
||||||
className={cn(
|
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function DropdownMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot="dropdown-menu-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
|
||||||
className,
|
|
||||||
align = "start",
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
align={align}
|
|
||||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
data-slot="dropdown-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
|
||||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
|
||||||
>
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
data-slot="dropdown-menu-radio-item"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
|
||||||
data-slot="dropdown-menu-radio-item-indicator"
|
|
||||||
>
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="dropdown-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
data-slot="dropdown-menu-sub-content"
|
|
||||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
FormProvider,
|
|
||||||
useFormContext,
|
|
||||||
type ControllerProps,
|
|
||||||
type FieldPath,
|
|
||||||
type FieldValues,
|
|
||||||
} from "react-hook-form"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
|
|
||||||
const Form = FormProvider
|
|
||||||
|
|
||||||
type FormFieldContextValue<
|
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
||||||
> = {
|
|
||||||
name: TName
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
|
||||||
|
|
||||||
const FormField = <
|
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
||||||
>({
|
|
||||||
...props
|
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
|
||||||
return (
|
|
||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
||||||
<Controller {...props} />
|
|
||||||
</FormFieldContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useFormField = () => {
|
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
|
||||||
const itemContext = React.useContext(FormItemContext)
|
|
||||||
const { getFieldState, formState } = useFormContext()
|
|
||||||
|
|
||||||
if (!fieldContext) {
|
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemContext) {
|
|
||||||
throw new Error("useFormField should be used within <FormItem>")
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
|
||||||
|
|
||||||
const { id } = itemContext
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: fieldContext.name,
|
|
||||||
formItemId: `${id}-form-item`,
|
|
||||||
formDescriptionId: `${id}-form-item-description`,
|
|
||||||
formMessageId: `${id}-form-item-message`,
|
|
||||||
...fieldState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormItemContextValue = {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const id = React.useId()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormItemContext.Provider value={{ id }}>
|
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
|
||||||
</FormItemContext.Provider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormItem.displayName = "FormItem"
|
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { error, formItemId } = useFormField()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(error && "text-destructive", className)}
|
|
||||||
htmlFor={formItemId}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormLabel.displayName = "FormLabel"
|
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Slot>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
|
||||||
>(({ ...props }, ref) => {
|
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slot
|
|
||||||
ref={ref}
|
|
||||||
id={formItemId}
|
|
||||||
aria-describedby={
|
|
||||||
!error
|
|
||||||
? `${formDescriptionId}`
|
|
||||||
: `${formDescriptionId} ${formMessageId}`
|
|
||||||
}
|
|
||||||
aria-invalid={!!error}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormControl.displayName = "FormControl"
|
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { formDescriptionId } = useFormField()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
id={formDescriptionId}
|
|
||||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormDescription.displayName = "FormDescription"
|
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, children, ...props }, ref) => {
|
|
||||||
const { error, formMessageId } = useFormField()
|
|
||||||
const body = error ? String(error?.message ?? "") : children
|
|
||||||
|
|
||||||
if (!body) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
id={formMessageId}
|
|
||||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormMessage.displayName = "FormMessage"
|
|
||||||
|
|
||||||
export {
|
|
||||||
useFormField,
|
|
||||||
Form,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormMessage,
|
|
||||||
FormField,
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Icon as IconifyIcon } from '@iconify/react';
|
|
||||||
|
|
||||||
export function Icon({ icon, className }: { icon: string; className?: string }) {
|
|
||||||
return <IconifyIcon icon={icon} className={className} />;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { forwardRef } from "react";
|
|
||||||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Textarea.displayName = "Textarea";
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Label as LabelPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Label({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<LabelPrimitive.Root
|
|
||||||
data-slot="label"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Label }
|
|
||||||
@@ -1,26 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { WifiOff } from "lucide-react";
|
import { WifiOff } from "lucide-react";
|
||||||
|
|
||||||
export function OfflineIndicator() {
|
export function OfflineIndicator() {
|
||||||
const [isOnline, setIsOnline] = useState(true);
|
const isOnline = useSyncExternalStore(
|
||||||
|
(onStoreChange) => {
|
||||||
useEffect(() => {
|
window.addEventListener("online", onStoreChange);
|
||||||
// Sadece client-side'da çalışır
|
window.addEventListener("offline", onStoreChange);
|
||||||
setIsOnline(navigator.onLine);
|
|
||||||
|
|
||||||
const handleOnline = () => setIsOnline(true);
|
|
||||||
const handleOffline = () => setIsOnline(false);
|
|
||||||
|
|
||||||
window.addEventListener("online", handleOnline);
|
|
||||||
window.addEventListener("offline", handleOffline);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("online", handleOnline);
|
window.removeEventListener("online", onStoreChange);
|
||||||
window.removeEventListener("offline", handleOffline);
|
window.removeEventListener("offline", onStoreChange);
|
||||||
};
|
};
|
||||||
}, []);
|
},
|
||||||
|
() => navigator.onLine,
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
|
||||||
if (isOnline) return null;
|
if (isOnline) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Loader2 } from "lucide-react";
|
|||||||
import Link, { type LinkProps } from "next/link";
|
import Link, { type LinkProps } from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
useEffect,
|
|
||||||
useState,
|
useState,
|
||||||
type AnchorHTMLAttributes,
|
type AnchorHTMLAttributes,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
@@ -33,11 +32,8 @@ export function PendingLink({
|
|||||||
...props
|
...props
|
||||||
}: PendingLinkProps) {
|
}: PendingLinkProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [pending, setPending] = useState(false);
|
const [pendingFromPath, setPendingFromPath] = useState<string | null>(null);
|
||||||
|
const pending = pendingFromPath === pathname;
|
||||||
useEffect(() => {
|
|
||||||
setPending(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
|
function handleClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||||
onClick?.(event);
|
onClick?.(event);
|
||||||
@@ -58,7 +54,7 @@ export function PendingLink({
|
|||||||
const nextPath = hrefValue.split("?")[0].split("#")[0];
|
const nextPath = hrefValue.split("?")[0].split("#")[0];
|
||||||
|
|
||||||
if (nextPath && nextPath !== pathname) {
|
if (nextPath && nextPath !== pathname) {
|
||||||
setPending(true);
|
setPendingFromPath(pathname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "poyraz-ui/atoms";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ export function PendingSubmitButton({
|
|||||||
type={type}
|
type={type}
|
||||||
disabled={disabled || pending}
|
disabled={disabled || pending}
|
||||||
aria-busy={pending}
|
aria-busy={pending}
|
||||||
|
loading={pending && !pendingIcon}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function Select({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Group
|
|
||||||
data-slot="select-group"
|
|
||||||
className={cn("scroll-my-1 p-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectTrigger({
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
||||||
size?: "sm" | "default"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
data-slot="select-trigger"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 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",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
position = "item-aligned",
|
|
||||||
align = "center",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
data-slot="select-content"
|
|
||||||
data-align-trigger={position === "item-aligned"}
|
|
||||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
|
||||||
position={position}
|
|
||||||
align={align}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
data-position={position}
|
|
||||||
className={cn(
|
|
||||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
|
||||||
position === "popper" && ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="pointer-events-none" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
|
||||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUpIcon
|
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon
|
|
||||||
/>
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
data-slot="separator"
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { ComponentPropsWithoutRef } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function Skeleton({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
data-slot="textarea"
|
|
||||||
className={cn(
|
|
||||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
type ToastTone = "success" | "error" | "info";
|
|
||||||
|
|
||||||
type ToastItem = {
|
|
||||||
id: number;
|
|
||||||
message: string;
|
|
||||||
tone: ToastTone;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ToastPayload = {
|
|
||||||
message: string;
|
|
||||||
tone?: ToastTone;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toastEventName = "neta:toast";
|
|
||||||
let toastId = 0;
|
|
||||||
|
|
||||||
export function showToast(payload: ToastPayload) {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastTitle({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
||||||
return <div className={cn("font-medium text-foreground", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToastDescription({ className, ...props }: ComponentPropsWithoutRef<"div">) {
|
|
||||||
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useToast } from "@/hooks/use-toast"
|
|
||||||
import {
|
|
||||||
Toast,
|
|
||||||
ToastClose,
|
|
||||||
ToastDescription,
|
|
||||||
ToastProvider,
|
|
||||||
ToastTitle,
|
|
||||||
ToastViewport,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
export function Toaster() {
|
|
||||||
const { toasts } = useToast()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToastProvider>
|
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
|
||||||
return (
|
|
||||||
<Toast key={id} {...props}>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
|
||||||
{description && (
|
|
||||||
<ToastDescription>{description}</ToastDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{action}
|
|
||||||
<ToastClose />
|
|
||||||
</Toast>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -350,7 +350,7 @@ type BrandingSettings = {
|
|||||||
|
|
||||||
Tema checklist'i:
|
Tema checklist'i:
|
||||||
|
|
||||||
- [ ] Poyraz UI semantic tokenları kullanılıyor.
|
- [x] Poyraz UI semantic tokenları kullanılıyor.
|
||||||
- [x] Marka ayarları root layout'ta server-side okunuyor.
|
- [x] Marka ayarları root layout'ta server-side okunuyor.
|
||||||
- [x] İlk render sırasında tema/renk parlaması yok.
|
- [x] İlk render sırasında tema/renk parlaması yok.
|
||||||
- [x] Primary ve accent renk girdileri doğrulanıyor.
|
- [x] Primary ve accent renk girdileri doğrulanıyor.
|
||||||
@@ -378,31 +378,31 @@ Referans paket: `poyraz-ui@3.0.2`
|
|||||||
|
|
||||||
### 12.2. Kurulum checklist'i
|
### 12.2. Kurulum checklist'i
|
||||||
|
|
||||||
- [ ] `poyraz-ui` v3'e yükseltildi.
|
- [x] `poyraz-ui` v3'e yükseltildi.
|
||||||
- [ ] `@import "poyraz-ui/preset.css";` global CSS'e eklendi.
|
- [x] `@import "poyraz-ui/preset.css";` global CSS'e eklendi.
|
||||||
- [ ] Atom importları `poyraz-ui/atoms` üzerinden.
|
- [x] Atom importları `poyraz-ui/atoms` üzerinden.
|
||||||
- [ ] Molecule importları `poyraz-ui/molecules` üzerinden.
|
- [x] Molecule importları `poyraz-ui/molecules` üzerinden.
|
||||||
- [ ] Organism importları `poyraz-ui/organisms` üzerinden.
|
- [x] Organism importları `poyraz-ui/organisms` üzerinden.
|
||||||
- [ ] Tema gerekiyorsa `poyraz-ui/themes` kullanımı değerlendirildi.
|
- [x] Tema gerekiyorsa `poyraz-ui/themes` kullanımı değerlendirildi.
|
||||||
- [x] Package modeli ana kullanım biçimi olarak belirlendi.
|
- [x] Package modeli ana kullanım biçimi olarak belirlendi.
|
||||||
- [x] Source registry yalnızca source ownership gereken istisnalar için kullanılacak.
|
- [x] Source registry yalnızca source ownership gereken istisnalar için kullanılacak.
|
||||||
|
|
||||||
### 12.3. Ortak UI standartları
|
### 12.3. Ortak UI standartları
|
||||||
|
|
||||||
- [ ] Typography hiyerarşisi tanımlandı.
|
- [x] Typography hiyerarşisi tanımlandı.
|
||||||
- [ ] Page header standardı tanımlandı.
|
- [x] Page header standardı tanımlandı.
|
||||||
- [ ] Primary ve secondary action standardı tanımlandı.
|
- [x] Primary ve secondary action standardı tanımlandı.
|
||||||
- [ ] Form field ve validation standardı tanımlandı.
|
- [x] Form field ve validation standardı tanımlandı.
|
||||||
- [ ] Status-to-Badge eşleme tablosu oluşturuldu.
|
- [x] Status-to-Badge eşleme tablosu oluşturuldu.
|
||||||
- [ ] Loading state standardı oluşturuldu.
|
- [x] Loading state standardı oluşturuldu.
|
||||||
- [ ] Empty state standardı oluşturuldu.
|
- [x] Empty state standardı oluşturuldu.
|
||||||
- [ ] Error state standardı oluşturuldu.
|
- [x] Error state standardı oluşturuldu.
|
||||||
- [ ] Permission/forbidden state standardı oluşturuldu.
|
- [x] Permission/forbidden state standardı oluşturuldu.
|
||||||
- [ ] Destructive confirmation standardı oluşturuldu.
|
- [x] Destructive confirmation standardı oluşturuldu.
|
||||||
- [ ] Desktop Dialog/Sheet ve mobil Drawer kullanım kuralı belirlendi.
|
- [x] Desktop Dialog/Sheet ve mobil Drawer kullanım kuralı belirlendi.
|
||||||
- [ ] Toast ve inline feedback ayrımı belirlendi.
|
- [x] Toast ve inline feedback ayrımı belirlendi.
|
||||||
- [ ] DataTable kullanım standardı belirlendi.
|
- [x] DataTable kullanım standardı belirlendi.
|
||||||
- [ ] Dashboard KPI kart standardı belirlendi.
|
- [x] Dashboard KPI kart standardı belirlendi.
|
||||||
|
|
||||||
### 12.4. Erişilebilirlik kalite kapısı
|
### 12.4. Erişilebilirlik kalite kapısı
|
||||||
|
|
||||||
@@ -668,13 +668,13 @@ Mobil hazırlık checklist'i:
|
|||||||
|
|
||||||
### 18.3. Poyraz UI v3 sonrası değerlendirilecekler
|
### 18.3. Poyraz UI v3 sonrası değerlendirilecekler
|
||||||
|
|
||||||
- [ ] `@base-ui/react`
|
- [x] `@base-ui/react`
|
||||||
- [ ] Doğrudan `@radix-ui/*` bağımlılıkları
|
- [x] Doğrudan `@radix-ui/*` bağımlılıkları
|
||||||
- [ ] `radix-ui`
|
- [x] `radix-ui`
|
||||||
- [ ] `shadcn`
|
- [x] `shadcn`
|
||||||
- [ ] Kullanılmayan local UI primitive'leri
|
- [x] Kullanılmayan local UI primitive'leri
|
||||||
- [ ] `framer-motion`, kullanım kalmadıysa
|
- [ ] `framer-motion`, kullanım kalmadıysa
|
||||||
- [ ] `next-themes`, tema başka şekilde çözüldüyse
|
- [x] `next-themes`, tema başka şekilde çözüldüyse
|
||||||
- [ ] Kullanılmayan icon/form/theme yardımcıları
|
- [ ] Kullanılmayan icon/form/theme yardımcıları
|
||||||
|
|
||||||
### 18.4. İşlevsel gerekçeyle korunabilecekler
|
### 18.4. İşlevsel gerekçeyle korunabilecekler
|
||||||
@@ -693,12 +693,12 @@ Mobil hazırlık checklist'i:
|
|||||||
Dependency kalite kapısı:
|
Dependency kalite kapısı:
|
||||||
|
|
||||||
- [ ] Her production dependency için aktif import veya açık gerekçe var.
|
- [ ] Her production dependency için aktif import veya açık gerekçe var.
|
||||||
- [ ] Aynı işi yapan iki UI primitive sistemi yok.
|
- [x] Aynı işi yapan iki UI primitive sistemi yok.
|
||||||
- [ ] Aynı işi yapan iki auth sistemi yok.
|
- [ ] Aynı işi yapan iki auth sistemi yok.
|
||||||
- [ ] Aynı işi yapan iki runtime database sistemi yok.
|
- [ ] Aynı işi yapan iki runtime database sistemi yok.
|
||||||
- [ ] Browser database bağımlılığı yok.
|
- [ ] Browser database bağımlılığı yok.
|
||||||
- [ ] Build çıktısında Supabase referansı yok.
|
- [ ] Build çıktısında Supabase referansı yok.
|
||||||
- [ ] Build çıktısında Poyraz UI v2 referansı yok.
|
- [x] Build çıktısında Poyraz UI v2 referansı yok.
|
||||||
|
|
||||||
## 19. Test stratejisi
|
## 19. Test stratejisi
|
||||||
|
|
||||||
@@ -769,7 +769,7 @@ Mümkün olduğunda küçük ve doğrudan test araçları tercih edilir; test al
|
|||||||
- [ ] Upgrade/migration akışı belgelendi.
|
- [ ] Upgrade/migration akışı belgelendi.
|
||||||
- [ ] Mobil API sınırı belgelendi.
|
- [ ] Mobil API sınırı belgelendi.
|
||||||
- [ ] Eski ve çelişkili Supabase belgeleri archive veya kaldırıldı.
|
- [ ] Eski ve çelişkili Supabase belgeleri archive veya kaldırıldı.
|
||||||
- [ ] ADR-0006 Poyraz UI v3 kararıyla güncellendi.
|
- [x] ADR-0006 Poyraz UI v3 kararıyla güncellendi.
|
||||||
- [ ] ADR-0007 ile PWA runtime durumu uyumlu hale getirildi.
|
- [ ] ADR-0007 ile PWA runtime durumu uyumlu hale getirildi.
|
||||||
|
|
||||||
## 22. Uygulama fazları
|
## 22. Uygulama fazları
|
||||||
@@ -867,14 +867,24 @@ Faz 3 tamamlanma notu (2026-07-16):
|
|||||||
|
|
||||||
Amaç: Sayfa dönüşümleri başlamadan önce ortak UI sistemini kurmak.
|
Amaç: Sayfa dönüşümleri başlamadan önce ortak UI sistemini kurmak.
|
||||||
|
|
||||||
- [ ] Poyraz UI v3 kuruldu.
|
- [x] Poyraz UI v3 kuruldu.
|
||||||
- [ ] Preset CSS eklendi.
|
- [x] Preset CSS eklendi.
|
||||||
- [ ] Global shell Poyraz Sidebar organism ile kuruldu.
|
- [x] Global shell Poyraz Sidebar organism ile kuruldu.
|
||||||
- [ ] Typography standardı tamamlandı.
|
- [x] Typography standardı tamamlandı.
|
||||||
- [ ] Form standardı tamamlandı.
|
- [x] Form standardı tamamlandı.
|
||||||
- [ ] Feedback state'leri tamamlandı.
|
- [x] Feedback state'leri tamamlandı.
|
||||||
- [ ] Tema/branding token bridge tamamlandı.
|
- [x] Tema/branding token bridge tamamlandı.
|
||||||
- [ ] Local primitive kaldırma politikası uygulandı.
|
- [x] Local primitive kaldırma politikası uygulandı.
|
||||||
|
|
||||||
|
Faz 4 tamamlanma notu (2026-07-16):
|
||||||
|
|
||||||
|
- `poyraz-ui@3.0.2`, preset CSS ve atoms/molecules/organisms subpath import sınırı pnpm/npm lockfile'larıyla birlikte kuruldu.
|
||||||
|
- Freelancer ve portal global shell'i Poyraz Sidebar organism'e; hesap menüsü DropdownMenu'ye, global toast katmanı Poyraz Toaster'a taşındı.
|
||||||
|
- Branding servisi primary, accent, focus ve radius değerlerini SSR sırasında doğrudan Poyraz semantic tokenlarına köprüler; system dark mode ilk paint öncesi uygulanır.
|
||||||
|
- Typography, page header, action, form, status badge, loading/empty/error/forbidden, destructive confirmation, overlay, DataTable ve KPI standartları kod bileşimleri ve Faz 4 belgesiyle tanımlandı.
|
||||||
|
- Duplicate local generic primitive'ler ve doğrudan UI dependency'leri kaldırıldı; yalnızca Neta'ya özgü pending/offline davranış bileşimleri Poyraz atomları üzerinde bırakıldı.
|
||||||
|
- `phase4:ui-boundary`, typecheck, hedefli ESLint, storage/branding smoke, auth/SSR branding smoke, production build ve `git diff --check` başarılıdır. Repo genel lint'i legacy Faz 5–7 borçları nedeniyle açık tutuldu.
|
||||||
|
- Uygulama ve doğrulama ayrıntıları `phase-4-poyraz-ui-foundation.md` belgesinde kaydedildi.
|
||||||
|
|
||||||
Çıkış kriteri: Yeni sayfalar ek bir primitive sistemi oluşturmadan geliştirilebiliyor.
|
Çıkış kriteri: Yeni sayfalar ek bir primitive sistemi oluşturmadan geliştirilebiliyor.
|
||||||
|
|
||||||
@@ -971,12 +981,12 @@ Amaç: React Native geliştirmesine başlamadan önce instance keşif ve stabil
|
|||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
- [ ] Poyraz UI v3 kuruldu.
|
- [x] Poyraz UI v3 kuruldu.
|
||||||
- [ ] Preset ve token sistemi kuruldu.
|
- [x] Preset ve token sistemi kuruldu.
|
||||||
- [ ] Global shell taşındı.
|
- [x] Global shell taşındı.
|
||||||
- [ ] Freelancer sayfaları taşındı.
|
- [ ] Freelancer sayfaları taşındı.
|
||||||
- [ ] Portal sayfaları taşındı.
|
- [ ] Portal sayfaları taşındı.
|
||||||
- [ ] Local primitive tekrarı temizlendi.
|
- [x] Local primitive tekrarı temizlendi.
|
||||||
- [ ] Light/dark ve responsive kontroller tamamlandı.
|
- [ ] Light/dark ve responsive kontroller tamamlandı.
|
||||||
|
|
||||||
### Özelleştirme
|
### Özelleştirme
|
||||||
@@ -1012,7 +1022,7 @@ Amaç: React Native geliştirmesine başlamadan önce instance keşif ve stabil
|
|||||||
- [ ] Production Docker smoke başarılı.
|
- [ ] Production Docker smoke başarılı.
|
||||||
- [ ] README güncel.
|
- [ ] README güncel.
|
||||||
- [ ] Supabase runtime referansı kalmadı.
|
- [ ] Supabase runtime referansı kalmadı.
|
||||||
- [ ] Poyraz UI v2 referansı kalmadı.
|
- [x] Poyraz UI v2 referansı kalmadı.
|
||||||
|
|
||||||
## 24. Definition of Done
|
## 24. Definition of Done
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Faz 4 — Poyraz UI v3 foundation
|
||||||
|
|
||||||
|
Tarih: 2026-07-16
|
||||||
|
|
||||||
|
Bu faz, freelancer ve portal sayfalarının dikey dönüşümünden önce ortak UI sözleşmesini kurar. Sayfa bazlı bilgi mimarisi ve görsel revizyonlar Faz 5–6'da kullanıcı yönlendirmesiyle yapılacaktır.
|
||||||
|
|
||||||
|
## Uygulanan temel
|
||||||
|
|
||||||
|
- `poyraz-ui` `3.0.2` sürümüne yükseltildi; hem pnpm hem npm lockfile'ları eşitlendi.
|
||||||
|
- Tailwind v4 girişine `poyraz-ui/preset.css` eklendi.
|
||||||
|
- Atom, molecule ve organism importları yalnızca ilgili package subpath'inden yapılıyor.
|
||||||
|
- Freelancer ve portal shell'i aynı Poyraz Sidebar organism bileşimini kullanıyor.
|
||||||
|
- Masaüstü daraltma, mobil overlay ve mobil aç/kapat state'i `SidebarProvider` tarafından yönetiliyor.
|
||||||
|
- Hesap menüsü custom event yönetimi yerine Poyraz `DropdownMenu`; icon-only shell aksiyonları Poyraz `Tooltip` kullanıyor.
|
||||||
|
- Root feedback katmanı Poyraz `Toaster`; auth bildirimleri Poyraz `Alert` ve `toast` kullanıyor.
|
||||||
|
- Auth alanları ve loading ekranları local kopyalar yerine Poyraz atomlarını kullanıyor.
|
||||||
|
- Instance adı ve açık/koyu logo shell ile auth yüzeylerine taşındı.
|
||||||
|
|
||||||
|
## Import ve ownership kuralı
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Button, Input, Typography } from "poyraz-ui/atoms";
|
||||||
|
import { Alert, Dialog, Form, Select, toast } from "poyraz-ui/molecules";
|
||||||
|
import { DataTable, Sidebar } from "poyraz-ui/organisms";
|
||||||
|
```
|
||||||
|
|
||||||
|
`poyraz-ui` package root importu kullanılmaz. Genel amaçlı UI primitive'i `components/ui` altında yeniden yazılmaz. Neta'ya özgü durum veya davranış, Poyraz bileşenlerinden `components/system` altında compose edilir.
|
||||||
|
|
||||||
|
İzin verilen local davranış yardımcıları:
|
||||||
|
|
||||||
|
- `OfflineIndicator`: browser bağlantı durumu davranışı.
|
||||||
|
- `PendingLink`: Next.js navigasyon pending davranışı.
|
||||||
|
- `PendingSubmitButton`: server action pending davranışı; görsel primitive olarak Poyraz `Button` kullanır.
|
||||||
|
|
||||||
|
Bu sınır `pnpm phase4:ui-boundary` ile otomatik doğrulanır.
|
||||||
|
|
||||||
|
## Typography ve page header
|
||||||
|
|
||||||
|
Poyraz `Typography` bütün yeni sayfalarda metin semantiğinin kaynağıdır:
|
||||||
|
|
||||||
|
| Rol | Poyraz variant | HTML semantiği |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Sayfa başlığı | `h1` | `h1` |
|
||||||
|
| Bölüm başlığı | `h2` / `h3` | sıradaki doğru heading seviyesi |
|
||||||
|
| Kart başlığı | `h4` / `large` | bağlama göre `h2`–`h3` |
|
||||||
|
| Gövde | `body` / `p` | `p` |
|
||||||
|
| Yardımcı açıklama | `muted` | `p` |
|
||||||
|
| Metadata | `caption` / `small` | `span` veya `p` |
|
||||||
|
|
||||||
|
Yeni liste ve detay sayfaları `components/system/page-header.tsx` bileşimini kullanır. Header sırası: isteğe bağlı eyebrow, tek `h1`, kısa açıklama, secondary aksiyonlar ve en sağda bir primary aksiyon. Bir görünümde birincil aksiyon sayısı bir olmalıdır. Secondary aksiyonlar `secondary`, `outline` veya `ghost`; riskli aksiyonlar yalnızca `destructive` variant kullanır.
|
||||||
|
|
||||||
|
## Form standardı
|
||||||
|
|
||||||
|
İki form yolu vardır:
|
||||||
|
|
||||||
|
1. Basit Server Action formları native `<form>` ile; görünür Poyraz `Label`, Poyraz `Input`/`Textarea`/`Select`, alan yanında açıklama ve inline hata kullanır.
|
||||||
|
2. Client-side etkileşimli veya çok alanlı formlar `react-hook-form` ile Poyraz `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription` ve `FormMessage` bileşimini kullanır. Zod doğrulaması gerektiğinde mevcut resolver katmanı kullanılır.
|
||||||
|
|
||||||
|
Kurallar:
|
||||||
|
|
||||||
|
- Placeholder label yerine geçmez.
|
||||||
|
- Server ve client aynı Zod/domain sözleşmesine göre doğrular; client doğrulaması güvenlik sınırı değildir.
|
||||||
|
- Alan hatası `FormMessage` veya alanın hemen altındaki inline metinle gösterilir. Toast tek başına form hatası değildir.
|
||||||
|
- Submit durumu Poyraz `Button` üzerindeki `loading`, `disabled` ve `aria-busy` proplarıyla gösterilir.
|
||||||
|
- Başarılı, formun dışında etkisi olan işlem toast ile; kullanıcının karar vermesi gereken veya kalıcı bilgi inline `Alert` ile gösterilir.
|
||||||
|
|
||||||
|
## Status-to-Badge standardı
|
||||||
|
|
||||||
|
Merkezi eşleme `components/system/status-badge.tsx` içindedir.
|
||||||
|
|
||||||
|
| Anlam | Örnek status'ler | Badge variant |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Başarılı/çalışır | `active`, `accepted`, `completed`, `done`, `paid` | `success` |
|
||||||
|
| Süreçte/bilgi | `in_progress`, `sent` | `info` |
|
||||||
|
| Bekliyor/dikkat | `pending`, `paused` | `warning` |
|
||||||
|
| Plan/taslak | `planning`, `planned`, `todo`, `draft` | `secondary` |
|
||||||
|
| Hata/risk | `overdue`, `rejected`, `revoked`, `expired` | `destructive` |
|
||||||
|
| Pasif terminal | `cancelled`, `archived` | `outline` |
|
||||||
|
|
||||||
|
Renk tek başına anlam taşımaz; badge her zaman Türkçe durum metni içerir.
|
||||||
|
|
||||||
|
## Feedback state standardı
|
||||||
|
|
||||||
|
`components/system/feedback-state.tsx` yeni sayfaların ortak state bileşimidir.
|
||||||
|
|
||||||
|
- Loading: layout'a yakın Poyraz `Skeleton`; kapsayıcıda `aria-busy` ve screen-reader label.
|
||||||
|
- Empty: nötr Poyraz `Card`, kısa neden ve mümkünse bir sonraki primary aksiyon.
|
||||||
|
- Error: Poyraz destructive `Alert`, kullanıcıya uygun açıklama ve retry aksiyonu.
|
||||||
|
- Forbidden: Poyraz warning `Alert`; var olmayan kaynağı açığa çıkarmayacak metin ve güvenli geri dönüş.
|
||||||
|
- Toast: kısa ömürlü, sayfalar arası kalıcı olmayan işlem sonucu.
|
||||||
|
- Inline Alert: kullanıcının okumadan devam etmemesi gereken, forma veya sayfaya bağlı durum.
|
||||||
|
|
||||||
|
## Overlay, confirmation, table ve KPI
|
||||||
|
|
||||||
|
- Geri döndürülemez işlem `components/system/destructive-confirmation.tsx` üzerinden açık `DialogTitle`, `DialogDescription`, vazgeç ve destructive onay aksiyonu ile kurulur.
|
||||||
|
- Kısa desktop formu `Dialog`, bağlamsal yan panel `Sheet`; küçük ekranda uzun/çok adımlı akış `Drawer` olur. Ekranı kaplayan özel div overlay yazılmaz.
|
||||||
|
- Veri listeleri seçim, arama, pagination, loading veya error gerektiriyorsa Poyraz organism `DataTable` kullanır. Basit iki sütunlu statik bilgiler native semantic table kalabilir.
|
||||||
|
- Dashboard metrikleri Poyraz `StatsCard` ile kurulur: kısa label, formatlanmış value, isteğe bağlı trend ve tek anlamlı icon. Renk yalnızca trend/durum bilgisini destekler.
|
||||||
|
|
||||||
|
## Branding token bridge
|
||||||
|
|
||||||
|
Branding servisi eski `--primary` benzeri uygulama tokenları yerine Poyraz v3 sözleşmesini server-side üretir:
|
||||||
|
|
||||||
|
- `--poyraz-primary` ve foreground/hover/active/scale rolleri
|
||||||
|
- `--poyraz-accent` ve foreground/hover rolleri
|
||||||
|
- `--poyraz-ring` ve `--poyraz-focus-ring`
|
||||||
|
- `--poyraz-radius-xs` … `--poyraz-radius-xl`
|
||||||
|
|
||||||
|
Değerler root `<html style>` içine SSR edilir; bu nedenle ilk render'da varsayılan renkten instance rengine geçiş parlaması oluşmaz. `system` renk modu ilk paint öncesi media query ile `.dark` sınıfına çevrilir ve işletim sistemi değişikliği dinlenir. Poyraz preset kalan surface, status, elevation, density ve motion rollerinin tek kaynağıdır.
|
||||||
|
|
||||||
|
Recharts içindeki eski `hsl(var(--...))` ifadeleri geçerli Poyraz CSS renk tokenlarına taşındı.
|
||||||
|
|
||||||
|
## Local primitive ve dependency sonucu
|
||||||
|
|
||||||
|
Kaldırılan local generic primitive'ler: Button, Card, Checkbox, Dialog, DropdownMenu, Field/Form, Input/Label, Select, Separator, Skeleton, Textarea ve Toast/Toaster. Kullanılmayan eski toast hook'u ve `next-themes` provider'ı da kaldırıldı.
|
||||||
|
|
||||||
|
Poyraz'ın kendi dependency ağacında yönettiği `@base-ui/react`, doğrudan Radix paketleri, `radix-ui`, `class-variance-authority`, `shadcn`, ayrıca kullanılmayan `@iconify/react` ve `next-themes` doğrudan Neta dependency listesinden çıkarıldı. Poyraz molecule entrypoint'i dinamik Mermaid importunu build sırasında çözdüğü için package'ın optional peer'i `mermaid` açık dependency olarak eklendi. Sonuçta doğrudan dependency sayısı net 14, pnpm kurulum ağacı yaklaşık 112 paket azaldı.
|
||||||
|
|
||||||
|
## Erişilebilirlik foundation kuralları
|
||||||
|
|
||||||
|
- Heading hiyerarşisi semantik component proplarıyla korunur.
|
||||||
|
- Icon-only aksiyonlar görünür label yoksa `aria-label`; bağlam açıklaması gerekiyorsa Tooltip kullanır.
|
||||||
|
- Dialog/Sheet/Drawer title ve description olmadan yayınlanmaz.
|
||||||
|
- Focus ring utility ile kapatılmaz; Poyraz focus tokenı branding primary rengine bağlıdır.
|
||||||
|
- Loading ve disabled görsel class taklidiyle değil component proplarıyla kurulur.
|
||||||
|
- Global reduced-motion kuralı ve component seviyesinde `motion-reduce` korunur.
|
||||||
|
- Durumlar renk yanında metin ve gerektiğinde icon ile anlatılır.
|
||||||
|
|
||||||
|
Bu faz foundation sözleşmesini doğrular. Legacy feature sayfalarının tamamına uygulanması ilgili Faz 5–6 dikey dilimlerinin kalite kapısıdır.
|
||||||
|
|
||||||
|
## Doğrulama
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm phase4:ui-boundary
|
||||||
|
pnpm typecheck
|
||||||
|
./node_modules/.bin/eslint app/layout.tsx components/layout components/system components/auth components/error-toaster.tsx components/ui/offline-indicator.tsx components/ui/pending-link.tsx components/ui/pending-submit-button.tsx app/login/page.tsx app/register/page.tsx 'app/invite/[token]/page.tsx' server/branding/service.ts scripts/phase4-ui-boundary.mjs
|
||||||
|
pnpm phase3:storage-smoke
|
||||||
|
pnpm phase1:auth-smoke
|
||||||
|
pnpm build
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Boundary testi v3 major sürümünü, preset importunu, Sidebar bileşimini, package subpath importlarını, izin verilen local UI dosyalarını ve kaldırılan duplicate UI bağımlılıklarını doğrular.
|
||||||
|
|
||||||
|
Foundation hedefli ESLint kontrolü temizdir. Repo genelindeki `pnpm lint`, henüz Faz 5–7'de taşınacak legacy feature/API dosyalarındaki mevcut `no-explicit-any`, `set-state-in-effect` ve benzeri borçlar nedeniyle bu fazda global kalite kapısı olarak kapatılmadı; master plandaki genel lint maddesi bu nedenle işaretlenmedi.
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/components/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
|
||||||
const ADD_TOAST = "ADD_TOAST"
|
|
||||||
const UPDATE_TOAST = "UPDATE_TOAST"
|
|
||||||
const DISMISS_TOAST = "DISMISS_TOAST"
|
|
||||||
const REMOVE_TOAST = "REMOVE_TOAST"
|
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
|
||||||
id: string
|
|
||||||
title?: React.ReactNode
|
|
||||||
description?: React.ReactNode
|
|
||||||
action?: ToastActionElement
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
function genId() {
|
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
|
||||||
return count.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| {
|
|
||||||
type: typeof ADD_TOAST
|
|
||||||
toast: ToasterToast
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: typeof UPDATE_TOAST
|
|
||||||
toast: Partial<ToasterToast>
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: typeof DISMISS_TOAST
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: typeof REMOVE_TOAST
|
|
||||||
toastId?: ToasterToast["id"]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
toasts: ToasterToast[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
|
||||||
if (toastTimeouts.has(toastId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
toastTimeouts.delete(toastId)
|
|
||||||
dispatch({
|
|
||||||
type: REMOVE_TOAST,
|
|
||||||
toastId: toastId,
|
|
||||||
})
|
|
||||||
}, TOAST_REMOVE_DELAY)
|
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
|
||||||
switch (action.type) {
|
|
||||||
case ADD_TOAST:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
||||||
}
|
|
||||||
|
|
||||||
case UPDATE_TOAST:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case DISMISS_TOAST: {
|
|
||||||
const { toastId } = action
|
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
|
||||||
// but I'll keep it here for simplicity
|
|
||||||
if (toastId) {
|
|
||||||
addToRemoveQueue(toastId)
|
|
||||||
} else {
|
|
||||||
state.toasts.forEach((toast) => {
|
|
||||||
addToRemoveQueue(toast.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.map((t) =>
|
|
||||||
t.id === toastId || toastId === undefined
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case REMOVE_TOAST:
|
|
||||||
if (action.toastId === undefined) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
memoryState = reducer(memoryState, action)
|
|
||||||
listeners.forEach((listener) => {
|
|
||||||
listener(memoryState)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
|
||||||
const id = genId()
|
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
|
||||||
dispatch({
|
|
||||||
type: UPDATE_TOAST,
|
|
||||||
toast: { ...props, id },
|
|
||||||
})
|
|
||||||
const dismiss = () => dispatch({ type: DISMISS_TOAST, toastId: id })
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: ADD_TOAST,
|
|
||||||
toast: {
|
|
||||||
...props,
|
|
||||||
id,
|
|
||||||
open: true,
|
|
||||||
onOpenChange: (open) => {
|
|
||||||
if (!open) dismiss()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
dismiss,
|
|
||||||
update,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useToast() {
|
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
listeners.push(setState)
|
|
||||||
return () => {
|
|
||||||
const index = listeners.indexOf(setState)
|
|
||||||
if (index > -1) {
|
|
||||||
listeners.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
toast,
|
|
||||||
dismiss: (toastId?: string) => dispatch({ type: DISMISS_TOAST, toastId }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useToast, toast }
|
|
||||||
Generated
+941
-3602
File diff suppressed because it is too large
Load Diff
+3
-17
@@ -17,36 +17,24 @@
|
|||||||
"phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs",
|
"phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs",
|
||||||
"phase3:storage-smoke": "node scripts/phase3-storage-smoke.mjs",
|
"phase3:storage-smoke": "node scripts/phase3-storage-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"
|
"phase4:ui-boundary": "node scripts/phase4-ui-boundary.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.80",
|
"@ai-sdk/google": "^3.0.80",
|
||||||
"@ai-sdk/groq": "^3.0.39",
|
"@ai-sdk/groq": "^3.0.39",
|
||||||
"@ai-sdk/openai": "^3.0.68",
|
"@ai-sdk/openai": "^3.0.68",
|
||||||
"@ai-sdk/react": "^3.0.199",
|
"@ai-sdk/react": "^3.0.199",
|
||||||
"@base-ui/react": "^1.5.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@ducanh2912/next-pwa": "^10.2.9",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@iconify/react": "^6.0.2",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
|
||||||
"@supabase/ssr": "^0.10.3",
|
"@supabase/ssr": "^0.10.3",
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"ai": "^6.0.197",
|
"ai": "^6.0.197",
|
||||||
"better-auth": "^1.6.23",
|
"better-auth": "^1.6.23",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
@@ -54,15 +42,13 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
|
"mermaid": "^11.16.0",
|
||||||
"next": "^16.2.7",
|
"next": "^16.2.7",
|
||||||
"next-themes": "^0.4.6",
|
"poyraz-ui": "^3.0.2",
|
||||||
"poyraz-ui": "^2.1.0",
|
|
||||||
"radix-ui": "^1.4.3",
|
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-hook-form": "^7.77.0",
|
"react-hook-form": "^7.77.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"shadcn": "^4.10.0",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
|
|||||||
Generated
+631
-2066
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,5 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
sharp: true
|
sharp: true
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- poyraz-ui@3.0.2
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ try {
|
|||||||
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Studio");
|
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Studio");
|
||||||
const brandedLoginHtml = await (await fetch(`${baseUrl}/login`)).text();
|
const brandedLoginHtml = await (await fetch(`${baseUrl}/login`)).text();
|
||||||
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Branding metadata must be server-rendered");
|
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Branding metadata must be server-rendered");
|
||||||
assert.match(brandedLoginHtml, /--primary:#336699/, "Brand tokens must be present in first HTML response");
|
assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response");
|
||||||
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
|
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
|
||||||
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
|
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
|
||||||
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
|
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ try {
|
|||||||
assert.equal(branding.primaryColor, "#336699");
|
assert.equal(branding.primaryColor, "#336699");
|
||||||
assert.equal(branding.darkLogoUrl, branding.lightLogoUrl, "Missing dark logo must fall back to light logo");
|
assert.equal(branding.darkLogoUrl, branding.lightLogoUrl, "Missing dark logo must fall back to light logo");
|
||||||
assert.equal(fileService.readPublicBranding(logo.id).metadata.id, logo.id);
|
assert.equal(fileService.readPublicBranding(logo.id).metadata.id, logo.id);
|
||||||
assert.ok(contrastRatio(branding.primaryColor, branding.cssVariables["--primary-foreground"]) >= 4.5);
|
assert.ok(contrastRatio(branding.primaryColor, branding.cssVariables["--poyraz-primary-foreground"]) >= 4.5);
|
||||||
assert.ok(contrastRatio(branding.accentColor, branding.cssVariables["--accent-foreground"]) >= 4.5);
|
assert.ok(contrastRatio(branding.accentColor, branding.cssVariables["--poyraz-accent-foreground"]) >= 4.5);
|
||||||
assertDomainError(() => brandingService.update(clientOne, { applicationName: "Attack" }), "FORBIDDEN");
|
assertDomainError(() => brandingService.update(clientOne, { applicationName: "Attack" }), "FORBIDDEN");
|
||||||
assertDomainError(() => brandingService.update(ownerTwo, { applicationName: "Attack" }), "FORBIDDEN");
|
assertDomainError(() => brandingService.update(ownerTwo, { applicationName: "Attack" }), "FORBIDDEN");
|
||||||
assertDomainError(() => brandingService.update(ownerOne, { primaryColor: "red" }), "VALIDATION_ERROR");
|
assertDomainError(() => brandingService.update(ownerOne, { primaryColor: "red" }), "VALIDATION_ERROR");
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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");
|
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||||
|
const globalsCss = fs.readFileSync(path.join(repoRoot, "app/globals.css"), "utf8");
|
||||||
|
const appShell = fs.readFileSync(path.join(repoRoot, "components/layout/app-shell.tsx"), "utf8");
|
||||||
|
|
||||||
|
assert.match(packageJson.dependencies["poyraz-ui"] ?? "", /^\^?3\./, "poyraz-ui must use major v3");
|
||||||
|
assert.match(
|
||||||
|
packageJson.dependencies.mermaid ?? "",
|
||||||
|
/^\^?11\./,
|
||||||
|
"Poyraz molecule bundle requires its Mermaid peer to be resolvable at build time",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
globalsCss.includes('@import "poyraz-ui/preset.css";'),
|
||||||
|
"Poyraz UI preset must be imported by app/globals.css",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const component of [
|
||||||
|
"SidebarProvider",
|
||||||
|
"SidebarPanel",
|
||||||
|
"SidebarHeader",
|
||||||
|
"SidebarBranding",
|
||||||
|
"SidebarContent",
|
||||||
|
"SidebarMenu",
|
||||||
|
"SidebarMenuItem",
|
||||||
|
"SidebarFooter",
|
||||||
|
"SidebarTrigger",
|
||||||
|
"SidebarUserProfile",
|
||||||
|
]) {
|
||||||
|
assert.ok(appShell.includes(component), `App shell must compose the Poyraz ${component} organism`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredSystemCompositions = [
|
||||||
|
"components/system/page-header.tsx",
|
||||||
|
"components/system/feedback-state.tsx",
|
||||||
|
"components/system/status-badge.tsx",
|
||||||
|
"components/system/destructive-confirmation.tsx",
|
||||||
|
];
|
||||||
|
for (const file of requiredSystemCompositions) {
|
||||||
|
assert.ok(fs.existsSync(path.join(repoRoot, file)), `Missing Neta system composition: ${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedLocalUiFiles = new Set([
|
||||||
|
"offline-indicator.tsx",
|
||||||
|
"pending-link.tsx",
|
||||||
|
"pending-submit-button.tsx",
|
||||||
|
]);
|
||||||
|
const localUiFiles = fs
|
||||||
|
.readdirSync(path.join(repoRoot, "components/ui"), { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => entry.name);
|
||||||
|
assert.deepEqual(
|
||||||
|
localUiFiles.sort(),
|
||||||
|
[...allowedLocalUiFiles].sort(),
|
||||||
|
"components/ui may contain only Neta-specific behavior compositions",
|
||||||
|
);
|
||||||
|
|
||||||
|
const removedDirectUiDependencies = [
|
||||||
|
"@base-ui/react",
|
||||||
|
"@iconify/react",
|
||||||
|
"@radix-ui/react-alert-dialog",
|
||||||
|
"@radix-ui/react-checkbox",
|
||||||
|
"@radix-ui/react-dialog",
|
||||||
|
"@radix-ui/react-dropdown-menu",
|
||||||
|
"@radix-ui/react-label",
|
||||||
|
"@radix-ui/react-select",
|
||||||
|
"@radix-ui/react-separator",
|
||||||
|
"@radix-ui/react-slot",
|
||||||
|
"@radix-ui/react-toast",
|
||||||
|
"class-variance-authority",
|
||||||
|
"next-themes",
|
||||||
|
"radix-ui",
|
||||||
|
"shadcn",
|
||||||
|
];
|
||||||
|
for (const dependency of removedDirectUiDependencies) {
|
||||||
|
assert.equal(packageJson.dependencies[dependency], undefined, `Duplicate UI dependency remains: ${dependency}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (entry.isDirectory()) return walk(path.relative(repoRoot, entryPath));
|
||||||
|
return /\.(css|tsx?|jsx?)$/.test(entry.name) ? [entryPath] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations = ["app", "components"]
|
||||||
|
.flatMap(walk)
|
||||||
|
.flatMap((filePath) => {
|
||||||
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
const relativePath = path.relative(repoRoot, filePath);
|
||||||
|
const matches = [];
|
||||||
|
|
||||||
|
if (/from\s+["']poyraz-ui["']/.test(content)) {
|
||||||
|
matches.push(`${relativePath}: imports from the package root instead of atoms/molecules/organisms`);
|
||||||
|
}
|
||||||
|
if (/@\/components\/ui\/(button|card|checkbox|dialog|dropdown-menu|field|form|icon|input|label|select|separator|skeleton|textarea|toast|toaster)/.test(content)) {
|
||||||
|
matches.push(`${relativePath}: imports a removed local generic primitive`);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(violations, [], `Phase 4 UI boundary violations:\n${violations.join("\n")}`);
|
||||||
|
console.log("Phase 4 Poyraz UI boundary passed");
|
||||||
+30
-14
@@ -162,21 +162,37 @@ export function buildBrandingTokens(
|
|||||||
accent: string,
|
accent: string,
|
||||||
radiusScale: "compact" | "default" | "soft",
|
radiusScale: "compact" | "default" | "soft",
|
||||||
): Record<`--${string}`, string> {
|
): Record<`--${string}`, string> {
|
||||||
const radius = radiusScale === "compact" ? "0.25rem" : radiusScale === "soft" ? "0.75rem" : "0.375rem";
|
const radii = {
|
||||||
|
compact: ["0.25rem", "0.375rem", "0.5rem", "0.625rem", "0.75rem"],
|
||||||
|
default: ["0.375rem", "0.5rem", "0.75rem", "1rem", "1.25rem"],
|
||||||
|
soft: ["0.5rem", "0.75rem", "1rem", "1.25rem", "1.5rem"],
|
||||||
|
}[radiusScale];
|
||||||
|
const primaryForeground = readableForeground(primary);
|
||||||
|
const accentForeground = readableForeground(accent);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"--primary": primary,
|
"--poyraz-primary": primary,
|
||||||
"--primary-foreground": readableForeground(primary),
|
"--poyraz-primary-foreground": primaryForeground,
|
||||||
"--primary-hover": mixHex(primary, "#000000", 0.14),
|
"--poyraz-primary-200": `color-mix(in srgb, ${primary} 28%, var(--poyraz-surface))`,
|
||||||
"--primary-pressed": mixHex(primary, "#000000", 0.28),
|
"--poyraz-primary-600": mixHex(primary, "#000000", 0.08),
|
||||||
"--accent": accent,
|
"--poyraz-primary-700": mixHex(primary, "#000000", 0.14),
|
||||||
"--accent-foreground": readableForeground(accent),
|
"--poyraz-primary-800": mixHex(primary, "#000000", 0.28),
|
||||||
"--accent-hover": mixHex(accent, readableForeground(accent), 0.1),
|
"--poyraz-primary-900": mixHex(primary, "#000000", 0.4),
|
||||||
"--ring": primary,
|
"--poyraz-primary-hover": mixHex(primary, "#000000", 0.14),
|
||||||
"--radius": radius,
|
"--poyraz-primary-active": mixHex(primary, "#000000", 0.28),
|
||||||
"--radius-sm": radiusScale === "soft" ? "0.5rem" : "0.25rem",
|
"--poyraz-primary-dark": mixHex(primary, "#000000", 0.4),
|
||||||
"--radius-md": radius,
|
"--poyraz-primary-muted": `color-mix(in srgb, ${primary} 12%, var(--poyraz-surface))`,
|
||||||
"--radius-lg": radiusScale === "compact" ? "0.375rem" : radiusScale === "soft" ? "1rem" : "0.5rem",
|
"--poyraz-primary-muted-foreground": primary,
|
||||||
"--radius-xl": radiusScale === "compact" ? "0.5rem" : radiusScale === "soft" ? "1.25rem" : "0.75rem",
|
"--poyraz-accent": accent,
|
||||||
|
"--poyraz-accent-foreground": accentForeground,
|
||||||
|
"--poyraz-accent-hover": mixHex(accent, accentForeground, 0.1),
|
||||||
|
"--poyraz-ring": primary,
|
||||||
|
"--poyraz-focus-ring": primary,
|
||||||
|
"--poyraz-radius-xs": radii[0],
|
||||||
|
"--poyraz-radius-sm": radii[1],
|
||||||
|
"--poyraz-radius-md": radii[2],
|
||||||
|
"--poyraz-radius-lg": radii[3],
|
||||||
|
"--poyraz-radius-xl": radii[4],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user