main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-09 02:16:10 +03:00
parent c1f94439ab
commit d09b1fbb6f
166 changed files with 5267 additions and 18148 deletions

View File

@@ -1,12 +1,9 @@
# NextAuth Configuration
# Generate a secret with: openssl rand -base64 32
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-secret-key-here
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# Backend API URL
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# Auth Mode: true = login required, false = public access with optional login
NEXT_PUBLIC_AUTH_REQUIRED=false
NEXT_PUBLIC_GOOGLE_API_KEY='api-key'
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

15
.gitignore vendored
View File

@@ -1,7 +1,8 @@
node_modules
.next
certificates
.env.local
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

387
README.md
View File

@@ -1,381 +1,20 @@
# 🚀 Enterprise Next.js Boilerplate (Antigravity Edition)
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
[![Next.js](https://img.shields.io/badge/Next.js%2016-000000?style=for-the-badge&logo=next.js&logoColor=white)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React%2019-61DAFB?style=for-the-badge&logo=react&logoColor=black)](https://react.dev/)
[![Chakra UI](https://img.shields.io/badge/Chakra%20UI%20v3-319795?style=for-the-badge&logo=chakraui&logoColor=white)](https://chakra-ui.com/)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![next-intl](https://img.shields.io/badge/next--intl-0070F3?style=for-the-badge&logo=next.js&logoColor=white)](https://next-intl-docs.vercel.app/)
# Run and deploy your AI Studio app
> **FOR AI AGENTS & DEVELOPERS:** This documentation is structured to provide deep context, architectural decisions, and operational details to ensure seamless handover to any AI coding assistant (like Antigravity) or human developer.
This contains everything you need to run your app locally.
---
View your app in AI Studio: https://ai.studio/apps/a8f2558c-b1ff-4a7d-a20e-4dd29827ba2c
## 🧠 Project Context & Architecture (Read Me First)
## Run Locally
This is an **opinionated, production-ready** frontend boilerplate built with Next.js 16 App Router. It is designed to work seamlessly with the companion **typescript-boilerplate-be** NestJS backend.
**Prerequisites:** Node.js
### 🏗️ Core Philosophy
- **Type Safety First:** Strict TypeScript configuration. DTOs and typed API responses connect frontend to backend.
- **App Router Native:** Built entirely on Next.js App Router with Server Components and React Server Actions support.
- **i18n Native:** Localization is baked into routing, API calls, and UI components via `next-intl`.
- **Theme-First Design:** Chakra UI v3 with custom theme system for consistent, accessible UI.
- **Auth by Default:** NextAuth.js integration with JWT token management and automatic session handling.
### 📐 Architectural Decision Records (ADR)
_To understand WHY things are the way they are:_
1. **Locale-Based Routing:**
- **Mechanism:** All routes are prefixed with locale (e.g., `/tr/dashboard`, `/en/login`).
- **Implementation:** `next-intl` plugin with `[locale]` dynamic segment in `app/` directory.
- **Config:** `localePrefix: 'always'` ensures consistent URL structure.
2. **API Client with Auto-Auth & Locale:**
- **Location:** `src/lib/api/create-api-client.ts`
- **Feature:** Automatically injects JWT `Authorization` header from NextAuth session.
- **Feature:** Automatically sets `Accept-Language` header based on current locale (cookie or URL path).
- **Auto-Logout:** 401 responses trigger automatic signOut and redirect.
3. **Backend Proxy (CORS Solution):**
- **Problem:** Local development CORS issues when frontend (port 3001) calls backend (port 3000).
- **Solution:** Next.js `rewrites` in `next.config.ts` proxies `/api/backend/*``http://localhost:3000/api/*`.
- **Usage:** API clients use `/api/backend/...` paths in development.
4. **Provider Composition:**
- **Location:** `src/components/ui/provider.tsx`
- **Stack:** `SessionProvider` (NextAuth) → `ChakraProvider` (UI) → `ColorModeProvider` (Dark/Light) → `Toaster` (Notifications)
- **Why:** Single import in layout provides all necessary contexts.
---
## 🚀 Quick Start for AI & Humans
### 1. Prerequisites
- **Node.js:** v20.19+ (LTS)
- **Package Manager:** `npm` (Lockfile: `package-lock.json`)
- **Backend:** Companion NestJS backend running on port 3000 (see `typescript-boilerplate-be`)
### 2. Environment Setup
```bash
cp .env.example .env.local
# Edit .env.local with your configuration
```
**Required Environment Variables:**
| Variable | Description | Example |
| --------------------------- | --------------------------------------------------------------- | --------------------------- |
| `NEXTAUTH_URL` | NextAuth callback URL | `http://localhost:3001` |
| `NEXTAUTH_SECRET` | Authentication secret (generate with `openssl rand -base64 32`) | `abc123...` |
| `NEXT_PUBLIC_API_URL` | Backend API base URL | `http://localhost:3001/api` |
| `NEXT_PUBLIC_AUTH_REQUIRED` | Require login for all pages | `true` or `false` |
### 3. Installation & Running
```bash
# Install dependencies
npm ci
# Development Mode (HTTPS enabled, port 3001)
npm run dev
# Production Build
npm run build
npm run start
```
> **Note:** Dev server runs on port 3001 with experimental HTTPS for secure cookie handling.
---
## 🌍 Internationalization (i18n) Guide
Deep integration with `next-intl` provides locale-aware routing and translations.
### Configuration
- **Supported Locales:** `['en', 'tr']`
- **Default Locale:** `tr`
- **Locale Detection:** Cookie (`NEXT_LOCALE`) → URL path → Default
### File Structure
```
messages/
├── en.json # English translations
└── tr.json # Turkish translations
src/i18n/
├── routing.ts # Locale routing configuration
├── navigation.ts # Typed navigation helpers (Link, useRouter)
└── request.ts # Server-side i18n setup
```
### Usage in Components
```tsx
// Client Component
"use client";
import { useTranslations } from "next-intl";
export function MyComponent() {
const t = useTranslations("common");
return <h1>{t("welcome")}</h1>;
}
// Navigation with Locale
import { Link } from "@/i18n/navigation";
<Link href="/dashboard">Dashboard</Link>; // Auto-prefixes with current locale
```
### Adding a New Locale
1. Add locale code to `src/i18n/routing.ts`:
```ts
export const locales = ["en", "tr", "de"]; // Add 'de'
```
2. Create `messages/de.json` with translations.
3. Update `getLocale()` in `create-api-client.ts` if needed.
---
## 🔐 Authentication System
Built on NextAuth.js with JWT strategy for seamless backend integration.
### How It Works
1. **Login:** User submits credentials → `/api/auth/[...nextauth]` → Backend validates → JWT returned.
2. **Session:** JWT stored in encrypted cookie, accessible via `useSession()` hook.
3. **API Calls:** `createApiClient()` automatically adds `Authorization: Bearer {token}` header.
4. **Auto-Refresh:** Token refreshed before expiry (managed by NextAuth).
### Protected Routes
```tsx
// Use middleware.ts or check session in layout
import { getServerSession } from "next-auth";
export default async function ProtectedLayout({ children }) {
const session = await getServerSession();
if (!session) redirect("/login");
return <>{children}</>;
}
```
### Auth Mode Toggle
Set `NEXT_PUBLIC_AUTH_REQUIRED=true` for mandatory authentication across all pages.
---
## 🎨 UI System (Chakra UI v3)
### Theme Configuration
- **Location:** `src/theme/theme.ts`
- **Font:** Bricolage Grotesque (Google Fonts, variable font)
- **Color Mode:** Light/Dark with system preference detection
### Component Organization
```
src/components/
├── ui/ # Chakra UI wrapper components
│ ├── provider.tsx # Root provider (Session + Chakra + Theme)
│ ├── color-mode.tsx # Dark/Light mode toggle
│ ├── feedback/ # Toaster, alerts, etc.
│ └── ...
├── layout/ # Page layout components (Header, Sidebar, Footer)
├── auth/ # Authentication forms and guards
└── site/ # Site-specific feature components
```
### Toast Notifications
```tsx
import { toaster } from "@/components/ui/feedback/toaster";
// Success toast
toaster.success({
title: "Saved!",
description: "Changes saved successfully.",
});
// Error toast
toaster.error({ title: "Error", description: "Something went wrong." });
```
---
## 📡 API Integration (Standard Architecture)
The project follows a strict **Service + Hook** pattern for API integration to ensure separation of concerns, type safety, and efficient caching.
**🏆 Golden Standard Reference:** [`src/lib/api/example`](src/lib/api/example)
### 🏗️ Architecture Pattern
For every domain (e.g., `auth`, `users`), we strictly follow this 4-layer structure:
| Layer | File | Purpose |
|-------|------|---------|
| **1. Types** | `types.ts` | **Contract:** pure TypeScript interfaces/DTOs matching Backend. |
| **2. Service** | `service.ts` | **Logic:** Pure functions calling `apiRequest`. Independent of React. |
| **3. Hooks** | `use-hooks.ts` | **State:** React Query wrappers (`useQuery`, `useMutation`) for caching & state. |
| **4. Barrel** | `index.ts` | **Public API:** Central export point for the module. |
### 📝 Implementation Guide
#### 1. Define Service (`service.ts`)
Uses `apiRequest` wrapper which handles client selection (`auth`, `core`, etc.), base URLs, and error normalization.
```ts
// src/lib/api/example/auth/service.ts
import { apiRequest } from "@/lib/api/api-service";
import { LoginDto, AuthResponse } from "./types";
const login = (data: LoginDto) => {
return apiRequest<ApiResponse<AuthResponse>>({
url: "/auth/login",
client: "auth", // Selects the correct axios instance from clientMap
method: "post",
data,
});
};
export const authService = { login };
```
#### 2. Create Hook (`use-hooks.ts`)
Wraps service in TanStack Query for loading states, caching, and invalidation.
```ts
// src/lib/api/example/auth/use-hooks.ts
import { useMutation } from "@tanstack/react-query";
import { authService } from "./service";
export const AuthQueryKeys = {
all: ["auth"] as const,
};
export function useLogin() {
return useMutation({
mutationFn: (data: LoginDto) => authService.login(data),
});
}
```
#### 3. Use in Components
Components only interact with the Hooks, never the Service or Axios directly.
```tsx
import { useLogin } from "@/lib/api/example"; // Import from barrel
export function LoginForm() {
const { mutate, isPending } = useLogin();
const handleSubmit = (data) => {
mutate(data, {
onSuccess: () => console.log("Logged in!"),
});
};
}
```
---
## 📂 System Map (Directory Structure)
```
src/
├── app/ # Next.js App Router
│ ├── [locale]/ # Locale-prefixed routes
│ │ ├── (auth)/ # Auth pages (login, register)
│ │ ├── (site)/ # Main site pages
│ │ ├── (error)/ # Error pages
│ │ ├── layout.tsx # Root layout with providers
│ │ └── page.tsx # Home page
│ └── api/ # Next.js API routes
│ └── auth/ # NextAuth endpoints
├── components/ # React components
│ ├── ui/ # Chakra UI wrappers & base components
│ ├── layout/ # Layout components (Header, Sidebar)
│ ├── auth/ # Auth-related components
│ └── site/ # Feature-specific components
├── config/ # App configuration
├── hooks/ # Custom React hooks
├── i18n/ # Internationalization setup
├── lib/ # Utilities & services
│ ├── api/ # API clients (organized by module)
│ │ ├── auth/ # Auth API (login, register)
│ │ ├── admin/ # Admin API (roles, users)
│ │ └── create-api-client.ts # Axios factory with interceptors
│ ├── services/ # Business logic services
│ └── utils/ # Helper functions
├── theme/ # Chakra UI theme configuration
├── types/ # TypeScript type definitions
└── proxy.ts # API proxy utilities
messages/ # Translation JSON files
public/ # Static assets
```
---
## 🧪 Testing & Development
### Available Scripts
```bash
npm run dev # Start dev server (HTTPS, port 3001)
npm run build # Production build
npm run start # Start production server
npm run lint # Run ESLint
```
### Development Tools
- **React Compiler:** Enabled for automatic optimization.
- **Top Loader:** Visual loading indicator for route transitions.
- **ESLint + Prettier:** Consistent code formatting.
---
## 🛠️ Troubleshooting (Known Issues)
**1. HTTPS Certificate Warnings in Development**
- **Context:** Dev server uses `--experimental-https` for secure cookies.
- **Fix:** Accept the self-signed certificate warning in your browser.
**2. Backend Connection Refused**
- **Fix:** Ensure NestJS backend is running on port 3000.
- **Check:** Run `curl http://localhost:3000/api/health` to verify.
**3. Session Not Persisting**
- **Fix:** Ensure `NEXTAUTH_SECRET` is set and consistent between restarts.
- **Fix:** Check that cookies are not blocked by browser settings.
**4. Locale Not Detected**
- **Context:** Default falls back to `tr` if cookie/URL detection fails.
- **Fix:** Clear `NEXT_LOCALE` cookie and refresh.
---
## 🔗 Related Projects
- **Backend:** [typescript-boilerplate-be](../typescript-boilerplate-be) - NestJS backend with Prisma, JWT auth, and i18n.
---
## 📃 License
This project is proprietary and confidential.
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

13
download.js Normal file
View File

@@ -0,0 +1,13 @@
import https from 'https';
import fs from 'fs';
const file = fs.createWriteStream("public/hero-bg.jpg");
https.get("https://drive.google.com/uc?export=download&id=1YVWlLN4_6B4aVQ5BMeK72wpJyB4Az5T9", function(response) {
if (response.statusCode === 302 || response.statusCode === 303) {
https.get(response.headers.location, function(res) {
res.pipe(file);
});
} else {
response.pipe(file);
}
});

View File

@@ -1,25 +0,0 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
{
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
},
{
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
];
export default eslintConfig;

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="tr" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Harun CAN - Oyuncu, Seslendirme Sanatçısı, Müzisyen</title>
<meta name="description" content="Kariyerine 1986 yılında başlayan Harun CAN; oyuncu, seslendirme sanatçısı, müzisyen ve içerik üreticisi olarak sanat hayatını çok yönlü bir şekilde sürdürmektedir." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;700&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-slate-950 text-slate-200 antialiased selection:bg-cyan-500/30">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,32 +0,0 @@
{
"home": "Home",
"about": "About",
"solutions": "Solutions",
"intelligent-transportation-systems": "Intelligent Transportation Systems",
"artificial-intelligence": "Artificial Intelligence",
"error": {
"not-found": "Oops! The page youre looking for doesnt exist.",
"404": "404",
"back-to-home": "Go back home"
},
"email": "E-Mail",
"password": "Password",
"auth": {
"remember-me": "Remember Me",
"dont-have-account": "Don't have an account?",
"sign-out": "Sign Out",
"sign-up": "Sign Up",
"sign-in": "Sign In",
"welcome-back": "Welcome Back",
"subtitle": "Enter your email and password to sign in",
"already-have-an-account": "Already have an account?",
"create-an-account-now": "Create an account now"
},
"all-right-reserved": "All rights reserved.",
"privacy-policy": "Privacy Policy",
"terms-of-service": "Terms of Service",
"name": "Name",
"low": "Low",
"medium": "Medium",
"high": "High"
}

View File

@@ -1,32 +0,0 @@
{
"home": "Anasayfa",
"about": "Hakkımızda",
"solutions": "Çözümler",
"intelligent-transportation-systems": "Akıllı Ulaşım Sistemleri",
"artificial-intelligence": "Yapay Zeka",
"error": {
"not-found": "Üzgünüz, aradığınız sayfa bulunamadı.",
"404": "404",
"back-to-home": "Ana sayfaya dön"
},
"email": "E-Posta",
"password": "Şifre",
"auth": {
"remember-me": "Beni Hatırla",
"dont-have-account": "Hesabınız yok mu?",
"sign-out": ıkış Yap",
"sign-up": "Kaydol",
"sign-in": "Giriş Yap",
"welcome-back": "Hoş Geldiniz",
"subtitle": "Giriş yapmak için e-postanızı ve şifrenizi giriniz",
"already-have-an-account": "Zaten bir hesabınız var mı?",
"create-an-account-now": "Hemen bir hesap oluşturun"
},
"all-right-reserved": "Tüm hakları saklıdır.",
"privacy-policy": "Gizlilik Politikası",
"terms-of-service": "Hizmet Şartları",
"name": "İsim",
"low": "Düşük",
"medium": "Orta",
"high": "Yüksek"
}

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Harun CAN Studio",
"description": "",
"requestFramePermissions": []
}

6
next-env.d.ts vendored
View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,21 +0,0 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {
output: 'standalone',
experimental: {
optimizePackageImports: ["@chakra-ui/react"],
},
reactCompiler: true,
async rewrites() {
return [
{
source: "/api/backend/:path*",
destination: "http://localhost:3000/api/:path*",
},
];
},
};
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

10452
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +1,35 @@
{
"name": "test-ui",
"version": "0.0.1",
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev --webpack -p 3001",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
"dev": "vite --port=5173 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0",
"@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.1",
"i18next": "^25.6.0",
"next": "16.0.0",
"next-auth": "^4.24.13",
"next-intl": "^4.4.0",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"yup": "^1.7.1"
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.2.0",
"express": "^4.21.2",
"dotenv": "^17.2.3",
"better-sqlite3": "^12.4.1",
"motion": "^12.23.24"
},
"devDependencies": {
"@chakra-ui/cli": "^3.27.1",
"@eslint/eslintrc": "^3.3.1",
"@types/node": "^20",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.37.0",
"eslint-config-next": "16.0.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.6.2",
"typescript": "^5"
},
"description": "Generated by Frontend CLI"
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"@types/express": "^4.17.21"
}
}

BIN
public/.DS_Store vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,19 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

BIN
src/.DS_Store vendored

Binary file not shown.

33
src/App.tsx Normal file
View File

@@ -0,0 +1,33 @@
import Navbar from './components/Navbar';
import Hero from './components/Hero';
import Works from './components/Works';
import Services from './components/Services';
import Clients from './components/Clients';
import Process from './components/Process';
import About from './components/About';
import Contact from './components/Contact';
import Footer from './components/Footer';
import DynamicBackground from './components/DynamicBackground';
import Admin from './components/Admin';
export default function App() {
return (
<div className="min-h-screen relative bg-[#050505]">
<DynamicBackground />
<div className="relative z-10">
<Navbar />
<main>
<Hero />
<Works />
<Services />
<Clients />
<Process />
<About />
<Contact />
</main>
<Footer />
</div>
<Admin />
</div>
);
}

BIN
src/app/.DS_Store vendored

Binary file not shown.

View File

@@ -1,15 +0,0 @@
'use client';
import Footer from '@/components/layout/footer/footer';
import { Box, Flex } from '@chakra-ui/react';
function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<Flex minH='100vh' direction='column'>
<Box as='main'>{children}</Box>
<Footer />
</Flex>
);
}
export default AuthLayout;

View File

