Compare commits

...

14 Commits

39 changed files with 2962 additions and 159 deletions

87
AGENTS.md Normal file
View 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`

View File

@@ -3,6 +3,8 @@
ENV ?= dev
COMPOSE_FILE = docker-compose$(if $(filter $(ENV),prod),.prod,).yml
COMPOSE = docker compose -f $(COMPOSE_FILE)
# Service name of the dev container
SERVICE_NAME = app
.PHONY: up stop build deploy logs
@@ -30,4 +32,10 @@ logs:
$(COMPOSE) logs -f
clean:
docker image prune -f
docker image prune -f
ui:
$(COMPOSE) exec $(SERVICE_NAME) npx shadcn@latest add $(component)
i:
$(COMPOSE) exec $(SERVICE_NAME) npm install $(package)

View File

@@ -1,82 +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="default" 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>
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import Header from "@/components/Header/index";
import Sidebar from "@/components/Sidebar";
import React, { useState } from "react";
import { usePathname } from "next/navigation";
interface LayoutProps {
children: React.ReactNode;
@@ -10,6 +11,25 @@ interface LayoutProps {
const Layout: React.FC<LayoutProps> = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const getPageTitle = (path: string) => {
const pathSegments = path.split("/").filter(Boolean);
if (pathSegments.length <= 1) return "Overview";
const purePath = `/${pathSegments.slice(1).join("/")}`;
const titles: Record<string, string> = {
"/users": "Users",
"/settings": "Settings",
"/profile": "Profile",
"/songs": "Songs Database",
"/events": "Worship & Events",
};
return titles[purePath] || "Dashboard";
};
return (
<div className="dashboard">
@@ -26,7 +46,11 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
<div
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">
{children}
</main>
@@ -35,4 +59,4 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
);
};
export default Layout;
export default Layout;

View 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
];

View File

@@ -2,6 +2,7 @@
import ModuleCard from "@/components/ModuleCard";
import StatCard from "@/components/StatCard";
import { Badge } from "@/components/ui/badge";
import { ModuleCategory } from "@/lib/types/dashboard";
import {
BookOpen,
@@ -20,10 +21,28 @@ import {
UserCircle,
Users,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams } from "next/navigation";
import Link from "next/link";
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 = () => {
// 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[] = [
{
title: "People & Community",
@@ -166,19 +185,21 @@ const Dashboard: React.FC = () => {
return (
<div className="mx-auto max-w-7xl space-y-8">
{/* Welcome Section */}
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-foreground text-3xl font-bold tracking-tight">
Community Dashboard
</h1>
<p className="text-muted-foreground mt-1">
Welcome to the admin area. Manage your community resources safely.
</p>
<div data-cmp="ModuleCard" className="group bg-card border border-border rounded-xl p-6 h-full overflow-hidden">
<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">
<span className="text-2xl md:text-3xl text-nowrap font-semibold leading-none">{t("greeting_name", { user: "Alexander" })}</span>
<span className="text-xl font-semibold text-nowrap leading-none"> {t("greeting")}</span>
</div>
<div className="flex space-x-3">
<span className="bg-primary/10 text-primary whitespace-nowrap rounded-full px-3 py-1 text-sm font-medium">
v2.4.0 Live
</span>
<div className="flex-col mt-4">
<p>
<span className="font-semibold">{t("verse_intro")}</span> <br />
<i className="italic text-foreground">"{todayVerse.content[lng]}"</i>
</p>
<div className="flex justify-end mt-3">
<Badge variant="outline" className="rounded-sm self-start">
{todayVerse.reference}
</Badge>
</div>
</div>
</div>
@@ -229,7 +250,7 @@ const Dashboard: React.FC = () => {
title={item.title}
description={item.description}
icon={item.icon}
//onClick={() => console.log(`Navigating to ${item.path}`)}
//onClick={() => console.log(`Navigating to ${item.path}`)}
/>
</Link>
))}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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
View 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>
);
}

View File

@@ -1,35 +1,11 @@
import type { Metadata } from "next";
import { ThemeProvider } from "next-themes";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ReactNode } from "react";
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",
type Props = {
children: ReactNode;
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider attribute="class" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
);
// Since we have a `not-found.tsx` page on the root, a layout file
// is required, even if it's just passing children through.
export default function RootLayout({ children }: Props) {
return children;
}

View File

@@ -1,18 +1,40 @@
"use client";
import { useTranslations } from "next-intl";
import Image from "next/image";
import Link from "next/link";
import NavItems from "./NavItems";
import UserDropdown from "./UserDropdown";
const Header = () => {
const t = useTranslations();
return (
<header className="sticky top-0 header">
<div className="container header-wrapper">
<header className="header sticky top-0">
<div className="header-wrapper container">
<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>
<nav className="hidden sm:block">
<NavItems />
</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 />
</div>
</header>

View File

@@ -3,15 +3,18 @@ import React from "react";
interface HeaderProps {
onMenuClick: () => void;
pageTitle?: string;
}
const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
const Header: React.FC<HeaderProps> = ({ onMenuClick, pageTitle }) => {
const [hasError, setHasError] = React.useState(false);
return (
<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
title="Menu"
onClick={onMenuClick}
@@ -21,6 +24,14 @@ const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
</button>
</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">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<input
@@ -30,7 +41,8 @@ const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
/>
</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
title="Notifications"
className="text-muted-foreground hover:text-foreground hover:bg-muted relative rounded-full p-2 transition-colors"
@@ -40,19 +52,27 @@ const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
</button>
<div className="border-border/50 flex items-center space-x-3 border-l pl-4">
<div className="hidden text-right sm:block">
<p className="text-foreground text-sm font-medium">
Community Admin
</p>
<div className="hidden text-right sm:block text-nowrap">
<p className="text-foreground text-sm font-medium">Community Admin</p>
<p className="text-muted-foreground text-xs">Super Administrator</p>
</div>
<div className="bg-primary/10 border-primary/20 flex h-9 w-9 items-center justify-center rounded-full border">
<User className="text-primary h-5 w-5" />
</div>
{hasError ? (
<div className="w-10 h-10 rounded-full border border-border bg-muted flex items-center justify-center">
<User className="w-6 h-6 text-muted-foreground" />
</div>
) : (
<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>
</header>
);
};
export default Header;
export default Header;

View File

@@ -1,17 +1,21 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
type NavItemProps = {
item: {
label: string;
labelKey: 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 t = useTranslations();
const isActive = (path: string) => {
if (path === "/") return pathname === "/";
@@ -19,12 +23,13 @@ export const NavItem: React.FC<NavItemProps> = ({ item: { label, href } }) => {
};
const active = isActive(href);
const label = t(labelKey);
return (
<li key={href}>
<Link
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}
</Link>

48
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

220
components/ui/calendar.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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",
});

View File

@@ -1,6 +1,6 @@
export const NAV_ITEMS = [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
{ label: "Admin", href: "/admin" },
{ labelKey: "nav.home", href: "/" },
{ labelKey: "nav.about", href: "/about" },
{ labelKey: "nav.contact", href: "/contact" },
{ labelKey: "nav.admin", href: "/admin" },
];

22
messages/de.json Normal file
View 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
View 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
View 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
View 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'
};

View File

@@ -1,8 +1,11 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
output: "standalone",
/* config options here */
};
export default nextConfig;
export default withNextIntl(nextConfig);

1054
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,28 @@
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@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-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"next": "16.1.4",
"next-intl": "^4.8.2",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"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": {
"@tailwindcss/postcss": "^4",

11
proxy.ts Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB