main
Some checks failed
UI Deploy (Next-Auth Support) 🎨 / build-and-deploy (push) Has been cancelled
19
.env.example
@@ -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
@@ -1,7 +1,8 @@
|
||||
node_modules
|
||||
|
||||
.next
|
||||
|
||||
certificates
|
||||
|
||||
.env.local
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
387
README.md
@@ -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>
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://chakra-ui.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 you’re looking for doesn’t 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"
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Harun CAN Studio",
|
||||
"description": "",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
6
next-env.d.ts
vendored
@@ -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.
|
||||
@@ -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);
|
||||
10472
package-lock.json
generated
63
package.json
@@ -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
BIN
public/assets/.DS_Store
vendored
|
Before Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -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
33
src/App.tsx
Normal 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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function AboutPage() {
|
||||
return <div>AboutPage</div>;
|
||||
}
|
||||
|
||||
export default AboutPage;
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
BIN
src/app/[locale]/.DS_Store
vendored
@@ -1,7 +0,0 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function Page() {
|
||||
redirect('/home');
|
||||
}
|
||||
BIN
src/app/api/.DS_Store
vendored
@@ -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
|
After Width: | Height: | Size: 420 KiB |
BIN
src/components/.DS_Store
vendored
80
src/components/About.tsx
Normal 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
@@ -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 açı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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/components/Clients.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/Contact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/components/DynamicBackground.tsx
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
41
src/components/Process.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/Services.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
BIN
src/components/ui/.DS_Store
vendored
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||