@@ -1,231 +0,0 @@
"use client";
import {
Box,
Flex,
Heading,
Input,
Link as ChakraLink,
Text,
ClientOnly,
} from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Switch } from "@/components/ui/forms/switch";
import { Field } from "@/components/ui/forms/field";
import { useTranslations } from "next-intl";
import signInImage from "/public/assets/img/sign-in-image.png";
import { InputGroup } from "@/components/ui/forms/input-group";
import { BiLock } from "react-icons/bi";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { Link, useRouter } from "@/i18n/navigation";
import { MdMail } from "react-icons/md";
import { PasswordInput } from "@/components/ui/forms/password-input";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().required(),
});
type SignInForm = yup.InferType<typeof schema>;
const defaultValues = {
email: "test@test.com.ue",
password: "test1234",
};
function SignInPage() {
const t = useTranslations();
const router = useRouter();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignInForm>({
resolver: yupResolver(schema),
mode: "onChange",
defaultValues,
});
const onSubmit = async (formData: SignInForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
router.replace("/home");
} catch (error) {
toaster.error({
title: (error as Error).message || "Giriş yaparken hata oluştu!",
type: "error",
});
} finally {
setLoading(false);
}
};
return (
<Box position="relative">
<Flex
h={{ sm: "initial", md: "75vh", lg: "85vh" }}
w="100%"
maxW="1044px"
mx="auto"
justifyContent="space-between"
mb="30px"
pt={{ sm: "100px", md: "0px" }}
>
<Flex
as="form"
onSubmit={handleSubmit(onSubmit)}
alignItems="center"
justifyContent="start"
style={{ userSelect: "none" }}
w={{ base: "100%", md: "50%", lg: "42%" }}
>
<Flex
direction="column"
w="100%"
background="transparent"
p="10"
mt={{ md: "150px", lg: "80px" }}
>
<Heading
color={{ base: "primary.400", _dark: "primary.200" }}
fontSize="32px"
mb="10px"
fontWeight="bold"
>
{t("auth.welcome-back")}
</Heading>
<Text
mb="36px"
ms="4px"
color={{ base: "gray.400", _dark: "white" }}
fontWeight="bold"
fontSize="14px"
>
{t("auth.subtitle")}
</Text>
<Field
mb="24px"
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="15px"
fontSize="sm"
type="text"
placeholder={t("email")}
size="lg"
{...register("email")}
/>
</InputGroup>
</Field>
<Field
mb="24px"
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="15px"
fontSize="sm"
placeholder={t("password")}
size="lg"
{...register("password")}
/>
</InputGroup>
</Field>
<Field mb="24px">
<Switch colorPalette="teal" label={t("auth.remember-me")}>
{t("auth.remember-me")}
</Switch>
</Field>
<Field mb="24px">
<ClientOnly fallback={<Skeleton height="45px" width="100%" />}>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
h="45px"
color="white"
_hover={{
bg: "primary.500",
}}
_active={{
bg: "primary.400",
}}
>
{t("auth.sign-in")}
</Button>
</ClientOnly>
</Field>
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
maxW="100%"
>
<Text
color={{ base: "gray.400", _dark: "white" }}
fontWeight="medium"
>
{t("auth.dont-have-account")}
<ChakraLink
as={Link}
href="/signup"
color={{ base: "primary.400", _dark: "primary.200" }}
ms="5px"
fontWeight="bold"
focusRing="none"
>
{t("auth.sign-up")}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
<Box
display={{ base: "none", md: "block" }}
overflowX="hidden"
h="100%"
w="40vw"
position="absolute"
right="0px"
>
<Box
bgImage={`url(${signInImage.src})`}
w="100%"
h="100%"
bgSize="cover"
bgPos="50%"
position="absolute"
borderBottomLeftRadius="20px"
/>
</Box>
</Flex>
</Box>
);
}
export default SignInPage;

View File

@@ -1,166 +0,0 @@
'use client';
import { Box, Flex, Input, Link as ChakraLink, Text, ClientOnly } from '@chakra-ui/react';
import signUpImage from '/public/assets/img/sign-up-image.png';
import { Button } from '@/components/ui/buttons/button';
import { Field } from '@/components/ui/forms/field';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { InputGroup } from '@/components/ui/forms/input-group';
import { BiLock, BiUser } from 'react-icons/bi';
import { Link } from '@/i18n/navigation';
import { MdMail } from 'react-icons/md';
import { useRouter } from 'next/navigation';
import { PasswordInput } from '@/components/ui/forms/password-input';
import { Skeleton } from '@/components/ui/feedback/skeleton';
const schema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
password: yup.string().required(),
});
type SignUpForm = yup.InferType<typeof schema>;
function SignUpPage() {
const t = useTranslations();
const router = useRouter();
const {
handleSubmit,
register,
formState: { errors },
} = useForm<SignUpForm>({ resolver: yupResolver(schema), mode: 'onChange' });
const onSubmit = async (formData: SignUpForm) => {
router.replace('/signin');
return formData;
};
return (
<Box>
<Box
position='absolute'
minH={{ base: '70vh', md: '50vh' }}
w={{ md: 'calc(100vw - 50px)' }}
borderRadius={{ md: '15px' }}
left='0'
right='0'
bgRepeat='no-repeat'
overflow='hidden'
zIndex='-1'
top='0'
bgImage={`url(${signUpImage.src})`}
bgSize='cover'
mx={{ md: 'auto' }}
mt={{ md: '14px' }}
/>
<Flex w='full' h='full' direction='column' alignItems='center' justifyContent='center'>
<Text
fontSize={{ base: '2xl', md: '3xl', lg: '4xl' }}
color='white'
fontWeight='bold'
mt={{ base: '2rem', md: '4.5rem', '2xl': '6.5rem' }}
mb={{ base: '2rem', md: '3rem', '2xl': '4rem' }}
>
{t('auth.create-an-account-now')}
</Text>
<Flex
direction='column'
w={{ base: '100%', md: '445px' }}
background='transparent'
borderRadius='15px'
p='10'
mx={{ base: '100px' }}
bg='bg.panel'
boxShadow='0 20px 27px 0 rgb(0 0 0 / 5%)'
mb='8'
>
<Flex
as='form'
onSubmit={handleSubmit(onSubmit)}
flexDirection='column'
alignItems='center'
justifyContent='start'
w='100%'
>
<Field mb='24px' label={t('name')} errorText={errors.name?.message} invalid={!!errors.name}>
<InputGroup w='full' startElement={<BiUser size='1rem' />}>
<Input
borderRadius='15px'
fontSize='sm'
type='text'
placeholder={t('name')}
size='lg'
{...register('name')}
/>
</InputGroup>
</Field>
<Field mb='24px' label={t('email')} errorText={errors.email?.message} invalid={!!errors.email}>
<InputGroup w='full' startElement={<MdMail size='1rem' />}>
<Input
borderRadius='15px'
fontSize='sm'
type='text'
placeholder={t('email')}
size='lg'
{...register('email')}
/>
</InputGroup>
</Field>
<Field mb='24px' label={t('password')} errorText={errors.password?.message} invalid={!!errors.password}>
<InputGroup w='full' startElement={<BiLock size='1rem' />}>
<PasswordInput
borderRadius='15px'
fontSize='sm'
placeholder={t('password')}
size='lg'
{...register('password')}
/>
</InputGroup>
</Field>
<Field mb='24px'>
<ClientOnly fallback={<Skeleton height='45px' width='100%' />}>
<Button
type='submit'
bg='primary.400'
color='white'
fontWeight='bold'
w='100%'
h='45px'
_hover={{
bg: 'primary.500',
}}
_active={{
bg: 'primary.400',
}}
>
{t('auth.sign-up')}
</Button>
</ClientOnly>
</Field>
</Flex>
<Flex flexDirection='column' justifyContent='center' alignItems='center' maxW='100%'>
<Text color={{ base: 'gray.400', _dark: 'white' }} fontWeight='medium'>
{t('auth.already-have-an-account')}
<ChakraLink
as={Link}
color={{ base: 'primary.400', _dark: 'primary.200' }}
ml='2'
href='/signin'
fontWeight='bold'
focusRing='none'
>
{t('auth.sign-in')}
</ChakraLink>
</Text>
</Flex>
</Flex>
</Flex>
</Box>
);
}
export default SignUpPage;

View File

@@ -1,5 +0,0 @@
import { notFound } from 'next/navigation';
export default function CatchAllPage() {
notFound();
}

View File

@@ -1,7 +0,0 @@
import React from 'react';
function AboutPage() {
return <div>AboutPage</div>;
}
export default AboutPage;

View File

@@ -1,14 +0,0 @@
import { getTranslations } from "next-intl/server";
import HomeCard from "@/components/site/home/home-card";
export async function generateMetadata() {
const t = await getTranslations();
return {
title: `${t("home")} | FCS`,
};
}
export default function Home() {
return <HomeCard />;
}

View File

@@ -1,21 +0,0 @@
'use client';
import { Container, Flex } from '@chakra-ui/react';
import Header from '@/components/layout/header/header';
import Footer from '@/components/layout/footer/footer';
import BackToTop from '@/components/ui/back-to-top';
function MainLayout({ children }: { children: React.ReactNode }) {
return (
<Flex minH='100vh' direction='column'>
<Header />
<Container as='main' maxW='8xl' flex='1' py={4}>
{children}
</Container>
<BackToTop />
<Footer />
</Flex>
);
}
export default MainLayout;

Binary file not shown.

View File

@@ -1,7 +0,0 @@
html {
scroll-behavior: smooth;
}
body {
overflow-x: hidden;
}

View File

@@ -1,41 +0,0 @@
import { Provider } from '@/components/ui/provider';
import { Bricolage_Grotesque } from 'next/font/google';
import { hasLocale, NextIntlClientProvider } from 'next-intl';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import { dir } from 'i18next';
import './global.css';
const bricolage = Bricolage_Grotesque({
variable: '--font-bricolage',
subsets: ['latin'],
});
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
return (
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
<head>
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
<link rel='manifest' href='/favicon/site.webmanifest' />
</head>
<body className={bricolage.variable}>
<NextIntlClientProvider>
<Provider>{children}</Provider>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -1,30 +0,0 @@
import { Link } from '@/i18n/navigation';
import { Flex, Text, Button, VStack, Heading } from '@chakra-ui/react';
import { getTranslations } from 'next-intl/server';
export default async function NotFoundPage() {
const t = await getTranslations();
return (
<Flex h='100vh' alignItems='center' justifyContent='center' textAlign='center' px={6}>
<VStack spaceY={6}>
<Heading
as='h1'
fontSize={{ base: '5xl', md: '6xl' }}
fontWeight='bold'
color={{ base: 'primary.600', _dark: 'primary.400' }}
>
{t('error.404')}
</Heading>
<Text fontSize={{ base: 'md', md: 'lg' }} color={{ base: 'fg.muted', _dark: 'white' }}>
{t('error.not-found')}
</Text>
<Link href='/home' passHref>
<Button size={{ base: 'md', md: 'lg' }} rounded='md'>
{t('error.back-to-home')}
</Button>
</Link>
</VStack>
</Flex>
);
}

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default async function Page() {
redirect('/home');
}

BIN
src/app/api/.DS_Store vendored

Binary file not shown.

View File

@@ -1,97 +0,0 @@
import baseUrl from "@/config/base-url";
import { authService } from "@/lib/api/example/auth/service";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
function randomToken() {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
const isMockMode = process.env.NEXT_PUBLIC_ENABLE_MOCK_MODE === "true";
const handler = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
console.log("credentials", credentials);
if (!credentials?.email || !credentials?.password) {
throw new Error("Email ve şifre gereklidir.");
}
// Eğer mock mod aktifse backend'e gitme
if (isMockMode) {
return {
id: credentials.email,
name: credentials.email.split("@")[0],
email: credentials.email,
accessToken: randomToken(),
refreshToken: randomToken(),
};
}
// Normal mod: backend'e istek at
const res = await authService.login({
email: credentials.email,
password: credentials.password,
});
console.log("res", res);
const response = res;
// Backend returns ApiResponse<TokenResponseDto>
// Structure: { data: { accessToken, refreshToken, expiresIn, user }, message, statusCode }
if (!res.success || !response?.data?.accessToken) {
throw new Error(response?.message || "Giriş başarısız");
}
const { accessToken, refreshToken, user } = response.data;
return {
id: user.id,
name: user.firstName
? `${user.firstName} ${user.lastName || ""}`.trim()
: user.email.split("@")[0],
email: user.email,
accessToken,
refreshToken,
roles: user.roles || [],
};
},
}),
],
callbacks: {
async jwt({ token, user }: any) {
if (user) {
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
token.id = user.id;
token.roles = user.roles;
}
return token;
},
async session({ session, token }: any) {
session.user.id = token.id;
session.user.roles = token.roles;
session.accessToken = token.accessToken;
session.refreshToken = token.refreshToken;
return session;
},
},
pages: {
signIn: "/signin",
error: "/signin",
},
session: { strategy: "jwt" },
secret: process.env.NEXTAUTH_SECRET,
});
export { handler as GET, handler as POST };

BIN
src/assets/harun01.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

80
src/components/About.tsx Normal file
View File

@@ -0,0 +1,80 @@
import { motion } from 'motion/react';
import { Film, Tv, Disc, Briefcase, Mic2 } from 'lucide-react';
import { useLanguage } from '../i18n/LanguageContext';
export default function About() {
const { t, language } = useLanguage();
return (
<section id="about" className="py-24 bg-slate-900/30 border-y border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Bio */}
<div>
<h2 className="text-sm font-mono text-[#FF5733] tracking-widest mb-2">{t.about.badge.toLocaleUpperCase(language)}</h2>
<h3 className="text-4xl md:text-5xl font-display font-bold mb-8">{t.about.title} <span className="text-lg font-normal text-slate-400">(Kurucu)</span></h3>
<div className="space-y-6 text-slate-300 leading-relaxed">
<p>{t.about.desc1}</p>
<p>{t.about.desc2}</p>
<p>{t.about.desc3}</p>
</div>
</div>
{/* Equipment */}
<div className="bg-slate-950 border border-white/10 p-8 relative">
<div className="absolute top-0 right-0 p-4 opacity-10">
<Mic2 className="w-32 h-32" />
</div>
<h4 className="text-2xl font-display font-bold mb-8 flex items-center gap-3">
<span className="w-2 h-2 bg-[#FF5733] rounded-full animate-pulse" />
{t.about.tech_title}
</h4>
<ul className="space-y-6 relative z-10">
<li className="flex items-start gap-4">
<div className="p-2 bg-white/5 rounded-md text-[#FF5733]">
<Film className="w-5 h-5" />
</div>
<div>
<strong className="block text-white mb-1">{t.about.tech_items[0].title}</strong>
<span className="text-sm text-slate-400">{t.about.tech_items[0].desc}</span>
</div>
</li>
<li className="flex items-start gap-4">
<div className="p-2 bg-white/5 rounded-md text-[#C70039]">
<Tv className="w-5 h-5" />
</div>
<div>
<strong className="block text-white mb-1">{t.about.tech_items[1].title}</strong>
<span className="text-sm text-slate-400">{t.about.tech_items[1].desc}</span>
</div>
</li>
<li className="flex items-start gap-4">
<div className="p-2 bg-white/5 rounded-md text-[#900C3F]">
<Disc className="w-5 h-5" />
</div>
<div>
<strong className="block text-white mb-1">{t.about.tech_items[2].title}</strong>
<span className="text-sm text-slate-400">{t.about.tech_items[2].desc}</span>
</div>
</li>
<li className="flex items-start gap-4">
<div className="p-2 bg-white/5 rounded-md text-[#511845]">
<Briefcase className="w-5 h-5" />
</div>
<div>
<strong className="block text-white mb-1">{t.about.tech_items[3].title}</strong>
<span className="text-sm text-slate-400">{t.about.tech_items[3].desc}</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
);
}

753
src/components/Admin.tsx Normal file
View File

