Compare commits
13 Commits
f26dee782c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19a9014f61 | |||
| 2cc52a3fab | |||
| 4c832549f2 | |||
| 3f7369fc86 | |||
| d77046194a | |||
| 6f1d93fc07 | |||
| 01cbaab498 | |||
| 9ac415145d | |||
| 85b618836b | |||
| a073665dc4 | |||
| 288cac4432 | |||
| 8ab553ac6c | |||
| 712a1a2937 |
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`
|
||||||
3
Makefile
3
Makefile
@@ -36,3 +36,6 @@ clean:
|
|||||||
|
|
||||||
ui:
|
ui:
|
||||||
$(COMPOSE) exec $(SERVICE_NAME) npx shadcn@latest add $(component)
|
$(COMPOSE) exec $(SERVICE_NAME) npx shadcn@latest add $(component)
|
||||||
|
|
||||||
|
i:
|
||||||
|
$(COMPOSE) exec $(SERVICE_NAME) npm install $(package)
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Search, Plus, MoreVertical, Filter } from "lucide-react";
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Users</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">Manage your community members and their permissions.</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Add User
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters & Search */}
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
className="w-full bg-card border border-border rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
||||||
placeholder="Search users by name or email..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Filter className="mr-2" />
|
|
||||||
Filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users List / Table */}
|
|
||||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead className="bg-muted/50 border-b border-border">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-muted-foreground tracking-wider">User</th>
|
|
||||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-muted-foreground tracking-wider">Role</th>
|
|
||||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-muted-foreground tracking-wider">Status</th>
|
|
||||||
<th className="px-6 py-3 text-xs font-semibold uppercase text-muted-foreground tracking-wider">Joined</th>
|
|
||||||
<th className="px-6 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border">
|
|
||||||
{/* Example Row */}
|
|
||||||
<tr className="hover:bg-muted/20 transition-colors cursor-pointer">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">JD</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground">John Doe</div>
|
|
||||||
<div className="text-xs text-muted-foreground">john@example.com</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="text-sm text-muted-foreground">Administrator</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/10 text-green-500">Active</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-muted-foreground">Jan 29, 2026</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<button className="p-2 hover:bg-muted rounded-full transition-colors">
|
|
||||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="hover:bg-muted/20 transition-colors cursor-pointer">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">JD</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-foreground">Ivan Ivanowitsch</div>
|
|
||||||
<div className="text-xs text-muted-foreground">ivan@example.com</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="text-sm text-muted-foreground">User</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-500">not active</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-muted-foreground">May 20, 2025</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<button className="p-2 hover:bg-muted rounded-full transition-colors">
|
|
||||||
<MoreVertical className="w-4 h-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import Header from "@/components/Header/index";
|
import Header from "@/components/Header/index";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -10,6 +11,25 @@ interface LayoutProps {
|
|||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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 (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
@@ -26,7 +46,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
|||||||
<div
|
<div
|
||||||
className={`flex min-h-screen flex-col transition-all duration-300 md:pl-64`}
|
className={`flex min-h-screen flex-col transition-all duration-300 md:pl-64`}
|
||||||
>
|
>
|
||||||
<Header onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
<Header
|
||||||
|
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
pageTitle={getPageTitle(pathname)}
|
||||||
|
/>
|
||||||
|
|
||||||
<main className="animate-in fade-in flex-1 p-4 duration-500 md:p-8">
|
<main className="animate-in fade-in flex-1 p-4 duration-500 md:p-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
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
|
||||||
|
];
|
||||||
@@ -21,10 +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 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",
|
||||||
@@ -167,19 +185,21 @@ const Dashboard: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-8">
|
<div className="mx-auto max-w-7xl space-y-8">
|
||||||
{/* Welcome Section */}
|
{/* Welcome Section */}
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<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-foreground text-3xl font-bold tracking-tight">
|
<span className="text-2xl md:text-3xl text-nowrap font-semibold leading-none">{t("greeting_name", { user: "Alexander" })}</span>
|
||||||
Community Dashboard
|
<span className="text-xl font-semibold text-nowrap leading-none"> {t("greeting")}</span>
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Welcome to the admin area. Manage your community resources safely.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-3">
|
<div className="flex-col mt-4">
|
||||||
<Badge variant="secondary" className="px-2.5 py-0.5 text-sm">
|
<p>
|
||||||
v2.4.0 Live
|
<span className="font-semibold">{t("verse_intro")}</span> <br />
|
||||||
</Badge>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,7 +250,7 @@ const Dashboard: React.FC = () => {
|
|||||||
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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
app/globals.css
168
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 === */
|
||||||
|
|||||||
@@ -1,35 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import { ReactNode } from "react";
|
||||||
import { ThemeProvider } from "next-themes";
|
|
||||||
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" suppressHydrationWarning>
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
|
||||||
<ThemeProvider attribute="class" enableSystem>
|
|
||||||
{children}
|
|
||||||
</ThemeProvider>
|
|
||||||
</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,15 +3,18 @@ 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="header border-border sticky top-0 z-20 flex h-16 items-center justify-between border-b px-4 md:px-8"
|
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) */}
|
||||||
|
<div className="flex flex-1 items-center md:hidden">
|
||||||
<button
|
<button
|
||||||
title="Menu"
|
title="Menu"
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
@@ -21,6 +24,14 @@ const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Center: Title (only on mobile) */}
|
||||||
|
<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">
|
<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" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
@@ -30,7 +41,8 @@ const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
|
|||||||
/>
|
/>
|
||||||
</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="text-muted-foreground hover:text-foreground hover:bg-muted relative rounded-full p-2 transition-colors"
|
className="text-muted-foreground hover:text-foreground hover:bg-muted relative rounded-full p-2 transition-colors"
|
||||||
@@ -40,15 +52,23 @@ const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="border-border/50 flex items-center space-x-3 border-l pl-4">
|
<div className="border-border/50 flex items-center space-x-3 border-l pl-4">
|
||||||
<div className="hidden text-right sm:block">
|
<div className="hidden text-right sm:block text-nowrap">
|
||||||
<p className="text-foreground text-sm font-medium">
|
<p className="text-foreground text-sm font-medium">Community Admin</p>
|
||||||
Community Admin
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">Super Administrator</p>
|
<p className="text-muted-foreground text-xs">Super Administrator</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-primary/10 border-primary/20 flex h-9 w-9 items-center justify-center rounded-full border">
|
{hasError ? (
|
||||||
<User className="text-primary h-5 w-5" />
|
<div className="w-10 h-10 rounded-full border border-border bg-muted flex items-center justify-center">
|
||||||
</div>
|
<User className="w-6 h-6 text-muted-foreground" />
|
||||||
|
</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>
|
||||||
|
|||||||
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);
|
||||||
|
|||||||
1054
package-lock.json
generated
1054
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
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",
|
||||||
|
|||||||
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