Initial commit

This commit is contained in:
测试环境第一个项目
2026-04-20 21:10:04 +08:00
commit 70c3c29179
39 changed files with 9939 additions and 0 deletions

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

130
src/app/globals.css Normal file
View File

@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

44
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { Providers } from "@/providers";
import { Header } from "@/components/layout/header";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Idea Guru",
description: "A Next.js starter template with a curated tech stack",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
suppressHydrationWarning
className={`geistSans.variable geistMono.variable h-full antialiased`}
>
<body className="flex min-h-full flex-col">
<Providers>
<Header />
<main className="flex-1">{children}</main>
<Toaster />
</Providers>
</body>
</html>
);
}

116
src/app/page.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client";
import { motion } from "framer-motion";
import {
Layers,
Paintbrush,
Database,
FileCode,
Zap,
ArrowRight
} from 'lucide-react';
import {Button } from "@/components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
const features = [
{
icon: Layers,
title: 'Next.js 16 + App Router',
description: 'Server components, layouts, and the latest React features'
},
{
icon: Paintbrush,
title:'Tailwind CSS + shadcn/ui',
description: 'Utility-first CSS framework with pre-designed components'
},
{
icon: Database,
title: 'Zustand + TanStack Query',
description: 'State management and data fetching solutions'
},
{
icon: FileCode,
title: 'React Hook Form + Zod',
description: 'Form handling and validation with TypeScript support'
},
{
icon: Zap,
title: 'Framer Motion + Lucide Icons',
description: 'Animation library and icon set for React'
},
{
icon: ArrowRight,
title: 'Axios + Interceptors',
description: 'HTTP client with request and response interceptors'
},
];
const containerVariants = {
hidden : { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
export default function Home() {
return (
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
{/* Hero */}
<motion.section
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-20"
>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Welcome to Idea Guru
</h1>
<p className="mt-4 mx-auto max-w-2xl text-lg text-muted-foreground">
A Next.js starter template with a curated tech stack
</p>
<div className="mt-8 flex items-center justify-center gap-4">
<Button size="lg">
Get Started
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
<Button variant="outline" size="lg">
View on GitHub
</Button>
</div>
</motion.section>
{/* Features */}
<motion.section
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
{features.map((feature, index) => (
<motion.div key={index} variants={itemVariants}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<feature.icon className="text-primary mb-2 h-8 w-8" />
<CardTitle className="text-lg">{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</Card>
</motion.div>
))}
</motion.section>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import Link from "next/link";
import { Sparkles } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function Header() {
return (
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href="/" className="flex items-center gap-2 font-semibold">
<Sparkles className="h-5 w-5 text-primary" />
<span>Idea Guru</span>
</Link>
<nav className="flex items-center gap-2">
<Link
href="/"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
>
Home
</Link>
<Button size='sm'>Get Started</Button>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
src/components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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 { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
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 }

View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,48 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/axios';
// Types
interface ExampleItem {
id: string;
title: string;
completed: boolean;
}
interface CreateItemInput {
title: string;
}
// Query keys - centralized for cache management
export const exampleKeys = {
all: ['example'] as const,
lists: () => [...exampleKeys.all, 'lists'] as const,
detail: (id: string) => [...exampleKeys.all, 'detail', id] as const,
}
// GET - fetch single item
export function useExampleDetail(id: string) {
return useQuery({
queryKey: exampleKeys.detail(id),
queryFn: async () => {
const { data } = await api.get<ExampleItem>(`/example/id`);
return data;
},
enabled: !!id, // only run if id is provided
});
}
// POST - create new item
export function useCreateExample() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: CreateItemInput) => {
const { data } = await api.post<ExampleItem>('/example', input);
return data;
},
onSuccess: () => {
// Invalidate and refetch example lists after creating a new item
queryClient.invalidateQueries({queryKey: exampleKeys.lists()});
}
});
}

View File

@@ -0,0 +1,18 @@
import { useCallback, useSyncExternalStore } from "react";
export function useMediaQuery(query: string): boolean {
const subscribe = useCallback(
(callback: () => void) => {
const media = window.matchMedia(query);
media.addEventListener("change", callback);
return () => media.removeEventListener("change", callback);
},
[query]
);
const getSnapshot = () => window.matchMedia(query).matches;
const getServerSnapshot = () => false; // Default to false on server
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

42
src/lib/axios.ts Normal file
View File

@@ -0,0 +1,42 @@
import axios from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || "/api",
timeout: 30000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
// Example: Add auth token from localStorage (replace with your auth logic)
const token = localStorage.getItem("authToken");
if (token) {
config.headers["Authorization"] = `Bearer token`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for global error handling
api.interceptors.response.use(
(response) => response,
(error) => {
// Example: Handle 401 Unauthorized globally
if (error.response?.status === 401) {
// Optionally, you can redirect to login page or show a toast
console.error("Unauthorized - redirecting to login");
if (typeof window !== "undefined") {
localStorage.removeItem("authToken"); // Clear token on unauthorized
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);

26
src/lib/env.ts Normal file
View File

@@ -0,0 +1,26 @@
import { z } from "zod";
// Client-side environment variables (must be prefixed with NEXT_PUBLIC_)
export const clientEnvSchema = z.object({
NEXT_PUBLIC_APP_NAME: z.string().default("Demo App"),
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
NEXT_PUBLIC_API_BASE_URL: z.string().url().default("http://localhost:3000/api"),
});
// Server-side environment variables (not prefixed)
export const serverEnvSchema = z.object({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
// Only parse server variables on the server, and client variables on the client
export const serverEnv =
typeof window === "undefined" ?
serverEnvSchema.parse({NODE_ENV: process.env.NODE_ENV}) :
({} as z.infer<typeof serverEnvSchema>);
export const clientEnv =
typeof window !== "undefined" ?
clientEnvSchema.parse({NODE_ENV: process.env.NODE_ENV}) :
({} as z.infer<typeof clientEnvSchema>);

34
src/lib/query-client.ts Normal file
View File

@@ -0,0 +1,34 @@
import {
QueryClient,
defaultShouldDehydrateQuery,
isServer,
} from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1, // Retry failed requests once
refetchOnWindowFocus: false, // Disable refetch on window focus
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending'
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient();
}
// On the client, reuse the same QueryClient instance
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

21
src/lib/validations.ts Normal file
View File

@@ -0,0 +1,21 @@
import { z } from "zod";
// Example: login form validation schema
export const loginSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
});
export type LoginFormValues = z.infer<typeof loginSchema>;
// Example: Contact form validation schema
export const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Invalid email address"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(500, "Message must be less than 500 characters"),
});
export type ContactFormValues = z.infer<typeof contactSchema>;

17
src/providers/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
'use client'
import { getQueryClient } from "@/lib/query-client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,27 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AppState {
// Example state properties
sidebarOpen: boolean;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
// Sidebar
sidebarOpen: true,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open })
}),
{
name: "app-store", // unique name for localStorage key
partialize: (state) => ({
sidebarOpen: state.sidebarOpen
}),
}
)
);

21
src/types/index.ts Normal file
View File

@@ -0,0 +1,21 @@
// Shared typescript types and interfaces
export interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ApiError {
message: string;
statusCode?: number;
errors?: Record<string, string[]>;
}