@@ -0,0 +1,753 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { useData } from '../context/DataContext';
import * as api from '../lib/api';
import { X, Plus, Trash2, GripVertical, Save, Settings, Upload, LogIn, LogOut, Image, FolderOpen, AlertCircle, RotateCcw, ScrollText } from 'lucide-react';
type Tab = 'projects' | 'clients' | 'logs' | 'login';
export default function Admin() {
const { data, addProject, updateProject, removeProject, restoreProject, refreshProjects, refreshClients } = useData();
const [isOpen, setIsOpen] = useState(false);
const [tab, setTab] = useState<Tab>('projects');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState(api.isAuthenticated());
// Login state
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loginLoading, setLoginLoading] = useState(false);
// Project editing state
const [editingProjects, setEditingProjects] = useState(data.projects);
const [uploadingFor, setUploadingFor] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setEditingProjects(data.projects);
}
}, [isOpen, data.projects]);
// Client state
const [newClientName, setNewClientName] = useState('');
const [clientUploading, setClientUploading] = useState(false);
const clientFileRef = useRef<HTMLInputElement>(null);
// Audit Logs state
const [auditLogs, setAuditLogs] = useState<api.AuditLogAPI[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
const [auditLoading, setAuditLoading] = useState(false);
const [auditFilter, setAuditFilter] = useState<string>('');
const loadAuditLogs = async (entityFilter?: string) => {
if (!isLoggedIn) return;
setAuditLoading(true);
try {
const result = await api.getAuditLogs({
entity: entityFilter || undefined,
limit: 50,
});
setAuditLogs(result.logs);
setAuditTotal(result.total);
} catch (err: any) {
showMessage(err.message || 'Loglar yüklenemedi', 'error');
} finally {
setAuditLoading(false);
}
};
useEffect(() => {
if (tab === 'logs' && isLoggedIn) {
loadAuditLogs(auditFilter);
}
}, [tab, isLoggedIn, auditFilter]);
const handleAddClient = async (file: File) => {
if (!newClientName.trim()) {
showMessage('Marka adı gerekli', 'error');
return;
}
if (!isLoggedIn) {
showMessage('Giriş yapmanız gerekiyor', 'error');
return;
}
try {
setClientUploading(true);
// Upload file - returns MediaFileAPI with url like /uploads/filename.ext
const media = await api.uploadFile(file);
let logoUrl = '';
const m = (media as any)?.data || media; // Unwrap completely if deeply nested
if (m && m.url) {
logoUrl = m.url;
} else if (m && m.filename) {
logoUrl = `/uploads/${m.filename}`;
} else {
logoUrl = `/uploads/${file.name}`;
}
await api.createClient({ name: newClientName.trim(), logo: logoUrl });
await refreshClients();
setNewClientName('');
showMessage('Marka eklendi', 'success');
} catch (err: any) {
showMessage(err.message || 'Marka eklenemedi', 'error');
} finally {
setClientUploading(false);
}
};
const handleRemoveClient = async (id: string) => {
if (!isLoggedIn) {
showMessage('Giriş yapmanız gerekiyor', 'error');
return;
}
if (!confirm('Bu markayı silmek istediğinize emin misiniz?')) return;
try {
await api.deleteClient(id);
await refreshClients();
showMessage('Marka silindi', 'success');
} catch (err: any) {
showMessage(err.message || 'Silinemedi', 'error');
}
};
const showMessage = (text: string, type: 'success' | 'error') => {
setMessage({ text, type });
setTimeout(() => setMessage(null), 3000);
};
// ── Auth ────────────────────────────────────
const handleLogin = async () => {
setLoginLoading(true);
try {
await api.login(email, password);
setIsLoggedIn(true);
setTab('projects');
showMessage('Giriş başarılı!', 'success');
} catch (err: any) {
showMessage(err.message || 'Giriş başarısız', 'error');
} finally {
setLoginLoading(false);
}
};
const handleLogout = () => {
api.logout();
setIsLoggedIn(false);
showMessage(ıkış yapıldı', 'success');
};
// ── Projects ────────────────────────────────
const handleAddProject = () => {
const newProject = {
id: `temp-${Date.now()}`,
title: 'New Project',
image: 'https://picsum.photos/seed/new/1920/1080?blur=2',
roles: ['Voiceover'],
color: '#FF5733',
order: editingProjects.length,
};
setEditingProjects([...editingProjects, newProject]);
};
const handleUpdateField = (id: string, field: string, value: any) => {
setEditingProjects(editingProjects.map(p =>
p.id === id ? { ...p, [field]: value } : p
));
};
const handleRemoveProject = async (id: string) => {
if (id.startsWith('temp-')) {
setEditingProjects(editingProjects.filter(p => p.id !== id));
return;
}
try {
await removeProject(id);
setEditingProjects(editingProjects.filter(p => p.id !== id));
showMessage('Proje silindi (geri getirilebilir)', 'success');
} catch (err: any) {
showMessage(err.message, 'error');
}
};
const handleRestoreProject = async (entityId: string) => {
try {
await restoreProject(entityId);
showMessage('Proje geri getirildi!', 'success');
// Refresh audit logs too
if (tab === 'logs') loadAuditLogs(auditFilter);
} catch (err: any) {
showMessage(err.message || 'Geri getirme başarısız', 'error');
}
};
const handleMoveProject = (index: number, direction: 'up' | 'down') => {
const newProjects = [...editingProjects];
if (direction === 'up' && index > 0) {
[newProjects[index - 1], newProjects[index]] = [newProjects[index], newProjects[index - 1]];
} else if (direction === 'down' && index < editingProjects.length - 1) {
[newProjects[index + 1], newProjects[index]] = [newProjects[index], newProjects[index + 1]];
}
setEditingProjects(newProjects);
};
// ── Image Upload ────────────────────────────
const handleImageUpload = async (projectId: string, file: File) => {
try {
setUploadingFor(projectId);
const rawMedia = await api.uploadFile(file);
const m = (rawMedia as any)?.data || rawMedia;
const imageUrl = m?.url || `/uploads/${m?.filename || file.name}`;
handleUpdateField(projectId, 'image', imageUrl);
showMessage('Görsel yüklendi!', 'success');
} catch (err: any) {
showMessage(`Yükleme hatası: ${err.message}`, 'error');
} finally {
setUploadingFor(null);
}
};
const triggerFileUpload = (projectId: string) => {
setUploadingFor(projectId);
fileInputRef.current?.click();
};
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && uploadingFor) {
handleImageUpload(uploadingFor, file);
}
e.target.value = '';
};
// ── Save All ────────────────────────────────
const handleSaveAll = async () => {
setSaving(true);
try {
for (const project of editingProjects) {
if (project.id.startsWith('temp-')) {
await addProject({
title: project.title,
image: project.image,
roles: project.roles,
color: project.color,
});
} else {
await updateProject(project.id, {
title: project.title,
image: project.image,
roles: project.roles,
color: project.color,
});
}
}
// Reorder
const reorderItems = editingProjects
.filter(p => !p.id.startsWith('temp-'))
.map((p, i) => ({ id: p.id, sortOrder: i }));
if (reorderItems.length > 0) {
await api.reorderProjects(reorderItems);
}
await refreshProjects();
showMessage('Tüm değişiklikler kaydedildi!', 'success');
} catch (err: any) {
showMessage(`Kayıt hatası: ${err.message}`, 'error');
} finally {
setSaving(false);
}
};
// ── Audit Log Helpers ─────────────────────────
const getActionBadge = (action: string) => {
switch (action) {
case 'CREATE': return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'UPDATE': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
case 'DELETE': return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'RESTORE': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30';
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
}
};
const getEntityIcon = (entity: string) => {
switch (entity) {
case 'project': return '📁';
case 'client': return '🏢';
case 'content': return '📝';
case 'media': return '🖼️';
default: return '📋';
}
};
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleString('tr-TR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
};
return (
<>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFileSelected}
/>
{/* Admin Toggle Button */}
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 z-50 w-12 h-12 bg-slate-900 border border-white/20 rounded-full flex items-center justify-center text-slate-400 hover:text-[#FF5733] hover:border-[#FF5733] transition-all box-glow-hover"
title="Admin Panel"
>
<Settings className="w-5 h-5" />
</button>
{/* Admin Modal */}
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-6 bg-black/80 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="w-full max-w-5xl max-h-[90vh] bg-slate-950 border border-white/10 rounded-xl overflow-hidden flex flex-col shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-white/10 bg-slate-900/50">
<div className="flex items-center gap-4">
<h2 className="text-xl font-display font-bold text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#FF5733]" />
Admin Panel
</h2>
{/* Tabs */}
<div className="flex gap-1 ml-4">
{(['projects', 'clients', 'logs', 'login'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-3 py-1.5 text-xs font-mono rounded-md transition-colors ${tab === t
? 'bg-[#FF5733] text-white'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{t === 'projects' ? '📁 Projeler' :
t === 'clients' ? '🏢 Markalar' :
t === 'logs' ? '📋 Log' :
'🔐 Giriş'}
</button>
))}
</div>
</div>
<div className="flex items-center gap-3">
{isLoggedIn && (
<button onClick={handleLogout} className="flex items-center gap-1 text-xs text-slate-400 hover:text-red-400 transition-colors">
<LogOut className="w-3 h-3" /> Çıkış
</button>
)}
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* Message Bar */}
<AnimatePresence>
{message && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className={`px-6 py-3 text-sm font-mono flex items-center gap-2 ${message.type === 'success' ? 'bg-green-500/10 text-green-400 border-b border-green-500/20' : 'bg-red-500/10 text-red-400 border-b border-red-500/20'
}`}
>
<AlertCircle className="w-4 h-4" />
{message.text}
</motion.div>
)}
</AnimatePresence>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* ── Login Tab ── */}
{tab === 'login' && (
<section className="max-w-md mx-auto">
<div className="p-8 bg-slate-900 border border-white/10 rounded-lg space-y-6">
<div className="text-center">
<LogIn className="w-12 h-12 text-[#FF5733] mx-auto mb-4" />
<h3 className="text-lg font-bold text-white mb-2">Admin Girişi</h3>
<p className="text-sm text-slate-400">CMS yönetimi için giriş yapın</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-400">E-POSTA</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-4 py-3 text-white focus:border-[#FF5733] outline-none transition-colors"
placeholder="admin@haruncan.com"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-400">ŞİFRE</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="w-full bg-slate-950 border border-white/10 px-4 py-3 text-white focus:border-[#FF5733] outline-none transition-colors"
placeholder="••••••••"
/>
</div>
<button
onClick={handleLogin}
disabled={loginLoading || !email || !password}
className="w-full py-3 bg-[#FF5733] text-white font-bold hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loginLoading ? 'Giriş yapılıyor...' : <><LogIn className="w-4 h-4" /> Giriş Yap</>}
</button>
</div>
{isLoggedIn && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-md text-sm text-green-400 text-center">
Oturum ık
</div>
)}
</div>
</section>
)}
{/* ── Clients Tab ── */}
{tab === 'clients' && (
<section>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
🏢 Markalar / Müşteriler
</h3>
</div>
{/* Add new client */}
<div className="p-4 bg-slate-900/50 border border-white/10 rounded-lg mb-6">
<h4 className="text-sm font-mono text-slate-400 mb-3">YENİ MARKA EKLE</h4>
<div className="flex items-end gap-4">
<div className="flex-1 space-y-2">
<label className="text-xs font-mono text-slate-500">MARKA ADI</label>
<input
type="text"
value={newClientName}
onChange={(e) => setNewClientName(e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-4 py-2.5 text-white focus:border-[#FF5733] outline-none transition-colors text-sm"
placeholder="Netflix, Disney, Warner Bros..."
/>
</div>
<div>
<input
ref={clientFileRef}
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) await handleAddClient(file);
e.target.value = '';
}}
/>
<button
onClick={() => clientFileRef.current?.click()}
disabled={clientUploading || !newClientName.trim()}
className="flex items-center gap-2 px-4 py-2.5 bg-[#FF5733] text-white font-bold text-sm hover:bg-white hover:text-slate-950 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
{clientUploading ? 'Yükleniyor...' : 'Logo Yükle & Ekle'}
</button>
</div>
</div>
</div>
{/* Client list */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{data.clients.map((client) => (
<div key={client.id} className="relative group p-4 bg-slate-900/50 border border-white/10 rounded-lg flex flex-col items-center">
<img
src={client.logo}
alt={client.name}
className="h-[60px] w-auto object-contain mb-3"
style={{ maxWidth: '200px' }}
/>
<span className="text-xs font-mono text-slate-400 text-center">{client.name}</span>
<button
onClick={() => handleRemoveClient(client.id)}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 text-red-400 hover:text-red-300"
title="Sil"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{data.clients.length === 0 && (
<div className="col-span-full text-center py-8 text-slate-500 text-sm font-mono">
Henüz marka eklenmemiş. Yukarıdan yeni marka ekleyebilirsiniz.
</div>
)}
</div>
</section>
)}
{/* ── Projects Tab ── */}
{tab === 'projects' && (
<section>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-[#FF5733]" />
Portfolio Projeleri
</h3>
<button
onClick={handleAddProject}
className="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-md text-sm font-medium transition-colors"
>
<Plus className="w-4 h-4" /> Yeni Proje
</button>
</div>
<div className="space-y-4">
{editingProjects.map((project, index) => (
<div key={project.id} className="flex gap-4 p-4 bg-slate-900 border border-white/5 rounded-lg hover:border-white/10 transition-colors">
{/* Move Buttons */}
<div className="flex flex-col gap-2 justify-center text-slate-500">
<button onClick={() => handleMoveProject(index, 'up')} disabled={index === 0} className="hover:text-white disabled:opacity-30 transition-colors"></button>
<GripVertical className="w-5 h-5" />
<button onClick={() => handleMoveProject(index, 'down')} disabled={index === editingProjects.length - 1} className="hover:text-white disabled:opacity-30 transition-colors"></button>
</div>
{/* Image Preview */}
<div className="relative w-24 h-24 rounded-md overflow-hidden border border-white/10 bg-slate-950 shrink-0 group">
<img src={project.image} alt={project.title} className="w-full h-full object-cover" />
<button
onClick={() => triggerFileUpload(project.id)}
className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
{uploadingFor === project.id ? (
<span className="text-xs text-white animate-pulse">Yükleniyor...</span>
) : (
<Upload className="w-6 h-6 text-white" />
)}
</button>
</div>
{/* Fields */}
<div className="flex-1 grid md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">BAŞLIK</label>
<input
type="text"
value={project.title}
onChange={(e) => handleUpdateField(project.id, 'title', e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-3 py-2 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">GÖRSEL URL</label>
<input
type="text"
value={project.image}
onChange={(e) => handleUpdateField(project.id, 'image', e.target.value)}
className="w-full bg-slate-950 border border-white/10 px-3 py-2 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">ROLLER (virgülle)</label>
<input
type="text"
value={project.roles.join(', ')}
onChange={(e) => handleUpdateField(project.id, 'roles', e.target.value.split(',').map(r => r.trim()))}
className="w-full bg-slate-950 border border-white/10 px-3 py-2 text-sm text-white focus:border-[#FF5733] outline-none rounded-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">TEMA RENGİ</label>
<div className="flex gap-2">
<input
type="color"
value={project.color}
onChange={(e) => handleUpdateField(project.id, 'color', e.target.value)}
className="w-10 h-10 bg-slate-950 border border-white/10 p-1 cursor-pointer rounded-sm"
/>
<input
type="text"
value={project.color}
onChange={(e) => handleUpdateField(project.id, 'color', e.target.value)}
className="flex-1 bg-slate-950 border border-white/10 px-3 py-2 text-sm text-white focus:border-[#FF5733] outline-none uppercase font-mono rounded-sm"
/>
</div>
</div>
</div>
{/* Delete */}
<button
onClick={() => handleRemoveProject(project.id)}
className="p-2 text-slate-500 hover:text-red-500 hover:bg-red-500/10 rounded-md transition-colors h-fit"
title="Projeyi Sil"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
))}
{editingProjects.length === 0 && (
<div className="text-center p-8 text-slate-500 border border-dashed border-white/10 rounded-lg">
Henüz proje yok. Yeni bir proje ekleyin.
</div>
)}
</div>
</section>
)}
{/* ── Audit Logs Tab ── */}
{tab === 'logs' && (
<section>
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<ScrollText className="w-5 h-5 text-[#FF5733]" />
İşlem Logları
</h3>
<div className="flex items-center gap-3">
<select
value={auditFilter}
onChange={(e) => setAuditFilter(e.target.value)}
className="bg-slate-900 border border-white/10 text-sm text-white px-3 py-1.5 rounded-md outline-none focus:border-[#FF5733]"
>
<option value="">Tümü</option>
<option value="project">Projeler</option>
<option value="client">Markalar</option>
<option value="content">İçerik</option>
<option value="media">Medya</option>
</select>
<button
onClick={() => loadAuditLogs(auditFilter)}
className="text-xs text-slate-400 hover:text-white flex items-center gap-1 transition-colors"
>
<RotateCcw className="w-3 h-3" /> Yenile
</button>
<span className="text-xs text-slate-500 font-mono">{auditTotal} kayıt</span>
</div>
</div>
{!isLoggedIn ? (
<div className="text-center py-12 text-slate-500 font-mono">
Logları görmek için giriş yapmanız gerekiyor.
</div>
) : auditLoading ? (
<div className="text-center py-12 text-slate-500 font-mono animate-pulse">
Loglar yükleniyor...
</div>
) : auditLogs.length === 0 ? (
<div className="text-center py-12 text-slate-500 font-mono">
Henüz işlem logu yok.
</div>
) : (
<div className="space-y-2">
{auditLogs.map((log) => (
<div key={log.id} className="flex items-center gap-4 p-3 bg-slate-900/50 border border-white/5 rounded-lg hover:border-white/10 transition-colors text-sm">
{/* Entity Icon */}
<span className="text-lg shrink-0">{getEntityIcon(log.entity)}</span>
{/* Action Badge */}
<span className={`px-2 py-0.5 text-xs font-mono border rounded shrink-0 ${getActionBadge(log.action)}`}>
{log.action}
</span>
{/* Entity Type */}
<span className="text-xs font-mono text-slate-500 shrink-0 w-16">
{log.entity}
</span>
{/* Entity ID (truncated) */}
<span className="text-xs font-mono text-slate-600 truncate max-w-[120px]" title={log.entityId}>
{log.entityId.substring(0, 8)}
</span>
{/* Details */}
<span className="flex-1 text-xs text-slate-400 truncate">
{log.after ? (() => {
try {
const data = JSON.parse(log.after);
return data.title || data.name || data.section || '—';
} catch { return '—'; }
})() : log.before ? (() => {
try {
const data = JSON.parse(log.before);
return data.title || data.name || data.section || '—';
} catch { return '—'; }
})() : '—'}
</span>
{/* Timestamp */}
<span className="text-xs font-mono text-slate-600 shrink-0">
{formatDate(log.createdAt)}
</span>
{/* Restore button for deleted projects */}
{log.action === 'DELETE' && log.entity === 'project' && (
<button
onClick={() => handleRestoreProject(log.entityId)}
className="flex items-center gap-1 px-2 py-1 text-xs font-mono text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded hover:bg-yellow-500/20 transition-colors shrink-0"
title="Projeyi geri getir"
>
<RotateCcw className="w-3 h-3" /> Geri Getir
</button>
)}
</div>
))}
</div>
)}
</section>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-white/10 bg-slate-900/50 flex items-center justify-between">
<div className="text-xs text-slate-500 font-mono">
{isLoggedIn ? '🟢 Bağlı — API: localhost:3000' : '🔴 Oturum kapalı'}
</div>
<div className="flex gap-4">
<button
onClick={() => setIsOpen(false)}
className="px-6 py-2.5 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
İptal
</button>
{(tab === 'projects' || tab === 'clients') && (
<button
onClick={handleSaveAll}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-[#FF5733] text-slate-950 font-bold text-sm hover:bg-white transition-colors rounded-md disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Kaydediliyor...' : 'Değişiklikleri Kaydet'}
</button>
)}
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,50 @@
import { useLanguage } from '../i18n/LanguageContext';
import { useData } from '../context/DataContext';
export default function Clients() {
const { t, language } = useLanguage();
const { data } = useData();
const clients = [...data.clients].sort((a, b) => a.order - b.order);
if (clients.length === 0) return null;
// Duplicate for seamless marquee
const duplicated = [...clients, ...clients, ...clients, ...clients];
return (
<section id="clients" className="py-24 relative overflow-hidden">
<div className="max-w-7xl mx-auto px-6 mb-16">
<h2 className="text-sm font-mono text-[#FF5733] tracking-widest mb-2">{t.clients.badge.toLocaleUpperCase(language)}</h2>
<h3 className="text-4xl md:text-5xl font-display font-bold">{t.clients.title}</h3>
</div>
{/* Marquee of client logos */}
<div className="w-full overflow-hidden relative">
{/* Fade masks */}
<div className="absolute top-0 left-0 w-16 md:w-48 h-full bg-gradient-to-r from-[#050505] to-transparent z-10 pointer-events-none" />
<div className="absolute top-0 right-0 w-16 md:w-48 h-full bg-gradient-to-l from-[#050505] to-transparent z-10 pointer-events-none" />
<div className="flex items-center gap-16 md:gap-24 animate-marquee-slow w-max px-8 hover:[animation-play-state:paused]">
{duplicated.map((client, index) => (
<a
key={`${client.id}-${index}`}
href={client.website || '#'}
target={client.website ? '_blank' : undefined}
rel="noopener noreferrer"
className="flex-shrink-0 group transition-all duration-500"
title={client.name}
>
<img
src={client.logo}
alt={client.name}
className="h-[50px] md:h-[60px] w-auto object-contain opacity-60 group-hover:opacity-100 transition-all duration-500"
style={{ maxWidth: '200px' }}
/>
</a>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,82 @@
import { motion } from 'motion/react';
import { MessageSquare, Send, Instagram, Youtube } from 'lucide-react';
import { useLanguage } from '../i18n/LanguageContext';
export default function Contact() {
const { t, language } = useLanguage();
return (
<section id="contact" className="py-24 relative overflow-hidden">
{/* Background Glow */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-[#C70039]/10 blur-[120px] rounded-full pointer-events-none" />
<div className="max-w-4xl mx-auto px-6 relative z-10">
<div className="text-center mb-16">
<h2 className="text-sm font-mono text-[#FF5733] tracking-widest mb-2">{t.contact.badge.toLocaleUpperCase(language)}</h2>
<h3 className="text-4xl md:text-5xl font-display font-bold mb-6">{t.contact.title}</h3>
<p className="text-slate-400 max-w-2xl mx-auto">
{t.contact.desc}
</p>
</div>
<form className="space-y-6 bg-slate-900/50 border border-white/10 p-8 md:p-12 backdrop-blur-sm">
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-400">{t.contact.form.name.toLocaleUpperCase(language)}</label>
<input
type="text"
className="w-full bg-slate-950 border border-white/10 px-4 py-3 text-white focus:outline-none focus:border-[#FF5733] transition-colors"
placeholder="John Doe / Epic Games"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-400">{t.contact.form.email.toLocaleUpperCase(language)}</label>
<input
type="email"
className="w-full bg-slate-950 border border-white/10 px-4 py-3 text-white focus:outline-none focus:border-[#FF5733] transition-colors"
placeholder="john@example.com"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-400">{t.contact.form.type.toLocaleUpperCase(language)}</label>
<select className="w-full bg-slate-950 border border-white/10 px-4 py-3 text-white focus:outline-none focus:border-[#FF5733] transition-colors appearance-none">
{t.contact.form.types.map((type, index) => (
<option key={index}>{type}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-400">{t.contact.form.details.toLocaleUpperCase(language)}</label>
<textarea
rows={4}
className="w-full bg-slate-950 border border-white/10 px-4 py-3 text-white focus:outline-none focus:border-[#FF5733] transition-colors resize-none"
/>
</div>
<button
type="button"
className="w-full py-4 bg-[#FF5733] text-slate-950 font-bold font-mono tracking-wider hover:bg-white transition-colors flex items-center justify-center gap-2"
>
<Send className="w-5 h-5" />
{t.contact.form.submit.toLocaleUpperCase(language)}
</button>
</form>
<div className="mt-12 flex flex-col md:flex-row items-center justify-center gap-8 text-sm text-slate-400 font-mono">
<a href="https://instagram.com/haruncan" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[#FF5733] transition-colors">
<Instagram className="w-4 h-4" />
@haruncan
</a>
<span className="hidden md:block text-white/20">|</span>
<a href="https://www.youtube.com/haruncan" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[#FF5733] transition-colors">
<Youtube className="w-4 h-4" />
@haruncan
</a>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,147 @@
import { useEffect, useRef } from 'react';
import { motion, useScroll, useTransform } from 'motion/react';
export default function DynamicBackground() {
const { scrollYProgress } = useScroll();
const canvasRef = useRef<HTMLCanvasElement>(null);
// Parallax effects for background elements
const y1 = useTransform(scrollYProgress, [0, 1], ['0%', '100%']);
const y2 = useTransform(scrollYProgress, [0, 1], ['0%', '-100%']);
const rotate1 = useTransform(scrollYProgress, [0, 1], [0, 360]);
const rotate2 = useTransform(scrollYProgress, [0, 1], [0, -360]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
let particles: Particle[] = [];
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', resize);
resize();
class Particle {
x: number;
y: number;
size: number;
speedX: number;
speedY: number;
color: string;
baseY: number;
constructor() {
this.x = Math.random() * canvas!.width;
this.y = Math.random() * canvas!.height;
this.baseY = this.y;
this.size = Math.random() * 2 + 0.5;
this.speedX = Math.random() * 0.5 - 0.25;
this.speedY = Math.random() * 0.5 - 0.25;
// Magma Theme Colors
const colors = ['#511845', '#900C3F', '#C70039', '#FF5733'];
this.color = colors[Math.floor(Math.random() * colors.length)];
}
update(scrollOffset: number) {
this.x += this.speedX;
// Apply scroll offset to particle Y position for a subtle parallax
this.y = this.baseY + this.speedY - (scrollOffset * 0.5);
if (this.x > canvas!.width) this.x = 0;
if (this.x < 0) this.x = canvas!.width;
// Wrap around vertically considering scroll
if (this.y > canvas!.height + 100) this.baseY = -100 + (scrollOffset * 0.5);
if (this.y < -100) this.baseY = canvas!.height + 100 + (scrollOffset * 0.5);
}
draw() {
if (!ctx) return;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
const init = () => {
particles = [];
for (let i = 0; i < 100; i++) {
particles.push(new Particle());
}
};
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Get current scroll position for particle update
const scrollOffset = window.scrollY;
for (let i = 0; i < particles.length; i++) {
particles[i].update(scrollOffset);
particles[i].draw();
}
// Draw connecting lines
for (let i = 0; i < particles.length; i++) {
for (let j = i; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 100) {
ctx.beginPath();
ctx.strokeStyle = `${particles[i].color}${Math.floor((1 - distance / 100) * 255).toString(16).padStart(2, '0')}`;
ctx.lineWidth = 0.5;
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
animationFrameId = requestAnimationFrame(animate);
};
init();
animate();
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<div className="fixed inset-0 z-[5] pointer-events-none overflow-hidden">
{/* Interactive Canvas */}
<canvas ref={canvasRef} className="absolute inset-0 opacity-40" />
{/* CSS Parallax Elements (Magma Theme) */}
<motion.div
style={{ y: y1, rotate: rotate1 }}
className="absolute top-[10%] left-[10%] w-[40vw] h-[40vw] rounded-full bg-gradient-to-br from-[#511845]/20 to-[#900C3F]/10 blur-[100px] mix-blend-screen"
/>
<motion.div
style={{ y: y2, rotate: rotate2 }}
className="absolute bottom-[10%] right-[10%] w-[50vw] h-[50vw] rounded-full bg-gradient-to-tl from-[#C70039]/10 to-[#FF5733]/5 blur-[120px] mix-blend-screen"
/>
{/* Noise Overlay for texture */}
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-overlay" />
{/* Grid pattern (Subtle) */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,87,51,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,87,51,0.02)_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,#000_70%,transparent_100%)]" />
</div>
);
}

13
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useLanguage } from '../i18n/LanguageContext';
export default function Footer() {
const { t } = useLanguage();
return (
<footer className="py-8 border-t border-white/10 text-center">
<p className="text-xs font-mono text-slate-500">
© {new Date().getFullYear()} HARUNCAN <span className="text-transparent bg-clip-text bg-gradient-to-r from-[#C70039] to-[#FF5733]">SoundArts</span>
</p>
</footer>
);
}

84
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { useState, useRef, DragEvent } from 'react';
import { motion } from 'motion/react';
import { Play, ArrowRight, ImagePlus } from 'lucide-react';
import { useLanguage } from '../i18n/LanguageContext';
import harunImg from '../assets/harun01.JPG';
export default function Hero() {
const { t, language } = useLanguage();
return (
<section
className="relative min-h-screen flex items-center pt-20 overflow-hidden"
>
{/* Harun Can Image Layer */}
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 0.5, x: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className="absolute top-0 right-0 w-full h-full md:w-2/3 pointer-events-none z-[1]"
style={{
backgroundImage: `url(${harunImg})`,
backgroundSize: 'cover',
backgroundPosition: 'center 20%',
maskImage: 'linear-gradient(to left, rgba(0,0,0,1) 30%, rgba(0,0,0,0) 100%)',
WebkitMaskImage: 'linear-gradient(to left, rgba(0,0,0,1) 30%, rgba(0,0,0,0) 100%)',
}}
/>
{/* Background Layer (Transparent to show DynamicBackground) */}
<div className="absolute inset-0 z-0 bg-slate-950/20" />
<div className="max-w-7xl mx-auto px-6 relative z-[20] w-full">
<div className="max-w-3xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex items-center gap-4 mb-6"
>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10">
<span className="w-2 h-2 rounded-full bg-[#FF5733] animate-pulse" />
<span className="text-xs font-mono text-slate-300 tracking-wider">{t.hero.badge.toLocaleUpperCase(language)}</span>
</div>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-5xl md:text-7xl font-display font-bold leading-[1.1] mb-6 tracking-tight"
>
{t.hero.title1}<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#C70039] to-[#FF5733]">{t.hero.title1_highlight}</span><br />
{t.hero.title2}<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#900C3F] to-[#C70039]">{t.hero.title2_highlight}</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-lg md:text-xl text-slate-400 mb-10 max-w-2xl leading-relaxed font-light"
>
{t.hero.desc}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="flex flex-wrap items-center gap-4"
>
<a href="#works" className="flex items-center gap-2 px-8 py-4 bg-[#FF5733] text-white font-bold hover:bg-white hover:text-[#050505] transition-all duration-300 rounded-sm">
<Play className="w-5 h-5 fill-current" />
<span>{t.hero.btn_works}</span>
</a>
<a href="#contact" className="flex items-center gap-2 px-8 py-4 bg-transparent border border-white/20 text-white hover:border-[#FF5733] hover:text-[#FF5733] transition-all duration-300 rounded-sm">
<span>{t.hero.btn_contact}</span>
<ArrowRight className="w-5 h-5" />
</a>
</motion.div>
</div>
</div>
</section>
);
}

119
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { Menu, X, Mic2, Globe } from 'lucide-react';
import { useLanguage } from '../i18n/LanguageContext';
import { Language } from '../i18n/translations';
export default function Navbar() {
const [isScrolled, setIsScrolled] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [isLangOpen, setIsLangOpen] = useState(false);
const { t, language, setLanguage } = useLanguage();
useEffect(() => {
const handleScroll = () => setIsScrolled(window.scrollY > 50);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const navLinks = [
{ name: t.nav.works, href: '#works' },
{ name: t.nav.services, href: '#services' },
{ name: t.nav.clients, href: '#clients' },
{ name: t.nav.process, href: '#process' },
{ name: t.nav.about, href: '#about' },
];
const languages: { code: Language; label: string }[] = [
{ code: 'en', label: 'EN' },
{ code: 'tr', label: 'TR' },
{ code: 'de', label: 'DE' },
{ code: 'es', label: 'ES' },
{ code: 'fr', label: 'FR' },
{ code: 'ja', label: 'JA' },
];
return (
<header className={`fixed top-0 w-full z-50 transition-all duration-300 ${isScrolled ? 'bg-slate-950/80 backdrop-blur-md border-b border-white/10 py-4' : 'bg-transparent py-6'}`}>
<div className="max-w-7xl mx-auto px-6 flex items-center justify-between">
<a href="#" className="flex items-center gap-2 text-white font-display font-bold text-xl tracking-tight">
<Mic2 className="text-[#FF5733]" />
<span>HARUNCAN <span className="text-transparent bg-clip-text bg-gradient-to-r from-[#C70039] to-[#FF5733]">SoundArts</span></span>
</a>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<a key={link.name} href={link.href} className="text-sm font-medium text-slate-300 hover:text-[#FF5733] transition-colors">
{link.name}
</a>
))}
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangOpen(!isLangOpen)}
className="flex items-center gap-2 text-sm font-medium text-slate-300 hover:text-[#FF5733] transition-colors"
>
<Globe className="w-4 h-4" />
{language.toLocaleUpperCase()}
</button>
{isLangOpen && (
<div className="absolute top-full right-0 mt-2 bg-slate-900 border border-white/10 rounded-md py-2 flex flex-col min-w-[80px]">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => { setLanguage(lang.code); setIsLangOpen(false); }}
className={`text-left px-4 py-2 text-sm hover:bg-white/5 ${language === lang.code ? 'text-[#FF5733]' : 'text-slate-300'}`}
>
{lang.label}
</button>
))}
</div>
)}
</div>
<a href="#contact" className="px-5 py-2.5 bg-[#FF5733]/10 text-[#FF5733] border border-[#FF5733]/30 rounded-none hover:bg-[#FF5733] hover:text-slate-950 transition-all font-mono text-sm tracking-wider box-glow-hover">
{t.nav.contact.toLocaleUpperCase(language)}
</a>
</nav>
{/* Mobile Toggle */}
<button className="md:hidden text-white" onClick={() => setIsOpen(!isOpen)}>
{isOpen ? <X /> : <Menu />}
</button>
</div>
{/* Mobile Nav */}
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="md:hidden absolute top-full left-0 w-full bg-slate-900 border-b border-white/10 py-4 px-6 flex flex-col gap-4"
>
{navLinks.map((link) => (
<a key={link.name} href={link.href} onClick={() => setIsOpen(false)} className="text-sm font-medium text-slate-300 hover:text-[#FF5733]">
{link.name}
</a>
))}
<div className="flex gap-4 py-2 border-y border-white/10">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => { setLanguage(lang.code); setIsOpen(false); }}
className={`text-sm font-mono ${language === lang.code ? 'text-[#FF5733]' : 'text-slate-400'}`}
>
{lang.label}
</button>
))}
</div>
<a href="#contact" onClick={() => setIsOpen(false)} className="px-5 py-2.5 bg-[#FF5733]/10 text-[#FF5733] border border-[#FF5733]/30 text-center hover:bg-[#FF5733] hover:text-slate-950 transition-all font-mono text-sm tracking-wider mt-2">
{t.nav.contact.toLocaleUpperCase(language)}
</a>
</motion.div>
)}
</header>
);
}

View File

@@ -0,0 +1,41 @@
import { motion } from 'motion/react';
import { useLanguage } from '../i18n/LanguageContext';
export default function Process() {
const { t, language } = useLanguage();
return (
<section id="process" className="py-24">
<div className="max-w-7xl mx-auto px-6">
<div className="mb-16">
<h2 className="text-sm font-mono text-[#FF5733] tracking-widest mb-2">{t.process.badge.toLocaleUpperCase(language)}</h2>
<h3 className="text-4xl md:text-5xl font-display font-bold">{t.process.title}</h3>
</div>
<div className="grid md:grid-cols-4 gap-8 relative">
{/* Connecting Line */}
<div className="hidden md:block absolute top-12 left-0 w-full h-[1px] bg-white/10" />
{t.process.items.map((step, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="relative"
>
<div className="w-24 h-24 bg-slate-900 border border-white/10 flex items-center justify-center mb-6 relative z-10 box-glow-hover transition-all">
<span className="font-display text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-br from-white to-[#FF5733]">
0{index + 1}
</span>
</div>
<h4 className="text-xl font-bold mb-3">{step.title}</h4>
<p className="text-slate-400 text-sm leading-relaxed">{step.desc}</p>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,47 @@
import { motion } from 'motion/react';
import { Mic2, Music, Headphones, Briefcase, Sliders, Users } from 'lucide-react';
import { useLanguage } from '../i18n/LanguageContext';
const icons = [Mic2, Music, Headphones, Briefcase, Sliders, Users];
const colors = ['#FF5733', '#C70039', '#900C3F', '#511845', '#FF5733', '#C70039'];
export default function Services() {
const { t, language } = useLanguage();
return (
<section id="services" className="py-24 bg-slate-900/30 border-y border-white/5">
<div className="max-w-7xl mx-auto px-6">
<div className="mb-16 md:text-center">
<h2 className="text-sm font-mono text-[#FF5733] tracking-widest mb-2">{t.services.badge.toLocaleUpperCase(language)}</h2>
<h3 className="text-4xl md:text-5xl font-display font-bold">{t.services.title}</h3>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{t.services.items.map((service, index) => {
const Icon = icons[index % icons.length];
const color = colors[index % colors.length];
return (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.1 }}
className="p-8 bg-slate-950 border border-white/10 hover:border-white/20 transition-colors group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-bl from-white/5 to-transparent rounded-bl-full -mr-16 -mt-16 transition-transform group-hover:scale-110" />
<Icon className="w-12 h-12 mb-6" style={{ color }} />
<h4 className="text-xl font-display font-bold mb-4">{service.title}</h4>
<p className="text-slate-400 leading-relaxed text-sm">
{service.desc}
</p>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

74
src/components/Works.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { motion } from 'motion/react';
import { useLanguage } from '../i18n/LanguageContext';
import { useData } from '../context/DataContext';
export default function Works() {
const { t, language } = useLanguage();
const { data } = useData();
// Sort projects by order
const projects = [...data.projects].sort((a, b) => a.order - b.order);
// Duplicate the array multiple times to create a seamless infinite scroll effect
const duplicatedProjects = projects.length > 0 ? [...projects, ...projects, ...projects, ...projects] : [];
return (
<section id="works" className="py-24 relative overflow-hidden">
<div className="max-w-7xl mx-auto px-6 mb-16">
<h2 className="text-sm font-mono text-[#FF5733] tracking-widest mb-2">{t.works.badge.toLocaleUpperCase(language)}</h2>
<h3 className="text-4xl md:text-5xl font-display font-bold">{t.works.title}</h3>
</div>
{/* Full width marquee container */}
<div className="w-full overflow-hidden relative flex">
{/* Left/Right Gradient Masks for smooth fade out at edges */}
<div className="absolute top-0 left-0 w-16 md:w-48 h-full bg-gradient-to-r from-[#050505] to-transparent z-10 pointer-events-none" />
<div className="absolute top-0 right-0 w-16 md:w-48 h-full bg-gradient-to-l from-[#050505] to-transparent z-10 pointer-events-none" />
{/* Scrolling Track */}
{duplicatedProjects.length > 0 ? (
<div className="flex gap-6 md:gap-8 animate-marquee w-max px-4 hover:[animation-play-state:paused]">
{duplicatedProjects.map((project, index) => (
<div
key={`${project.id}-${index}`}
className="relative w-[85vw] md:w-[800px] h-[400px] md:h-[520px] rounded-3xl overflow-hidden group shrink-0 border border-white/5 hover:border-white/20 transition-all duration-500 cursor-pointer bg-slate-900/50"
>
<img
src={project.image}
alt={project.title}
className="absolute inset-0 w-full h-full object-cover opacity-40 group-hover:opacity-70 group-hover:scale-110 transition-all duration-700 ease-out"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/40 to-transparent" />
<div className="absolute bottom-0 left-0 p-8 w-full transform translate-y-4 group-hover:translate-y-0 transition-transform duration-500 ease-out">
<h4 className="text-2xl md:text-3xl font-display font-bold mb-4 transition-colors duration-300" style={{ textShadow: `0 0 30px ${project.color}80` }}>
{project.title}
</h4>
<div className="flex flex-wrap gap-2 opacity-70 group-hover:opacity-100 transition-opacity duration-300">
<span className="text-sm font-mono text-slate-400 mr-2 flex items-center">{t.works.roles}</span>
{project.roles.map((role, i) => (
<span key={i} className="text-xs font-mono px-3 py-1.5 bg-white/10 backdrop-blur-md border border-white/10 rounded-full text-slate-200">
{role.trim()}
</span>
))}
</div>
</div>
{/* Hover Glow Effect */}
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none mix-blend-screen"
style={{ boxShadow: `inset 0 0 80px ${project.color}40` }}
/>
</div>
))}
</div>
) : (
<div className="w-full text-center py-12 text-slate-500 font-mono">
No projects found.
</div>
)}
</div>
</section>
);
}

View File

@@ -1,154 +0,0 @@
"use client";
import { Box, Heading, Input, Text, VStack } from "@chakra-ui/react";
import { Button } from "@/components/ui/buttons/button";
import { Field } from "@/components/ui/forms/field";
import { InputGroup } from "@/components/ui/forms/input-group";
import { PasswordInput } from "@/components/ui/forms/password-input";
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogHeader,
DialogRoot,
DialogTitle,
} from "@/components/ui/overlays/dialog";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { signIn } from "next-auth/react";
import { toaster } from "@/components/ui/feedback/toaster";
import { useState } from "react";
import { MdMail } from "react-icons/md";
import { BiLock } from "react-icons/bi";
import { Link } from "@/i18n/navigation";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(6).required(),
});
type LoginForm = yup.InferType<typeof schema>;
interface LoginModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
const t = useTranslations();
const [loading, setLoading] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<LoginForm>({
resolver: yupResolver(schema),
mode: "onChange",
});
const onSubmit = async (formData: LoginForm) => {
try {
setLoading(true);
const res = await signIn("credentials", {
redirect: false,
email: formData.email,
password: formData.password,
});
if (res?.error) {
throw new Error(res.error);
}
onOpenChange(false);
toaster.success({
title: t("auth.login-success") || "Login successful!",
type: "success",
});
} catch (error) {
toaster.error({
title: (error as Error).message || "Login failed!",
type: "error",
});
} finally {
setLoading(false);
}
};
return (
<DialogRoot open={open} onOpenChange={(e) => onOpenChange(e.open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Heading size="lg" color="primary.500">
{t("auth.sign-in")}
</Heading>
</DialogTitle>
<DialogCloseTrigger />
</DialogHeader>
<DialogBody>
<Box as="form" onSubmit={handleSubmit(onSubmit)}>
<VStack gap={4}>
<Field
label={t("email")}
errorText={errors.email?.message}
invalid={!!errors.email}
>
<InputGroup w="full" startElement={<MdMail size="1rem" />}>
<Input
borderRadius="md"
fontSize="sm"
type="text"
placeholder={t("email")}
{...register("email")}
/>
</InputGroup>
</Field>
<Field
label={t("password")}
errorText={errors.password?.message}
invalid={!!errors.password}
>
<InputGroup w="full" startElement={<BiLock size="1rem" />}>
<PasswordInput
borderRadius="md"
fontSize="sm"
placeholder={t("password")}
{...register("password")}
/>
</InputGroup>
</Field>
<Button
loading={loading}
type="submit"
bg="primary.400"
w="100%"
color="white"
_hover={{ bg: "primary.500" }}
>
{t("auth.sign-in")}
</Button>
<Text fontSize="sm" color="fg.muted">
{t("auth.dont-have-account")}{" "}
<Link
href="/signup"
style={{
color: "var(--chakra-colors-primary-500)",
fontWeight: "bold",
}}
>
{t("auth.sign-up")}
</Link>
</Text>
</VStack>
</Box>
</DialogBody>
</DialogContent>
</DialogRoot>
);
}

View File

@@ -1,71 +0,0 @@
import { Box, Text, HStack, Link as ChakraLink } from "@chakra-ui/react";
import { Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
export default function Footer() {
const t = useTranslations();
return (
<Box as="footer" bg="bg.muted" mt="auto">
<HStack
display="flex"
justify={{ base: "center", md: "space-between" }}
alignContent="center"
maxW="8xl"
mx="auto"
wrap="wrap"
px={{ base: 4, md: 8 }}
position="relative"
minH="16"
>
<Text fontSize="sm" color="fg.muted">
© {new Date().getFullYear()}
<ChakraLink
target="_blank"
rel="noopener noreferrer"
href="https://www.fcs.com.tr"
color={{ base: "primary.500", _dark: "primary.300" }}
focusRing="none"
ml="1"
>
{"FCS"}
</ChakraLink>
. {t("all-right-reserved")}
</Text>
<HStack spaceX={4}>
<ChakraLink
as={Link}
href="/privacy"
fontSize="sm"
color="fg.muted"
focusRing="none"
position="relative"
textDecor="none"
transition="color 0.3s ease-in-out"
_hover={{
color: { base: "primary.500", _dark: "primary.300" },
}}
>
{t("privacy-policy")}
</ChakraLink>
<ChakraLink
as={Link}
href="/terms"
fontSize="sm"
color="fg.muted"
focusRing="none"
position="relative"
textDecor="none"
transition="color 0.3s ease-in-out"
_hover={{
color: { base: "primary.500", _dark: "primary.300" },
}}
>
{t("terms-of-service")}
</ChakraLink>
</HStack>
</HStack>
</Box>
);
}

View File

@@ -1,104 +0,0 @@
import { Box, Link as ChakraLink, Text } from '@chakra-ui/react';
import { NavItem } from '@/config/navigation';
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '@/components/ui/overlays/menu';
import { RxChevronDown } from 'react-icons/rx';
import { useActiveNavItem } from '@/hooks/useActiveNavItem';
import { Link } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
function HeaderLink({ item }: { item: NavItem }) {
const t = useTranslations();
const { isActive, isChildActive } = useActiveNavItem(item);
const [open, setOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseOpen = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setOpen(true);
};
const handleMouseClose = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setOpen(false), 150);
};
return (
<Box key={item.label}>
{item.children ? (
<Box onMouseEnter={handleMouseOpen} onMouseLeave={handleMouseClose}>
<MenuRoot open={open} onOpenChange={(e) => setOpen(e.open)}>
<MenuTrigger asChild>
<Text
display='inline-flex'
alignItems='center'
gap='1'
cursor='pointer'
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
position='relative'
fontWeight='semibold'
>
{t(item.label)}
<RxChevronDown
style={{ transform: open ? 'rotate(-180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
/>
</Text>
</MenuTrigger>
<MenuContent>
{item.children.map((child, index) => {
const isActiveChild = isChildActive(child.href);
return (
<MenuItem key={index} value={child.href}>
<ChakraLink
key={index}
as={Link}
href={child.href}
focusRing='none'
w='full'
color={isActiveChild ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
position='relative'
textDecor='none'
fontWeight='semibold'
>
{t(child.label)}
</ChakraLink>
</MenuItem>
);
})}
</MenuContent>
</MenuRoot>
</Box>
) : (
<ChakraLink
as={Link}
href={item.href}
focusRing='none'
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
position='relative'
textDecor='none'
fontWeight='semibold'
_after={{
content: "''",
position: 'absolute',
left: 0,
bottom: '-2px',
width: '0%',
height: '1.5px',
bg: { base: 'primary.500', _dark: 'primary.300' },
transition: 'width 0.3s ease-in-out',
}}
_hover={{
_after: {
width: '100%',
},
}}
>
{t(item.label)}
</ChakraLink>
)}
</Box>
);
}
export default HeaderLink;

View File

@@ -1,229 +0,0 @@
"use client";
import {
Box,
Flex,
HStack,
IconButton,
Link as ChakraLink,
Stack,
VStack,
Button,
MenuItem,
ClientOnly,
} from "@chakra-ui/react";
import { Link, useRouter } from "@/i18n/navigation";
import { ColorModeButton } from "@/components/ui/color-mode";
import {
PopoverBody,
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from "@/components/ui/overlays/popover";
import { RxHamburgerMenu } from "react-icons/rx";
import { NAV_ITEMS } from "@/config/navigation";
import HeaderLink from "./header-link";
import MobileHeaderLink from "./mobile-header-link";
import LocaleSwitcher from "@/components/ui/locale-switcher";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
MenuContent,
MenuRoot,
MenuTrigger,
} from "@/components/ui/overlays/menu";
import { Avatar } from "@/components/ui/data-display/avatar";
import { Skeleton } from "@/components/ui/feedback/skeleton";
import { signOut, useSession } from "next-auth/react";
import { authConfig } from "@/config/auth";
import { LoginModal } from "@/components/auth/login-modal";
import { LuLogIn } from "react-icons/lu";
export default function Header() {
const t = useTranslations();
const [isSticky, setIsSticky] = useState(false);
const [loginModalOpen, setLoginModalOpen] = useState(false);
const router = useRouter();
const { data: session, status } = useSession();
const isAuthenticated = !!session;
const isLoading = status === "loading";
useEffect(() => {
const handleScroll = () => {
setIsSticky(window.scrollY >= 10);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
const handleLogout = async () => {
await signOut({ redirect: false });
if (authConfig.isAuthRequired) {
router.replace("/signin");
}
};
// Render user menu or login button based on auth state
const renderAuthSection = () => {
if (isLoading) {
return <Skeleton boxSize="10" rounded="full" />;
}
if (isAuthenticated) {
return (
<MenuRoot positioning={{ placement: "bottom-start" }}>
<MenuTrigger rounded="full" focusRing="none">
<Avatar name={session?.user?.name || "User"} variant="solid" />
</MenuTrigger>
<MenuContent>
<MenuItem onClick={handleLogout} value="sign-out">
{t("auth.sign-out")}
</MenuItem>
</MenuContent>
</MenuRoot>
);
}
// Not authenticated - show login button
return (
<Button
variant="solid"
colorPalette="primary"
size="sm"
onClick={() => setLoginModalOpen(true)}
>
<LuLogIn />
{t("auth.sign-in")}
</Button>
);
};
// Render mobile auth section
const renderMobileAuthSection = () => {
if (isLoading) {
return <Skeleton height="10" width="full" />;
}
if (isAuthenticated) {
return (
<>
<Avatar name={session?.user?.name || "User"} variant="solid" />
<Button
variant="surface"
size="sm"
width="full"
onClick={handleLogout}
>
{t("auth.sign-out")}
</Button>
</>
);
}
return (
<Button
variant="solid"
colorPalette="primary"
size="sm"
width="full"
onClick={() => setLoginModalOpen(true)}
>
<LuLogIn />
{t("auth.sign-in")}
</Button>
);
};
return (
<>
<Box
as="nav"
bg={isSticky ? "rgba(255, 255, 255, 0.6)" : "white"}
_dark={{
bg: isSticky ? "rgba(1, 1, 1, 0.6)" : "black",
}}
shadow={isSticky ? "sm" : "none"}
backdropFilter="blur(12px) saturate(180%)"
border="1px solid"
borderColor={isSticky ? "whiteAlpha.300" : "transparent"}
borderBottomRadius={isSticky ? "xl" : "none"}
transition="all 0.4s ease-in-out"
px={{ base: 4, md: 8 }}
py="3"
position="sticky"
top={0}
zIndex={10}
w="full"
>
<Flex justify="space-between" align="center" maxW="8xl" mx="auto">
{/* Logo */}
<HStack>
<ChakraLink
as={Link}
href="/home"
fontSize="lg"
fontWeight="bold"
color={{ base: "primary.500", _dark: "primary.300" }}
focusRing="none"
textDecor="none"
transition="all 0.3s ease-in-out"
_hover={{
color: { base: "primary.900", _dark: "primary.50" },
}}
>
{"FCS "}
</ChakraLink>
</HStack>
{/* DESKTOP NAVIGATION */}
<HStack spaceX={4} display={{ base: "none", lg: "flex" }}>
{NAV_ITEMS.map((item, index) => (
<HeaderLink key={index} item={item} />
))}
</HStack>
<HStack>
<ColorModeButton colorPalette="gray" />
<Box display={{ base: "none", lg: "inline-flex" }} gap={2}>
<LocaleSwitcher />
<ClientOnly fallback={<Skeleton boxSize="10" rounded="full" />}>
{renderAuthSection()}
</ClientOnly>
</Box>
{/* MOBILE NAVIGATION */}
<Stack display={{ base: "inline-flex", lg: "none" }}>
<ClientOnly fallback={<Skeleton boxSize="9" />}>
<PopoverRoot>
<PopoverTrigger as="span">
<IconButton aria-label="Open menu" variant="ghost">
<RxHamburgerMenu />
</IconButton>
</PopoverTrigger>
<PopoverContent width={{ base: "xs", sm: "sm", md: "md" }}>
<PopoverBody>
<VStack mt="2" align="start" spaceY="2" w="full">
{NAV_ITEMS.map((item) => (
<MobileHeaderLink key={item.label} item={item} />
))}
<LocaleSwitcher />
{renderMobileAuthSection()}
</VStack>
</PopoverBody>
</PopoverContent>
</PopoverRoot>
</ClientOnly>
</Stack>
</HStack>
</Flex>
</Box>
{/* Login Modal */}
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
</>
);
}

View File

@@ -1,84 +0,0 @@
import { Text, Box, Link as ChakraLink, useDisclosure, VStack } from '@chakra-ui/react';
import { RxChevronDown } from 'react-icons/rx';
import { NavItem } from '@/config/navigation';
import { useActiveNavItem } from '@/hooks/useActiveNavItem';
import { Link } from '@/i18n/navigation';
import { useTranslations } from 'next-intl';
function MobileHeaderLink({ item }: { item: NavItem }) {
const t = useTranslations();
const { isActive, isChildActive } = useActiveNavItem(item);
const { open, onToggle } = useDisclosure();
return (
<Box key={item.label} w='full'>
{item.children ? (
<VStack align='start' w='full' spaceY={0}>
<Text
onClick={onToggle}
display='inline-flex'
alignItems='center'
gap='1'
cursor='pointer'
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
textUnderlineOffset='4px'
textUnderlinePosition='from-font'
textDecoration={isActive ? 'underline' : 'none'}
fontWeight='semibold'
fontSize={{ base: 'md', md: 'lg' }}
_hover={{
color: { base: 'primary.500', _dark: 'primary.300' },
transition: 'all 0.2s ease-in-out',
}}
>
{t(item.label)}
<RxChevronDown
style={{ transform: open ? 'rotate(-180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
/>
</Text>
{open && item.children && (
<VStack align='start' pl='4' pt='1' pb='2' w='full' spaceY={1}>
{item.children.map((child, index) => {
const isActiveChild = isChildActive(child.href);
return (
<ChakraLink
key={index}
as={Link}
href={child.href}
focusRing='none'
color={isActiveChild ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
textUnderlineOffset='4px'
textUnderlinePosition='from-font'
textDecoration={isActiveChild ? 'underline' : 'none'}
fontWeight='semibold'
fontSize={{ base: 'md', md: 'lg' }}
>
{t(child.label)}
</ChakraLink>
);
})}
</VStack>
)}
</VStack>
) : (
<ChakraLink
as={Link}
href={item.href}
w='full'
focusRing='none'
color={isActive ? { base: 'primary.500', _dark: 'primary.300' } : 'fg.muted'}
textUnderlineOffset='4px'
textUnderlinePosition='from-font'
textDecoration={isActive ? 'underline' : 'none'}
fontWeight='semibold'
fontSize={{ base: 'md', md: 'lg' }}
>
{t(item.label)}
</ChakraLink>
)}
</Box>
);
}
export default MobileHeaderLink;

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,53 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Icon, IconButton, Presence } from '@chakra-ui/react';
import { FiChevronUp } from 'react-icons/fi';
const BackToTop = () => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsVisible(window.pageYOffset > 300);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<Presence
unmountOnExit
present={isVisible}
animationName={{ _open: 'fade-in', _closed: 'fade-out' }}
animationDuration='moderate'
>
<IconButton
variant={{ base: 'solid', _dark: 'subtle' }}
aria-label='Back to top'
position='fixed'
bottom='8'
right='8'
borderRadius='full'
size='lg'
shadow='lg'
zIndex='999'
onClick={scrollToTop}
>
<Icon>
<FiChevronUp />
</Icon>
</IconButton>
</Presence>
);
};
export default BackToTop;

View File

@@ -1,33 +0,0 @@
import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react';
import { AbsoluteCenter, Button as ChakraButton, Span, Spinner } from '@chakra-ui/react';
import * as React from 'react';
interface ButtonLoadingProps {
loading?: boolean;
loadingText?: React.ReactNode;
}
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props;
return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? (
<>
<AbsoluteCenter display='inline-flex'>
<Spinner size='inherit' color='inherit' />
</AbsoluteCenter>
<Span opacity={0}>{children}</Span>
</>
) : loading && loadingText ? (
<>
<Spinner size='inherit' color='inherit' />
{loadingText}
</>
) : (
children
)}
</ChakraButton>
);
});

View File

@@ -1,14 +0,0 @@
import type { ButtonProps } from '@chakra-ui/react';
import { IconButton as ChakraIconButton } from '@chakra-ui/react';
import * as React from 'react';
import { LuX } from 'react-icons/lu';
export type CloseButtonProps = ButtonProps;
export const CloseButton = React.forwardRef<HTMLButtonElement, CloseButtonProps>(function CloseButton(props, ref) {
return (
<ChakraIconButton variant='ghost' aria-label='Close' ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
);
});

View File

@@ -1,11 +0,0 @@
'use client';
import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react';
import { createRecipeContext } from '@chakra-ui/react';
export interface LinkButtonProps extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
const { withContext } = createRecipeContext({ key: 'button' });
// Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>('a');

View File

@@ -1,44 +0,0 @@
'use client';
import type { ButtonProps } from '@chakra-ui/react';
import { Button, Toggle as ChakraToggle, useToggleContext } from '@chakra-ui/react';
import * as React from 'react';
interface ToggleProps extends ChakraToggle.RootProps {
variant?: keyof typeof variantMap;
size?: ButtonProps['size'];
}
const variantMap = {
solid: { on: 'solid', off: 'outline' },
surface: { on: 'surface', off: 'outline' },
subtle: { on: 'subtle', off: 'ghost' },
ghost: { on: 'subtle', off: 'ghost' },
} as const;
export const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(props, ref) {
const { variant = 'subtle', size, children, ...rest } = props;
const variantConfig = variantMap[variant];
return (
<ChakraToggle.Root asChild {...rest}>
<ToggleBaseButton size={size} variant={variantConfig} ref={ref}>
{children}
</ToggleBaseButton>
</ChakraToggle.Root>
);
});
interface ToggleBaseButtonProps extends Omit<ButtonProps, 'variant'> {
variant: Record<'on' | 'off', ButtonProps['variant']>;
}
const ToggleBaseButton = React.forwardRef<HTMLButtonElement, ToggleBaseButtonProps>(
function ToggleBaseButton(props, ref) {
const toggle = useToggleContext();
const { variant, ...rest } = props;
return <Button variant={toggle.pressed ? variant.on : variant.off} ref={ref} {...rest} />;
},
);
export const ToggleIndicator = ChakraToggle.Indicator;

View File

@@ -1,91 +0,0 @@
'use client';
import { Combobox as ChakraCombobox, Portal } from '@chakra-ui/react';
import { CloseButton } from '@/components/ui/buttons/close-button';
import * as React from 'react';
interface ComboboxControlProps extends ChakraCombobox.ControlProps {
clearable?: boolean;
}
export const ComboboxControl = React.forwardRef<HTMLDivElement, ComboboxControlProps>(
function ComboboxControl(props, ref) {
const { children, clearable, ...rest } = props;
return (
<ChakraCombobox.Control {...rest} ref={ref}>
{children}
<ChakraCombobox.IndicatorGroup>
{clearable && <ComboboxClearTrigger />}
<ChakraCombobox.Trigger />
</ChakraCombobox.IndicatorGroup>
</ChakraCombobox.Control>
);
},
);
const ComboboxClearTrigger = React.forwardRef<HTMLButtonElement, ChakraCombobox.ClearTriggerProps>(
function ComboboxClearTrigger(props, ref) {
return (
<ChakraCombobox.ClearTrigger asChild {...props} ref={ref}>
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
</ChakraCombobox.ClearTrigger>
);
},
);
interface ComboboxContentProps extends ChakraCombobox.ContentProps {
portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>;
}
export const ComboboxContent = React.forwardRef<HTMLDivElement, ComboboxContentProps>(
function ComboboxContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraCombobox.Positioner>
<ChakraCombobox.Content {...rest} ref={ref} />
</ChakraCombobox.Positioner>
</Portal>
);
},
);
export const ComboboxItem = React.forwardRef<HTMLDivElement, ChakraCombobox.ItemProps>(
function ComboboxItem(props, ref) {
const { item, children, ...rest } = props;
return (
<ChakraCombobox.Item key={item.value} item={item} {...rest} ref={ref}>
{children}
<ChakraCombobox.ItemIndicator />
</ChakraCombobox.Item>
);
},
);
export const ComboboxRoot = React.forwardRef<HTMLDivElement, ChakraCombobox.RootProps>(
function ComboboxRoot(props, ref) {
return <ChakraCombobox.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }} />;
},
) as ChakraCombobox.RootComponent;
interface ComboboxItemGroupProps extends ChakraCombobox.ItemGroupProps {
label: React.ReactNode;
}
export const ComboboxItemGroup = React.forwardRef<HTMLDivElement, ComboboxItemGroupProps>(
function ComboboxItemGroup(props, ref) {
const { children, label, ...rest } = props;
return (
<ChakraCombobox.ItemGroup {...rest} ref={ref}>
<ChakraCombobox.ItemGroupLabel>{label}</ChakraCombobox.ItemGroupLabel>
{children}
</ChakraCombobox.ItemGroup>
);
},
);
export const ComboboxLabel = ChakraCombobox.Label;
export const ComboboxInput = ChakraCombobox.Input;
export const ComboboxEmpty = ChakraCombobox.Empty;
export const ComboboxItemText = ChakraCombobox.ItemText;

View File

@@ -1,28 +0,0 @@
'use client';
import { Listbox as ChakraListbox } from '@chakra-ui/react';
import * as React from 'react';
export const ListboxRoot = React.forwardRef<HTMLDivElement, ChakraListbox.RootProps>(function ListboxRoot(props, ref) {
return <ChakraListbox.Root {...props} ref={ref} />;
}) as ChakraListbox.RootComponent;
export const ListboxContent = React.forwardRef<HTMLDivElement, ChakraListbox.ContentProps>(
function ListboxContent(props, ref) {
return <ChakraListbox.Content {...props} ref={ref} />;
},
);
export const ListboxItem = React.forwardRef<HTMLDivElement, ChakraListbox.ItemProps>(function ListboxItem(props, ref) {
const { children, ...rest } = props;
return (
<ChakraListbox.Item {...rest} ref={ref}>
{children}
<ChakraListbox.ItemIndicator />
</ChakraListbox.Item>
);
});
export const ListboxLabel = ChakraListbox.Label;
export const ListboxItemText = ChakraListbox.ItemText;
export const ListboxEmpty = ChakraListbox.Empty;

View File

@@ -1,118 +0,0 @@
'use client';
import type { CollectionItem } from '@chakra-ui/react';
import { Select as ChakraSelect, Portal } from '@chakra-ui/react';
import { CloseButton } from '../buttons/close-button';
import * as React from 'react';
interface SelectTriggerProps extends ChakraSelect.ControlProps {
clearable?: boolean;
}
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
function SelectTrigger(props, ref) {
const { children, clearable, ...rest } = props;
return (
<ChakraSelect.Control {...rest}>
<ChakraSelect.Trigger ref={ref}>{children}</ChakraSelect.Trigger>
<ChakraSelect.IndicatorGroup>
{clearable && <SelectClearTrigger />}
<ChakraSelect.Indicator />
</ChakraSelect.IndicatorGroup>
</ChakraSelect.Control>
);
},
);
const SelectClearTrigger = React.forwardRef<HTMLButtonElement, ChakraSelect.ClearTriggerProps>(
function SelectClearTrigger(props, ref) {
return (
<ChakraSelect.ClearTrigger asChild {...props} ref={ref}>
<CloseButton size='xs' variant='plain' focusVisibleRing='inside' focusRingWidth='2px' pointerEvents='auto' />
</ChakraSelect.ClearTrigger>
);
},
);
interface SelectContentProps extends ChakraSelect.ContentProps {
portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>;
}
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(function SelectContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraSelect.Positioner>
<ChakraSelect.Content {...rest} ref={ref} />
</ChakraSelect.Positioner>
</Portal>
);
});
export const SelectItem = React.forwardRef<HTMLDivElement, ChakraSelect.ItemProps>(function SelectItem(props, ref) {
const { item, children, ...rest } = props;
return (
<ChakraSelect.Item key={item.value} item={item} {...rest} ref={ref}>
{children}
<ChakraSelect.ItemIndicator />
</ChakraSelect.Item>
);
});
interface SelectValueTextProps extends Omit<ChakraSelect.ValueTextProps, 'children'> {
children?(items: CollectionItem[]): React.ReactNode;
}
export const SelectValueText = React.forwardRef<HTMLSpanElement, SelectValueTextProps>(
function SelectValueText(props, ref) {
const { children, ...rest } = props;
return (
<ChakraSelect.ValueText {...rest} ref={ref}>
<ChakraSelect.Context>
{(select) => {
const items = select.selectedItems;
if (items.length === 0) return props.placeholder;
if (children) return children(items);
if (items.length === 1) return select.collection.stringifyItem(items[0]);
return `${items.length} selected`;
}}
</ChakraSelect.Context>
</ChakraSelect.ValueText>
);
},
);
export const SelectRoot = React.forwardRef<HTMLDivElement, ChakraSelect.RootProps>(function SelectRoot(props, ref) {
return (
<ChakraSelect.Root {...props} ref={ref} positioning={{ sameWidth: true, ...props.positioning }}>
{props.asChild ? (
props.children
) : (
<>
<ChakraSelect.HiddenSelect />
{props.children}
</>
)}
</ChakraSelect.Root>
);
}) as ChakraSelect.RootComponent;
interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps {
label: React.ReactNode;
}
export const SelectItemGroup = React.forwardRef<HTMLDivElement, SelectItemGroupProps>(
function SelectItemGroup(props, ref) {
const { children, label, ...rest } = props;
return (
<ChakraSelect.ItemGroup {...rest} ref={ref}>
<ChakraSelect.ItemGroupLabel>{label}</ChakraSelect.ItemGroupLabel>
{children}
</ChakraSelect.ItemGroup>
);
},
);
export const SelectLabel = ChakraSelect.Label;
export const SelectItemText = ChakraSelect.ItemText;

View File

@@ -1,60 +0,0 @@
'use client';
import { TreeView as ChakraTreeView } from '@chakra-ui/react';
import * as React from 'react';
export const TreeViewRoot = React.forwardRef<HTMLDivElement, ChakraTreeView.RootProps>(
function TreeViewRoot(props, ref) {
return <ChakraTreeView.Root {...props} ref={ref} />;
},
);
interface TreeViewTreeProps extends ChakraTreeView.TreeProps {}
export const TreeViewTree = React.forwardRef<HTMLDivElement, TreeViewTreeProps>(function TreeViewTree(props, ref) {
const { ...rest } = props;
return <ChakraTreeView.Tree {...rest} ref={ref} />;
});
export const TreeViewBranch = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchProps>(
function TreeViewBranch(props, ref) {
return <ChakraTreeView.Branch {...props} ref={ref} />;
},
);
export const TreeViewBranchControl = React.forwardRef<HTMLDivElement, ChakraTreeView.BranchControlProps>(
function TreeViewBranchControl(props, ref) {
return <ChakraTreeView.BranchControl {...props} ref={ref} />;
},
);
export const TreeViewItem = React.forwardRef<HTMLDivElement, ChakraTreeView.ItemProps>(
function TreeViewItem(props, ref) {
return <ChakraTreeView.Item {...props} ref={ref} />;
},
);
export const TreeViewLabel = ChakraTreeView.Label;
export const TreeViewBranchIndicator = ChakraTreeView.BranchIndicator;
export const TreeViewBranchText = ChakraTreeView.BranchText;
export const TreeViewBranchContent = ChakraTreeView.BranchContent;
export const TreeViewBranchIndentGuide = ChakraTreeView.BranchIndentGuide;
export const TreeViewItemText = ChakraTreeView.ItemText;
export const TreeViewNode = ChakraTreeView.Node;
export const TreeViewNodeProvider = ChakraTreeView.NodeProvider;
export const TreeView = {
Root: TreeViewRoot,
Label: TreeViewLabel,
Tree: TreeViewTree,
Branch: TreeViewBranch,
BranchControl: TreeViewBranchControl,
BranchIndicator: TreeViewBranchIndicator,
BranchText: TreeViewBranchText,
BranchContent: TreeViewBranchContent,
BranchIndentGuide: TreeViewBranchIndentGuide,
Item: TreeViewItem,
ItemText: TreeViewItemText,
Node: TreeViewNode,
NodeProvider: TreeViewNodeProvider,
};

View File

@@ -1,108 +0,0 @@
'use client';
import type { IconButtonProps, SpanProps } from '@chakra-ui/react';
import { ClientOnly, IconButton, Skeleton, Span } from '@chakra-ui/react';
import { ThemeProvider, useTheme } from 'next-themes';
import type { ThemeProviderProps } from 'next-themes';
import * as React from 'react';
import { LuMoon, LuSun } from 'react-icons/lu';
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return <ThemeProvider attribute='class' disableTransitionOnChange {...props} />;
}
export type ColorMode = 'light' | 'dark';
export interface UseColorModeReturn {
colorMode: ColorMode;
setColorMode: (colorMode: ColorMode) => void;
toggleColorMode: () => void;
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme();
const colorMode = forcedTheme || resolvedTheme;
const toggleColorMode = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
};
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (!mounted) {
return light;
}
return colorMode === 'dark' ? dark : light;
}
export function ColorModeIcon() {
const { colorMode } = useColorMode();
return colorMode === 'dark' ? <LuMoon /> : <LuSun />;
}
interface ColorModeButtonProps extends Omit<IconButtonProps, 'aria-label'> {}
export const ColorModeButton = React.forwardRef<HTMLButtonElement, ColorModeButtonProps>(
function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode();
return (
<ClientOnly fallback={<Skeleton boxSize='9' />}>
<IconButton
onClick={toggleColorMode}
variant='ghost'
aria-label='Toggle color mode'
size='sm'
ref={ref}
{...props}
css={{
_icon: {
width: '5',
height: '5',
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
);
},
);
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(function LightMode(props, ref) {
return (
<Span
color='fg'
display='contents'
className='chakra-theme light'
colorPalette='gray'
colorScheme='light'
ref={ref}
{...props}
/>
);
});
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(function DarkMode(props, ref) {
return (
<Span
color='fg'
display='contents'
className='chakra-theme dark'
colorPalette='gray'
colorScheme='dark'
ref={ref}
{...props}
/>
);
});

View File

@@ -1,26 +0,0 @@
import { Avatar as ChakraAvatar, AvatarGroup as ChakraAvatarGroup } from '@chakra-ui/react';
import * as React from 'react';
type ImageProps = React.ImgHTMLAttributes<HTMLImageElement>;
export interface AvatarProps extends ChakraAvatar.RootProps {
name?: string;
src?: string;
srcSet?: string;
loading?: ImageProps['loading'];
icon?: React.ReactElement;
fallback?: React.ReactNode;
}
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(function Avatar(props, ref) {
const { name, src, srcSet, loading, icon, fallback, children, ...rest } = props;
return (
<ChakraAvatar.Root ref={ref} {...rest}>
<ChakraAvatar.Fallback name={name}>{icon || fallback}</ChakraAvatar.Fallback>
<ChakraAvatar.Image src={src} srcSet={srcSet} loading={loading} />
{children}
</ChakraAvatar.Root>
);
});
export const AvatarGroup = ChakraAvatarGroup;

View File

@@ -1,79 +0,0 @@
import type { ButtonProps, InputProps } from '@chakra-ui/react';
import { Button, Clipboard as ChakraClipboard, IconButton, Input } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck, LuClipboard, LuLink } from 'react-icons/lu';
const ClipboardIcon = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
function ClipboardIcon(props, ref) {
return (
<ChakraClipboard.Indicator copied={<LuCheck />} {...props} ref={ref}>
<LuClipboard />
</ChakraClipboard.Indicator>
);
},
);
const ClipboardCopyText = React.forwardRef<HTMLDivElement, ChakraClipboard.IndicatorProps>(
function ClipboardCopyText(props, ref) {
return (
<ChakraClipboard.Indicator copied='Copied' {...props} ref={ref}>
Copy
</ChakraClipboard.Indicator>
);
},
);
export const ClipboardLabel = React.forwardRef<HTMLLabelElement, ChakraClipboard.LabelProps>(
function ClipboardLabel(props, ref) {
return (
<ChakraClipboard.Label textStyle='sm' fontWeight='medium' display='inline-block' mb='1' {...props} ref={ref} />
);
},
);
export const ClipboardButton = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardButton(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<Button ref={ref} size='sm' variant='surface' {...props}>
<ClipboardIcon />
<ClipboardCopyText />
</Button>
</ChakraClipboard.Trigger>
);
});
export const ClipboardLink = React.forwardRef<HTMLButtonElement, ButtonProps>(function ClipboardLink(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<Button unstyled variant='plain' size='xs' display='inline-flex' alignItems='center' gap='2' ref={ref} {...props}>
<LuLink />
<ClipboardCopyText />
</Button>
</ChakraClipboard.Trigger>
);
});
export const ClipboardIconButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ClipboardIconButton(props, ref) {
return (
<ChakraClipboard.Trigger asChild>
<IconButton ref={ref} size='xs' variant='subtle' {...props}>
<ClipboardIcon />
<ClipboardCopyText srOnly />
</IconButton>
</ChakraClipboard.Trigger>
);
},
);
export const ClipboardInput = React.forwardRef<HTMLInputElement, InputProps>(
function ClipboardInputElement(props, ref) {
return (
<ChakraClipboard.Input asChild>
<Input ref={ref} {...props} />
</ChakraClipboard.Input>
);
},
);
export const ClipboardRoot = ChakraClipboard.Root;

View File

@@ -1,26 +0,0 @@
import { DataList as ChakraDataList } from '@chakra-ui/react';
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
import * as React from 'react';
export const DataListRoot = ChakraDataList.Root;
interface ItemProps extends ChakraDataList.ItemProps {
label: React.ReactNode;
value: React.ReactNode;
info?: React.ReactNode;
grow?: boolean;
}
export const DataListItem = React.forwardRef<HTMLDivElement, ItemProps>(function DataListItem(props, ref) {
const { label, info, value, children, grow, ...rest } = props;
return (
<ChakraDataList.Item ref={ref} {...rest}>
<ChakraDataList.ItemLabel flex={grow ? '1' : undefined}>
{label}
{info && <InfoTip>{info}</InfoTip>}
</ChakraDataList.ItemLabel>
<ChakraDataList.ItemValue flex={grow ? '1' : undefined}>{value}</ChakraDataList.ItemValue>
{children}
</ChakraDataList.Item>
);
});

View File

@@ -1,20 +0,0 @@
import { QrCode as ChakraQrCode } from '@chakra-ui/react';
import * as React from 'react';
export interface QrCodeProps extends Omit<ChakraQrCode.RootProps, 'fill' | 'overlay'> {
fill?: string;
overlay?: React.ReactNode;
}
export const QrCode = React.forwardRef<HTMLDivElement, QrCodeProps>(function QrCode(props, ref) {
const { children, fill, overlay, ...rest } = props;
return (
<ChakraQrCode.Root ref={ref} {...rest}>
<ChakraQrCode.Frame style={{ fill }}>
<ChakraQrCode.Pattern />
</ChakraQrCode.Frame>
{children}
{overlay && <ChakraQrCode.Overlay>{overlay}</ChakraQrCode.Overlay>}
</ChakraQrCode.Root>
);
});

View File

@@ -1,53 +0,0 @@
import { Badge, type BadgeProps, Stat as ChakraStat, FormatNumber } from '@chakra-ui/react';
import { InfoTip } from '@/components/ui/overlays/toggle-tip';
import * as React from 'react';
interface StatLabelProps extends ChakraStat.LabelProps {
info?: React.ReactNode;
}
export const StatLabel = React.forwardRef<HTMLDivElement, StatLabelProps>(function StatLabel(props, ref) {
const { info, children, ...rest } = props;
return (
<ChakraStat.Label {...rest} ref={ref}>
{children}
{info && <InfoTip>{info}</InfoTip>}
</ChakraStat.Label>
);
});
interface StatValueTextProps extends ChakraStat.ValueTextProps {
value?: number;
formatOptions?: Intl.NumberFormatOptions;
}
export const StatValueText = React.forwardRef<HTMLDivElement, StatValueTextProps>(function StatValueText(props, ref) {
const { value, formatOptions, children, ...rest } = props;
return (
<ChakraStat.ValueText {...rest} ref={ref}>
{children || (value != null && <FormatNumber value={value} {...formatOptions} />)}
</ChakraStat.ValueText>
);
});
export const StatUpTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatUpTrend(props, ref) {
return (
<Badge colorPalette='green' gap='0' {...props} ref={ref}>
<ChakraStat.UpIndicator />
{props.children}
</Badge>
);
});
export const StatDownTrend = React.forwardRef<HTMLDivElement, BadgeProps>(function StatDownTrend(props, ref) {
return (
<Badge colorPalette='red' gap='0' {...props} ref={ref}>
<ChakraStat.DownIndicator />
{props.children}
</Badge>
);
});
export const StatRoot = ChakraStat.Root;
export const StatHelpText = ChakraStat.HelpText;
export const StatValueUnit = ChakraStat.ValueUnit;

View File

@@ -1,26 +0,0 @@
import { Tag as ChakraTag } from '@chakra-ui/react';
import * as React from 'react';
export interface TagProps extends ChakraTag.RootProps {
startElement?: React.ReactNode;
endElement?: React.ReactNode;
onClose?: VoidFunction;
closable?: boolean;
}
export const Tag = React.forwardRef<HTMLSpanElement, TagProps>(function Tag(props, ref) {
const { startElement, endElement, onClose, closable = !!onClose, children, ...rest } = props;
return (
<ChakraTag.Root ref={ref} {...rest}>
{startElement && <ChakraTag.StartElement>{startElement}</ChakraTag.StartElement>}
<ChakraTag.Label>{children}</ChakraTag.Label>
{endElement && <ChakraTag.EndElement>{endElement}</ChakraTag.EndElement>}
{closable && (
<ChakraTag.EndElement>
<ChakraTag.CloseTrigger onClick={onClose} />
</ChakraTag.EndElement>
)}
</ChakraTag.Root>
);
});

View File

@@ -1,25 +0,0 @@
import { Timeline as ChakraTimeline } from '@chakra-ui/react';
import * as React from 'react';
interface TimelineConnectorProps extends ChakraTimeline.IndicatorProps {
icon?: React.ReactNode;
}
export const TimelineConnector = React.forwardRef<HTMLDivElement, TimelineConnectorProps>(function TimelineConnector(
{ icon, ...props },
ref,
) {
return (
<ChakraTimeline.Connector ref={ref}>
<ChakraTimeline.Separator />
<ChakraTimeline.Indicator {...props}>{icon}</ChakraTimeline.Indicator>
</ChakraTimeline.Connector>
);
});
export const TimelineRoot = ChakraTimeline.Root;
export const TimelineContent = ChakraTimeline.Content;
export const TimelineItem = ChakraTimeline.Item;
export const TimelineIndicator = ChakraTimeline.Indicator;
export const TimelineTitle = ChakraTimeline.Title;
export const TimelineDescription = ChakraTimeline.Description;

View File

@@ -1,45 +0,0 @@
import { Accordion, HStack } from '@chakra-ui/react';
import * as React from 'react';
import { LuChevronDown } from 'react-icons/lu';
interface AccordionItemTriggerProps extends Accordion.ItemTriggerProps {
indicatorPlacement?: 'start' | 'end';
}
export const AccordionItemTrigger = React.forwardRef<HTMLButtonElement, AccordionItemTriggerProps>(
function AccordionItemTrigger(props, ref) {
const { children, indicatorPlacement = 'end', ...rest } = props;
return (
<Accordion.ItemTrigger {...rest} ref={ref}>
{indicatorPlacement === 'start' && (
<Accordion.ItemIndicator rotate={{ base: '-90deg', _open: '0deg' }}>
<LuChevronDown />
</Accordion.ItemIndicator>
)}
<HStack gap='4' flex='1' textAlign='start' width='full'>
{children}
</HStack>
{indicatorPlacement === 'end' && (
<Accordion.ItemIndicator>
<LuChevronDown />
</Accordion.ItemIndicator>
)}
</Accordion.ItemTrigger>
);
},
);
interface AccordionItemContentProps extends Accordion.ItemContentProps {}
export const AccordionItemContent = React.forwardRef<HTMLDivElement, AccordionItemContentProps>(
function AccordionItemContent(props, ref) {
return (
<Accordion.ItemContent>
<Accordion.ItemBody {...props} ref={ref} />
</Accordion.ItemContent>
);
},
);
export const AccordionRoot = Accordion.Root;
export const AccordionItem = Accordion.Item;

View File

@@ -1,35 +0,0 @@
import { Breadcrumb, type SystemStyleObject } from '@chakra-ui/react';
import * as React from 'react';
export interface BreadcrumbRootProps extends Breadcrumb.RootProps {
separator?: React.ReactNode;
separatorGap?: SystemStyleObject['gap'];
}
export const BreadcrumbRoot = React.forwardRef<HTMLDivElement, BreadcrumbRootProps>(
function BreadcrumbRoot(props, ref) {
const { separator, separatorGap, children, ...rest } = props;
const validChildren = React.Children.toArray(children).filter(React.isValidElement);
return (
<Breadcrumb.Root ref={ref} {...rest}>
<Breadcrumb.List gap={separatorGap}>
{validChildren.map((child, index) => {
const last = index === validChildren.length - 1;
return (
<React.Fragment key={index}>
<Breadcrumb.Item>{child}</Breadcrumb.Item>
{!last && <Breadcrumb.Separator>{separator}</Breadcrumb.Separator>}
</React.Fragment>
);
})}
</Breadcrumb.List>
</Breadcrumb.Root>
);
},
);
export const BreadcrumbLink = Breadcrumb.Link;
export const BreadcrumbCurrentLink = Breadcrumb.CurrentLink;
export const BreadcrumbEllipsis = Breadcrumb.Ellipsis;

View File

@@ -1,182 +0,0 @@
'use client';
import type { ButtonProps, TextProps } from '@chakra-ui/react';
import {
Button,
Pagination as ChakraPagination,
IconButton,
Text,
createContext,
usePaginationContext,
} from '@chakra-ui/react';
import * as React from 'react';
import { HiChevronLeft, HiChevronRight, HiMiniEllipsisHorizontal } from 'react-icons/hi2';
import { LinkButton } from '@/components/ui/buttons/link-button';
interface ButtonVariantMap {
current: ButtonProps['variant'];
default: ButtonProps['variant'];
ellipsis: ButtonProps['variant'];
}
type PaginationVariant = 'outline' | 'solid' | 'subtle';
interface ButtonVariantContext {
size: ButtonProps['size'];
variantMap: ButtonVariantMap;
getHref?: (page: number) => string;
}
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
name: 'RootPropsProvider',
});
export interface PaginationRootProps extends Omit<ChakraPagination.RootProps, 'type'> {
size?: ButtonProps['size'];
variant?: PaginationVariant;
getHref?: (page: number) => string;
}
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
outline: { default: 'ghost', ellipsis: 'plain', current: 'outline' },
solid: { default: 'outline', ellipsis: 'outline', current: 'solid' },
subtle: { default: 'ghost', ellipsis: 'plain', current: 'subtle' },
};
export const PaginationRoot = React.forwardRef<HTMLDivElement, PaginationRootProps>(
function PaginationRoot(props, ref) {
const { size = 'sm', variant = 'outline', getHref, ...rest } = props;
return (
<RootPropsProvider value={{ size, variantMap: variantMap[variant], getHref }}>
<ChakraPagination.Root ref={ref} type={getHref ? 'link' : 'button'} {...rest} />
</RootPropsProvider>
);
},
);
export const PaginationEllipsis = React.forwardRef<HTMLDivElement, ChakraPagination.EllipsisProps>(
function PaginationEllipsis(props, ref) {
const { size, variantMap } = useRootProps();
return (
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
<Button as='span' variant={variantMap.ellipsis} size={size}>
<HiMiniEllipsisHorizontal />
</Button>
</ChakraPagination.Ellipsis>
);
},
);
export const PaginationItem = React.forwardRef<HTMLButtonElement, ChakraPagination.ItemProps>(
function PaginationItem(props, ref) {
const { page } = usePaginationContext();
const { size, variantMap, getHref } = useRootProps();
const current = page === props.value;
const variant = current ? variantMap.current : variantMap.default;
if (getHref) {
return (
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
{props.value}
</LinkButton>
);
}
return (
<ChakraPagination.Item ref={ref} {...props} asChild>
<Button variant={variant} size={size}>
{props.value}
</Button>
</ChakraPagination.Item>
);
},
);
export const PaginationPrevTrigger = React.forwardRef<HTMLButtonElement, ChakraPagination.PrevTriggerProps>(
function PaginationPrevTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps();
const { previousPage } = usePaginationContext();
if (getHref) {
return (
<LinkButton
href={previousPage != null ? getHref(previousPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronLeft />
</LinkButton>
);
}
return (
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronLeft />
</IconButton>
</ChakraPagination.PrevTrigger>
);
},
);
export const PaginationNextTrigger = React.forwardRef<HTMLButtonElement, ChakraPagination.NextTriggerProps>(
function PaginationNextTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps();
const { nextPage } = usePaginationContext();
if (getHref) {
return (
<LinkButton href={nextPage != null ? getHref(nextPage) : undefined} variant={variantMap.default} size={size}>
<HiChevronRight />
</LinkButton>
);
}
return (
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronRight />
</IconButton>
</ChakraPagination.NextTrigger>
);
},
);
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
return (
<ChakraPagination.Context>
{({ pages }) =>
pages.map((page, index) => {
return page.type === 'ellipsis' ? (
<PaginationEllipsis key={index} index={index} {...props} />
) : (
<PaginationItem key={index} type='page' value={page.value} {...props} />
);
})
}
</ChakraPagination.Context>
);
};
interface PageTextProps extends TextProps {
format?: 'short' | 'compact' | 'long';
}
export const PaginationPageText = React.forwardRef<HTMLParagraphElement, PageTextProps>(
function PaginationPageText(props, ref) {
const { format = 'compact', ...rest } = props;
const { page, totalPages, pageRange, count } = usePaginationContext();
const content = React.useMemo(() => {
if (format === 'short') return `${page} / ${totalPages}`;
if (format === 'compact') return `${page} of ${totalPages}`;
return `${pageRange.start + 1} - ${Math.min(pageRange.end, count)} of ${count}`;
}, [format, page, totalPages, pageRange, count]);
return (
<Text fontWeight='medium' ref={ref} {...rest}>
{content}
</Text>
);
},
);

View File

@@ -1,73 +0,0 @@
import { Box, Steps as ChakraSteps } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck } from 'react-icons/lu';
interface StepInfoProps {
title?: React.ReactNode;
description?: React.ReactNode;
}
export interface StepsItemProps extends Omit<ChakraSteps.ItemProps, 'title'>, StepInfoProps {
completedIcon?: React.ReactNode;
icon?: React.ReactNode;
disableTrigger?: boolean;
}
export const StepsItem = React.forwardRef<HTMLDivElement, StepsItemProps>(function StepsItem(props, ref) {
const { title, description, completedIcon, icon, disableTrigger, ...rest } = props;
return (
<ChakraSteps.Item {...rest} ref={ref}>
<ChakraSteps.Trigger disabled={disableTrigger}>
<ChakraSteps.Indicator>
<ChakraSteps.Status complete={completedIcon || <LuCheck />} incomplete={icon || <ChakraSteps.Number />} />
</ChakraSteps.Indicator>
<StepInfo title={title} description={description} />
</ChakraSteps.Trigger>
<ChakraSteps.Separator />
</ChakraSteps.Item>
);
});
const StepInfo = (props: StepInfoProps) => {
const { title, description } = props;
if (title && description) {
return (
<Box>
<ChakraSteps.Title>{title}</ChakraSteps.Title>
<ChakraSteps.Description>{description}</ChakraSteps.Description>
</Box>
);
}
return (
<>
{title && <ChakraSteps.Title>{title}</ChakraSteps.Title>}
{description && <ChakraSteps.Description>{description}</ChakraSteps.Description>}
</>
);
};
interface StepsIndicatorProps {
completedIcon: React.ReactNode;
icon?: React.ReactNode;
}
export const StepsIndicator = React.forwardRef<HTMLDivElement, StepsIndicatorProps>(
function StepsIndicator(props, ref) {
const { icon = <ChakraSteps.Number />, completedIcon } = props;
return (
<ChakraSteps.Indicator ref={ref}>
<ChakraSteps.Status complete={completedIcon} incomplete={icon} />
</ChakraSteps.Indicator>
);
},
);
export const StepsList = ChakraSteps.List;
export const StepsRoot = ChakraSteps.Root;
export const StepsContent = ChakraSteps.Content;
export const StepsCompletedContent = ChakraSteps.CompletedContent;
export const StepsNextTrigger = ChakraSteps.NextTrigger;
export const StepsPrevTrigger = ChakraSteps.PrevTrigger;

View File

@@ -1,27 +0,0 @@
import { Alert as ChakraAlert } from '@chakra-ui/react';
import * as React from 'react';
export interface AlertProps extends Omit<ChakraAlert.RootProps, 'title'> {
startElement?: React.ReactNode;
endElement?: React.ReactNode;
title?: React.ReactNode;
icon?: React.ReactElement;
}
export const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert(props, ref) {
const { title, children, icon, startElement, endElement, ...rest } = props;
return (
<ChakraAlert.Root ref={ref} {...rest}>
{startElement || <ChakraAlert.Indicator>{icon}</ChakraAlert.Indicator>}
{children ? (
<ChakraAlert.Content>
<ChakraAlert.Title>{title}</ChakraAlert.Title>
<ChakraAlert.Description>{children}</ChakraAlert.Description>
</ChakraAlert.Content>
) : (
<ChakraAlert.Title flex='1'>{title}</ChakraAlert.Title>
)}
{endElement}
</ChakraAlert.Root>
);
});

View File

@@ -1,28 +0,0 @@
import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react';
import * as React from 'react';
export interface EmptyStateProps extends ChakraEmptyState.RootProps {
title: string;
description?: string;
icon?: React.ReactNode;
}
export const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(function EmptyState(props, ref) {
const { title, description, icon, children, ...rest } = props;
return (
<ChakraEmptyState.Root ref={ref} {...rest}>
<ChakraEmptyState.Content>
{icon && <ChakraEmptyState.Indicator>{icon}</ChakraEmptyState.Indicator>}
{description ? (
<VStack textAlign='center'>
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
<ChakraEmptyState.Description>{description}</ChakraEmptyState.Description>
</VStack>
) : (
<ChakraEmptyState.Title>{title}</ChakraEmptyState.Title>
)}
{children}
</ChakraEmptyState.Content>
</ChakraEmptyState.Root>
);
});

View File

@@ -1,32 +0,0 @@
import type { SystemStyleObject } from '@chakra-ui/react';
import { AbsoluteCenter, ProgressCircle as ChakraProgressCircle } from '@chakra-ui/react';
import * as React from 'react';
interface ProgressCircleRingProps extends ChakraProgressCircle.CircleProps {
trackColor?: SystemStyleObject['stroke'];
cap?: SystemStyleObject['strokeLinecap'];
}
export const ProgressCircleRing = React.forwardRef<SVGSVGElement, ProgressCircleRingProps>(
function ProgressCircleRing(props, ref) {
const { trackColor, cap, color, ...rest } = props;
return (
<ChakraProgressCircle.Circle {...rest} ref={ref}>
<ChakraProgressCircle.Track stroke={trackColor} />
<ChakraProgressCircle.Range stroke={color} strokeLinecap={cap} />
</ChakraProgressCircle.Circle>
);
},
);
export const ProgressCircleValueText = React.forwardRef<HTMLDivElement, ChakraProgressCircle.ValueTextProps>(
function ProgressCircleValueText(props, ref) {
return (
<AbsoluteCenter>
<ChakraProgressCircle.ValueText {...props} ref={ref} />
</AbsoluteCenter>
);
},
);
export const ProgressCircleRoot = ChakraProgressCircle.Root;

View File

@@ -1,30 +0,0 @@
import { Progress as ChakraProgress } from '@chakra-ui/react';
import { InfoTip } from '../overlays/toggle-tip';
import * as React from 'react';
export const ProgressBar = React.forwardRef<HTMLDivElement, ChakraProgress.TrackProps>(
function ProgressBar(props, ref) {
return (
<ChakraProgress.Track {...props} ref={ref}>
<ChakraProgress.Range />
</ChakraProgress.Track>
);
},
);
export interface ProgressLabelProps extends ChakraProgress.LabelProps {
info?: React.ReactNode;
}
export const ProgressLabel = React.forwardRef<HTMLDivElement, ProgressLabelProps>(function ProgressLabel(props, ref) {
const { children, info, ...rest } = props;
return (
<ChakraProgress.Label {...rest} ref={ref}>
{children}
{info && <InfoTip>{info}</InfoTip>}
</ChakraProgress.Label>
);
});
export const ProgressRoot = ChakraProgress.Root;
export const ProgressValueText = ChakraProgress.ValueText;

View File

@@ -1,35 +0,0 @@
import type { SkeletonProps as ChakraSkeletonProps, CircleProps } from '@chakra-ui/react';
import { Skeleton as ChakraSkeleton, Circle, Stack } from '@chakra-ui/react';
import * as React from 'react';
export interface SkeletonCircleProps extends ChakraSkeletonProps {
size?: CircleProps['size'];
}
export const SkeletonCircle = React.forwardRef<HTMLDivElement, SkeletonCircleProps>(
function SkeletonCircle(props, ref) {
const { size, ...rest } = props;
return (
<Circle size={size} asChild ref={ref}>
<ChakraSkeleton {...rest} />
</Circle>
);
},
);
export interface SkeletonTextProps extends ChakraSkeletonProps {
noOfLines?: number;
}
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(function SkeletonText(props, ref) {
const { noOfLines = 3, gap, ...rest } = props;
return (
<Stack gap={gap} width='full' ref={ref}>
{Array.from({ length: noOfLines }).map((_, index) => (
<ChakraSkeleton height='4' key={index} {...props} _last={{ maxW: '80%' }} {...rest} />
))}
</Stack>
);
});
export const Skeleton = ChakraSkeleton;

View File

@@ -1,27 +0,0 @@
import type { ColorPalette } from '@chakra-ui/react';
import { Status as ChakraStatus } from '@chakra-ui/react';
import * as React from 'react';
type StatusValue = 'success' | 'error' | 'warning' | 'info';
export interface StatusProps extends ChakraStatus.RootProps {
value?: StatusValue;
}
const statusMap: Record<StatusValue, ColorPalette> = {
success: 'green',
error: 'red',
warning: 'orange',
info: 'blue',
};
export const Status = React.forwardRef<HTMLDivElement, StatusProps>(function Status(props, ref) {
const { children, value = 'info', ...rest } = props;
const colorPalette = rest.colorPalette ?? statusMap[value];
return (
<ChakraStatus.Root ref={ref} {...rest} colorPalette={colorPalette}>
<ChakraStatus.Indicator />
{children}
</ChakraStatus.Root>
);
});

View File

@@ -1,28 +0,0 @@
'use client';
import { Toaster as ChakraToaster, Portal, Spinner, Stack, Toast, createToaster } from '@chakra-ui/react';
export const toaster = createToaster({
placement: 'bottom-end',
pauseOnPageIdle: true,
});
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: '4' }}>
{(toast) => (
<Toast.Root width={{ md: 'sm' }}>
{toast.type === 'loading' ? <Spinner size='sm' color='blue.solid' /> : <Toast.Indicator />}
<Stack gap='1' flex='1' maxWidth='100%'>
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && <Toast.Description>{toast.description}</Toast.Description>}
</Stack>
{toast.action && <Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
);
};

