Compare commits
20 Commits
061e59925c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19a9014f61 | |||
| 2cc52a3fab | |||
| 4c832549f2 | |||
| 3f7369fc86 | |||
| d77046194a | |||
| 6f1d93fc07 | |||
| 01cbaab498 | |||
| 9ac415145d | |||
| 85b618836b | |||
| a073665dc4 | |||
| 288cac4432 | |||
| 8ab553ac6c | |||
| 712a1a2937 | |||
| f26dee782c | |||
| a13c40bfc1 | |||
| dd8dca4efb | |||
| c77682ff46 | |||
| e4b5771b8c | |||
| 66e3f5a3d1 | |||
| 30228bf6b1 |
5
.prettierrc
Normal file
5
.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
87
AGENTS.md
Normal file
87
AGENTS.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to WARP (warp.dev) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
CGR App - A Next.js 16 admin dashboard application with internationalization (i18n) support for English, German, and Russian. Uses React 19, TypeScript, Tailwind CSS v4, and shadcn/ui components.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
# Run dev server via Docker (preferred)
|
||||||
|
make run
|
||||||
|
|
||||||
|
# Or directly with npm
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build & Lint
|
||||||
|
```bash
|
||||||
|
npm run build # Production build
|
||||||
|
npm run lint # ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Operations
|
||||||
|
```bash
|
||||||
|
make run # Start dev container (docker-compose.yml)
|
||||||
|
make stop # Stop containers
|
||||||
|
make logs # View container logs
|
||||||
|
make build # Rebuild container
|
||||||
|
|
||||||
|
# Production deployment (uses docker-compose.prod.yml)
|
||||||
|
make ENV=prod deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Dependencies
|
||||||
|
```bash
|
||||||
|
# Install npm package via Docker
|
||||||
|
make i package=<package-name>
|
||||||
|
|
||||||
|
# Add shadcn/ui component via Docker
|
||||||
|
make ui component=<component-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### App Router Structure
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── layout.tsx # Root layout (pass-through for i18n)
|
||||||
|
├── [locale]/ # Dynamic locale segment (en, de, ru)
|
||||||
|
│ ├── layout.tsx # Main layout with providers (Theme, i18n)
|
||||||
|
│ └── (backend)/ # Route group for admin dashboard
|
||||||
|
│ ├── layout.tsx # Dashboard shell (Sidebar + Header)
|
||||||
|
│ ├── page.tsx # Overview page
|
||||||
|
│ ├── admin/
|
||||||
|
│ └── users/
|
||||||
|
│ └── components/ # Page-specific components
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**Internationalization (next-intl)**
|
||||||
|
- Routing config: `i18n/routing.ts` defines supported locales
|
||||||
|
- Request handler: `i18n/request.ts` loads messages dynamically
|
||||||
|
- Navigation: Use `@/i18n/navigation` exports (`Link`, `useRouter`, `usePathname`) for locale-aware navigation
|
||||||
|
- Messages: JSON files in `messages/` directory (`en.json`, `de.json`, `ru.json`)
|
||||||
|
|
||||||
|
**Component Organization**
|
||||||
|
- `components/ui/` - shadcn/ui primitives (new-york style, RSC enabled)
|
||||||
|
- `components/` - App-specific components (Header, Sidebar, etc.)
|
||||||
|
- Page-specific components live under their route: `app/[locale]/(backend)/users/components/`
|
||||||
|
|
||||||
|
**Providers Hierarchy** (in `app/[locale]/layout.tsx`)
|
||||||
|
```
|
||||||
|
ThemeProvider (next-themes) → NextIntlClientProvider → children
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
- `@/*` maps to project root (e.g., `@/components`, `@/lib/utils`, `@/hooks`)
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- Tailwind CSS v4 with CSS variables for theming
|
||||||
|
- `cn()` utility from `lib/utils.ts` for conditional class merging
|
||||||
|
- Global styles in `app/globals.css`
|
||||||
8
Makefile
8
Makefile
@@ -3,6 +3,8 @@
|
|||||||
ENV ?= dev
|
ENV ?= dev
|
||||||
COMPOSE_FILE = docker-compose$(if $(filter $(ENV),prod),.prod,).yml
|
COMPOSE_FILE = docker-compose$(if $(filter $(ENV),prod),.prod,).yml
|
||||||
COMPOSE = docker compose -f $(COMPOSE_FILE)
|
COMPOSE = docker compose -f $(COMPOSE_FILE)
|
||||||
|
# Service name of the dev container
|
||||||
|
SERVICE_NAME = app
|
||||||
|
|
||||||
.PHONY: up stop build deploy logs
|
.PHONY: up stop build deploy logs
|
||||||
|
|
||||||
@@ -31,3 +33,9 @@ logs:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|
||||||
|
ui:
|
||||||
|
$(COMPOSE) exec $(SERVICE_NAME) npx shadcn@latest add $(component)
|
||||||
|
|
||||||
|
i:
|
||||||
|
$(COMPOSE) exec $(SERVICE_NAME) npm install $(package)
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Header from "@/components/Header/index";
|
|
||||||
import Sidebar from "@/components/Sidebar";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-cmp="DashboardLayout" className="min-h-screen bg-muted/30">
|
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
{/* Mobile Sidebar Overlay */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={() => setSidebarOpen(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`md:pl-64 flex flex-col min-h-screen transition-all duration-300`}>
|
|
||||||
<Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
|
||||||
<main className="flex-1 p-4 md:p-8 animate-in fade-in duration-500">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
62
app/[locale]/(backend)/layout.tsx
Normal file
62
app/[locale]/(backend)/layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Header from "@/components/Header/index";
|
||||||
|
import Sidebar from "@/components/Sidebar";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const getPageTitle = (path: string) => {
|
||||||
|
const pathSegments = path.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
if (pathSegments.length <= 1) return "Overview";
|
||||||
|
const purePath = `/${pathSegments.slice(1).join("/")}`;
|
||||||
|
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
"/users": "Users",
|
||||||
|
"/settings": "Settings",
|
||||||
|
"/profile": "Profile",
|
||||||
|
"/songs": "Songs Database",
|
||||||
|
"/events": "Worship & Events",
|
||||||
|
};
|
||||||
|
|
||||||
|
return titles[purePath] || "Dashboard";
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<Sidebar open={sidebarOpen} close={() => setSidebarOpen(false)} />
|
||||||
|
|
||||||
|
{/* Mobile Sidebar Overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex min-h-screen flex-col transition-all duration-300 md:pl-64`}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
pageTitle={getPageTitle(pathname)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="animate-in fade-in flex-1 p-4 duration-500 md:p-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
32
app/[locale]/(backend)/lib/dailyVerseData.ts
Normal file
32
app/[locale]/(backend)/lib/dailyVerseData.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Local data storage for development without DB
|
||||||
|
// Each object represents a verse for a specific day
|
||||||
|
export interface localizedContent {
|
||||||
|
en: string;
|
||||||
|
de: string;
|
||||||
|
ru: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BibleVerse {
|
||||||
|
reference: string;
|
||||||
|
content: localizedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bibleVerses: BibleVerse[] = [
|
||||||
|
{
|
||||||
|
reference: "Epheser 2:19-22",
|
||||||
|
content: {
|
||||||
|
en: "So then you are no longer strangers and aliens, but you are fellow citizens with the saints and members of the household of God, built on the foundation of the apostles and prophets, Christ Jesus himself being the cornerstone, in whom the whole structure, being joined together, grows into a holy temple in the Lord. In him you also are being built together into a dwelling place for God by the Spirit.",
|
||||||
|
de: "So seid ihr nun nicht mehr Gäste und Fremdlinge, sondern Mitbürger der Heiligen und Gottes Hausgenossen, erbaut auf den Grund der Apostel und Propheten, da Jesus Christus der Eckstein ist, auf welchem der ganze Bau ineinandergefügt wächst zu einem heiligen Tempel in dem Herrn. Durch ihn werdet auch ihr mit erbaut zu einer Wohnung Gottes im Geist.",
|
||||||
|
ru: "Итак, вы уже не чужие и не пришельцы, но сограждане святым и свои Богу, быв утверждены на основании Апостолов и пророков, имея Самого Иисуса Христа краеугольным камнем, на котором всё здание, слагаясь стройно, возрастает в святой храм в Господе, на котором и вы устрояетесь в жилище Божие Духом."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: "Psalm 23:1",
|
||||||
|
content: {
|
||||||
|
en: "The Lord is my shepherd...",
|
||||||
|
de: "Der Herr ist mein Hirte...",
|
||||||
|
ru: "Господь — Пастырь мой..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Add as many as you want here
|
||||||
|
];
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import ModuleCard from "@/components/ModuleCard";
|
import ModuleCard from "@/components/ModuleCard";
|
||||||
import StatCard from "@/components/StatCard";
|
import StatCard from "@/components/StatCard";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ModuleCategory } from "@/lib/types/dashboard";
|
import { ModuleCategory } from "@/lib/types/dashboard";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -20,9 +21,28 @@ import {
|
|||||||
UserCircle,
|
UserCircle,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { bibleVerses, localizedContent } from "./lib/dailyVerseData";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
|
// 1. Get a unique number for the current day
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0); // Normalize to midnight
|
||||||
|
const dayTimestamp = today.getTime();
|
||||||
|
|
||||||
|
// 2. Use the timestamp to get a consistent index
|
||||||
|
// The modulo (%) operator ensures the index is always within array bounds
|
||||||
|
const verseIndex = dayTimestamp % bibleVerses.length;
|
||||||
|
const todayVerse = bibleVerses[verseIndex];
|
||||||
|
const params = useParams();
|
||||||
|
const lng = params.locale as keyof localizedContent || "en"; // Default to English if no locale is provided
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
|
|
||||||
const categories: ModuleCategory[] = [
|
const categories: ModuleCategory[] = [
|
||||||
{
|
{
|
||||||
title: "People & Community",
|
title: "People & Community",
|
||||||
@@ -51,7 +71,8 @@ const Dashboard: React.FC = () => {
|
|||||||
{
|
{
|
||||||
id: "addresses",
|
id: "addresses",
|
||||||
title: "Addresses",
|
title: "Addresses",
|
||||||
description: "Database of addresses for registered users and locations.",
|
description:
|
||||||
|
"Database of addresses for registered users and locations.",
|
||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
path: "/addresses",
|
path: "/addresses",
|
||||||
},
|
},
|
||||||
@@ -63,14 +84,16 @@ const Dashboard: React.FC = () => {
|
|||||||
{
|
{
|
||||||
id: "services",
|
id: "services",
|
||||||
title: "Services & Lectures",
|
title: "Services & Lectures",
|
||||||
description: "Schedule and manage church services and lecture events.",
|
description:
|
||||||
|
"Schedule and manage church services and lecture events.",
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
path: "/services",
|
path: "/services",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lectures_list",
|
id: "lectures_list",
|
||||||
title: "Lectures List",
|
title: "Lectures List",
|
||||||
description: "Browse the complete archive of lectures from all communities.",
|
description:
|
||||||
|
"Browse the complete archive of lectures from all communities.",
|
||||||
icon: Mic2,
|
icon: Mic2,
|
||||||
path: "/lectures",
|
path: "/lectures",
|
||||||
},
|
},
|
||||||
@@ -136,7 +159,8 @@ const Dashboard: React.FC = () => {
|
|||||||
{
|
{
|
||||||
id: "types",
|
id: "types",
|
||||||
title: "Lecture Types",
|
title: "Lecture Types",
|
||||||
description: "Define and categorize different types of presentations.",
|
description:
|
||||||
|
"Define and categorize different types of presentations.",
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
path: "/types",
|
path: "/types",
|
||||||
},
|
},
|
||||||
@@ -159,45 +183,76 @@ const Dashboard: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-cmp="Dashboard" className="max-w-7xl mx-auto space-y-8">
|
<div className="mx-auto max-w-7xl space-y-8">
|
||||||
{/* Welcome Section */}
|
{/* Welcome Section */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div data-cmp="ModuleCard" className="group bg-card border border-border rounded-xl p-6 h-full overflow-hidden">
|
||||||
<div>
|
<div className="animate-in items-baseline fade-in slide-in-from-bottom-4 duration-700 flex flex-col sm:flex-row grow pr-6 gap-2">
|
||||||
<h1 className="text-3xl font-bold text-foreground tracking-tight">Community Dashboard</h1>
|
<span className="text-2xl md:text-3xl text-nowrap font-semibold leading-none">{t("greeting_name", { user: "Alexander" })}</span>
|
||||||
<p className="text-muted-foreground mt-1">
|
<span className="text-xl font-semibold text-nowrap leading-none"> {t("greeting")}</span>
|
||||||
Welcome to the admin area. Manage your community resources safely.
|
</div>
|
||||||
</p>
|
<div className="flex-col mt-4">
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">{t("verse_intro")}</span> <br />
|
||||||
|
<i className="italic text-foreground">"{todayVerse.content[lng]}"</i>
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<Badge variant="outline" className="rounded-sm self-start">
|
||||||
|
{todayVerse.reference}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
|
||||||
<span className="text-sm px-3 py-1 bg-primary/10 text-primary rounded-full font-medium">v2.4.0 Live</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
<StatCard label="Total Members" value="2,845" icon={Users} trend="up" change="12% this month" />
|
<StatCard
|
||||||
<StatCard label="Upcoming Services" value="24" icon={Calendar} trend="neutral" change="Next 7 days" />
|
label="Total Members"
|
||||||
<StatCard label="Songs Database" value="1,430" icon={Music} trend="up" change="5 new added" />
|
value="2,845"
|
||||||
|
icon={Users}
|
||||||
|
trend="up"
|
||||||
|
change="12% this month"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Upcoming Services"
|
||||||
|
value="24"
|
||||||
|
icon={Calendar}
|
||||||
|
trend="neutral"
|
||||||
|
change="Next 7 days"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Songs Database"
|
||||||
|
value="1,430"
|
||||||
|
icon={Music}
|
||||||
|
trend="up"
|
||||||
|
change="5 new added"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category.title} className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div
|
||||||
<div className="flex items-center space-x-2 mb-6">
|
key={category.title}
|
||||||
<h2 className="text-xl font-semibold text-foreground border-l-4 border-primary pl-3">{category.title}</h2>
|
className="animate-in fade-in slide-in-from-bottom-4 duration-700"
|
||||||
<div className="h-px bg-border flex-1 ml-4 opacity-50"></div>
|
>
|
||||||
|
<div className="mb-6 flex items-center space-x-2">
|
||||||
|
<h2 className="text-foreground border-primary border-l-4 pl-3 text-xl font-semibold">
|
||||||
|
{category.title}
|
||||||
|
</h2>
|
||||||
|
<div className="bg-border ml-4 h-px flex-1 opacity-50"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{category.items.map((item) => (
|
{category.items.map((item) => (
|
||||||
|
<Link key={item.id} href={item.path} className="block">
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
onClick={() => console.log(`Navigating to ${item.path}`)}
|
//onClick={() => console.log(`Navigating to ${item.path}`)}
|
||||||
/>
|
/>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
136
app/[locale]/(backend)/users/components/UserForm/index.tsx
Normal file
136
app/[locale]/(backend)/users/components/UserForm/index.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
|
||||||
|
// Shadcn UI components
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
|
||||||
|
// 1. Schema definition
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address."),
|
||||||
|
role: z.string().min(1, "Please select a role."), // Rollenbezeichnung
|
||||||
|
status: z.boolean().default(true), // Status (Active/Inactive)
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters."),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
export default function UserForm() {
|
||||||
|
// 2. Initialize the form
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
role: "user",
|
||||||
|
status: true,
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Define the submit handler
|
||||||
|
function onSubmit(values: FormValues) {
|
||||||
|
// All comments in code must be in English
|
||||||
|
// Later we will connect this to a Server Action
|
||||||
|
console.log("Form values:", values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-4">
|
||||||
|
{/* Email Field */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="example@mail.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Role Select Field */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role (Rollenbezeichnung)</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="editor">Editor</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status Switch Field */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Active Status</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">Save User</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
app/[locale]/(backend)/users/components/UserFormWrapper.tsx
Normal file
73
app/[locale]/(backend)/users/components/UserFormWrapper.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer"
|
||||||
|
import UserForm from "./UserForm"
|
||||||
|
|
||||||
|
export function UserFormWrapper() {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
|
// Common button content
|
||||||
|
const TriggerButton = (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-secondary hover:bg-secondary/90 border border-border rounded-lg cursor-pointer h-10"
|
||||||
|
>
|
||||||
|
<Plus className="text-muted-foreground size-5 md:mr-2" />
|
||||||
|
<span className="hidden md:inline text-sm text-muted-foreground">
|
||||||
|
Add New User
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{TriggerButton}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-106.25 bg-card text-white border-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<UserForm />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
{TriggerButton}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="bg-card border-border text-white max-h-[96dvh]">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>Add New User</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="px-4 overflow-y-auto pb-4">
|
||||||
|
<UserForm />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { MoreVertical, Mail, Shield, Calendar } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface UserMobileCardProps {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
joinedAt?: string;
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserMobileCard: React.FC<UserMobileCardProps> = ({ name, email, role, joinedAt, status }) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 shadow-sm md:hidden active:scale-[0.99] transition-all">
|
||||||
|
{/* Header section */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar will be here */}
|
||||||
|
<div className="size-11 rounded-full bg-primary/10 flex items-center justify-center border border-primary/20">
|
||||||
|
<span className="text-primary font-bold text-sm">
|
||||||
|
{name.split(" ").map(n => n[0]).join("")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-foreground leading-none">{name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||||
|
<Mail className="size-3" />
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant={status === "active" ? "default" : "secondary"}
|
||||||
|
className="text-[10px] px-2 py-0 h-5"
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer section: role and options */}
|
||||||
|
<div className="flex items-center justify-between mt-5 pt-3 border-t border-border/50">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Shield className="size-3 text-primary/70" />
|
||||||
|
<span>Role: <b className="text-foreground font-medium">{role}</b></span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/80">
|
||||||
|
<Calendar className="size-3" />
|
||||||
|
<span>Joined: {joinedAt}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
185
app/[locale]/(backend)/users/page.tsx
Normal file
185
app/[locale]/(backend)/users/page.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Filter, MoreVertical, Search } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UserFormWrapper } from "./components/UserFormWrapper";
|
||||||
|
import { UserMobileCard } from "./components/UserMobileCard";
|
||||||
|
|
||||||
|
// Temporary user data for demonstration
|
||||||
|
const tempUsers = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "John Doe",
|
||||||
|
email: "john@example.com",
|
||||||
|
role: "Admin",
|
||||||
|
joinedAt: "2023-01-01",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Alice Smith",
|
||||||
|
email: "alice@test.com",
|
||||||
|
role: "Editor",
|
||||||
|
joinedAt: "2023-02-15",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Bob Johnson",
|
||||||
|
email: "bob@community.com",
|
||||||
|
role: "Member",
|
||||||
|
joinedAt: "2023-03-10",
|
||||||
|
status: "inactive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Charlie Brown",
|
||||||
|
email: "charlie@community.com",
|
||||||
|
role: "Member",
|
||||||
|
joinedAt: "2023-04-20",
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const t = useTranslations("users");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-6 p-0 md:gap-10">
|
||||||
|
{/* Header: Title + Button for Desktop only */}
|
||||||
|
<div className="hidden items-center justify-between md:flex">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-white">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-3 hidden max-w-2xl text-lg md:block">
|
||||||
|
{t("welcome")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Button: Add New User for Desktop only */}
|
||||||
|
<UserFormWrapper />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Filter */}
|
||||||
|
<div className="flex gap-3 md:gap-4">
|
||||||
|
<div className="group relative w-full">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
className="bg-card border-border h-10 w-full rounded-lg border py-1 pr-12 pl-10 focus:outline-1"
|
||||||
|
placeholder="Search users..."
|
||||||
|
/>
|
||||||
|
<div className="absolute top-1/2 right-2 -translate-y-1/2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="hover:bg-muted text-muted-foreground size-8 transition-colors hover:text-white md:size-10"
|
||||||
|
>
|
||||||
|
<Filter className="size-4 md:size-5" />
|
||||||
|
<span className="sr-only">Filters</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Button: Add New User for Mobile only */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<UserFormWrapper />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- For Mobile Phones --- */}
|
||||||
|
<div className="flex flex-col gap-4 md:hidden">
|
||||||
|
{tempUsers.map((user) => (
|
||||||
|
<UserMobileCard
|
||||||
|
key={user.id}
|
||||||
|
name={user.name}
|
||||||
|
email={user.email}
|
||||||
|
role={user.role}
|
||||||
|
joinedAt={user.joinedAt}
|
||||||
|
status={user.status as "active" | "inactive"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users List / Table */}
|
||||||
|
<div className="bg-card border-border hidden overflow-hidden rounded-xl border shadow-sm md:block">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead className="bg-muted/50 border-border border-b">
|
||||||
|
<tr>
|
||||||
|
<th className="text-muted-foreground px-6 py-3 text-xs font-semibold tracking-wider uppercase">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="text-muted-foreground px-6 py-3 text-xs font-semibold tracking-wider uppercase">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="text-muted-foreground px-6 py-3 text-xs font-semibold tracking-wider uppercase">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="text-muted-foreground px-6 py-3 text-xs font-semibold tracking-wider uppercase">
|
||||||
|
Joined
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-border divide-y">
|
||||||
|
{/* Example Row */}
|
||||||
|
{tempUsers.map((user) => (
|
||||||
|
<tr
|
||||||
|
key={user.id}
|
||||||
|
className="hover:bg-muted/20 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-primary/20 text-primary flex h-10 w-10 items-center justify-center rounded-full font-bold">
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-foreground text-sm font-medium">
|
||||||
|
{user.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
user.status === "active"
|
||||||
|
? "bg-green-500/10 text-green-500"
|
||||||
|
: "bg-red-500/10 text-red-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground px-6 py-4 text-sm">
|
||||||
|
{user.joinedAt}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button
|
||||||
|
title="More options"
|
||||||
|
className="hover:bg-muted rounded-full p-2 transition-colors"
|
||||||
|
>
|
||||||
|
<MoreVertical className="text-muted-foreground h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/[locale]/layout.tsx
Normal file
45
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CGR App",
|
||||||
|
description: "Manager for CGR Data",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale?: string }>;
|
||||||
|
}>) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale || routing.defaultLocale} suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
<ThemeProvider attribute="class" enableSystem>
|
||||||
|
<NextIntlClientProvider locale={locale || routing.defaultLocale}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
421
app/globals.css
421
app/globals.css
@@ -44,72 +44,114 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.125rem;
|
||||||
--background: oklch(1 0 0);
|
/* ---------- Base background (warm light) ---------- */
|
||||||
--foreground: oklch(0.129 0.042 264.695);
|
--background: oklch(0.97 0.01 95);
|
||||||
--card: oklch(1 0 0);
|
--foreground: oklch(0.22 0.02 30);
|
||||||
--card-foreground: oklch(0.129 0.042 264.695);
|
|
||||||
--popover: oklch(1 0 0);
|
/* ---------- Surfaces ---------- */
|
||||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
--card: oklch(0.99 0.005 95);
|
||||||
--primary: oklch(0.208 0.042 265.755);
|
--card-foreground: oklch(0.22 0.02 30);
|
||||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
|
||||||
--secondary: oklch(0.968 0.007 247.896);
|
--popover: oklch(1 0.004 95);
|
||||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
--popover-foreground: oklch(0.22 0.02 30);
|
||||||
--muted: oklch(0.968 0.007 247.896);
|
|
||||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
/* ---------- Ubuntu orange primary ---------- */
|
||||||
--accent: oklch(0.968 0.007 247.896);
|
--primary: oklch(0.62 0.2 45);
|
||||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
--primary-foreground: oklch(0.99 0.005 95);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.929 0.013 255.508);
|
/* ---------- Secondary / muted warm grays ---------- */
|
||||||
--input: oklch(0.929 0.013 255.508);
|
--secondary: oklch(0.92 0.01 90);
|
||||||
--ring: oklch(0.704 0.04 256.788);
|
--secondary-foreground: oklch(0.3 0.02 35);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--muted: oklch(0.94 0.008 90);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--muted-foreground: oklch(0.45 0.015 35);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--accent: oklch(0.9 0.015 85);
|
||||||
--sidebar: oklch(0.984 0.003 247.858);
|
--accent-foreground: oklch(0.3 0.02 35);
|
||||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
|
||||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
/* ---------- Destructive ---------- */
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--destructive: oklch(0.58 0.23 25);
|
||||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
|
||||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
/* ---------- Borders / inputs ---------- */
|
||||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
--border: oklch(0.85 0.01 90);
|
||||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
--input: oklch(0.88 0.01 90);
|
||||||
|
--ring: oklch(0.62 0.2 45 / 0.5);
|
||||||
|
|
||||||
|
/* ---------- Charts ---------- */
|
||||||
|
--chart-1: oklch(0.62 0.2 45); /* orange */
|
||||||
|
--chart-2: oklch(0.55 0.16 150); /* green */
|
||||||
|
--chart-3: oklch(0.7 0.17 85); /* yellow */
|
||||||
|
--chart-4: oklch(0.6 0.18 300); /* purple */
|
||||||
|
--chart-5: oklch(0.6 0.2 20); /* red */
|
||||||
|
|
||||||
|
/* ---------- Sidebar ---------- */
|
||||||
|
--sidebar: oklch(0.95 0.01 90);
|
||||||
|
--sidebar-foreground: oklch(0.28 0.02 30);
|
||||||
|
|
||||||
|
--sidebar-primary: oklch(0.62 0.2 45);
|
||||||
|
--sidebar-primary-foreground: oklch(0.99 0.005 95);
|
||||||
|
|
||||||
|
--sidebar-accent: oklch(0.9 0.015 85);
|
||||||
|
--sidebar-accent-foreground: oklch(0.3 0.02 35);
|
||||||
|
|
||||||
|
--sidebar-border: oklch(0.85 0.01 90);
|
||||||
|
--sidebar-ring: oklch(0.62 0.2 45 / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.129 0.042 264.695);
|
/* ---------- Base background (warm charcoal) ---------- */
|
||||||
--foreground: oklch(0.984 0.003 247.858);
|
--background: oklch(0.11 0.015 30);
|
||||||
--card: oklch(0.208 0.042 265.755);
|
--foreground: oklch(0.97 0.01 95);
|
||||||
--card-foreground: oklch(0.984 0.003 247.858);
|
|
||||||
--popover: oklch(0.208 0.042 265.755);
|
/* ---------- Surfaces ---------- */
|
||||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
--card: oklch(0.16 0.018 32);
|
||||||
--primary: oklch(0.929 0.013 255.508);
|
--card-foreground: oklch(0.97 0.01 95);
|
||||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
|
||||||
--secondary: oklch(0.279 0.041 260.031);
|
--popover: oklch(0.18 0.02 32);
|
||||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
--popover-foreground: oklch(0.97 0.01 95);
|
||||||
--muted: oklch(0.279 0.041 260.031);
|
|
||||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
/* ---------- Ubuntu orange primary ---------- */
|
||||||
--accent: oklch(0.279 0.041 260.031);
|
--primary: oklch(0.68 0.19 45);
|
||||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
--primary-foreground: oklch(0.98 0.01 95);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
/* ---------- Secondary / muted warm grays ---------- */
|
||||||
--input: oklch(1 0 0 / 15%);
|
--secondary: oklch(0.22 0.02 30);
|
||||||
--ring: oklch(0.551 0.027 264.364);
|
--secondary-foreground: oklch(0.92 0.015 95);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--muted: oklch(0.22 0.02 30);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--muted-foreground: oklch(0.68 0.02 90);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--accent: oklch(0.26 0.025 35);
|
||||||
--sidebar: oklch(0.208 0.042 265.755);
|
--accent-foreground: oklch(0.95 0.01 95);
|
||||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
/* ---------- Destructive (Ubuntu red tone) ---------- */
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--destructive: oklch(0.62 0.22 25);
|
||||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
|
||||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
/* ---------- Borders / inputs ---------- */
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--border: oklch(0.3 0.015 35 / 0.6);
|
||||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
--input: oklch(0.3 0.015 35 / 0.8);
|
||||||
|
--ring: oklch(0.68 0.19 45 / 0.6);
|
||||||
|
|
||||||
|
/* ---------- Charts ---------- */
|
||||||
|
--chart-1: oklch(0.68 0.19 45); /* orange */
|
||||||
|
--chart-2: oklch(0.7 0.15 150); /* green */
|
||||||
|
--chart-3: oklch(0.78 0.16 85); /* yellow */
|
||||||
|
--chart-4: oklch(0.66 0.18 300); /* purple */
|
||||||
|
--chart-5: oklch(0.7 0.18 20); /* red */
|
||||||
|
|
||||||
|
/* ---------- Sidebar (slightly darker & warmer) ---------- */
|
||||||
|
--sidebar: oklch(0.14 0.018 30);
|
||||||
|
--sidebar-foreground: oklch(0.95 0.01 95);
|
||||||
|
|
||||||
|
--sidebar-primary: oklch(0.68 0.19 45);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0.01 95);
|
||||||
|
|
||||||
|
--sidebar-accent: oklch(0.24 0.02 32);
|
||||||
|
--sidebar-accent-foreground: oklch(0.95 0.01 95);
|
||||||
|
|
||||||
|
--sidebar-border: oklch(0.3 0.015 35 / 0.5);
|
||||||
|
--sidebar-ring: oklch(0.68 0.19 45 / 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === CUSTOM COLOR THEME === */
|
/* === CUSTOM COLOR THEME === */
|
||||||
@@ -118,263 +160,349 @@
|
|||||||
--color-gray-900: #050505;
|
--color-gray-900: #050505;
|
||||||
--color-gray-800: #141414;
|
--color-gray-800: #141414;
|
||||||
--color-gray-700: #212328;
|
--color-gray-700: #212328;
|
||||||
--color-gray-600: #30333A;
|
--color-gray-600: #30333a;
|
||||||
--color-gray-500: #9095A1;
|
--color-gray-500: #9095a1;
|
||||||
--color-gray-400: #CCDADC;
|
--color-gray-400: #ccdadc;
|
||||||
|
--color-gray-100: #fafafa;
|
||||||
|
|
||||||
/* Vibrant Colors */
|
/* Vibrant Colors */
|
||||||
--color-blue-600: #5862FF;
|
--color-blue-600: #5862ff;
|
||||||
--color-yellow-400: #FDD458;
|
--color-yellow-400: #fdd458;
|
||||||
--color-yellow-500: #E8BA40;
|
--color-yellow-500: #e8ba40;
|
||||||
--color-teal-400: #0FEDBE;
|
--color-teal-400: #0fedbe;
|
||||||
--color-red-500: #FF495B;
|
--color-red-500: #ff495b;
|
||||||
--color-orange-500: #FF8243;
|
--color-orange-500: #ff8243;
|
||||||
--color-purple-500: #D13BFF;
|
--color-purple-500: #d13bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-900 text-foreground;
|
@apply text-foreground bg-gray-900;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.dashboard {
|
||||||
|
@apply min-h-screen bg-gray-100 dark:bg-gray-900;
|
||||||
|
}
|
||||||
.container {
|
.container {
|
||||||
@apply mx-auto max-w-screen-2xl px-4 md:px-6 lg:px-8;
|
@apply mx-auto max-w-screen-2xl px-4 md:px-6 lg:px-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yellow-btn {
|
.yellow-btn {
|
||||||
@apply h-12 cursor-pointer bg-gradient-to-b from-yellow-400 to-yellow-500 hover:from-yellow-500 hover:to-yellow-400 text-gray-950 font-medium text-base rounded-lg shadow-lg disabled:opacity-50;
|
@apply h-12 cursor-pointer rounded-lg bg-gradient-to-b from-yellow-400 to-yellow-500 text-base font-medium text-gray-950 shadow-lg hover:from-yellow-500 hover:to-yellow-400 disabled:opacity-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-wrapper {
|
.home-wrapper {
|
||||||
@apply text-gray-400 flex-col gap-4 md:gap-10 items-center sm:items-start;
|
@apply flex-col items-center gap-4 text-gray-400 sm:items-start md:gap-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-section {
|
.home-section {
|
||||||
@apply w-full gap-8 grid-cols-1 md:grid-cols-2 xl:grid-cols-3;
|
@apply w-full grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@apply z-50 w-full h-[70px] bg-gray-800;
|
@apply z-50 bg-white dark:bg-gray-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.side-bar {
|
||||||
|
@apply z-50 bg-white dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
.header-wrapper {
|
.header-wrapper {
|
||||||
@apply flex justify-between items-center px-6 py-4 text-gray-500;
|
@apply flex items-center justify-between px-6 py-4 text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-layout {
|
.auth-layout {
|
||||||
@apply flex flex-col justify-between lg:flex-row h-screen bg-gray-900 relative overflow-hidden;
|
@apply relative flex h-screen flex-col justify-between overflow-hidden bg-gray-900 lg:flex-row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-logo {
|
.auth-logo {
|
||||||
@apply pt-6 lg:pt-8 mb-8 lg:mb-12;
|
@apply mb-8 pt-6 lg:mb-12 lg:pt-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-left-section {
|
.auth-left-section {
|
||||||
@apply w-full lg:w-[45%] lg:h-screen px-6 lg:px-16 flex flex-col overflow-y-auto;
|
@apply flex w-full flex-col overflow-y-auto px-6 lg:h-screen lg:w-[45%] lg:px-16;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-right-section {
|
.auth-right-section {
|
||||||
@apply w-full max-lg:border-t max-lg:border-gray-600 lg:w-[55%] lg:h-screen bg-gray-800 px-6 py-4 md:p-6 lg:py-12 lg:px-18 flex flex-col justify-start;
|
@apply flex w-full flex-col justify-start bg-gray-800 px-6 py-4 max-lg:border-t max-lg:border-gray-600 md:p-6 lg:h-screen lg:w-[55%] lg:px-18 lg:py-12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-blockquote {
|
.auth-blockquote {
|
||||||
@apply text-sm md:text-xl lg:text-2xl font-medium text-gray-400 mb-1 md:mb-6 lg:mb-8;
|
@apply mb-1 text-sm font-medium text-gray-400 md:mb-6 md:text-xl lg:mb-8 lg:text-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-testimonial-author {
|
.auth-testimonial-author {
|
||||||
@apply text-xs md:text-lg font-bold text-gray-400 not-italic;
|
@apply text-xs font-bold text-gray-400 not-italic md:text-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-dashboard-preview {
|
.auth-dashboard-preview {
|
||||||
@apply border-6 border-gray-800 left-0 hidden w-[1024px] h-auto max-w-none lg:block rounded-xl shadow-2xl;
|
@apply left-0 hidden h-auto w-[1024px] max-w-none rounded-xl border-6 border-gray-800 shadow-2xl lg:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-title {
|
.form-title {
|
||||||
@apply text-4xl font-bold text-gray-400 mb-10;
|
@apply mb-10 text-4xl font-bold text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
@apply text-sm font-medium text-gray-400;
|
@apply text-sm font-medium text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
@apply h-12 px-3 py-3 text-white text-base placeholder:text-gray-500 border-gray-600 bg-gray-800 rounded-lg focus:!border-yellow-500 focus:ring-0;
|
@apply rounded-lg !border-yellow-500 border-gray-600 bg-gray-800 px-3 py-3 text-base text-white placeholder:text-gray-500 focus:h-12 focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-trigger {
|
.select-trigger {
|
||||||
@apply w-full !h-12 px-3 py-3 text-base border-gray-600 bg-gray-800 text-white rounded-lg focus:!border-yellow-500 focus:ring-0;
|
@apply w-full rounded-lg !border-yellow-500 border-gray-600 bg-gray-800 px-3 py-3 text-base text-white focus:!h-12 focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-select-trigger {
|
.country-select-trigger {
|
||||||
@apply h-12 px-3 py-3 text-base w-full justify-between font-normal border-gray-600 bg-gray-800 text-gray-400 rounded-lg focus:!border-yellow-500 focus:ring-0;
|
@apply w-full justify-between rounded-lg !border-yellow-500 border-gray-600 bg-gray-800 px-3 py-3 text-base font-normal text-gray-400 focus:h-12 focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-select-input {
|
.country-select-input {
|
||||||
@apply !bg-gray-800 text-gray-400 border-0 border-b border-gray-600 rounded-none focus:ring-0 placeholder:text-gray-500;
|
@apply rounded-none border-0 border-b border-gray-600 !bg-gray-800 text-gray-400 placeholder:text-gray-500 focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-select-empty {
|
.country-select-empty {
|
||||||
@apply text-gray-500 py-6 text-center !bg-gray-800;
|
@apply !bg-gray-800 py-6 text-center text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.country-select-item {
|
.country-select-item {
|
||||||
@apply text-white cursor-pointer px-3 py-2 rounded-sm bg-gray-800 hover:!bg-gray-600;
|
@apply rounded-sm !bg-gray-600 bg-gray-800 px-3 py-2 text-white hover:cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-link {
|
.footer-link {
|
||||||
@apply text-gray-400 font-medium hover:text-yellow-400 hover:underline transition-colors;
|
@apply font-medium text-gray-400 transition-colors hover:text-yellow-400 hover:underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-text {
|
.search-text {
|
||||||
@apply cursor-pointer hover:text-yellow-500;
|
@apply cursor-pointer hover:text-yellow-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn {
|
.search-btn {
|
||||||
@apply cursor-pointer px-4 py-2 w-fit flex items-center gap-2 text-sm md:text-base bg-yellow-500 hover:bg-yellow-500 text-black font-medium rounded;
|
@apply flex w-fit cursor-pointer items-center gap-2 rounded bg-yellow-500 px-4 py-2 text-sm font-medium text-black hover:bg-yellow-500 md:text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-dialog {
|
.search-dialog {
|
||||||
@apply !bg-gray-800 lg:min-w-[800px] border-gray-600 fixed top-10 left-1/2 -translate-x-1/2 translate-y-10;
|
@apply fixed top-10 left-1/2 -translate-x-1/2 translate-y-10 border-gray-600 !bg-gray-800 lg:min-w-[800px];
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-field {
|
.search-field {
|
||||||
@apply !bg-gray-800 border-b border-gray-600 relative;
|
@apply relative border-b border-gray-600 !bg-gray-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-list {
|
.search-list {
|
||||||
@apply !bg-gray-800 max-h-[400px];
|
@apply max-h-[400px] !bg-gray-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-list-indicator {
|
.search-list-indicator {
|
||||||
@apply px-5 py-2
|
@apply px-5 py-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-list-empty {
|
.search-list-empty {
|
||||||
@apply py-6 !bg-transparent text-center text-gray-500;
|
@apply !bg-transparent py-6 text-center text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
@apply !bg-gray-800 border-0 text-gray-400 placeholder:text-gray-500 focus:ring-0 text-base h-14 pr-10;
|
@apply h-14 border-0 !bg-gray-800 pr-10 text-base text-gray-400 placeholder:text-gray-500 focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-loader {
|
.search-loader {
|
||||||
@apply absolute right-12 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 animate-spin;
|
@apply absolute top-1/2 right-12 h-4 w-4 -translate-y-1/2 animate-spin text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-count {
|
.search-count {
|
||||||
@apply py-2 px-4 text-sm font-medium text-gray-400 bg-gray-700 border-b border-gray-700;
|
@apply border-b border-gray-700 bg-gray-700 px-4 py-2 text-sm font-medium text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-item {
|
.search-item {
|
||||||
@apply rounded-none my-3 px-1 w-full data-[selected=true]:bg-gray-600;
|
@apply my-3 w-full rounded-none px-1 data-[selected=true]:bg-gray-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-item-link {
|
.search-item-link {
|
||||||
@apply px-2 w-full cursor-pointer border-b border-gray-600 last:border-b-0 transition-colors flex items-center gap-3;
|
@apply flex w-full cursor-pointer items-center gap-3 border-b border-gray-600 px-2 transition-colors last:border-b-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-item-name {
|
.search-item-name {
|
||||||
@apply font-medium text-base text-gray-400;
|
@apply text-base font-medium text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-list {
|
.nav-list {
|
||||||
@apply flex flex-col sm:flex-row p-2 gap-3 sm:gap-10 font-medium;
|
@apply flex flex-col gap-3 p-2 font-medium sm:flex-row sm:gap-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stock-details-container {
|
.stock-details-container {
|
||||||
@apply w-full grid-cols-1 gap-6 xl:grid-cols-3 space-y-6 sm:space-y-8;
|
@apply w-full grid-cols-1 gap-6 space-y-6 sm:space-y-8 xl:grid-cols-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-btn {
|
.watchlist-btn {
|
||||||
@apply bg-yellow-500 text-base hover:bg-yellow-500 text-gray-900 w-full rounded h-11 font-semibold cursor-pointer;
|
@apply h-11 w-full cursor-pointer rounded bg-yellow-500 text-base font-semibold text-gray-900 hover:bg-yellow-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-remove {
|
.watchlist-remove {
|
||||||
@apply bg-red-500! hover:bg-red-500! text-gray-900!
|
@apply bg-red-500! text-gray-900! hover:bg-red-500!;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-empty-container {
|
.watchlist-empty-container {
|
||||||
@apply container gap-8 flex-col items-center md:mt-10 p-6 text-center;
|
@apply container flex-col items-center gap-8 p-6 text-center md:mt-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-empty {
|
.watchlist-empty {
|
||||||
@apply flex flex-col items-center justify-center text-center;
|
@apply flex flex-col items-center justify-center text-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-star {
|
.watchlist-star {
|
||||||
@apply h-16 w-16 text-gray-500 mb-4;
|
@apply mb-4 h-16 w-16 text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-title {
|
.empty-title {
|
||||||
@apply text-xl font-semibold text-gray-400 mb-2;
|
@apply mb-2 text-xl font-semibold text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-description {
|
.empty-description {
|
||||||
@apply text-gray-500 mb-6 max-w-md;
|
@apply mb-6 max-w-md text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-container {
|
.watchlist-container {
|
||||||
@apply flex flex-col-reverse lg:grid lg:grid-cols-3 gap-8;
|
@apply flex flex-col-reverse gap-8 lg:grid lg:grid-cols-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist {
|
.watchlist {
|
||||||
@apply lg:col-span-2 space-y-8;
|
@apply space-y-8 lg:col-span-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-alerts {
|
.watchlist-alerts {
|
||||||
@apply items-start gap-6 h-full flex-col w-full lg:col-span-1;
|
@apply h-full w-full flex-col items-start gap-6 lg:col-span-1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-icon-btn {
|
.watchlist-icon-btn {
|
||||||
@apply w-fit cursor-pointer hover:bg-transparent! text-gray-400 hover:text-yellow-500;
|
@apply w-fit cursor-pointer text-gray-400 hover:bg-transparent! hover:text-yellow-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-icon-added {
|
.watchlist-icon-added {
|
||||||
@apply !text-yellow-500 hover:!text-yellow-600;
|
@apply !text-yellow-600 hover:!text-yellow-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-icon {
|
.watchlist-icon {
|
||||||
@apply w-8 h-8 rounded-full flex items-center justify-center bg-gray-700/50;
|
@apply flex h-8 w-8 items-center justify-center rounded-full bg-gray-700/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trash-icon {
|
.trash-icon {
|
||||||
@apply h-4 w-4 text-gray-400 hover:text-red-400;
|
@apply h-4 w-4 text-gray-400 hover:text-red-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.star-icon {
|
.star-icon {
|
||||||
@apply h-4 w-4;
|
@apply h-4 w-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-title {
|
.watchlist-title {
|
||||||
@apply text-xl md:text-2xl font-bold text-gray-100;
|
@apply text-xl font-bold text-gray-100 md:text-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-table {
|
.watchlist-table {
|
||||||
@apply !relative overflow-hidden !w-full bg-gray-800 border !border-gray-600 !rounded-lg;
|
@apply !relative !w-full overflow-hidden !rounded-lg border !border-gray-600 bg-gray-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header-row {
|
.table-header-row {
|
||||||
@apply text-gray-400 font-medium bg-gray-700 border-b border-gray-600 hover:bg-gray-700;
|
@apply border-b border-gray-600 bg-gray-700 font-medium text-gray-400 hover:bg-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-header:first-child {
|
.table-header:first-child {
|
||||||
@apply pl-4;
|
@apply pl-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-row {
|
.table-row {
|
||||||
@apply border-b cursor-pointer text-gray-100 border-gray-600 hover:bg-gray-700/50 transition-colors;
|
@apply cursor-pointer border-b border-gray-600 text-gray-100 transition-colors hover:bg-gray-700/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cell {
|
.table-cell {
|
||||||
@apply font-medium text-base
|
@apply text-base font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-alert {
|
.add-alert {
|
||||||
@apply flex text-sm items-center whitespace-nowrap gap-1.5 px-3 w-fit py-2 text-yellow-600 border border-yellow-600/20 rounded font-medium bg-transparent hover:bg-transparent cursor-pointer transition-colors;
|
@apply flex w-fit cursor-pointer items-center gap-1.5 rounded border border-yellow-600/20 bg-transparent px-3 py-2 text-sm font-medium whitespace-nowrap text-yellow-600 transition-colors hover:bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-news {
|
.watchlist-news {
|
||||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
|
@apply grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-item {
|
.news-item {
|
||||||
@apply bg-gray-800 rounded-lg border w-full border-gray-600 p-4 duration-200 hover:border-gray-600 cursor-pointer;
|
@apply w-full cursor-pointer rounded-lg border border-gray-600 bg-gray-800 p-4 duration-200 hover:border-gray-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-tag {
|
.news-tag {
|
||||||
@apply inline-block w-fit px-2 py-1 mb-5 rounded bg-gray-600/60 text-green-500 text-sm font-mono font-medium;
|
@apply mb-5 inline-block w-fit rounded bg-gray-600/60 px-2 py-1 font-mono text-sm font-medium text-green-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-title {
|
.news-title {
|
||||||
@apply text-lg font-semibold text-gray-100 leading-tight mb-3 line-clamp-2;
|
@apply mb-3 line-clamp-2 text-lg leading-tight font-semibold text-gray-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-meta {
|
.news-meta {
|
||||||
@apply flex items-center text-sm text-gray-500 mb-1;
|
@apply mb-1 flex items-center text-sm text-gray-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-summary {
|
.news-summary {
|
||||||
@apply text-gray-400 flex-1 text-base leading-relaxed mb-3 line-clamp-3;
|
@apply mb-3 line-clamp-3 flex-1 text-base leading-relaxed text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.news-cta {
|
.news-cta {
|
||||||
@apply text-sm align-bottom text-yellow-500 hover:text-gray-400;
|
@apply align-bottom text-sm text-yellow-500 hover:text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-dialog {
|
.alert-dialog {
|
||||||
@apply bg-gray-800 border-gray-600 text-gray-400 max-w-md;
|
@apply max-w-md border-gray-600 bg-gray-800 text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-title {
|
.alert-title {
|
||||||
@apply text-xl font-semibold text-gray-100;
|
@apply text-xl font-semibold text-gray-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-list {
|
.alert-list {
|
||||||
@apply overflow-y-auto w-full max-h-[911px] rounded-lg flex border border-gray-600 flex-col gap-4 bg-gray-800 p-3 flex-1;
|
@apply flex max-h-[911px] w-full flex-1 flex-col gap-4 overflow-y-auto rounded-lg border border-gray-600 bg-gray-800 p-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-empty {
|
.alert-empty {
|
||||||
@apply px-6 py-8 text-center text-gray-500/50;
|
@apply px-6 py-8 text-center text-gray-500/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-item {
|
.alert-item {
|
||||||
@apply p-4 rounded-lg bg-gray-700 border border-gray-600;
|
@apply rounded-lg border border-gray-600 bg-gray-700 p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-name {
|
.alert-name {
|
||||||
@apply mb-2 text-lg text-yellow-500 font-semibold;
|
@apply mb-2 text-lg font-semibold text-yellow-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-details {
|
.alert-details {
|
||||||
@apply flex border-b pb-3 items-center justify-between gap-3 mb-2;
|
@apply mb-2 flex items-center justify-between gap-3 border-b pb-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-company {
|
.alert-company {
|
||||||
@apply text-gray-400 text-base;
|
@apply text-base text-gray-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-price {
|
.alert-price {
|
||||||
@apply text-gray-100 font-bold;
|
@apply font-bold text-gray-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-actions {
|
.alert-actions {
|
||||||
@apply flex items-end justify-between;
|
@apply flex items-end justify-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-update-btn {
|
.alert-update-btn {
|
||||||
@apply text-gray-400 rounded-full bg-transparent hover:bg-green-500/15 cursor-pointer;
|
@apply cursor-pointer rounded-full bg-transparent text-gray-400 hover:bg-green-500/15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-delete-btn {
|
.alert-delete-btn {
|
||||||
@apply text-gray-400 rounded-full hover:bg-red-600/15 bg-transparent cursor-pointer transition-colors;
|
@apply cursor-pointer rounded-full bg-transparent text-gray-400 transition-colors hover:bg-red-600/15;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +531,6 @@
|
|||||||
.tradingview-widget-container__widget {
|
.tradingview-widget-container__widget {
|
||||||
background-color: #141414 !important;
|
background-color: #141414 !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-stock-heatmap-container .screenerMapWrapper-BBVfGP0b {
|
.widget-stock-heatmap-container .screenerMapWrapper-BBVfGP0b {
|
||||||
@@ -451,7 +578,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-chart.tradingview-widget-container iframe {
|
.custom-chart.tradingview-widget-container iframe {
|
||||||
border: 1px solid #30333A;
|
border: 1px solid #30333a;
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
@@ -477,13 +604,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hide-default:hover {
|
.scrollbar-hide-default:hover {
|
||||||
scrollbar-color: #30333A transparent;
|
scrollbar-color: #30333a transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hide-default:hover::-webkit-scrollbar-thumb {
|
.scrollbar-hide-default:hover::-webkit-scrollbar-thumb {
|
||||||
background-color: #30333A;
|
background-color: #30333a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hide-default::-webkit-scrollbar-thumb:hover {
|
.scrollbar-hide-default::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #9095A1;
|
background-color: #9095a1;
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import { ReactNode } from "react";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
type Props = {
|
||||||
variable: "--font-geist-sans",
|
children: ReactNode;
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "CGR App",
|
|
||||||
description: "Manager for CGR Data",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
// Since we have a `not-found.tsx` page on the root, a layout file
|
||||||
children,
|
// is required, even if it's just passing children through.
|
||||||
}: Readonly<{
|
export default function RootLayout({ children }: Props) {
|
||||||
children: React.ReactNode;
|
return children;
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en" className="dark">
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import NavItems from "./NavItems";
|
import NavItems from "./NavItems";
|
||||||
import UserDropdown from "./UserDropdown";
|
import UserDropdown from "./UserDropdown";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 header">
|
<header className="header sticky top-0">
|
||||||
<div className="container header-wrapper">
|
<div className="header-wrapper container">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Image src="/logo.svg" alt="CGR Logo" width={100} height={100} className="h-8 w-auto cursor-pointer" />
|
<Image
|
||||||
|
src="/logo.svg"
|
||||||
|
alt={t("header.title")}
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
|
className="h-8 w-auto cursor-pointer"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden sm:block">
|
<nav className="hidden sm:block">
|
||||||
<NavItems />
|
<NavItems />
|
||||||
</nav>
|
</nav>
|
||||||
|
<div className="mr-4 flex items-center gap-2">
|
||||||
|
<Link href="/" locale="en" className="text-sm">
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
<Link href="/" locale="de" className="text-sm">
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
<Link href="/" locale="rus" className="text-sm">
|
||||||
|
RU
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<UserDropdown />
|
<UserDropdown />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,46 +3,72 @@ import React from "react";
|
|||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
|
pageTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
|
const Header: React.FC<HeaderProps> = ({ onMenuClick, pageTitle }) => {
|
||||||
|
const [hasError, setHasError] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
data-cmp="Header"
|
data-cmp="Header"
|
||||||
className="h-16 bg-card border-b border-border sticky top-0 z-20 px-4 md:px-8 flex items-center justify-between"
|
className="header border-border bg-background/95 sticky top-0 z-20 flex h-16 items-center border-b px-4 backdrop-blur md:px-8"
|
||||||
>
|
>
|
||||||
<div className="flex items-center md:hidden">
|
{/* 1. Left: Menu button (only on mobile) */}
|
||||||
<button title="Menu" onClick={onMenuClick} className="p-2 -ml-2 text-foreground hover:bg-muted rounded-md">
|
<div className="flex flex-1 items-center md:hidden">
|
||||||
<Menu className="w-6 h-6" />
|
<button
|
||||||
|
title="Menu"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="text-foreground hover:bg-muted -ml-2 rounded-md p-2"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex flex-1 max-w-xl relative">
|
{/* 2. Center: Title (only on mobile) */}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 md:hidden">
|
||||||
|
<span className="text-xl font-bold tracking-tight text-muted-foreground">
|
||||||
|
{pageTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Desktop: Search */}
|
||||||
|
<div className="relative hidden max-w-xl flex-1 md:flex">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search modules, users, or files..."
|
placeholder="Search modules, users, or files..."
|
||||||
className="w-full pl-10 pr-4 py-2 bg-muted/50 border-none rounded-lg text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
className="bg-muted/50 text-foreground focus:ring-primary/20 w-full rounded-lg border-none py-2 pr-4 pl-10 text-sm transition-all focus:ring-2 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
{/* 4. RIGHT SIDE: Notifications and Profile */}
|
||||||
|
<div className="flex flex-1 items-center justify-end space-x-4">
|
||||||
<button
|
<button
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
className="relative p-2 text-muted-foreground hover:text-foreground hover:bg-muted rounded-full transition-colors"
|
className="text-muted-foreground hover:text-foreground hover:bg-muted relative rounded-full p-2 transition-colors"
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="h-5 w-5" />
|
||||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-destructive rounded-full border-2 border-card"></span>
|
<span className="bg-destructive border-card absolute top-1.5 right-1.5 h-2 w-2 rounded-full border-2"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 pl-4 border-l border-border/50">
|
<div className="border-border/50 flex items-center space-x-3 border-l pl-4">
|
||||||
<div className="text-right hidden sm:block">
|
<div className="hidden text-right sm:block text-nowrap">
|
||||||
<p className="text-sm font-medium text-foreground">Community Admin</p>
|
<p className="text-foreground text-sm font-medium">Community Admin</p>
|
||||||
<p className="text-xs text-muted-foreground">Super Administrator</p>
|
<p className="text-muted-foreground text-xs">Super Administrator</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-9 h-9 bg-primary/10 rounded-full flex items-center justify-center border border-primary/20">
|
{hasError ? (
|
||||||
<User className="w-5 h-5 text-primary" />
|
<div className="w-10 h-10 rounded-full border border-border bg-muted flex items-center justify-center">
|
||||||
|
<User className="w-6 h-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src="/images/IMG_84271.jpg"
|
||||||
|
alt="Profile"
|
||||||
|
className="w-10 h-10 rounded-full object-cover border border-border"
|
||||||
|
/* If image fails to load, set hasError to true */
|
||||||
|
onError={() => setHasError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
type NavItemProps = {
|
type NavItemProps = {
|
||||||
item: {
|
item: {
|
||||||
label: string;
|
labelKey: string;
|
||||||
href: string;
|
href: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NavItem: React.FC<NavItemProps> = ({ item: { label, href } }) => {
|
export const NavItem: React.FC<NavItemProps> = ({
|
||||||
|
item: { labelKey, href },
|
||||||
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === "/") return pathname === "/";
|
if (path === "/") return pathname === "/";
|
||||||
@@ -19,12 +23,13 @@ export const NavItem: React.FC<NavItemProps> = ({ item: { label, href } }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const active = isActive(href);
|
const active = isActive(href);
|
||||||
|
const label = t(labelKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={href}>
|
<li key={href}>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={`hover:text-yellow-500 transition-colors ${active ? "text-gray-100 font-bold" : ""}`}
|
className={`transition-colors hover:text-yellow-500 ${active ? "font-bold text-gray-100" : ""}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { Building2, Calendar, FileText, LayoutDashboard, LogOut, Music, Settings, Users } from "lucide-react";
|
import {
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Music,
|
||||||
|
Settings,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -12,46 +21,60 @@ const navItems = [
|
|||||||
{ icon: Settings, label: "Settings", path: "#settings" },
|
{ icon: Settings, label: "Settings", path: "#settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Sidebar: React.FC = () => {
|
type SidebarProps = {
|
||||||
|
open?: boolean;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ open, close }) => {
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
data-cmp="Sidebar"
|
data-cmp="Sidebar"
|
||||||
className="hidden md:flex flex-col w-64 h-screen bg-card border-r border-border fixed left-0 top-0 z-30"
|
className={`side-bar border-border fixed top-0 left-0 z-30 h-screen w-64 flex-col border-r md:flex ${open ? "flex" : "hidden"}`}
|
||||||
>
|
>
|
||||||
<div className="p-6 border-b border-border">
|
<div className="border-border border-b p-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
<div className="bg-primary flex h-8 w-8 items-center justify-center rounded-lg">
|
||||||
<Building2 className="w-5 h-5 text-primary-foreground" />
|
<Building2 className="text-primary-foreground h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
|
<span className="from-primary bg-gradient-to-r to-purple-600 bg-clip-text text-xl font-bold text-transparent">
|
||||||
CGR Admin
|
CGR Admin
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto py-6 px-3 space-y-1">
|
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-6">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = pathName === item.path;
|
const isActive = pathName === item.path;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.label}
|
key={item.label}
|
||||||
href={item.path}
|
href={item.path}
|
||||||
className={`flex items-center space-x-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`flex items-center space-x-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
isActive ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted hover:text-foreground"
|
isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (open) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<item.icon className={`w-5 h-5 ${isActive ? "text-primary" : ""}`} />
|
<item.icon
|
||||||
|
className={`h-5 w-5 ${isActive ? "text-primary" : ""}`}
|
||||||
|
/>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-border">
|
<div className="border-border border-t p-4">
|
||||||
<button className="flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors">
|
<button className="text-destructive hover:bg-destructive/10 flex w-full items-center space-x-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors">
|
||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="h-5 w-5" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ const StatCard: React.FC<StatCardProps> = ({ label, value, icon: Icon, trend, ch
|
|||||||
<Icon className="w-4 h-4 text-foreground" />
|
<Icon className="w-4 h-4 text-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline space-x-2">
|
<div className="flex items-baseline flex-wrap space-x-2">
|
||||||
<h2 className="text-3xl font-bold text-foreground">{value}</h2>
|
<h2 className="text-3xl font-bold text-foreground">{value}</h2>
|
||||||
{change && (
|
{change && (
|
||||||
<span
|
<span
|
||||||
className={`flex items-center text-xs font-medium ${
|
className={`flex items-center text-xs font-medium whitespace-nowrap ${
|
||||||
trend === "up" ? "text-green-600" : trend === "down" ? "text-red-500" : "text-muted-foreground"
|
trend === "up" ? "text-green-600" : trend === "down" ? "text-red-500" : "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
48
components/ui/badge.tsx
Normal file
48
components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
220
components/ui/calendar.tsx
Normal file
220
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
getDefaultClassNames,
|
||||||
|
type DayButton,
|
||||||
|
} from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
158
components/ui/dialog.tsx
Normal file
158
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
135
components/ui/drawer.tsx
Normal file
135
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
167
components/ui/form.tsx
Normal file
167
components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import type * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
190
components/ui/select.tsx
Normal file
190
components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
35
components/ui/switch.tsx
Normal file
35
components/ui/switch.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
19
hooks/use-media-query.tsx
Normal file
19
hooks/use-media-query.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string) {
|
||||||
|
const [value, setValue] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function onChange(event: MediaQueryListEvent) {
|
||||||
|
setValue(event.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = window.matchMedia(query)
|
||||||
|
result.addEventListener("change", onChange)
|
||||||
|
setValue(result.matches)
|
||||||
|
|
||||||
|
return () => result.removeEventListener("change", onChange)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
7
i18n/navigation.ts
Normal file
7
i18n/navigation.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createNavigation } from "next-intl/navigation";
|
||||||
|
import { routing } from "./routing";
|
||||||
|
|
||||||
|
// Lightweight wrappers around Next.js' navigation
|
||||||
|
// APIs that consider the routing configuration
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||||
|
createNavigation(routing);
|
||||||
16
i18n/request.ts
Normal file
16
i18n/request.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { hasLocale } from "next-intl";
|
||||||
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
import { routing } from "./routing";
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
// Typically corresponds to the `[locale]` segment
|
||||||
|
const requested = await requestLocale;
|
||||||
|
const locale = hasLocale(routing.locales, requested)
|
||||||
|
? requested
|
||||||
|
: routing.defaultLocale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
|
};
|
||||||
|
});
|
||||||
9
i18n/routing.ts
Normal file
9
i18n/routing.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
// A list of all locales that are supported
|
||||||
|
locales: ["en", "de", "ru"],
|
||||||
|
|
||||||
|
// Used when no locale matches
|
||||||
|
defaultLocale: "en",
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{ label: "Home", href: "/" },
|
{ labelKey: "nav.home", href: "/" },
|
||||||
{ label: "About", href: "/about" },
|
{ labelKey: "nav.about", href: "/about" },
|
||||||
{ label: "Contact", href: "/contact" },
|
{ labelKey: "nav.contact", href: "/contact" },
|
||||||
{ label: "Admin", href: "/admin" },
|
{ labelKey: "nav.admin", href: "/admin" },
|
||||||
];
|
];
|
||||||
|
|||||||
22
messages/de.json
Normal file
22
messages/de.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"title": "CGR Anwendung"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"about": "Über",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"admin": "Admin"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Community-Dashboard",
|
||||||
|
"welcome": "Willkommen im Admin-Bereich. Verwalten Sie Ihre Community-Ressourcen sicher.",
|
||||||
|
"greeting_name": "Lieber Bruder {user},",
|
||||||
|
"greeting": "Friede sei mit Dir!",
|
||||||
|
"verse_intro": "Zur Ermutigung:"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Benutzer",
|
||||||
|
"welcome": "Verwalten Sie hier Ihre Community-Mitglieder, Rollen und Berechtigungen."
|
||||||
|
}
|
||||||
|
}
|
||||||
22
messages/en.json
Normal file
22
messages/en.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"title": "CGR App"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"contact": "Contact",
|
||||||
|
"admin": "Admin"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Community Dashboard",
|
||||||
|
"welcome": "Welcome to the admin area. Manage your community resources safely.",
|
||||||
|
"greeting_name": "Dear brother {user},",
|
||||||
|
"greeting": "peace be with you!",
|
||||||
|
"verse_intro": "For encouragement:"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"welcome": "Manage your community members, roles, and permissions here."
|
||||||
|
}
|
||||||
|
}
|
||||||
22
messages/ru.json
Normal file
22
messages/ru.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"title": "CGR Приложение"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Главная",
|
||||||
|
"about": "О нас",
|
||||||
|
"contact": "Контакт",
|
||||||
|
"admin": "Админ"
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Community Dashboard",
|
||||||
|
"welcome": "Добро пожаловать в административный раздел. Безопасно управляйте ресурсами вашего сообщества.",
|
||||||
|
"greeting_name": "Дорогой брат {user},",
|
||||||
|
"greeting": "мир Тебе!",
|
||||||
|
"verse_intro": "Для ободрения:"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Пользователи",
|
||||||
|
"welcome": "Управляйте членами вашего сообщества, ролями и разрешениями здесь."
|
||||||
|
}
|
||||||
|
}
|
||||||
7
next-intl.config.js
Normal file
7
next-intl.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next-intl').NextIntlConfig} */
|
||||||
|
module.exports = {
|
||||||
|
locales: ['en', 'de', 'ru'],
|
||||||
|
defaultLocale: 'en',
|
||||||
|
// Directory where your message JSON files live
|
||||||
|
messagesDirectory: './messages'
|
||||||
|
};
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
/* config options here */
|
/* config options here */
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
1151
package-lock.json
generated
1151
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -9,17 +9,28 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.4",
|
"next": "16.1.4",
|
||||||
|
"next-intl": "^4.8.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.4.0"
|
"react-hook-form": "^7.71.1",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -29,6 +40,8 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.4",
|
"eslint-config-next": "16.1.4",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
11
proxy.ts
Normal file
11
proxy.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
import { routing } from "./i18n/routing";
|
||||||
|
|
||||||
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match all pathnames except for
|
||||||
|
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
|
||||||
|
// - … the ones containing a dot (e.g. `favicon.ico`)
|
||||||
|
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
||||||
|
};
|
||||||
BIN
public/images/IMG_84271.jpg
Normal file
BIN
public/images/IMG_84271.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
Reference in New Issue
Block a user