View File

@@ -1,49 +0,0 @@
import { CheckboxCard as ChakraCheckboxCard } from '@chakra-ui/react';
import * as React from 'react';
export interface CheckboxCardProps extends ChakraCheckboxCard.RootProps {
icon?: React.ReactElement;
label?: React.ReactNode;
description?: React.ReactNode;
addon?: React.ReactNode;
indicator?: React.ReactNode | null;
indicatorPlacement?: 'start' | 'end' | 'inside';
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const CheckboxCard = React.forwardRef<HTMLInputElement, CheckboxCardProps>(function CheckboxCard(props, ref) {
const {
inputProps,
label,
description,
icon,
addon,
indicator = <ChakraCheckboxCard.Indicator />,
indicatorPlacement = 'end',
...rest
} = props;
const hasContent = label || description || icon;
const ContentWrapper = indicator ? ChakraCheckboxCard.Content : React.Fragment;
return (
<ChakraCheckboxCard.Root {...rest}>
<ChakraCheckboxCard.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckboxCard.Control>
{indicatorPlacement === 'start' && indicator}
{hasContent && (
<ContentWrapper>
{icon}
{label && <ChakraCheckboxCard.Label>{label}</ChakraCheckboxCard.Label>}
{description && <ChakraCheckboxCard.Description>{description}</ChakraCheckboxCard.Description>}
{indicatorPlacement === 'inside' && indicator}
</ContentWrapper>
)}
{indicatorPlacement === 'end' && indicator}
</ChakraCheckboxCard.Control>
{addon && <ChakraCheckboxCard.Addon>{addon}</ChakraCheckboxCard.Addon>}
</ChakraCheckboxCard.Root>
);
});
export const CheckboxCardIndicator = ChakraCheckboxCard.Indicator;

View File

@@ -1,19 +0,0 @@
import { Checkbox as ChakraCheckbox } from '@chakra-ui/react';
import * as React from 'react';
export interface CheckboxProps extends ChakraCheckbox.RootProps {
icon?: React.ReactNode;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
rootRef?: React.RefObject<HTMLLabelElement | null>;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(props, ref) {
const { icon, children, inputProps, rootRef, ...rest } = props;
return (
<ChakraCheckbox.Root ref={rootRef} {...rest}>
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckbox.Control>{icon || <ChakraCheckbox.Indicator />}</ChakraCheckbox.Control>
{children != null && <ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>}
</ChakraCheckbox.Root>
);
});

View File

@@ -1,174 +0,0 @@
import type { IconButtonProps, StackProps } from '@chakra-ui/react';
import { ColorPicker as ChakraColorPicker, For, IconButton, Portal, Span, Stack, Text, VStack } from '@chakra-ui/react';
import * as React from 'react';
import { LuCheck, LuPipette } from 'react-icons/lu';
export const ColorPickerTrigger = React.forwardRef<
HTMLButtonElement,
ChakraColorPicker.TriggerProps & { fitContent?: boolean }
>(function ColorPickerTrigger(props, ref) {
const { fitContent, ...rest } = props;
return (
<ChakraColorPicker.Trigger data-fit-content={fitContent || undefined} ref={ref} {...rest}>
{props.children || <ChakraColorPicker.ValueSwatch />}
</ChakraColorPicker.Trigger>
);
});
export const ColorPickerInput = React.forwardRef<
HTMLInputElement,
Omit<ChakraColorPicker.ChannelInputProps, 'channel'>
>(function ColorHexInput(props, ref) {
return <ChakraColorPicker.ChannelInput channel='hex' ref={ref} {...props} />;
});
interface ColorPickerContentProps extends ChakraColorPicker.ContentProps {
portalled?: boolean;
portalRef?: React.RefObject<HTMLElement | null>;
}
export const ColorPickerContent = React.forwardRef<HTMLDivElement, ColorPickerContentProps>(
function ColorPickerContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props;
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraColorPicker.Positioner>
<ChakraColorPicker.Content ref={ref} {...rest} />
</ChakraColorPicker.Positioner>
</Portal>
);
},
);
export const ColorPickerInlineContent = React.forwardRef<HTMLDivElement, ChakraColorPicker.ContentProps>(
function ColorPickerInlineContent(props, ref) {
return <ChakraColorPicker.Content animation='none' shadow='none' padding='0' ref={ref} {...props} />;
},
);
export const ColorPickerSliders = React.forwardRef<HTMLDivElement, StackProps>(function ColorPickerSliders(props, ref) {
return (
<Stack gap='1' flex='1' px='1' ref={ref} {...props}>
<ColorPickerChannelSlider channel='hue' />
<ColorPickerChannelSlider channel='alpha' />
</Stack>
);
});
export const ColorPickerArea = React.forwardRef<HTMLDivElement, ChakraColorPicker.AreaProps>(
function ColorPickerArea(props, ref) {
return (
<ChakraColorPicker.Area ref={ref} {...props}>
<ChakraColorPicker.AreaBackground />
<ChakraColorPicker.AreaThumb />
</ChakraColorPicker.Area>
);
},
);
export const ColorPickerEyeDropper = React.forwardRef<HTMLButtonElement, IconButtonProps>(
function ColorPickerEyeDropper(props, ref) {
return (
<ChakraColorPicker.EyeDropperTrigger asChild>
<IconButton size='xs' variant='outline' ref={ref} {...props}>
<LuPipette />
</IconButton>
</ChakraColorPicker.EyeDropperTrigger>
);
},
);
export const ColorPickerChannelSlider = React.forwardRef<HTMLDivElement, ChakraColorPicker.ChannelSliderProps>(
function ColorPickerSlider(props, ref) {
return (
<ChakraColorPicker.ChannelSlider ref={ref} {...props}>
<ChakraColorPicker.TransparencyGrid size='0.6rem' />
<ChakraColorPicker.ChannelSliderTrack />
<ChakraColorPicker.ChannelSliderThumb />
</ChakraColorPicker.ChannelSlider>
);
},
);
export const ColorPickerSwatchTrigger = React.forwardRef<
HTMLButtonElement,
ChakraColorPicker.SwatchTriggerProps & {
swatchSize?: ChakraColorPicker.SwatchTriggerProps['boxSize'];
}
>(function ColorPickerSwatchTrigger(props, ref) {
const { swatchSize, children, ...rest } = props;
return (
<ChakraColorPicker.SwatchTrigger ref={ref} style={{ ['--color' as string]: props.value }} {...rest}>
{children || (
<ChakraColorPicker.Swatch boxSize={swatchSize} value={props.value}>
<ChakraColorPicker.SwatchIndicator>
<LuCheck />
</ChakraColorPicker.SwatchIndicator>
</ChakraColorPicker.Swatch>
)}
</ChakraColorPicker.SwatchTrigger>
);
});
export const ColorPickerRoot = React.forwardRef<HTMLDivElement, ChakraColorPicker.RootProps>(
function ColorPickerRoot(props, ref) {
return (
<ChakraColorPicker.Root ref={ref} {...props}>
{props.children}
<ChakraColorPicker.HiddenInput tabIndex={-1} />
</ChakraColorPicker.Root>
);
},
);
const formatMap = {
rgba: ['red', 'green', 'blue', 'alpha'],
hsla: ['hue', 'saturation', 'lightness', 'alpha'],
hsba: ['hue', 'saturation', 'brightness', 'alpha'],
hexa: ['hex', 'alpha'],
} as const;
export const ColorPickerChannelInputs = React.forwardRef<HTMLDivElement, ChakraColorPicker.ViewProps>(
function ColorPickerChannelInputs(props, ref) {
const channels = formatMap[props.format];
return (
<ChakraColorPicker.View flexDirection='row' ref={ref} {...props}>
{channels.map((channel) => (
<VStack gap='1' key={channel} flex='1'>
<ColorPickerChannelInput channel={channel} px='0' height='7' textStyle='xs' textAlign='center' />
<Text textStyle='xs' color='fg.muted' fontWeight='medium'>
{channel.charAt(0).toUpperCase()}
</Text>
</VStack>
))}
</ChakraColorPicker.View>
);
},
);
export const ColorPickerChannelSliders = React.forwardRef<HTMLDivElement, ChakraColorPicker.ViewProps>(
function ColorPickerChannelSliders(props, ref) {
const channels = formatMap[props.format];
return (
<ChakraColorPicker.View {...props} ref={ref}>
<For each={channels}>
{(channel) => (
<Stack gap='1' key={channel}>
<Span textStyle='xs' minW='5ch' textTransform='capitalize' fontWeight='medium'>
{channel}
</Span>
<ColorPickerChannelSlider channel={channel} />
</Stack>
)}
</For>
</ChakraColorPicker.View>
);
},
);
export const ColorPickerLabel = ChakraColorPicker.Label;
export const ColorPickerControl = ChakraColorPicker.Control;
export const ColorPickerValueText = ChakraColorPicker.ValueText;
export const ColorPickerValueSwatch = ChakraColorPicker.ValueSwatch;
export const ColorPickerChannelInput = ChakraColorPicker.ChannelInput;
export const ColorPickerSwatchGroup = ChakraColorPicker.SwatchGroup;

View File

@@ -1,26 +0,0 @@
import { Field as ChakraField } from '@chakra-ui/react';
import * as React from 'react';
export interface FieldProps extends Omit<ChakraField.RootProps, 'label'> {
label?: React.ReactNode;
helperText?: React.ReactNode;
errorText?: React.ReactNode;
optionalText?: React.ReactNode;
}
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(function Field(props, ref) {
const { label, children, helperText, errorText, optionalText, ...rest } = props;
return (
<ChakraField.Root ref={ref} {...rest}>
{label && (
<ChakraField.Label>
{label}
<ChakraField.RequiredIndicator fallback={optionalText} />
</ChakraField.Label>
)}
{children}
{helperText && <ChakraField.HelperText>{helperText}</ChakraField.HelperText>}
{errorText && <ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>}
</ChakraField.Root>
);
});

View File

@@ -1,150 +0,0 @@
'use client';
import type { ButtonProps, RecipeProps } from '@chakra-ui/react';
import {
Button,
FileUpload as ChakraFileUpload,
Icon,
IconButton,
Span,
Text,
useFileUploadContext,
useRecipe,
} from '@chakra-ui/react';
import * as React from 'react';
import { LuFile, LuUpload, LuX } from 'react-icons/lu';
export interface FileUploadRootProps extends ChakraFileUpload.RootProps {
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const FileUploadRoot = React.forwardRef<HTMLInputElement, FileUploadRootProps>(
function FileUploadRoot(props, ref) {
const { children, inputProps, ...rest } = props;
return (
<ChakraFileUpload.Root {...rest}>
<ChakraFileUpload.HiddenInput ref={ref} {...inputProps} />
{children}
</ChakraFileUpload.Root>
);
},
);
export interface FileUploadDropzoneProps extends ChakraFileUpload.DropzoneProps {
label: React.ReactNode;
description?: React.ReactNode;
}
export const FileUploadDropzone = React.forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
function FileUploadDropzone(props, ref) {
const { children, label, description, ...rest } = props;
return (
<ChakraFileUpload.Dropzone ref={ref} {...rest}>
<Icon fontSize='xl' color='fg.muted'>
<LuUpload />
</Icon>
<ChakraFileUpload.DropzoneContent>
<div>{label}</div>
{description && <Text color='fg.muted'>{description}</Text>}
</ChakraFileUpload.DropzoneContent>
{children}
</ChakraFileUpload.Dropzone>
);
},
);
interface VisibilityProps {
showSize?: boolean;
clearable?: boolean;
}
interface FileUploadItemProps extends VisibilityProps {
file: File;
}
const FileUploadItem = React.forwardRef<HTMLLIElement, FileUploadItemProps>(function FileUploadItem(props, ref) {
const { file, showSize, clearable } = props;
return (
<ChakraFileUpload.Item file={file} ref={ref}>
<ChakraFileUpload.ItemPreview asChild>
<Icon fontSize='lg' color='fg.muted'>
<LuFile />
</Icon>
</ChakraFileUpload.ItemPreview>
{showSize ? (
<ChakraFileUpload.ItemContent>
<ChakraFileUpload.ItemName />
<ChakraFileUpload.ItemSizeText />
</ChakraFileUpload.ItemContent>
) : (
<ChakraFileUpload.ItemName flex='1' />
)}
{clearable && (
<ChakraFileUpload.ItemDeleteTrigger asChild>
<IconButton variant='ghost' color='fg.muted' size='xs'>
<LuX />
</IconButton>
</ChakraFileUpload.ItemDeleteTrigger>
)}
</ChakraFileUpload.Item>
);
});
interface FileUploadListProps extends VisibilityProps, ChakraFileUpload.ItemGroupProps {
files?: File[];
}
export const FileUploadList = React.forwardRef<HTMLUListElement, FileUploadListProps>(
function FileUploadList(props, ref) {
const { showSize, clearable, files, ...rest } = props;
const fileUpload = useFileUploadContext();
const acceptedFiles = files ?? fileUpload.acceptedFiles;
if (acceptedFiles.length === 0) return null;
return (
<ChakraFileUpload.ItemGroup ref={ref} {...rest}>
{acceptedFiles.map((file) => (
<FileUploadItem key={file.name} file={file} showSize={showSize} clearable={clearable} />
))}
</ChakraFileUpload.ItemGroup>
);
},
);
type Assign<T, U> = Omit<T, keyof U> & U;
interface FileInputProps extends Assign<ButtonProps, RecipeProps<'input'>> {
placeholder?: React.ReactNode;
}
export const FileInput = React.forwardRef<HTMLButtonElement, FileInputProps>(function FileInput(props, ref) {
const inputRecipe = useRecipe({ key: 'input' });
const [recipeProps, restProps] = inputRecipe.splitVariantProps(props);
const { placeholder = 'Select file(s)', ...rest } = restProps;
return (
<ChakraFileUpload.Trigger asChild>
<Button unstyled py='0' ref={ref} {...rest} css={[inputRecipe(recipeProps), props.css]}>
<ChakraFileUpload.Context>
{({ acceptedFiles }) => {
if (acceptedFiles.length === 1) {
return <span>{acceptedFiles[0].name}</span>;
}
if (acceptedFiles.length > 1) {
return <span>{acceptedFiles.length} files</span>;
}
return <Span color='fg.subtle'>{placeholder}</Span>;
}}
</ChakraFileUpload.Context>
</Button>
</ChakraFileUpload.Trigger>
);
});
export const FileUploadLabel = ChakraFileUpload.Label;
export const FileUploadClearTrigger = ChakraFileUpload.ClearTrigger;
export const FileUploadTrigger = ChakraFileUpload.Trigger;
export const FileUploadFileText = ChakraFileUpload.FileText;

View File

@@ -1,50 +0,0 @@
import type { BoxProps, InputElementProps } from '@chakra-ui/react';
import { Group, InputElement } from '@chakra-ui/react';
import * as React from 'react';
export interface InputGroupProps extends BoxProps {
startElementProps?: InputElementProps;
endElementProps?: InputElementProps;
startElement?: React.ReactNode;
endElement?: React.ReactNode;
children: React.ReactElement<InputElementProps>;
startOffset?: InputElementProps['paddingStart'];
endOffset?: InputElementProps['paddingEnd'];
}
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(function InputGroup(props, ref) {
const {
startElement,
startElementProps,
endElement,
endElementProps,
children,
startOffset = '6px',
endOffset = '6px',
...rest
} = props;
const child = React.Children.only<React.ReactElement<InputElementProps>>(children);
return (
<Group ref={ref} {...rest}>
{startElement && (
<InputElement pointerEvents='none' {...startElementProps}>
{startElement}
</InputElement>
)}
{React.cloneElement(child, {
...(startElement && {
ps: `calc(var(--input-height) - ${startOffset})`,
}),
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
...children.props,
})}
{endElement && (
<InputElement placement='end' {...endElementProps}>
{endElement}
</InputElement>
)}
</Group>
);
});

View File

@@ -1,52 +0,0 @@
'use client';
import { NativeSelect as Select } from '@chakra-ui/react';
import * as React from 'react';
interface NativeSelectRootProps extends Select.RootProps {
icon?: React.ReactNode;
}
export const NativeSelectRoot = React.forwardRef<HTMLDivElement, NativeSelectRootProps>(
function NativeSelect(props, ref) {
const { icon, children, ...rest } = props;
return (
<Select.Root ref={ref} {...rest}>
{children}
<Select.Indicator>{icon}</Select.Indicator>
</Select.Root>
);
},
);
interface NativeSelectItem {
value: string;
label: string;
disabled?: boolean;
}
interface NativeSelectFieldProps extends Select.FieldProps {
items?: Array<string | NativeSelectItem>;
}
export const NativeSelectField = React.forwardRef<HTMLSelectElement, NativeSelectFieldProps>(
function NativeSelectField(props, ref) {
const { items: itemsProp, children, ...rest } = props;
const items = React.useMemo(
() => itemsProp?.map((item) => (typeof item === 'string' ? { label: item, value: item } : item)),
[itemsProp],
);
return (
<Select.Field ref={ref} {...rest}>
{children}
{items?.map((item) => (
<option key={item.value} value={item.value} disabled={item.disabled}>
{item.label}
</option>
))}
</Select.Field>
);
},
);

View File

@@ -1,21 +0,0 @@
import { NumberInput as ChakraNumberInput } from '@chakra-ui/react';
import * as React from 'react';
export interface NumberInputProps extends ChakraNumberInput.RootProps {}
export const NumberInputRoot = React.forwardRef<HTMLDivElement, NumberInputProps>(function NumberInput(props, ref) {
const { children, ...rest } = props;
return (
<ChakraNumberInput.Root ref={ref} variant='outline' {...rest}>
{children}
<ChakraNumberInput.Control>
<ChakraNumberInput.IncrementTrigger />
<ChakraNumberInput.DecrementTrigger />
</ChakraNumberInput.Control>
</ChakraNumberInput.Root>
);
});
export const NumberInputField = ChakraNumberInput.Input;
export const NumberInputScrubber = ChakraNumberInput.Scrubber;
export const NumberInputLabel = ChakraNumberInput.Label;

View File

@@ -1,136 +0,0 @@
'use client';
import type { ButtonProps, GroupProps, InputProps, StackProps } from '@chakra-ui/react';
import { Box, HStack, IconButton, Input, InputGroup, Stack, mergeRefs, useControllableState } from '@chakra-ui/react';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { LuEye, LuEyeOff } from 'react-icons/lu';
export interface PasswordVisibilityProps {
/**
* The default visibility state of the password input.
*/
defaultVisible?: boolean;
/**
* The controlled visibility state of the password input.
*/
visible?: boolean;
/**
* Callback invoked when the visibility state changes.
*/
onVisibleChange?: (visible: boolean) => void;
/**
* Custom icons for the visibility toggle button.
*/
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode };
}
export interface PasswordInputProps extends InputProps, PasswordVisibilityProps {
rootProps?: GroupProps;
}
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
...rest
} = props;
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
});
const inputRef = React.useRef<HTMLInputElement>(null);
return (
<InputGroup
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return;
if (e.button !== 0) return;
e.preventDefault();
setVisible(!visible);
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input {...rest} ref={mergeRefs(ref, inputRef)} type={visible ? 'text' : 'password'} />
</InputGroup>
);
});
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me='-2'
aspectRatio='square'
borderRadius='full'
size='sm'
variant='ghost'
height='calc(100% - {spacing.2})'
aria-label='Toggle password visibility'
{...props}
/>
);
});
interface PasswordStrengthMeterProps extends StackProps {
max?: number;
value: number;
}
export const PasswordStrengthMeter = React.forwardRef<HTMLDivElement, PasswordStrengthMeterProps>(
function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props;
const t = useTranslations();
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: t('low'), colorPalette: 'red' };
case percent < 66:
return { label: t('medium'), colorPalette: 'orange' };
default:
return { label: t('high'), colorPalette: 'green' };
}
}
const percent = (value / max) * 100;
const { label, colorPalette } = getColorPalette(percent);
return (
<Stack align='flex-end' gap='1' ref={ref} {...rest}>
<HStack width='full' {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height='1'
flex='1'
rounded='sm'
data-selected={index < value ? '' : undefined}
layerStyle='fill.subtle'
colorPalette='gray'
_selected={{
colorPalette,
layerStyle: 'fill.solid',
}}
/>
))}
</HStack>
{label && <HStack textStyle='xs'>{label}</HStack>}
</Stack>
);
},
);

View File

@@ -1,25 +0,0 @@
import { PinInput as ChakraPinInput, Group } from '@chakra-ui/react';
import * as React from 'react';
export interface PinInputProps extends ChakraPinInput.RootProps {
rootRef?: React.RefObject<HTMLDivElement | null>;
count?: number;
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
attached?: boolean;
}
export const PinInput = React.forwardRef<HTMLInputElement, PinInputProps>(function PinInput(props, ref) {
const { count = 4, inputProps, rootRef, attached, ...rest } = props;
return (
<ChakraPinInput.Root ref={rootRef} {...rest}>
<ChakraPinInput.HiddenInput ref={ref} {...inputProps} />
<ChakraPinInput.Control>
<Group attached={attached}>
{Array.from({ length: count }).map((_, index) => (
<ChakraPinInput.Input key={index} index={index} />
))}
</Group>
</ChakraPinInput.Control>
</ChakraPinInput.Root>
);
});

View File

@@ -1,51 +0,0 @@
import { RadioCard } from '@chakra-ui/react';
import * as React from 'react';
interface RadioCardItemProps extends RadioCard.ItemProps {
icon?: React.ReactElement;
label?: React.ReactNode;
description?: React.ReactNode;
addon?: React.ReactNode;
indicator?: React.ReactNode | null;
indicatorPlacement?: 'start' | 'end' | 'inside';
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
}
export const RadioCardItem = React.forwardRef<HTMLInputElement, RadioCardItemProps>(function RadioCardItem(props, ref) {
const {
inputProps,
label,
description,
addon,
icon,
indicator = <RadioCard.ItemIndicator />,
indicatorPlacement = 'end',
...rest
} = props;
const hasContent = label || description || icon;
const ContentWrapper = indicator ? RadioCard.ItemContent : React.Fragment;
return (
<RadioCard.Item {...rest}>
<RadioCard.ItemHiddenInput ref={ref} {...inputProps} />
<RadioCard.ItemControl>
{indicatorPlacement === 'start' && indicator}
{hasContent && (
<ContentWrapper>
{icon}
{label && <RadioCard.ItemText>{label}</RadioCard.ItemText>}
{description && <RadioCard.ItemDescription>{description}</RadioCard.ItemDescription>}
{indicatorPlacement === 'inside' && indicator}
</ContentWrapper>
)}
{indicatorPlacement === 'end' && indicator}
</RadioCard.ItemControl>
{addon && <RadioCard.ItemAddon>{addon}</RadioCard.ItemAddon>}
</RadioCard.Item>
);
});
export const RadioCardRoot = RadioCard.Root;
export const RadioCardLabel = RadioCard.Label;
export const RadioCardItemIndicator = RadioCard.ItemIndicator;

Some files were not shown because too many files have changed in this diff Show More