Compare commits

..

15 Commits

Author SHA1 Message Date
e2726083f6 Update logo 2026-03-09 00:16:33 +01:00
2a10c1ce70 Update readme 2026-03-08 23:53:22 +01:00
56a2e1c7b9 CHnage readme logo 2026-03-08 23:25:43 +01:00
898f355a12 Added dark mode logo 2026-03-08 23:25:14 +01:00
3f90949c2b Add logo 2026-03-08 17:53:11 +01:00
fe2361be64 Merge branch 'main' of https://git.azuze.fr/kawa/CubeAdmin 2026-03-08 17:52:00 +01:00
6f827e0c7b Add logo 2026-03-08 17:51:56 +01:00
dd3a42eddf Add logo 2026-03-08 17:48:56 +01:00
b506276bf9 Delete .claude/settings.local.json 2026-03-08 17:41:33 +01:00
1b6848917c Add more generic SMTP options 2026-03-08 17:31:33 +01:00
193fcb3791 Add a thorough description of the app in the readme 2026-03-08 17:24:33 +01:00
c8895c8e80 BugFixes galore 2026-03-08 17:01:36 +01:00
781f0f14fa Added Claude's folder to gitignore 2026-03-08 15:50:54 +01:00
47127f276d Initial push 2026-03-08 15:49:34 +01:00
8da12bb7d1 feat: initial commit 2026-03-08 15:04:47 +01:00
125 changed files with 19000 additions and 2 deletions

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.claude/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

390
README.md
View File

@@ -1,3 +1,389 @@
# CubeAdmin
![CubeAdmin-logo](https://git.azuze.fr/kawa/CubeAdmin/raw/branch/main/cubeadmin-logo-dark.png)
Application d'administration de serveurs Minecraft.
A production-ready Minecraft server administration panel built with Next.js, Bun, and SQLite.
[Website](https://cubeadmin.kawa.zip) - [Wiki](https://cubeadmin.kawa.zip/wiki)
## Features
- **Real-time console** — stream server output and send commands via Socket.io
- **Server control** — start, stop, and restart your Minecraft server from the dashboard
- **Player management** — view online/offline players, ban/unban, manage the whitelist
- **Plugin management** — list, enable/disable, and upload plugins
- **File explorer** — browse, upload, download, and delete server files
- **Backup system** — create and restore backups on demand or on a schedule
- **Team management** — invite team members by email with role-based access control
- **World map** — embedded BlueMap 3D map integration
- **Server version management** — switch between Vanilla, Paper, and Fabric versions
- **Monitoring** — live CPU and memory charts
- **Task scheduler** — run RCON commands on a cron schedule
- **Audit log** — full record of all administrative actions
- **Dark/light theme** — persisted per user
---
## Tech Stack
| Layer | Technology |
|---|---|
| Runtime | [Bun](https://bun.sh) |
| Framework | [Next.js 16](https://nextjs.org) (App Router) |
| Database | SQLite via `bun:sqlite` + [Drizzle ORM](https://orm.drizzle.team) |
| Auth | [Better Auth v1.5](https://better-auth.com) |
| UI | [shadcn/ui v4](https://ui.shadcn.com) + [Base UI](https://base-ui.com) + Tailwind CSS v4 |
| Real-time | [Socket.io](https://socket.io) |
| Email | [Nodemailer](https://nodemailer.com) (any SMTP server) |
---
## Quick Start (Development)
### Prerequisites
- [Bun](https://bun.sh) ≥ 1.1
- A Minecraft server with RCON enabled (or use the provided `docker-compose.dev.yml`)
### 1. Clone and install
```bash
git clone https://github.com/your-org/cubeadmin.git
cd cubeadmin
bun install
```
### 2. Configure environment
```bash
cp .env.example .env.local
```
Edit `.env.local` — at minimum you need:
```env
BETTER_AUTH_SECRET=your-32-char-secret-here
MC_SERVER_PATH=/path/to/your/minecraft/server
MC_RCON_PASSWORD=your-rcon-password
```
### 3. Spin up a local Minecraft server (optional)
```bash
docker compose -f docker-compose.dev.yml up -d
```
This starts a Paper Minecraft server with RCON exposed on port 25575.
### 4. Run the development server
```bash
bun dev
```
Open [http://localhost:3000](http://localhost:3000). The first account you register automatically becomes the administrator.
---
## Deployment
### Option A — Docker Compose (recommended)
This is the easiest way to run CubeAdmin and a Minecraft server together on a single host.
#### 1. Create your environment file
```bash
cp .env.example .env
```
Fill in all required values (see [Environment Variables](#environment-variables) below).
#### 2. Start the stack
```bash
docker compose up -d
```
This starts three services:
| Service | Description | Port |
|---|---|---|
| `cubeadmin` | The admin panel | 3000 |
| `minecraft` | Paper Minecraft server | 25565 |
| `bluemap` | 3D world map (optional) | 8100 |
#### 3. Reverse proxy (recommended)
Put Nginx or Caddy in front of CubeAdmin on port 3000. Example Caddyfile:
```
cubeadmin.example.com {
reverse_proxy localhost:3000
}
```
> **Important:** Socket.io requires WebSocket support. Ensure your proxy forwards the `Upgrade` header.
Nginx snippet:
```nginx
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
#### 4. First login
Navigate to `https://your-domain.com` and register the first account — it is automatically granted the `superadmin` role.
---
### Option B — Bare Metal (Bun)
#### 1. Build
```bash
bun install
bun run build
```
#### 2. Run
```bash
NODE_ENV=production bun --bun run server.ts
```
The server binds to `0.0.0.0:3000` by default. Use `PORT` and `HOSTNAME` env vars to change this.
#### 3. Run as a systemd service
```ini
# /etc/systemd/system/cubeadmin.service
[Unit]
Description=CubeAdmin
After=network.target
[Service]
Type=simple
User=cubeadmin
WorkingDirectory=/opt/cubeadmin
ExecStart=/usr/local/bin/bun --bun run server.ts
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=/opt/cubeadmin/.env
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable --now cubeadmin
```
---
## Database Migrations
CubeAdmin uses Drizzle ORM with a SQLite database. Migrations are applied automatically at startup via `lib/db/migrate.ts`.
To generate a new migration after changing `lib/db/schema.ts`:
```bash
bun run db:generate
```
> **Note:** `bun run db:migrate` (drizzle-kit) does not work with Bun's native `bun:sqlite` driver. Always apply migrations through the app startup migrator or manually with `bun:sqlite` scripts.
To open the Drizzle Studio database browser:
```bash
bun run db:studio
```
---
## Environment Variables
### Authentication
| Variable | Required | Default | Description |
|---|---|---|---|
| `BETTER_AUTH_SECRET` | **Yes** | — | Secret key used to sign sessions and tokens. Must be at least 32 characters. Generate one with `openssl rand -base64 32`. |
| `BETTER_AUTH_URL` | No | `http://localhost:3000` | The public base URL of the CubeAdmin app. Used by Better Auth to construct callback URLs. |
| `NEXT_PUBLIC_BETTER_AUTH_URL` | No | *(inferred from browser)* | Browser-side auth URL. Only needed if the public URL differs from what the browser can infer (e.g. behind a proxy). |
| `BETTER_AUTH_TRUSTED_ORIGINS` | No | `http://localhost:3000` | Comma-separated list of allowed origins for CORS and CSRF protection. Add your public domain in production. |
### Email (SMTP)
CubeAdmin sends email via any standard SMTP server — Gmail, Mailgun, Postfix, Brevo, Amazon SES, or your own self-hosted relay. Email is only used for team invitations and magic-link sign-in. The app starts without email configured; invitations will fail gracefully with a logged error.
| Variable | Required | Default | Description |
|---|---|---|---|
| `SMTP_HOST` | No* | — | SMTP server hostname (e.g. `smtp.gmail.com`, `smtp.mailgun.org`, `localhost`). Required for any email to be sent. |
| `SMTP_PORT` | No | `587` | SMTP server port. Common values: `587` (STARTTLS), `465` (TLS/SSL), `25` (plain, relay), `1025` (local dev). |
| `SMTP_SECURE` | No | `false` | Set to `true` to use implicit TLS (port 465). Leave `false` for STARTTLS (port 587) or plain. |
| `SMTP_USER` | No | — | SMTP authentication username. Leave unset for unauthenticated relay (e.g. local Postfix). |
| `SMTP_PASS` | No | — | SMTP authentication password or app-specific password. |
| `EMAIL_FROM` | No | `CubeAdmin <noreply@example.com>` | The `From` address on outgoing emails. Must be authorised by your SMTP provider to avoid spam filtering. |
#### Common SMTP configurations
**Gmail** (app password required — enable 2FA first):
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=you@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=CubeAdmin <you@gmail.com>
```
**Mailgun**:
```env
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=postmaster@mg.yourdomain.com
SMTP_PASS=your-mailgun-smtp-password
EMAIL_FROM=CubeAdmin <noreply@yourdomain.com>
```
**Amazon SES**:
```env
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-ses-access-key-id
SMTP_PASS=your-ses-smtp-secret
EMAIL_FROM=CubeAdmin <noreply@yourdomain.com>
```
**Local dev with Mailpit** (catches all emails, no real sending):
```env
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
# no SMTP_USER / SMTP_PASS needed
```
Run Mailpit with `docker run -p 1025:1025 -p 8025:8025 axllent/mailpit` and view emails at [http://localhost:8025](http://localhost:8025).
### Minecraft Server
| Variable | Required | Default | Description |
|---|---|---|---|
| `MC_SERVER_PATH` | No | `/opt/minecraft/server` | Absolute path to the Minecraft server directory on the host. CubeAdmin reads/writes files here and spawns the server process from this directory. |
| `MC_RCON_HOST` | No | `127.0.0.1` | Hostname or IP where the Minecraft RCON interface is listening. Use `minecraft` when running the Docker Compose stack. |
| `MC_RCON_PORT` | No | `25575` | TCP port for the Minecraft RCON interface. Must match `rcon.port` in `server.properties`. |
| `MC_RCON_PASSWORD` | **Yes*** | — | Password for the Minecraft RCON interface. Must match `rcon.password` in `server.properties`. Required for player management, whitelist, and scheduler features. |
*Only truly required if you want RCON-based features (player commands, scheduler). The app will start without it.
### Database
| Variable | Required | Default | Description |
|---|---|---|---|
| `DATABASE_PATH` | No | `./data/cubeadmin.db` | Path to the SQLite database file. The directory must exist and be writable. In Docker, this is mapped to a named volume. |
### Server
| Variable | Required | Default | Description |
|---|---|---|---|
| `PORT` | No | `3000` | TCP port the HTTP server listens on. |
| `HOSTNAME` | No | `0.0.0.0` | Hostname the HTTP server binds to. |
| `NODE_ENV` | No | `development` | Set to `production` for production deployments. Affects CSP headers, error verbosity, and Hot Module Replacement. |
### Optional / Advanced
| Variable | Required | Default | Description |
|---|---|---|---|
| `BLUEMAP_URL` | No | — | URL where BlueMap is accessible from the browser (e.g. `http://localhost:8100`). Enables the Map page. Configurable from the Server Settings UI as well. |
| `RATE_LIMIT_RPM` | No | `100` | Maximum number of API requests per minute per IP address. Applied to all `/api/*` routes by the middleware. |
---
## Role System
CubeAdmin has three roles:
| Role | Description |
|---|---|
| `superadmin` | Full access — server control, settings, team management, everything. Automatically granted to the first registered user. |
| `admin` | Can manage players, plugins, files, backups, and the scheduler. Cannot change server settings or manage team roles. |
| `moderator` | Read-only access to most sections. Can send console commands and manage players. |
Roles are assigned when inviting team members. The initial superadmin can promote/demote others from the Team page.
---
## Project Structure
```
.
├── app/
│ ├── (auth)/ # Login, register, accept-invite pages
│ ├── (dashboard)/ # All protected dashboard pages
│ │ ├── dashboard/ # Overview page (/)
│ │ ├── console/ # Real-time server console
│ │ ├── players/ # Player management
│ │ ├── plugins/ # Plugin management
│ │ ├── files/ # File explorer
│ │ ├── backups/ # Backup manager
│ │ ├── monitoring/ # CPU/RAM charts
│ │ ├── scheduler/ # Cron task scheduler
│ │ ├── team/ # Team & invitations
│ │ ├── audit/ # Audit log
│ │ ├── map/ # BlueMap integration
│ │ ├── server/ # Server settings
│ │ ├── updates/ # Version management
│ │ └── settings/ # Account settings
│ └── api/ # API routes
├── components/
│ ├── layout/ # Sidebar, Topbar
│ └── ui/ # shadcn/ui components
├── lib/
│ ├── auth/ # Better Auth config + client
│ ├── backup/ # Backup manager
│ ├── db/ # Drizzle schema + migrations
│ ├── email/ # Nodemailer SMTP client + email templates
│ ├── minecraft/ # Process manager, RCON, version fetcher
│ ├── security/ # Rate limiting
│ └── socket/ # Socket.io server setup
├── data/ # SQLite database (gitignored)
├── server.ts # Bun entry point (HTTP + Socket.io)
├── proxy.ts # Next.js middleware (auth guard + CSP)
├── docker-compose.yml # Production stack
└── docker-compose.dev.yml # Dev Minecraft server only
```
---
## Enabling RCON on Your Minecraft Server
Add these lines to your `server.properties`:
```properties
enable-rcon=true
rcon.port=25575
rcon.password=your-strong-password
broadcast-rcon-to-ops=false
```
Restart the Minecraft server after editing `server.properties`.
---
## Security Notes
- **Change the default `BETTER_AUTH_SECRET`** before going to production. A leaked secret allows anyone to forge session tokens.
- **Use a strong RCON password.** RCON has full server control — never expose RCON port 25575 to the public internet.
- **HTTPS in production.** Better Auth cookies are `Secure` in production; the app will not authenticate over plain HTTP.
- **`BETTER_AUTH_TRUSTED_ORIGINS`** — add your production domain to prevent CSRF attacks from other origins.
- The middleware (`proxy.ts`) enforces a strict Content Security Policy with per-request nonces. No inline scripts are permitted.
- All API routes are rate-limited to 100 requests/minute per IP by default.

View File

@@ -0,0 +1,166 @@
"use client";
import { Suspense, useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert } from "@/components/ui/alert";
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
function AcceptInvitePageInner() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError("Invalid or missing invitation token.");
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch("/api/accept-invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, name, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setSuccess(true);
setTimeout(() => router.push("/login"), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 text-2xl font-bold text-emerald-500">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="2" y="2" width="11" height="11" fill="#10b981" rx="2" />
<rect x="15" y="2" width="11" height="11" fill="#10b981" rx="2" opacity="0.6" />
<rect x="2" y="15" width="11" height="11" fill="#10b981" rx="2" opacity="0.6" />
<rect x="15" y="15" width="11" height="11" fill="#10b981" rx="2" />
</svg>
CubeAdmin
</div>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-white">Accept Invitation</CardTitle>
<CardDescription className="text-zinc-400">
Create your account to access the Minecraft server panel.
</CardDescription>
</CardHeader>
<CardContent>
{success ? (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<CheckCircle2 className="w-10 h-10 text-emerald-500" />
<p className="text-white font-medium">Account created!</p>
<p className="text-zinc-400 text-sm">
Redirecting you to the login page...
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert className="bg-red-500/10 border-red-500/30 text-red-400">
<AlertCircle className="w-4 h-4" />
<span className="ml-2 text-sm">{error}</span>
</Alert>
)}
<div className="space-y-1.5">
<Label className="text-zinc-300">Your Name</Label>
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
required
disabled={!token || loading}
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Password</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
required
disabled={!token || loading}
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Confirm Password</Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Repeat your password"
required
disabled={!token || loading}
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<Button
type="submit"
disabled={!token || loading || !name || !password || !confirmPassword}
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account...
</>
) : (
"Create Account"
)}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}
export default function AcceptInvitePage() {
return (
<Suspense>
<AcceptInvitePageInner />
</Suspense>
);
}

388
app/(auth)/login/page.tsx Normal file
View File

@@ -0,0 +1,388 @@
"use client";
import React, { Suspense, useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { z } from "zod";
import { toast } from "sonner";
import { authClient } from "@/lib/auth/client";
import { cn } from "@/lib/utils";
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// ---------------------------------------------------------------------------
// Validation schema
// ---------------------------------------------------------------------------
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
type FieldErrors = Partial<Record<keyof LoginFormValues, string>>;
// ---------------------------------------------------------------------------
// CubeAdmin logo (duplicated from sidebar so this page is self-contained)
// ---------------------------------------------------------------------------
function CubeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
<polygon points="16,4 28,10 16,16 4,10" fill="#059669" opacity="0.9" />
<polygon points="4,10 16,16 16,28 4,22" fill="#047857" opacity="0.95" />
<polygon points="28,10 16,16 16,28 28,22" fill="#10b981" opacity="0.85" />
<polyline
points="4,10 16,4 28,10 16,16 4,10"
stroke="#34d399"
strokeWidth="0.75"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<line
x1="16" y1="16" x2="16" y2="28"
stroke="#34d399"
strokeWidth="0.75"
opacity="0.4"
/>
</svg>
);
}
// ---------------------------------------------------------------------------
// Form field component
// ---------------------------------------------------------------------------
interface FormFieldProps {
id: string;
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
error?: string;
placeholder?: string;
autoComplete?: string;
disabled?: boolean;
children?: React.ReactNode; // for additional elements (e.g., show/hide button)
className?: string;
}
function FormField({
id,
label,
type = "text",
value,
onChange,
error,
placeholder,
autoComplete,
disabled,
children,
className,
}: FormFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id} className="text-xs font-medium text-zinc-300">
{label}
</Label>
<div className={cn("relative", className)}>
<Input
id={id}
type={type}
value={value}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
placeholder={placeholder}
autoComplete={autoComplete}
disabled={disabled}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
className={cn(
"h-9 bg-zinc-900/60 border-zinc-800 text-zinc-100 placeholder:text-zinc-600 focus-visible:border-emerald-500/50 focus-visible:ring-emerald-500/20",
children && "pr-10",
error &&
"border-red-500/50 focus-visible:border-red-500/50 focus-visible:ring-red-500/20"
)}
/>
{children}
</div>
{error && (
<p
id={`${id}-error`}
className="flex items-center gap-1 text-xs text-red-400"
role="alert"
>
<AlertCircle className="h-3 w-3 flex-shrink-0" />
{error}
</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Login page
// ---------------------------------------------------------------------------
function LoginPageInner() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [globalError, setGlobalError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function validate(): LoginFormValues | null {
const result = loginSchema.safeParse({ email, password });
if (!result.success) {
const errors: FieldErrors = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof LoginFormValues;
if (!errors[field]) errors[field] = issue.message;
}
setFieldErrors(errors);
return null;
}
setFieldErrors({});
return result.data;
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setGlobalError(null);
const values = validate();
if (!values) return;
startTransition(async () => {
try {
const { error } = await authClient.signIn.email({
email: values.email,
password: values.password,
});
if (error) {
// Map common Better Auth error codes to friendly messages
const message = mapAuthError(error.code ?? error.message ?? "");
setGlobalError(message);
return;
}
toast.success("Signed in successfully");
router.push(callbackUrl);
router.refresh();
} catch (err) {
setGlobalError("An unexpected error occurred. Please try again.");
console.error("[login] Unexpected error:", err);
}
});
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
{/* Background grid pattern */}
<div
className="pointer-events-none fixed inset-0 bg-[size:32px_32px] opacity-[0.02]"
style={{
backgroundImage:
"linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)",
}}
aria-hidden="true"
/>
{/* Subtle radial glow */}
<div
className="pointer-events-none fixed inset-0 flex items-center justify-center"
aria-hidden="true"
>
<div className="h-[500px] w-[500px] rounded-full bg-emerald-500/5 blur-3xl" />
</div>
{/* Card */}
<div className="relative z-10 w-full max-w-sm">
{/* Logo + branding */}
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-zinc-900 ring-1 ring-white/[0.08]">
<CubeIcon className="h-9 w-9" />
</div>
<div className="text-center">
<h1 className="text-xl font-semibold tracking-tight text-zinc-100">
CubeAdmin
</h1>
<p className="mt-0.5 text-xs text-zinc-500">
Minecraft Server Management
</p>
</div>
</div>
{/* Form card */}
<div className="rounded-xl bg-zinc-900/50 p-6 ring-1 ring-white/[0.08] backdrop-blur-sm">
<div className="mb-5">
<h2 className="text-base font-semibold text-zinc-100">
Sign in to your account
</h2>
<p className="mt-1 text-xs text-zinc-500">
Enter your credentials to access the admin panel
</p>
</div>
{/* Global error banner */}
{globalError && (
<div
className="mb-4 flex items-start gap-2.5 rounded-lg bg-red-500/10 px-3 py-2.5 ring-1 ring-red-500/20"
role="alert"
aria-live="assertive"
>
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" />
<p className="text-xs text-red-300">{globalError}</p>
</div>
)}
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
{/* Email */}
<FormField
id="email"
label="Email address"
type="email"
value={email}
onChange={setEmail}
error={fieldErrors.email}
placeholder="admin@example.com"
autoComplete="email"
disabled={isPending}
/>
{/* Password */}
<FormField
id="password"
label="Password"
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
error={fieldErrors.password}
placeholder="••••••••"
autoComplete="current-password"
disabled={isPending}
>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
aria-label={showPassword ? "Hide password" : "Show password"}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</FormField>
{/* Forgot password */}
<div className="flex justify-end">
<Link
href="/forgot-password"
className="text-xs text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:underline"
>
Forgot password?
</Link>
</div>
{/* Submit */}
<Button
type="submit"
disabled={isPending}
className="mt-1 h-9 w-full bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/50 disabled:opacity-60 border-0 font-medium"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Signing in
</>
) : (
"Sign in"
)}
</Button>
</form>
</div>
{/* Register link */}
<p className="mt-4 text-center text-xs text-zinc-500">
No account?{" "}
<Link
href="/register"
className="text-zinc-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:underline"
>
Create one
</Link>
</p>
{/* Footer */}
<p className="mt-4 text-center text-[11px] text-zinc-600">
CubeAdmin &mdash; Secure server management
</p>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense>
<LoginPageInner />
</Suspense>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mapAuthError(code: string): string {
const normalised = code.toLowerCase();
if (
normalised.includes("invalid_credentials") ||
normalised.includes("invalid credentials") ||
normalised.includes("user_not_found") ||
normalised.includes("incorrect password") ||
normalised.includes("wrong password")
) {
return "Invalid email or password. Please check your credentials and try again.";
}
if (normalised.includes("account_not_found")) {
return "No account found with this email address.";
}
if (
normalised.includes("email_not_verified") ||
normalised.includes("email not verified")
) {
return "Please verify your email address before signing in.";
}
if (
normalised.includes("too_many_requests") ||
normalised.includes("rate_limit")
) {
return "Too many sign-in attempts. Please wait a few minutes before trying again.";
}
if (
normalised.includes("account_disabled") ||
normalised.includes("user_banned")
) {
return "Your account has been disabled. Please contact your administrator.";
}
return "Sign in failed. Please try again or contact your administrator.";
}

View File

@@ -0,0 +1,321 @@
"use client";
import React, { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { z } from "zod";
import { toast } from "sonner";
import { authClient } from "@/lib/auth/client";
import { cn } from "@/lib/utils";
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const registerSchema = z
.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirm: z.string(),
})
.refine((d) => d.password === d.confirm, {
message: "Passwords do not match",
path: ["confirm"],
});
type RegisterFormValues = z.infer<typeof registerSchema>;
type FieldErrors = Partial<Record<keyof RegisterFormValues, string>>;
function CubeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
<polygon points="16,4 28,10 16,16 4,10" fill="#059669" opacity="0.9" />
<polygon points="4,10 16,16 16,28 4,22" fill="#047857" opacity="0.95" />
<polygon points="28,10 16,16 16,28 28,22" fill="#10b981" opacity="0.85" />
</svg>
);
}
interface FormFieldProps {
id: string;
label: string;
type?: string;
value: string;
onChange: (value: string) => void;
error?: string;
placeholder?: string;
autoComplete?: string;
disabled?: boolean;
children?: React.ReactNode;
}
function FormField({
id,
label,
type = "text",
value,
onChange,
error,
placeholder,
autoComplete,
disabled,
children,
}: FormFieldProps) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id} className="text-xs font-medium text-zinc-300">
{label}
</Label>
<div className="relative">
<Input
id={id}
type={type}
value={value}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
placeholder={placeholder}
autoComplete={autoComplete}
disabled={disabled}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
className={cn(
"h-9 bg-zinc-900/60 border-zinc-800 text-zinc-100 placeholder:text-zinc-600 focus-visible:border-emerald-500/50 focus-visible:ring-emerald-500/20",
children && "pr-10",
error &&
"border-red-500/50 focus-visible:border-red-500/50 focus-visible:ring-red-500/20",
)}
/>
{children}
</div>
{error && (
<p
id={`${id}-error`}
className="flex items-center gap-1 text-xs text-red-400"
role="alert"
>
<AlertCircle className="h-3 w-3 flex-shrink-0" />
{error}
</p>
)}
</div>
);
}
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [globalError, setGlobalError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function validate(): RegisterFormValues | null {
const result = registerSchema.safeParse({ name, email, password, confirm });
if (!result.success) {
const errors: FieldErrors = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof RegisterFormValues;
if (!errors[field]) errors[field] = issue.message;
}
setFieldErrors(errors);
return null;
}
setFieldErrors({});
return result.data;
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setGlobalError(null);
const values = validate();
if (!values) return;
startTransition(async () => {
try {
const { error } = await authClient.signUp.email({
name: values.name,
email: values.email,
password: values.password,
});
if (error) {
const msg = error.code?.toLowerCase().includes("user_already_exists")
? "An account with this email already exists."
: (error.message ?? "Registration failed. Please try again.");
setGlobalError(msg);
return;
}
toast.success("Account created — welcome to CubeAdmin!");
router.push("/dashboard");
router.refresh();
} catch {
setGlobalError("An unexpected error occurred. Please try again.");
}
});
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
{/* Background grid */}
<div
className="pointer-events-none fixed inset-0 bg-[size:32px_32px] opacity-[0.02]"
style={{
backgroundImage:
"linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)",
}}
aria-hidden="true"
/>
{/* Radial glow */}
<div
className="pointer-events-none fixed inset-0 flex items-center justify-center"
aria-hidden="true"
>
<div className="h-[500px] w-[500px] rounded-full bg-emerald-500/5 blur-3xl" />
</div>
<div className="relative z-10 w-full max-w-sm">
{/* Logo */}
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-zinc-900 ring-1 ring-white/[0.08]">
<CubeIcon className="h-9 w-9" />
</div>
<div className="text-center">
<h1 className="text-xl font-semibold tracking-tight text-zinc-100">
CubeAdmin
</h1>
<p className="mt-0.5 text-xs text-zinc-500">
Minecraft Server Management
</p>
</div>
</div>
{/* Card */}
<div className="rounded-xl bg-zinc-900/50 p-6 ring-1 ring-white/[0.08] backdrop-blur-sm">
<div className="mb-5">
<h2 className="text-base font-semibold text-zinc-100">
Create an account
</h2>
<p className="mt-1 text-xs text-zinc-500">
The first account registered becomes the administrator.
</p>
</div>
{globalError && (
<div
className="mb-4 flex items-start gap-2.5 rounded-lg bg-red-500/10 px-3 py-2.5 ring-1 ring-red-500/20"
role="alert"
aria-live="assertive"
>
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" />
<p className="text-xs text-red-300">{globalError}</p>
</div>
)}
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
<FormField
id="name"
label="Display name"
value={name}
onChange={setName}
error={fieldErrors.name}
placeholder="Your name"
autoComplete="name"
disabled={isPending}
/>
<FormField
id="email"
label="Email address"
type="email"
value={email}
onChange={setEmail}
error={fieldErrors.email}
placeholder="admin@example.com"
autoComplete="email"
disabled={isPending}
/>
<FormField
id="password"
label="Password"
type={showPassword ? "text" : "password"}
value={password}
onChange={setPassword}
error={fieldErrors.password}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={isPending}
>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
aria-label={showPassword ? "Hide password" : "Show password"}
tabIndex={-1}
>
{showPassword ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</FormField>
<FormField
id="confirm"
label="Confirm password"
type={showConfirm ? "text" : "password"}
value={confirm}
onChange={setConfirm}
error={fieldErrors.confirm}
placeholder="Repeat your password"
autoComplete="new-password"
disabled={isPending}
>
<button
type="button"
onClick={() => setShowConfirm((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
aria-label={showConfirm ? "Hide password" : "Show password"}
tabIndex={-1}
>
{showConfirm ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</FormField>
<Button
type="submit"
disabled={isPending}
className="mt-1 h-9 w-full bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/50 disabled:opacity-60 border-0 font-medium"
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Creating account
</>
) : (
"Create account"
)}
</Button>
</form>
</div>
<p className="mt-4 text-center text-xs text-zinc-500">
Already have an account?{" "}
<Link
href="/login"
className="text-zinc-300 transition-colors hover:text-white focus-visible:outline-none focus-visible:underline"
>
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollText, RefreshCw, Search, ChevronLeft, ChevronRight } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
interface AuditEntry {
log: {
id: string;
userId: string;
action: string;
target: string;
targetId: string | null;
details: string | null;
ipAddress: string | null;
createdAt: number;
};
userName: string | null;
userEmail: string | null;
}
const ACTION_COLORS: Record<string, string> = {
"server.start": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
"server.stop": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
"server.restart": "bg-blue-500/20 text-blue-400 border-blue-500/30",
"player.ban": "bg-red-500/20 text-red-400 border-red-500/30",
"player.unban": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
"player.kick": "bg-amber-500/20 text-amber-400 border-amber-500/30",
"file.upload": "bg-blue-500/20 text-blue-400 border-blue-500/30",
"file.delete": "bg-red-500/20 text-red-400 border-red-500/30",
"plugin.enable": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
"plugin.disable": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
};
function getActionColor(action: string): string {
return ACTION_COLORS[action] ?? "bg-zinc-500/20 text-zinc-400 border-zinc-500/30";
}
export default function AuditPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [hasMore, setHasMore] = useState(true);
const LIMIT = 50;
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
limit: String(LIMIT),
});
if (search) params.set("action", search);
const res = await fetch(`/api/audit?${params}`);
if (res.ok) {
const data = await res.json();
setEntries(data.logs);
setHasMore(data.logs.length === LIMIT);
}
} finally {
setLoading(false);
}
}, [page, search]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
<p className="text-zinc-400 text-sm mt-1">
Complete history of admin actions
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchLogs}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
{/* Filter */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Filter by action (e.g. player.ban)"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
))}
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<ScrollText className="w-10 h-10 mb-3 opacity-50" />
<p>No audit log entries</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span>Action</span>
<span>Details</span>
<span>User</span>
<span>Time</span>
</div>
{entries.map(({ log, userName, userEmail }) => (
<div
key={log.id}
className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-3 items-start hover:bg-zinc-800/50 transition-colors"
>
<Badge className={`text-xs shrink-0 mt-0.5 ${getActionColor(log.action)}`}>
{log.action}
</Badge>
<div>
{log.targetId && (
<p className="text-sm text-zinc-300">
Target:{" "}
<span className="font-mono text-xs text-zinc-400">
{log.targetId}
</span>
</p>
)}
{log.details && (
<p className="text-xs text-zinc-500 font-mono mt-0.5 truncate max-w-xs">
{log.details}
</p>
)}
{log.ipAddress && (
<p className="text-xs text-zinc-600 mt-0.5">
IP: {log.ipAddress}
</p>
)}
</div>
<div className="text-right">
<p className="text-sm text-zinc-300">
{userName ?? "Unknown"}
</p>
<p className="text-xs text-zinc-600">{userEmail ?? ""}</p>
</div>
<p className="text-xs text-zinc-500 whitespace-nowrap">
{formatDistanceToNow(new Date(log.createdAt), {
addSuffix: true,
})}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-500">Page {page}</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1 || loading}
onClick={() => setPage((p) => p - 1)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasMore || loading}
onClick={() => setPage((p) => p + 1)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,324 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Archive,
Download,
Trash2,
RefreshCw,
Plus,
MoreHorizontal,
Globe,
Puzzle,
Settings,
Package,
} from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow, format } from "date-fns";
interface Backup {
id: string;
name: string;
type: "worlds" | "plugins" | "config" | "full";
size: number;
path: string;
createdAt: number;
status: "pending" | "running" | "completed" | "failed";
triggeredBy: string;
}
const TYPE_CONFIG = {
worlds: { label: "Worlds", icon: Globe, color: "text-blue-400", bg: "bg-blue-500/10" },
plugins: { label: "Plugins", icon: Puzzle, color: "text-amber-400", bg: "bg-amber-500/10" },
config: { label: "Config", icon: Settings, color: "text-violet-400", bg: "bg-violet-500/10" },
full: { label: "Full", icon: Package, color: "text-emerald-400", bg: "bg-emerald-500/10" },
};
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export default function BackupsPage() {
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const fetchBackups = useCallback(async () => {
try {
const res = await fetch("/api/backups");
if (res.ok) setBackups((await res.json()).backups);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchBackups();
// Poll every 5s to catch running->completed transitions
const interval = setInterval(fetchBackups, 5000);
return () => clearInterval(interval);
}, [fetchBackups]);
const createBackup = async (type: Backup["type"]) => {
setCreating(true);
try {
const res = await fetch("/api/backups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${TYPE_CONFIG[type].label} backup started`);
fetchBackups();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Backup failed");
} finally {
setCreating(false);
}
};
const deleteBackup = async (id: string) => {
try {
const res = await fetch(`/api/backups/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Backup deleted");
setBackups((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
}
setDeleteId(null);
};
const downloadBackup = (id: string) => {
window.open(`/api/backups/${id}`, "_blank");
};
const statusBadge = (status: Backup["status"]) => {
const config = {
pending: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
running: "bg-blue-500/20 text-blue-400 border-blue-500/30",
completed: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
failed: "bg-red-500/20 text-red-400 border-red-500/30",
};
return (
<Badge className={`text-xs ${config[status]}`}>
{status === "running" && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5 animate-pulse" />
)}
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
);
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Backups</h1>
<p className="text-zinc-400 text-sm mt-1">
Create and manage server backups
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchBackups}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger disabled={creating} className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium h-7 gap-1 px-2.5 bg-emerald-600 hover:bg-emerald-500 text-white transition-all">
<Plus className="w-4 h-4 mr-1.5" />
New Backup
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<DropdownMenuItem
key={type}
onClick={() => createBackup(type)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Icon className={`w-4 h-4 mr-2 ${config.color}`} />
{config.label} backup
</DropdownMenuItem>
);
},
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Type summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
([type, config]) => {
const Icon = config.icon;
const count = backups.filter(
(b) => b.type === type && b.status === "completed",
).length;
return (
<Card
key={type}
className="bg-zinc-900 border-zinc-800 cursor-pointer hover:border-zinc-700 transition-colors"
onClick={() => createBackup(type)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`p-2 rounded-lg ${config.bg}`}>
<Icon className={`w-5 h-5 ${config.color}`} />
</div>
<div>
<p className="text-sm font-medium text-white">
{config.label}
</p>
<p className="text-xs text-zinc-500">{count} backup(s)</p>
</div>
</CardContent>
</Card>
);
},
)}
</div>
{/* Backups list */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full bg-zinc-800" />
))}
</div>
) : backups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Archive className="w-10 h-10 mb-3 opacity-50" />
<p>No backups yet</p>
<p className="text-sm mt-1">Create your first backup above</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span />
<span>Name</span>
<span>Size</span>
<span>Status</span>
<span>Created</span>
<span />
</div>
{backups.map((backup) => {
const typeConfig = TYPE_CONFIG[backup.type];
const Icon = typeConfig.icon;
return (
<div
key={backup.id}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
>
<div className={`p-1.5 rounded-md ${typeConfig.bg}`}>
<Icon className={`w-4 h-4 ${typeConfig.color}`} />
</div>
<div>
<p className="text-sm font-medium text-white truncate max-w-xs">
{backup.name}
</p>
<p className="text-xs text-zinc-500 capitalize">
{backup.type}
</p>
</div>
<span className="text-sm text-zinc-400">
{backup.size > 0 ? formatBytes(backup.size) : "—"}
</span>
{statusBadge(backup.status)}
<span className="text-sm text-zinc-500">
{formatDistanceToNow(new Date(backup.createdAt), {
addSuffix: true,
})}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
{backup.status === "completed" && (
<DropdownMenuItem
onClick={() => downloadBackup(backup.id)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Download className="w-4 h-4 mr-2" />
Download
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setDeleteId(backup.id)}
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Delete backup?
</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">
This will permanently delete the backup file from disk. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteBackup(deleteId)}
className="bg-red-600 hover:bg-red-500 text-white"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,255 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { io, Socket } from "socket.io-client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Terminal, Send, Trash2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
interface LogLine {
text: string;
timestamp: number;
type: "info" | "warn" | "error" | "raw";
}
function classifyLine(line: string): LogLine["type"] {
if (/\[WARN\]|WARNING/i.test(line)) return "warn";
if (/\[ERROR\]|SEVERE|Exception|Error/i.test(line)) return "error";
return "info";
}
function LineColor({ type }: { type: LogLine["type"] }) {
const colors = {
info: "text-zinc-300",
warn: "text-amber-400",
error: "text-red-400",
raw: "text-zinc-500",
};
return colors[type];
}
const MAX_LINES = 1000;
export default function ConsolePage() {
const [lines, setLines] = useState<LogLine[]>([]);
const [command, setCommand] = useState("");
const [connected, setConnected] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const socketRef = useRef<Socket | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
useEffect(() => {
const socket = io("/console", {
transports: ["websocket"],
});
socketRef.current = socket;
socket.on("connect", () => setConnected(true));
socket.on("disconnect", () => setConnected(false));
socket.on("connect_error", () => {
toast.error("Failed to connect to server console");
});
socket.on("output", (data: { line: string; timestamp: number }) => {
setLines((prev) => {
const newLine: LogLine = {
text: data.line,
timestamp: data.timestamp,
type: classifyLine(data.line),
};
const updated = [...prev, newLine];
return updated.length > MAX_LINES ? updated.slice(-MAX_LINES) : updated;
});
});
// Receive buffered history on connect
socket.on("history", (data: string[] | { lines: string[] }) => {
const rawLines = Array.isArray(data) ? data : (data?.lines ?? []);
const historicalLines = rawLines.map((line) => ({
text: line,
timestamp: Date.now(),
type: classifyLine(line) as LogLine["type"],
}));
setLines(historicalLines);
});
return () => {
socket.disconnect();
};
}, []);
// Auto-scroll to bottom
useEffect(() => {
if (autoScrollRef.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lines]);
const sendCommand = useCallback(() => {
const cmd = command.trim();
if (!cmd || !socketRef.current) return;
// Add to local history
setHistory((prev) => {
const updated = [cmd, ...prev.filter((h) => h !== cmd)].slice(0, 50);
return updated;
});
setHistoryIndex(-1);
// Echo to console
setLines((prev) => [
...prev,
{ text: `> ${cmd}`, timestamp: Date.now(), type: "raw" },
]);
socketRef.current.emit("command", { command: cmd });
setCommand("");
}, [command]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
sendCommand();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const newIndex = Math.min(historyIndex + 1, history.length - 1);
setHistoryIndex(newIndex);
if (history[newIndex]) setCommand(history[newIndex]);
} else if (e.key === "ArrowDown") {
e.preventDefault();
const newIndex = Math.max(historyIndex - 1, -1);
setHistoryIndex(newIndex);
setCommand(newIndex === -1 ? "" : history[newIndex] ?? "");
}
};
const formatTime = (ts: number) =>
new Date(ts).toLocaleTimeString("en", { hour12: false });
return (
<div className="p-6 h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Server Console</h1>
<p className="text-zinc-400 text-sm mt-1">
Real-time server output and command input
</p>
</div>
<div className="flex items-center gap-3">
<Badge
className={
connected
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
: "bg-red-500/20 text-red-400 border-red-500/30"
}
>
<span
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
connected ? "bg-emerald-500" : "bg-red-500"
} animate-pulse`}
/>
{connected ? "Connected" : "Disconnected"}
</Badge>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400 hover:text-white"
onClick={() => setLines([])}
>
<Trash2 className="w-4 h-4 mr-1.5" />
Clear
</Button>
</div>
</div>
<Card className="bg-zinc-900 border-zinc-800 flex-1 min-h-0 flex flex-col">
<CardHeader className="py-3 px-4 border-b border-zinc-800 flex-row items-center gap-2">
<Terminal className="w-4 h-4 text-emerald-500" />
<CardTitle className="text-sm font-medium text-zinc-400">
Console Output
</CardTitle>
<span className="ml-auto text-xs text-zinc-600">
{lines.length} lines
</span>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0">
<div
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
autoScrollRef.current =
el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
}}
className="h-full overflow-y-auto font-mono text-xs p-4 space-y-0.5"
style={{ maxHeight: "calc(100vh - 280px)" }}
>
{lines.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-zinc-600">
<Terminal className="w-8 h-8 mb-2 opacity-50" />
<p>Waiting for server output...</p>
</div>
) : (
lines.map((line, i) => (
<div key={i} className="flex gap-3 leading-5">
<span className="text-zinc-700 shrink-0 select-none">
{formatTime(line.timestamp)}
</span>
<span
className={`break-all ${
{
info: "text-zinc-300",
warn: "text-amber-400",
error: "text-red-400",
raw: "text-emerald-400",
}[line.type]
}`}
>
{line.text}
</span>
</div>
))
)}
</div>
</CardContent>
{/* Command input */}
<div className="p-3 border-t border-zinc-800 flex gap-2">
<div className="flex-1 flex items-center gap-2 bg-zinc-950 border border-zinc-700 rounded-md px-3 focus-within:border-emerald-500/50 transition-colors">
<span className="text-emerald-500 font-mono text-sm select-none">
/
</span>
<input
type="text"
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter server command... (↑↓ for history)"
disabled={!connected}
className="flex-1 bg-transparent py-2 text-sm text-white placeholder:text-zinc-600 outline-none font-mono"
/>
</div>
<Button
onClick={sendCommand}
disabled={!connected || !command.trim()}
className="bg-emerald-600 hover:bg-emerald-500 text-white shrink-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
</Card>
{!connected && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
Console disconnected. The server may be offline or restarting.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import {
Users,
Puzzle,
HardDrive,
Activity,
Clock,
Zap,
TrendingUp,
AlertTriangle,
} from "lucide-react";
interface MonitoringData {
system: {
cpuPercent: number;
totalMemMb: number;
usedMemMb: number;
loadAvg: number[];
uptime: number;
};
server: {
running: boolean;
uptime?: number;
startedAt?: string;
};
timestamp: number;
}
interface StatsData {
totalPlayers: number;
onlinePlayers: number;
enabledPlugins: number;
totalPlugins: number;
pendingBackups: number;
recentAlerts: string[];
}
function StatCard({
title,
value,
subtitle,
icon: Icon,
accent = "emerald",
loading = false,
}: {
title: string;
value: string | number;
subtitle?: string;
icon: React.ElementType;
accent?: "emerald" | "blue" | "amber" | "red";
loading?: boolean;
}) {
const colors = {
emerald: "text-emerald-500 bg-emerald-500/10",
blue: "text-blue-500 bg-blue-500/10",
amber: "text-amber-500 bg-amber-500/10",
red: "text-red-500 bg-red-500/10",
};
return (
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-zinc-400 mb-1">{title}</p>
{loading ? (
<Skeleton className="h-8 w-20 bg-zinc-800" />
) : (
<p className="text-2xl font-bold text-white">{value}</p>
)}
{subtitle && (
<p className="text-xs text-zinc-500 mt-1">{subtitle}</p>
)}
</div>
<div className={`p-2.5 rounded-lg ${colors[accent]}`}>
<Icon className="w-5 h-5" />
</div>
</div>
</CardContent>
</Card>
);
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export default function DashboardPage() {
const [monitoring, setMonitoring] = useState<MonitoringData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchMonitoring = async () => {
try {
const res = await fetch("/api/monitoring");
if (res.ok) setMonitoring(await res.json());
} finally {
setLoading(false);
}
};
fetchMonitoring();
const interval = setInterval(fetchMonitoring, 5000);
return () => clearInterval(interval);
}, []);
const memPercent = monitoring
? Math.round((monitoring.system.usedMemMb / monitoring.system.totalMemMb) * 100)
: 0;
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 text-sm mt-1">
Real-time overview of your Minecraft server
</p>
</div>
{/* Status banner */}
<div
className={`flex items-center gap-3 p-4 rounded-xl border ${
monitoring?.server.running
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "bg-red-500/10 border-red-500/30 text-red-400"
}`}
>
<div
className={`w-2.5 h-2.5 rounded-full animate-pulse ${
monitoring?.server.running ? "bg-emerald-500" : "bg-red-500"
}`}
/>
<span className="font-medium">
Server is {monitoring?.server.running ? "Online" : "Offline"}
</span>
{monitoring?.server.running && monitoring.server.uptime && (
<span className="text-sm opacity-70 ml-auto">
Up for {formatUptime(monitoring.server.uptime)}
</span>
)}
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard
title="CPU Usage"
value={loading ? "—" : `${monitoring?.system.cpuPercent ?? 0}%`}
subtitle={`Load avg: ${monitoring?.system.loadAvg[0].toFixed(2) ?? "—"}`}
icon={Activity}
accent="blue"
loading={loading}
/>
<StatCard
title="Memory"
value={loading ? "—" : `${monitoring?.system.usedMemMb ?? 0} MB`}
subtitle={`of ${monitoring?.system.totalMemMb ?? 0} MB total`}
icon={HardDrive}
accent={memPercent > 85 ? "red" : memPercent > 60 ? "amber" : "emerald"}
loading={loading}
/>
<StatCard
title="System Uptime"
value={loading ? "—" : formatUptime(monitoring?.system.uptime ?? 0)}
icon={Clock}
accent="emerald"
loading={loading}
/>
<StatCard
title="Server Status"
value={monitoring?.server.running ? "Online" : "Offline"}
icon={Zap}
accent={monitoring?.server.running ? "emerald" : "red"}
loading={loading}
/>
</div>
{/* Resource gauges */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<Activity className="w-4 h-4 text-blue-500" />
CPU Usage
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-4 w-full bg-zinc-800" />
) : (
<>
<div className="flex justify-between text-sm mb-2">
<span className="text-white font-medium">
{monitoring?.system.cpuPercent ?? 0}%
</span>
<span className="text-zinc-500">100%</span>
</div>
<Progress
value={monitoring?.system.cpuPercent ?? 0}
className="h-2 bg-zinc-800"
/>
</>
)}
</CardContent>
</Card>
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<HardDrive className="w-4 h-4 text-emerald-500" />
Memory Usage
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-4 w-full bg-zinc-800" />
) : (
<>
<div className="flex justify-between text-sm mb-2">
<span className="text-white font-medium">
{monitoring?.system.usedMemMb ?? 0} MB
</span>
<span className="text-zinc-500">
{monitoring?.system.totalMemMb ?? 0} MB
</span>
</div>
<Progress
value={memPercent}
className="h-2 bg-zinc-800"
/>
</>
)}
</CardContent>
</Card>
</div>
{/* Quick info */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-emerald-500" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "View Console", href: "/console", icon: "⌨️" },
{ label: "Manage Players", href: "/players", icon: "👥" },
{ label: "Plugins", href: "/plugins", icon: "🔌" },
{ label: "Create Backup", href: "/backups", icon: "💾" },
].map(({ label, href, icon }) => (
<a
key={href}
href={href}
className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-sm text-zinc-300 hover:text-white"
>
<span>{icon}</span>
<span>{label}</span>
</a>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,441 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
FolderOpen,
File,
FileText,
ChevronRight,
Upload,
Download,
Trash2,
RefreshCw,
ArrowLeft,
Home,
} from "lucide-react";
import { toast } from "sonner";
import { useDropzone } from "react-dropzone";
import { formatDistanceToNow } from "date-fns";
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size: number;
modifiedAt: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
const TEXT_EXTENSIONS = new Set([".txt", ".yml", ".yaml", ".json", ".properties", ".toml", ".cfg", ".conf", ".log", ".md", ".sh", ".ini"]);
function getEditorLanguage(name: string): string {
const ext = name.split(".").pop() ?? "";
const map: Record<string, string> = {
json: "json", yml: "yaml", yaml: "yaml",
properties: "ini", toml: "ini", cfg: "ini",
sh: "shell", md: "markdown", log: "plaintext",
};
return map[ext] ?? "plaintext";
}
function isEditable(name: string): boolean {
const ext = "." + name.split(".").pop()?.toLowerCase();
return TEXT_EXTENSIONS.has(ext);
}
export default function FilesPage() {
const [currentPath, setCurrentPath] = useState("/");
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(null);
const [editFile, setEditFile] = useState<{ path: string; content: string } | null>(null);
const [saving, setSaving] = useState(false);
const fetchEntries = useCallback(async (path: string) => {
setLoading(true);
try {
const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error((await res.json()).error);
const data = await res.json();
setEntries(data.entries);
setCurrentPath(data.path);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load directory");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchEntries("/"); }, [fetchEntries]);
const handleOpen = (entry: FileEntry) => {
if (entry.isDirectory) {
fetchEntries(entry.path);
} else if (isEditable(entry.name)) {
openEditor(entry);
} else {
window.open(`/api/files/download?path=${encodeURIComponent(entry.path)}`, "_blank");
}
};
const openEditor = async (entry: FileEntry) => {
try {
const res = await fetch(`/api/files/download?path=${encodeURIComponent(entry.path)}`);
const text = await res.text();
setEditFile({ path: entry.path, content: text });
} catch {
toast.error("Failed to open file");
}
};
const saveFile = async () => {
if (!editFile) return;
setSaving(true);
try {
const blob = new Blob([editFile.content], { type: "text/plain" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const FileClass = (globalThis as any).File ?? Blob;
const file = new FileClass([blob], editFile.path.split("/").pop() ?? "file") as File;
const dir = editFile.path.split("/").slice(0, -1).join("/") || "/";
const form = new FormData();
form.append("file", file);
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(dir)}`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success("File saved");
setEditFile(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleDelete = async (entry: FileEntry) => {
try {
const res = await fetch("/api/files/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filePath: entry.path }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${entry.name} deleted`);
fetchEntries(currentPath);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
}
setDeleteTarget(null);
};
const { getRootProps, getInputProps, isDragActive, open: openUpload } = useDropzone({
noClick: true,
noKeyboard: true,
onDrop: async (acceptedFiles) => {
for (const file of acceptedFiles) {
const form = new FormData();
form.append("file", file);
try {
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(currentPath)}`, {
method: "POST",
body: form,
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${file.name} uploaded`);
} catch (err) {
toast.error(`Failed to upload ${file.name}`);
}
}
fetchEntries(currentPath);
},
});
// Breadcrumbs
const parts = currentPath === "/" ? [] : currentPath.split("/").filter(Boolean);
const navigateUp = () => {
if (parts.length === 0) return;
const parent = parts.length === 1 ? "/" : "/" + parts.slice(0, -1).join("/");
fetchEntries(parent);
};
if (editFile) {
const fileName = editFile.path.split("/").pop() ?? "file";
return (
<div className="p-6 h-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => setEditFile(null)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<ArrowLeft className="w-4 h-4 mr-1.5" />
Back
</Button>
<div>
<h1 className="text-lg font-bold text-white">{fileName}</h1>
<p className="text-xs text-zinc-500 font-mono">{editFile.path}</p>
</div>
</div>
<Button
onClick={saveFile}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save File"}
</Button>
</div>
<div className="flex-1 rounded-xl overflow-hidden border border-zinc-800" style={{ minHeight: "500px" }}>
<MonacoEditor
height="100%"
language={getEditorLanguage(fileName)}
theme="vs-dark"
value={editFile.content}
onChange={(v) => setEditFile((prev) => prev ? { ...prev, content: v ?? "" } : null)}
options={{
fontSize: 13,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "on",
padding: { top: 16, bottom: 16 },
}}
/>
</div>
</div>
);
}
return (
<div className="p-6 space-y-4" {...getRootProps()}>
<input {...getInputProps()} />
{isDragActive && (
<div className="fixed inset-0 z-50 bg-emerald-500/10 border-2 border-dashed border-emerald-500/50 flex items-center justify-center pointer-events-none">
<div className="text-center text-emerald-400">
<Upload className="w-12 h-12 mx-auto mb-2" />
<p className="text-lg font-semibold">Drop files to upload</p>
</div>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">File Explorer</h1>
<p className="text-zinc-400 text-sm mt-1">
Browse and manage Minecraft server files
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fetchEntries(currentPath)}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
size="sm"
onClick={openUpload}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
<Upload className="w-4 h-4 mr-1.5" />
Upload
</Button>
</div>
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-1 text-sm text-zinc-500">
<Button
variant="ghost"
size="sm"
onClick={() => fetchEntries("/")}
className="h-7 px-2 text-zinc-400 hover:text-white"
>
<Home className="w-3.5 h-3.5" />
</Button>
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="w-3.5 h-3.5" />
<Button
variant="ghost"
size="sm"
onClick={() => fetchEntries("/" + parts.slice(0, i + 1).join("/"))}
className="h-7 px-2 text-zinc-400 hover:text-white"
>
{part}
</Button>
</span>
))}
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full bg-zinc-800" />
))}
</div>
) : entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<FolderOpen className="w-10 h-10 mb-3 opacity-50" />
<p>Empty directory</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
{parts.length > 0 && (
<button
onClick={navigateUp}
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
>
<ArrowLeft className="w-4 h-4 text-zinc-600" />
<span className="text-sm text-zinc-500">..</span>
</button>
)}
{entries.map((entry) => (
<ContextMenu key={entry.path}>
<ContextMenuTrigger>
<button
onDoubleClick={() => handleOpen(entry)}
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
>
{entry.isDirectory ? (
<FolderOpen className="w-4 h-4 text-amber-500 shrink-0" />
) : isEditable(entry.name) ? (
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
) : (
<File className="w-4 h-4 text-zinc-500 shrink-0" />
)}
<span className="flex-1 text-sm text-zinc-300 truncate">
{entry.name}
</span>
{!entry.isDirectory && (
<span className="text-xs text-zinc-600 shrink-0">
{formatBytes(entry.size)}
</span>
)}
<span className="text-xs text-zinc-600 shrink-0 hidden sm:block">
{entry.modifiedAt
? formatDistanceToNow(new Date(entry.modifiedAt), {
addSuffix: true,
})
: ""}
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent className="bg-zinc-900 border-zinc-700">
{entry.isDirectory ? (
<ContextMenuItem
onClick={() => fetchEntries(entry.path)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<FolderOpen className="w-4 h-4 mr-2" />
Open
</ContextMenuItem>
) : (
<>
{isEditable(entry.name) && (
<ContextMenuItem
onClick={() => openEditor(entry)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<FileText className="w-4 h-4 mr-2" />
Edit
</ContextMenuItem>
)}
<ContextMenuItem
onClick={() =>
window.open(
`/api/files/download?path=${encodeURIComponent(entry.path)}`,
"_blank",
)
}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Download className="w-4 h-4 mr-2" />
Download
</ContextMenuItem>
</>
)}
<ContextMenuSeparator className="bg-zinc-700" />
<ContextMenuItem
onClick={() => setDeleteTarget(entry)}
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</CardContent>
</Card>
<p className="text-xs text-zinc-600 text-center">
Double-click to open files/folders Right-click for options Drag and drop to upload
</p>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Delete {deleteTarget?.isDirectory ? "folder" : "file"}?
</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">
<span className="font-mono text-white">{deleteTarget?.name}</span>{" "}
will be permanently deleted.
{deleteTarget?.isDirectory && " All contents will be removed."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && handleDelete(deleteTarget)}
className="bg-red-600 hover:bg-red-500 text-white"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
// ---------------------------------------------------------------------------
// Dashboard Layout
// Renders the persistent sidebar + topbar shell around all dashboard pages.
// Auth protection is handled in middleware.ts.
// ---------------------------------------------------------------------------
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen w-full overflow-hidden bg-zinc-950">
{/* Fixed sidebar */}
<Sidebar />
{/* Main content column */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Sticky topbar */}
<Topbar />
{/* Scrollable page content */}
<main className="flex-1 overflow-y-auto bg-zinc-950">
<div className="min-h-full p-6">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Map, ExternalLink, AlertCircle } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
export default function MapPage() {
const [bluemapUrl, setBluemapUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [iframeLoaded, setIframeLoaded] = useState(false);
const [iframeError, setIframeError] = useState(false);
useEffect(() => {
// Fetch BlueMap URL from server settings
fetch("/api/server/settings")
.then((r) => r.json())
.then((data) => {
setBluemapUrl(data?.settings?.bluemapUrl ?? null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-48 bg-zinc-800" />
<Skeleton className="h-[70vh] w-full bg-zinc-800 rounded-xl" />
</div>
);
}
if (!bluemapUrl) {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
<p className="text-zinc-400 text-sm mt-1">
Interactive BlueMap integration
</p>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="flex flex-col items-center justify-center py-20">
<Map className="w-12 h-12 text-zinc-600 mb-4" />
<h3 className="text-white font-semibold mb-2">
BlueMap not configured
</h3>
<p className="text-zinc-500 text-sm text-center max-w-md mb-6">
BlueMap is a 3D world map plugin for Minecraft servers. Configure
its URL in <strong>Server Settings</strong> to embed it here.
</p>
<a href="/server" className={buttonVariants({ className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>Go to Server Settings</a>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
<p className="text-zinc-400 text-sm mt-1">
Powered by BlueMap live world view with player positions
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs">
BlueMap
</Badge>
<a href={bluemapUrl} target="_blank" rel="noopener noreferrer" className={buttonVariants({ variant: "outline", size: "sm", className: "border-zinc-700 text-zinc-400 hover:text-white" })}>
<ExternalLink className="w-4 h-4 mr-1.5" />
Open in new tab
</a>
</div>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-zinc-800 overflow-hidden bg-zinc-950 relative">
{!iframeLoaded && !iframeError && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
<div className="flex flex-col items-center gap-3 text-zinc-500">
<div className="w-8 h-8 border-2 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin" />
<p className="text-sm">Loading BlueMap...</p>
</div>
</div>
)}
{iframeError && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
<div className="flex flex-col items-center gap-3 text-zinc-500 max-w-sm text-center">
<AlertCircle className="w-10 h-10 text-amber-500" />
<p className="text-white font-medium">Could not load BlueMap</p>
<p className="text-sm">
Make sure BlueMap is running at{" "}
<code className="text-emerald-400 text-xs">{bluemapUrl}</code>{" "}
and that the URL is accessible from your browser.
</p>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400"
onClick={() => {
setIframeError(false);
setIframeLoaded(false);
}}
>
Retry
</Button>
</div>
</div>
)}
<iframe
src={bluemapUrl}
className="w-full h-full border-0"
style={{ minHeight: "600px" }}
onLoad={() => setIframeLoaded(true)}
onError={() => setIframeError(true)}
title="BlueMap 3D World Map"
sandbox="allow-scripts allow-same-origin allow-forms"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { io } from "socket.io-client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
import { Activity, HardDrive, Clock, Cpu, Server } from "lucide-react";
interface DataPoint {
time: string;
cpu: number;
memory: number;
players: number;
}
interface MonitoringData {
system: {
cpuPercent: number;
totalMemMb: number;
usedMemMb: number;
loadAvg: number[];
uptime: number;
};
server: { running: boolean; uptime?: number };
timestamp: number;
}
const MAX_HISTORY = 60; // 60 data points (e.g. 2 minutes at 2s intervals)
function formatUptime(s: number) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${s % 60}s`;
}
export default function MonitoringPage() {
const [data, setData] = useState<MonitoringData | null>(null);
const [history, setHistory] = useState<DataPoint[]>([]);
const socketRef = useRef<ReturnType<typeof io> | null>(null);
useEffect(() => {
// Use REST polling (Socket.io monitoring namespace is optional here)
const poll = async () => {
try {
const res = await fetch("/api/monitoring");
if (!res.ok) return;
const json: MonitoringData = await res.json();
setData(json);
const time = new Date(json.timestamp).toLocaleTimeString("en", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setHistory((prev) => {
const updated = [
...prev,
{
time,
cpu: json.system.cpuPercent,
memory: Math.round(
(json.system.usedMemMb / json.system.totalMemMb) * 100,
),
players: 0,
},
];
return updated.length > MAX_HISTORY
? updated.slice(-MAX_HISTORY)
: updated;
});
} catch {}
};
poll();
const interval = setInterval(poll, 2000);
return () => clearInterval(interval);
}, []);
const memPercent = data
? Math.round((data.system.usedMemMb / data.system.totalMemMb) * 100)
: 0;
const chartTheme = {
grid: "#27272a",
axis: "#52525b",
tooltip: { bg: "#18181b", border: "#3f3f46", text: "#fff" },
};
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Monitoring</h1>
<p className="text-zinc-400 text-sm mt-1">
Real-time system and server performance metrics
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{
title: "CPU",
value: `${data?.system.cpuPercent ?? 0}%`,
icon: Cpu,
color: "text-blue-500",
bg: "bg-blue-500/10",
},
{
title: "Memory",
value: `${memPercent}%`,
icon: HardDrive,
color: "text-emerald-500",
bg: "bg-emerald-500/10",
sub: `${data?.system.usedMemMb ?? 0} / ${data?.system.totalMemMb ?? 0} MB`,
},
{
title: "Load Avg",
value: data?.system.loadAvg[0].toFixed(2) ?? "—",
icon: Activity,
color: "text-amber-500",
bg: "bg-amber-500/10",
},
{
title: "Uptime",
value: data ? formatUptime(data.system.uptime) : "—",
icon: Clock,
color: "text-violet-500",
bg: "bg-violet-500/10",
},
].map(({ title, value, icon: Icon, color, bg, sub }) => (
<Card key={title} className="bg-zinc-900 border-zinc-800">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<p className="text-sm text-zinc-400">{title}</p>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
</div>
<p className="text-2xl font-bold text-white">{value}</p>
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
</CardContent>
</Card>
))}
</div>
{/* CPU chart */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<Cpu className="w-4 h-4 text-blue-500" />
CPU Usage Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={history}>
<defs>
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="time"
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: chartTheme.tooltip.bg,
border: `1px solid ${chartTheme.tooltip.border}`,
borderRadius: 8,
color: chartTheme.tooltip.text,
fontSize: 12,
}}
formatter={(v) => [`${v}%`, "CPU"]}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="#3b82f6"
strokeWidth={2}
fill="url(#cpuGrad)"
dot={false}
activeDot={{ r: 4, fill: "#3b82f6" }}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Memory chart */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<HardDrive className="w-4 h-4 text-emerald-500" />
Memory Usage Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={history}>
<defs>
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="time"
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
interval="preserveStartEnd"
/>
<YAxis
domain={[0, 100]}
stroke={chartTheme.axis}
tick={{ fontSize: 11, fill: "#71717a" }}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: chartTheme.tooltip.bg,
border: `1px solid ${chartTheme.tooltip.border}`,
borderRadius: 8,
color: chartTheme.tooltip.text,
fontSize: 12,
}}
formatter={(v) => [`${v}%`, "Memory"]}
/>
<Area
type="monotone"
dataKey="memory"
stroke="#10b981"
strokeWidth={2}
fill="url(#memGrad)"
dot={false}
activeDot={{ r: 4, fill: "#10b981" }}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,386 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Search,
MoreHorizontal,
Ban,
UserX,
Shield,
Clock,
Users,
WifiOff,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
interface Player {
id: string;
uuid: string | null;
username: string;
firstSeen: number;
lastSeen: number;
isOnline: boolean;
playTime: number;
role: string | null;
isBanned: boolean;
notes: string | null;
}
function PlayerAvatar({ username }: { username: string }) {
return (
<Avatar className="w-8 h-8">
<AvatarImage
src={`https://crafatar.com/avatars/${username}?size=32&overlay`}
alt={username}
/>
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
{username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
);
}
function formatPlayTime(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}m`;
return `${h}h ${m}m`;
}
export default function PlayersPage() {
const [players, setPlayers] = useState<Player[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "online" | "banned">("all");
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [banReason, setBanReason] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const fetchPlayers = useCallback(async () => {
try {
const params = new URLSearchParams({ q: search, limit: "100" });
if (filter === "online") params.set("online", "true");
if (filter === "banned") params.set("banned", "true");
const res = await fetch(`/api/players?${params}`);
if (res.ok) {
const data = await res.json();
setPlayers(data.players);
}
} finally {
setLoading(false);
}
}, [search, filter]);
useEffect(() => {
setLoading(true);
fetchPlayers();
}, [fetchPlayers]);
const handleBan = async () => {
if (!selectedPlayer || !banReason.trim()) return;
setActionLoading(true);
try {
const res = await fetch(`/api/players/${selectedPlayer.id}?action=ban`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: banReason }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${selectedPlayer.username} has been banned`);
setBanDialogOpen(false);
setBanReason("");
fetchPlayers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to ban player");
} finally {
setActionLoading(false);
}
};
const handleUnban = async (player: Player) => {
try {
const res = await fetch(`/api/players/${player.id}?action=unban`, {
method: "POST",
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${player.username} has been unbanned`);
fetchPlayers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to unban player");
}
};
const handleKick = async (player: Player) => {
try {
const res = await fetch(`/api/players/${player.id}?action=kick`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "Kicked by admin" }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${player.username} has been kicked`);
fetchPlayers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to kick player");
}
};
const onlinePlayers = players.filter((p) => p.isOnline).length;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Players</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage players, bans, and permissions
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse" />
{onlinePlayers} online
</Badge>
<Button
variant="outline"
size="sm"
className="border-zinc-700 text-zinc-400 hover:text-white"
onClick={fetchPlayers}
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search players..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<div className="flex gap-2">
{(["all", "online", "banned"] as const).map((f) => (
<Button
key={f}
variant={filter === f ? "default" : "outline"}
size="sm"
onClick={() => setFilter(f)}
className={
filter === f
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: "border-zinc-700 text-zinc-400 hover:text-white"
}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</Button>
))}
</div>
</div>
{/* Players table */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
))}
</div>
) : players.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Users className="w-10 h-10 mb-3 opacity-50" />
<p>No players found</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
{/* Header */}
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span className="w-8" />
<span>Player</span>
<span>Status</span>
<span>Play Time</span>
<span>Last Seen</span>
<span />
</div>
{players.map((player) => (
<div
key={player.id}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
>
<PlayerAvatar username={player.username} />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">
{player.username}
</span>
{player.role && (
<Badge
variant="outline"
className="text-xs border-zinc-700 text-zinc-400"
>
{player.role}
</Badge>
)}
{player.isBanned && (
<Badge className="text-xs bg-red-500/20 text-red-400 border-red-500/30">
Banned
</Badge>
)}
</div>
<span className="text-xs text-zinc-500">
{player.uuid ?? "UUID unknown"}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className={`w-1.5 h-1.5 rounded-full ${
player.isOnline ? "bg-emerald-500" : "bg-zinc-600"
}`}
/>
<span
className={`text-sm ${
player.isOnline ? "text-emerald-400" : "text-zinc-500"
}`}
>
{player.isOnline ? "Online" : "Offline"}
</span>
</div>
<span className="text-sm text-zinc-400">
{formatPlayTime(player.playTime)}
</span>
<span className="text-sm text-zinc-500">
{formatDistanceToNow(new Date(player.lastSeen), {
addSuffix: true,
})}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-zinc-900 border-zinc-700"
>
<DropdownMenuItem
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
onClick={() =>
(window.location.href = `/players/${player.id}`)
}
>
<Shield className="w-4 h-4 mr-2" />
View Profile
</DropdownMenuItem>
{player.isOnline && (
<DropdownMenuItem
className="text-amber-400 focus:text-amber-300 focus:bg-zinc-800"
onClick={() => handleKick(player)}
>
<WifiOff className="w-4 h-4 mr-2" />
Kick
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="bg-zinc-700" />
{player.isBanned ? (
<DropdownMenuItem
className="text-emerald-400 focus:text-emerald-300 focus:bg-zinc-800"
onClick={() => handleUnban(player)}
>
<UserX className="w-4 h-4 mr-2" />
Unban
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
onClick={() => {
setSelectedPlayer(player);
setBanDialogOpen(true);
}}
>
<Ban className="w-4 h-4 mr-2" />
Ban
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Ban Dialog */}
<Dialog open={banDialogOpen} onOpenChange={setBanDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">
Ban {selectedPlayer?.username}
</DialogTitle>
<DialogDescription className="text-zinc-400">
This will ban the player from the server and record the ban in the
history.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Label className="text-zinc-300">Reason</Label>
<Textarea
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder="Enter ban reason..."
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 resize-none"
rows={3}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setBanDialogOpen(false)}
className="border-zinc-700 text-zinc-400"
>
Cancel
</Button>
<Button
onClick={handleBan}
disabled={!banReason.trim() || actionLoading}
className="bg-red-600 hover:bg-red-500 text-white"
>
{actionLoading ? "Banning..." : "Ban Player"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button, buttonVariants } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Puzzle,
Search,
RefreshCw,
MoreHorizontal,
Power,
RotateCcw,
Upload,
} from "lucide-react";
import { toast } from "sonner";
interface Plugin {
id: string;
name: string;
version: string | null;
description: string | null;
isEnabled: boolean;
jarFile: string | null;
installedAt: number;
}
export default function PluginsPage() {
const [plugins, setPlugins] = useState<Plugin[]>([]);
const [jarFiles, setJarFiles] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [actionLoading, setActionLoading] = useState<string | null>(null);
const fetchPlugins = useCallback(async () => {
try {
const res = await fetch("/api/plugins");
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins);
setJarFiles(data.jarFiles);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
const handleAction = async (
name: string,
action: "enable" | "disable" | "reload",
) => {
setActionLoading(`${name}-${action}`);
try {
const res = await fetch(`/api/plugins?action=${action}&name=${encodeURIComponent(name)}`, {
method: "POST",
});
if (!res.ok) throw new Error((await res.json()).error);
const labels = { enable: "enabled", disable: "disabled", reload: "reloaded" };
toast.success(`${name} ${labels[action]}`);
fetchPlugins();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Action failed");
} finally {
setActionLoading(null);
}
};
const filtered = plugins.filter((p) =>
p.name.toLowerCase().includes(search.toLowerCase()),
);
const enabledCount = plugins.filter((p) => p.isEnabled).length;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Plugins</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage your server plugins
</p>
</div>
<div className="flex items-center gap-3">
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
{enabledCount} / {plugins.length} active
</Badge>
<Button
variant="outline"
size="sm"
onClick={fetchPlugins}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<a href="/files?path=plugins" className={buttonVariants({ size: "sm", className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>
<Upload className="w-4 h-4 mr-1.5" />
Upload Plugin
</a>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Input
placeholder="Search plugins..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
{/* Plugins grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-32 bg-zinc-800 rounded-xl" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-zinc-600">
<Puzzle className="w-10 h-10 mb-3 opacity-50" />
<p>
{plugins.length === 0
? "No plugins installed"
: "No plugins match your search"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((plugin) => (
<Card
key={plugin.id}
className={`bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors ${
!plugin.isEnabled ? "opacity-60" : ""
}`}
>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div
className={`p-1.5 rounded-md ${
plugin.isEnabled
? "bg-emerald-500/10"
: "bg-zinc-800"
}`}
>
<Puzzle
className={`w-4 h-4 ${
plugin.isEnabled
? "text-emerald-500"
: "text-zinc-500"
}`}
/>
</div>
<div>
<p className="text-sm font-semibold text-white">
{plugin.name}
</p>
{plugin.version && (
<p className="text-xs text-zinc-500">
v{plugin.version}
</p>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-7 h-7 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-zinc-900 border-zinc-700"
>
<DropdownMenuItem
onClick={() =>
handleAction(
plugin.name,
plugin.isEnabled ? "disable" : "enable",
)
}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
disabled={!!actionLoading}
>
<Power className="w-4 h-4 mr-2" />
{plugin.isEnabled ? "Disable" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleAction(plugin.name, "reload")}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
disabled={!plugin.isEnabled || !!actionLoading}
>
<RotateCcw className="w-4 h-4 mr-2" />
Reload
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{plugin.description && (
<p className="text-xs text-zinc-500 line-clamp-2 mb-3">
{plugin.description}
</p>
)}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-800">
<Badge
className={
plugin.isEnabled
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs"
: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30 text-xs"
}
>
{plugin.isEnabled ? "Enabled" : "Disabled"}
</Badge>
<Switch
checked={plugin.isEnabled}
disabled={
actionLoading === `${plugin.name}-enable` ||
actionLoading === `${plugin.name}-disable`
}
onCheckedChange={(checked) =>
handleAction(plugin.name, checked ? "enable" : "disable")
}
className="scale-75"
/>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Jar files not in DB */}
{jarFiles.filter(
(jar) => !plugins.find((p) => p.jarFile === jar || p.name + ".jar" === jar),
).length > 0 && (
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-zinc-500">
Jar files detected (not yet in database)
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{jarFiles.map((jar) => (
<Badge
key={jar}
variant="outline"
className="border-zinc-700 text-zinc-400 text-xs"
>
{jar}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,346 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Clock, Plus, MoreHorizontal, Trash2, Edit, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
interface Task {
id: string;
name: string;
description: string | null;
cronExpression: string;
command: string;
isEnabled: boolean;
lastRun: number | null;
nextRun: number | null;
createdAt: number;
}
const CRON_PRESETS = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" },
{ label: "Every hour", value: "0 * * * *" },
{ label: "Every day at midnight", value: "0 0 * * *" },
{ label: "Every Sunday at 3am", value: "0 3 * * 0" },
];
function TaskForm({
initial,
onSubmit,
onCancel,
loading,
}: {
initial?: Partial<Task>;
onSubmit: (data: Omit<Task, "id" | "lastRun" | "nextRun" | "createdAt">) => void;
onCancel: () => void;
loading: boolean;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [cronExpression, setCronExpression] = useState(initial?.cronExpression ?? "0 0 * * *");
const [command, setCommand] = useState(initial?.command ?? "");
const [isEnabled, setIsEnabled] = useState(initial?.isEnabled ?? true);
return (
<div className="space-y-4">
<div>
<Label className="text-zinc-300">Task Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Daily restart"
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<div>
<Label className="text-zinc-300">Description (optional)</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description"
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
/>
</div>
<div>
<Label className="text-zinc-300">Cron Expression</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="* * * * *"
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
/>
<div className="flex flex-wrap gap-1 mt-2">
{CRON_PRESETS.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setCronExpression(p.value)}
className="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white transition-colors"
>
{p.label}
</button>
))}
</div>
</div>
<div>
<Label className="text-zinc-300">Minecraft Command</Label>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="e.g. say Server restart in 5 minutes"
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
/>
<p className="text-xs text-zinc-500 mt-1">
Enter a Minecraft command (without leading /) to execute via RCON.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
checked={isEnabled}
onCheckedChange={setIsEnabled}
/>
<Label className="text-zinc-300">Enable immediately</Label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} className="border-zinc-700 text-zinc-400">
Cancel
</Button>
<Button
onClick={() => onSubmit({ name, description: description || null, cronExpression, command, isEnabled })}
disabled={!name || !cronExpression || !command || loading}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{loading ? "Saving..." : "Save Task"}
</Button>
</DialogFooter>
</div>
);
}
export default function SchedulerPage() {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editTask, setEditTask] = useState<Task | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const fetchTasks = useCallback(async () => {
try {
const res = await fetch("/api/scheduler");
if (res.ok) setTasks((await res.json()).tasks);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTasks(); }, [fetchTasks]);
const handleCreate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
setSaving(true);
try {
const res = await fetch("/api/scheduler", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Task created");
setDialogOpen(false);
fetchTasks();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create task");
} finally {
setSaving(false);
}
};
const handleUpdate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
if (!editTask) return;
setSaving(true);
try {
const res = await fetch(`/api/scheduler/${editTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Task updated");
setEditTask(null);
fetchTasks();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update task");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/scheduler/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Task deleted");
setTasks((p) => p.filter((t) => t.id !== id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete");
}
setDeleteId(null);
};
const toggleTask = async (task: Task) => {
try {
const res = await fetch(`/api/scheduler/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isEnabled: !task.isEnabled }),
});
if (!res.ok) throw new Error((await res.json()).error);
fetchTasks();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to toggle");
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Scheduler</h1>
<p className="text-zinc-400 text-sm mt-1">Automated recurring tasks</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchTasks} className="border-zinc-700 text-zinc-400 hover:text-white">
<RefreshCw className="w-4 h-4" />
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setDialogOpen(true)}>
<Plus className="w-4 h-4 mr-1.5" /> New Task
</Button>
</div>
</div>
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{[1,2,3].map(i => <Skeleton key={i} className="h-16 w-full bg-zinc-800" />)}
</div>
) : tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Clock className="w-10 h-10 mb-3 opacity-50" />
<p>No scheduled tasks</p>
<p className="text-sm mt-1">Create a task to automate server commands</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
{tasks.map(task => (
<div key={task.id} className="flex items-center gap-4 px-4 py-4 hover:bg-zinc-800/50 transition-colors">
<Switch checked={task.isEnabled} onCheckedChange={() => toggleTask(task)} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<p className="text-sm font-medium text-white">{task.name}</p>
{task.description && (
<span className="text-xs text-zinc-500"> {task.description}</span>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<code className="text-xs text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded">{task.cronExpression}</code>
<code className="text-xs text-zinc-400 font-mono">{task.command}</code>
</div>
</div>
<div className="text-right shrink-0">
{task.lastRun ? (
<p className="text-xs text-zinc-500">Last: {formatDistanceToNow(new Date(task.lastRun), { addSuffix: true })}</p>
) : (
<p className="text-xs text-zinc-600">Never run</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
<DropdownMenuItem onClick={() => setEditTask(task)} className="text-zinc-300 focus:text-white focus:bg-zinc-800">
<Edit className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteId(task.id)} className="text-red-400 focus:text-red-300 focus:bg-zinc-800">
<Trash2 className="w-4 h-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Create dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">New Scheduled Task</DialogTitle>
<DialogDescription className="text-zinc-400">Schedule a Minecraft command to run automatically.</DialogDescription>
</DialogHeader>
<TaskForm onSubmit={handleCreate} onCancel={() => setDialogOpen(false)} loading={saving} />
</DialogContent>
</Dialog>
{/* Edit dialog */}
<Dialog open={!!editTask} onOpenChange={(o) => !o && setEditTask(null)}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">Edit Task</DialogTitle>
</DialogHeader>
{editTask && <TaskForm initial={editTask} onSubmit={handleUpdate} onCancel={() => setEditTask(null)} loading={saving} />}
</DialogContent>
</Dialog>
{/* Delete confirm */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Delete task?</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">This will stop and remove the scheduled task permanently.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteId && handleDelete(deleteId)} className="bg-red-600 hover:bg-red-500 text-white">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,362 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Settings, RefreshCw, Download, Server, Map } from "lucide-react";
import { toast } from "sonner";
interface ServerSettings {
minecraftPath?: string;
serverJar?: string;
serverVersion?: string;
serverType?: string;
maxRam?: number;
minRam?: number;
rconEnabled?: boolean;
rconPort?: number;
javaArgs?: string;
autoStart?: boolean;
restartOnCrash?: boolean;
backupEnabled?: boolean;
backupSchedule?: string;
bluemapEnabled?: boolean;
bluemapUrl?: string;
}
const SERVER_TYPES = ["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"];
export default function ServerPage() {
const [settings, setSettings] = useState<ServerSettings>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [versions, setVersions] = useState<string[]>([]);
const [loadingVersions, setLoadingVersions] = useState(false);
const [selectedType, setSelectedType] = useState("paper");
const fetchSettings = useCallback(async () => {
try {
const res = await fetch("/api/server/settings");
if (res.ok) {
const data = await res.json();
if (data.settings) {
setSettings(data.settings);
setSelectedType(data.settings.serverType ?? "paper");
}
}
} finally {
setLoading(false);
}
}, []);
const fetchVersions = useCallback(async (type: string) => {
setLoadingVersions(true);
try {
const res = await fetch(`/api/server/versions?type=${type}`);
if (res.ok) setVersions((await res.json()).versions);
} finally {
setLoadingVersions(false);
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
useEffect(() => { fetchVersions(selectedType); }, [selectedType, fetchVersions]);
const save = async (updates: Partial<ServerSettings>) => {
setSaving(true);
try {
const res = await fetch("/api/server/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error((await res.json()).error);
setSettings((prev) => ({ ...prev, ...updates }));
toast.success("Settings saved");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-48 bg-zinc-800" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full bg-zinc-800 rounded-xl" />
))}
</div>
);
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Server Settings</h1>
<p className="text-zinc-400 text-sm mt-1">
Configure your Minecraft server and CubeAdmin options
</p>
</div>
<Tabs defaultValue="general" className="space-y-4">
<TabsList className="bg-zinc-900 border border-zinc-800">
<TabsTrigger value="general" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
General
</TabsTrigger>
<TabsTrigger value="performance" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
Performance
</TabsTrigger>
<TabsTrigger value="updates" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
Updates
</TabsTrigger>
<TabsTrigger value="integrations" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
Integrations
</TabsTrigger>
</TabsList>
{/* General */}
<TabsContent value="general" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-500" />
Server Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Minecraft Server Path</Label>
<Input
value={settings.minecraftPath ?? ""}
onChange={(e) => setSettings(p => ({ ...p, minecraftPath: e.target.value }))}
placeholder="/opt/minecraft/server"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Server JAR filename</Label>
<Input
value={settings.serverJar ?? ""}
onChange={(e) => setSettings(p => ({ ...p, serverJar: e.target.value }))}
placeholder="server.jar"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<div>
<p className="text-sm font-medium text-zinc-300">Auto-start on boot</p>
<p className="text-xs text-zinc-500 mt-0.5">Start server when CubeAdmin starts</p>
</div>
<Switch
checked={settings.autoStart ?? false}
onCheckedChange={(v) => save({ autoStart: v })}
/>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<div>
<p className="text-sm font-medium text-zinc-300">Auto-restart on crash</p>
<p className="text-xs text-zinc-500 mt-0.5">Automatically restart if server crashes</p>
</div>
<Switch
checked={settings.restartOnCrash ?? false}
onCheckedChange={(v) => save({ restartOnCrash: v })}
/>
</div>
</div>
<Button
onClick={() => save({ minecraftPath: settings.minecraftPath, serverJar: settings.serverJar })}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
</TabsContent>
{/* Performance */}
<TabsContent value="performance" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300">JVM Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Min RAM (MB)</Label>
<Input
type="number"
value={settings.minRam ?? 512}
onChange={(e) => setSettings(p => ({ ...p, minRam: parseInt(e.target.value) }))}
className="bg-zinc-800 border-zinc-700 text-white"
min={256}
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Max RAM (MB)</Label>
<Input
type="number"
value={settings.maxRam ?? 2048}
onChange={(e) => setSettings(p => ({ ...p, maxRam: parseInt(e.target.value) }))}
className="bg-zinc-800 border-zinc-700 text-white"
min={512}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Additional Java Arguments</Label>
<Input
value={settings.javaArgs ?? ""}
onChange={(e) => setSettings(p => ({ ...p, javaArgs: e.target.value }))}
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
<p className="text-xs text-zinc-500">
Aikar&apos;s flags are applied by default with the Docker image.
</p>
</div>
<Button
onClick={() => save({ minRam: settings.minRam, maxRam: settings.maxRam, javaArgs: settings.javaArgs })}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
</TabsContent>
{/* Updates */}
<TabsContent value="updates" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Download className="w-4 h-4 text-emerald-500" />
Server Version
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Server Type</Label>
<Select
value={selectedType}
onValueChange={(v) => { if (v) { setSelectedType(v); fetchVersions(v); } }}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{SERVER_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-zinc-300 focus:bg-zinc-700 focus:text-white capitalize">
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Version</Label>
<Select
value={settings.serverVersion ?? ""}
onValueChange={(v) => setSettings(p => ({ ...p, serverVersion: v ?? undefined }))}
disabled={loadingVersions}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder={loadingVersions ? "Loading..." : "Select version"} />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-60">
{versions.map((v) => (
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
<span></span>
<p>Changing server version requires a server restart. Always backup first!</p>
</div>
<Button
onClick={() => save({ serverType: selectedType, serverVersion: settings.serverVersion })}
disabled={saving || !settings.serverVersion}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Apply Version"}
</Button>
</CardContent>
</Card>
</TabsContent>
{/* Integrations */}
<TabsContent value="integrations" className="space-y-4">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Map className="w-4 h-4 text-emerald-500" />
BlueMap Integration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<div>
<p className="text-sm font-medium text-zinc-300">Enable BlueMap</p>
<p className="text-xs text-zinc-500 mt-0.5">Show the 3D map in the Map section</p>
</div>
<Switch
checked={settings.bluemapEnabled ?? false}
onCheckedChange={(v) => save({ bluemapEnabled: v })}
/>
</div>
{settings.bluemapEnabled && (
<div className="space-y-1.5">
<Label className="text-zinc-300">BlueMap URL</Label>
<Input
value={settings.bluemapUrl ?? ""}
onChange={(e) => setSettings(p => ({ ...p, bluemapUrl: e.target.value }))}
placeholder="http://localhost:8100"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
/>
<p className="text-xs text-zinc-500">
The URL where BlueMap is accessible from your browser.
</p>
</div>
)}
<Button
onClick={() => save({ bluemapEnabled: settings.bluemapEnabled, bluemapUrl: settings.bluemapUrl })}
disabled={saving}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{saving ? "Saving..." : "Save"}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useState, useTransition } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { User, Lock, Shield } from "lucide-react";
import { toast } from "sonner";
import { authClient, useSession } from "@/lib/auth/client";
export default function SettingsPage() {
const { data: session, isPending } = useSession();
const [name, setName] = useState("");
const [nameLoading, startNameTransition] = useTransition();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordLoading, startPasswordTransition] = useTransition();
// Populate name field once session loads
const displayName = name || session?.user?.name || "";
function handleNameSave() {
if (!displayName.trim()) {
toast.error("Name cannot be empty");
return;
}
startNameTransition(async () => {
const { error } = await authClient.updateUser({ name: displayName.trim() });
if (error) {
toast.error(error.message ?? "Failed to update name");
} else {
toast.success("Display name updated");
}
});
}
function handlePasswordChange() {
if (!currentPassword) {
toast.error("Current password is required");
return;
}
if (newPassword.length < 8) {
toast.error("New password must be at least 8 characters");
return;
}
if (newPassword !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
startPasswordTransition(async () => {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: false,
});
if (error) {
toast.error(error.message ?? "Failed to change password");
} else {
toast.success("Password changed successfully");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
});
}
return (
<div className="p-6 max-w-2xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Account Settings</h1>
<p className="text-zinc-400 text-sm mt-1">Manage your profile and security preferences</p>
</div>
{/* Profile */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<User className="w-4 h-4 text-emerald-500" />
Profile
</CardTitle>
<CardDescription className="text-zinc-500">
Update your display name and view account info
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Email</Label>
<Input
value={session?.user?.email ?? ""}
disabled
className="bg-zinc-800 border-zinc-700 text-zinc-400 cursor-not-allowed"
/>
<p className="text-xs text-zinc-500">Email address cannot be changed</p>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Display Name</Label>
<Input
value={name || session?.user?.name || ""}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
disabled={isPending}
/>
</div>
{session?.user && (
<div className="flex items-center gap-2 text-xs text-zinc-500">
<Shield className="w-3 h-3" />
Role: <span className="text-zinc-300 capitalize">{(session.user as { role?: string }).role ?? "moderator"}</span>
</div>
)}
<Button
onClick={handleNameSave}
disabled={nameLoading || isPending}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{nameLoading ? "Saving…" : "Save Name"}
</Button>
</CardContent>
</Card>
<Separator className="bg-zinc-800" />
{/* Password */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Lock className="w-4 h-4 text-emerald-500" />
Change Password
</CardTitle>
<CardDescription className="text-zinc-500">
Choose a strong password with at least 8 characters
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Current Password</Label>
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="••••••••"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">New Password</Label>
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="••••••••"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Confirm New Password</Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
<Button
onClick={handlePasswordChange}
disabled={passwordLoading}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
{passwordLoading ? "Changing…" : "Change Password"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UserPlus, Mail, Clock, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
interface TeamUser {
id: string;
name: string;
email: string;
role: string | null;
createdAt: number;
image: string | null;
}
interface PendingInvite {
id: string;
email: string;
role: string;
expiresAt: number;
createdAt: number;
}
const ROLE_CONFIG: Record<string, { label: string; color: string }> = {
superadmin: { label: "Super Admin", color: "bg-amber-500/20 text-amber-400 border-amber-500/30" },
admin: { label: "Admin", color: "bg-blue-500/20 text-blue-400 border-blue-500/30" },
moderator: { label: "Moderator", color: "bg-violet-500/20 text-violet-400 border-violet-500/30" },
};
export default function TeamPage() {
const [users, setUsers] = useState<TeamUser[]>([]);
const [invites, setInvites] = useState<PendingInvite[]>([]);
const [loading, setLoading] = useState(true);
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<"admin" | "moderator">("moderator");
const [inviting, setInviting] = useState(false);
const fetchTeam = useCallback(async () => {
try {
const res = await fetch("/api/team");
if (res.ok) {
const data = await res.json();
setUsers(data.users);
setInvites(data.pendingInvites ?? []);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTeam(); }, [fetchTeam]);
const handleInvite = async () => {
if (!inviteEmail) return;
setInviting(true);
try {
const res = await fetch("/api/team", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
toast.success(`Invitation sent to ${inviteEmail}`);
setInviteOpen(false);
setInviteEmail("");
fetchTeam();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to send invitation");
} finally {
setInviting(false);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Team</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage who can access the CubeAdmin panel
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchTeam} className="border-zinc-700 text-zinc-400 hover:text-white">
<RefreshCw className="w-4 h-4" />
</Button>
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setInviteOpen(true)}>
<UserPlus className="w-4 h-4 mr-1.5" />
Invite Member
</Button>
</div>
</div>
{/* Active members */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-14 w-full bg-zinc-800" />)}
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Active Members ({users.length})
</div>
{users.map(user => (
<div key={user.id} className="flex items-center gap-4 px-4 py-3 hover:bg-zinc-800/50 transition-colors">
<Avatar className="w-9 h-9 shrink-0">
<AvatarImage src={user.image ?? undefined} />
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-sm">
{user.name?.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">{user.name}</p>
<p className="text-xs text-zinc-500 truncate">{user.email}</p>
</div>
<Badge className={`text-xs ${ROLE_CONFIG[user.role ?? ""]?.color ?? "bg-zinc-500/20 text-zinc-400"}`}>
{ROLE_CONFIG[user.role ?? ""]?.label ?? user.role ?? "No role"}
</Badge>
<p className="text-xs text-zinc-600 hidden sm:block shrink-0">
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pending invites */}
{invites.length > 0 && (
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
<CardContent className="p-0">
<div className="divide-y divide-zinc-800">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
Pending Invitations ({invites.length})
</div>
{invites.map(invite => (
<div key={invite.id} className="flex items-center gap-4 px-4 py-3">
<div className="w-9 h-9 rounded-full bg-zinc-800 flex items-center justify-center shrink-0">
<Mail className="w-4 h-4 text-zinc-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300">{invite.email}</p>
<p className="text-xs text-zinc-500 flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3" />
Expires {formatDistanceToNow(new Date(invite.expiresAt), { addSuffix: true })}
</p>
</div>
<Badge className={`text-xs ${ROLE_CONFIG[invite.role]?.color ?? ""}`}>
{ROLE_CONFIG[invite.role]?.label ?? invite.role}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Invite dialog */}
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogContent className="bg-zinc-900 border-zinc-700">
<DialogHeader>
<DialogTitle className="text-white">Invite Team Member</DialogTitle>
<DialogDescription className="text-zinc-400">
They&apos;ll receive an email with a link to create their account.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-zinc-300">Email Address</Label>
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="teammate@example.com"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
/>
</div>
<div className="space-y-1.5">
<Label className="text-zinc-300">Role</Label>
<Select value={inviteRole} onValueChange={(v) => { if (v === "admin" || v === "moderator") setInviteRole(v); }}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value="admin" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
Admin Full access except team management
</SelectItem>
<SelectItem value="moderator" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
Moderator Player management only
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInviteOpen(false)} className="border-zinc-700 text-zinc-400">
Cancel
</Button>
<Button
onClick={handleInvite}
disabled={!inviteEmail || inviting}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{inviting ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,254 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Download, RefreshCw, AlertTriangle, CheckCircle2, Server } from "lucide-react";
import { toast } from "sonner";
const SERVER_TYPES = ["vanilla", "paper", "fabric"] as const;
type ServerType = (typeof SERVER_TYPES)[number];
interface Settings {
serverType?: string;
serverVersion?: string;
serverJar?: string;
}
export default function UpdatesPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedType, setSelectedType] = useState<ServerType>("paper");
const [versions, setVersions] = useState<string[]>([]);
const [selectedVersion, setSelectedVersion] = useState("");
const [loadingSettings, setLoadingSettings] = useState(true);
const [loadingVersions, setLoadingVersions] = useState(false);
const [saving, setSaving] = useState(false);
const fetchSettings = useCallback(async () => {
try {
const res = await fetch("/api/server/settings");
if (res.ok) {
const data = await res.json();
if (data.settings) {
setSettings(data.settings);
setSelectedType((data.settings.serverType as ServerType) ?? "paper");
setSelectedVersion(data.settings.serverVersion ?? "");
}
}
} finally {
setLoadingSettings(false);
}
}, []);
const fetchVersions = useCallback(async (type: string) => {
setLoadingVersions(true);
setVersions([]);
try {
const res = await fetch(`/api/server/versions?type=${type}`);
if (res.ok) {
const data = await res.json();
setVersions(data.versions ?? []);
} else {
toast.error("Failed to fetch versions");
}
} catch {
toast.error("Network error fetching versions");
} finally {
setLoadingVersions(false);
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
useEffect(() => {
fetchVersions(selectedType);
}, [selectedType, fetchVersions]);
const isUpToDate =
settings?.serverVersion === selectedVersion &&
settings?.serverType === selectedType;
async function handleApply() {
if (!selectedVersion) {
toast.error("Please select a version");
return;
}
setSaving(true);
try {
const res = await fetch("/api/server/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serverType: selectedType, serverVersion: selectedVersion }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? "Failed to apply");
}
setSettings((prev) => ({ ...prev, serverType: selectedType, serverVersion: selectedVersion }));
toast.success(`Server version set to ${selectedType} ${selectedVersion}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to apply version");
} finally {
setSaving(false);
}
}
const currentVersion = settings?.serverVersion
? `${settings.serverType ?? "unknown"} ${settings.serverVersion}`
: "Not configured";
return (
<div className="p-6 max-w-2xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Server Updates</h1>
<p className="text-zinc-400 text-sm mt-1">
Manage your Minecraft server version
</p>
</div>
{/* Current version */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-500" />
Current Version
</CardTitle>
</CardHeader>
<CardContent>
{loadingSettings ? (
<Skeleton className="h-6 w-48 bg-zinc-800" />
) : (
<div className="flex items-center gap-3">
<span className="text-white font-mono text-sm">{currentVersion}</span>
{settings?.serverVersion && (
<Badge
className={
isUpToDate
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
}
>
{isUpToDate ? "Up to date" : "Update available"}
</Badge>
)}
</div>
)}
</CardContent>
</Card>
{/* Version picker */}
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
<Download className="w-4 h-4 text-emerald-500" />
Select Version
</CardTitle>
<CardDescription className="text-zinc-500">
Choose a server type and version to apply
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-sm text-zinc-300">Server Type</label>
<Select
value={selectedType}
onValueChange={(v) => { if (v) setSelectedType(v as ServerType); }}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{SERVER_TYPES.map((t) => (
<SelectItem key={t} value={t} className="text-zinc-300 focus:bg-zinc-700 focus:text-white capitalize">
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm text-zinc-300">Version</label>
<Select
value={selectedVersion}
onValueChange={(v) => { if (v) setSelectedVersion(v); }}
disabled={loadingVersions || versions.length === 0}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue
placeholder={
loadingVersions
? "Loading…"
: versions.length === 0
? "No versions found"
: "Select version"
}
/>
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-64">
{versions.map((v) => (
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white font-mono text-sm">
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleApply}
disabled={saving || !selectedVersion || loadingVersions}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
size="sm"
>
<Download className="w-3.5 h-3.5 mr-1.5" />
{saving ? "Applying…" : "Apply Version"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fetchVersions(selectedType)}
disabled={loadingVersions}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
<RefreshCw className={`w-3.5 h-3.5 mr-1.5 ${loadingVersions ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{selectedVersion && settings?.serverVersion && selectedVersion !== settings.serverVersion && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-amber-400 text-sm">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<p>
Changing from <span className="font-mono">{settings.serverVersion}</span> to{" "}
<span className="font-mono">{selectedVersion}</span> requires a server restart.
Make sure to create a backup first.
</p>
</div>
)}
{isUpToDate && selectedVersion && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-emerald-400 text-sm">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<p>This version is already configured.</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { invitations, users } from "@/lib/db/schema";
import { auth } from "@/lib/auth";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { nanoid } from "nanoid";
const AcceptSchema = z.object({
token: z.string().min(1),
name: z.string().min(2).max(100),
password: z.string().min(8).max(128),
});
export async function POST(req: NextRequest) {
let body: z.infer<typeof AcceptSchema>;
try {
body = AcceptSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const invitation = await db
.select()
.from(invitations)
.where(eq(invitations.token, body.token))
.get();
if (!invitation) {
return NextResponse.json({ error: "Invalid invitation token" }, { status: 404 });
}
if (invitation.acceptedAt) {
return NextResponse.json({ error: "Invitation already used" }, { status: 409 });
}
if (Number(invitation.expiresAt) < Date.now()) {
return NextResponse.json({ error: "Invitation has expired" }, { status: 410 });
}
// Check if email already registered
const existing = await db.select().from(users).where(eq(users.email, invitation.email)).get();
if (existing) {
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
}
// Create user via Better Auth
try {
await auth.api.signUpEmail({
body: {
email: invitation.email,
password: body.password,
name: body.name,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create account";
return NextResponse.json({ error: message }, { status: 500 });
}
// Mark invitation as accepted and set role
await db
.update(invitations)
.set({ acceptedAt: Date.now() })
.where(eq(invitations.token, body.token));
await db
.update(users)
.set({ role: invitation.role })
.where(eq(users.email, invitation.email));
return NextResponse.json({ success: true });
}

48
app/api/audit/route.ts Normal file
View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { auditLogs, users } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "50"));
const offset = (page - 1) * limit;
const userId = searchParams.get("userId");
const action = searchParams.get("action");
const from = searchParams.get("from");
const to = searchParams.get("to");
const conditions = [];
if (userId) conditions.push(eq(auditLogs.userId, userId));
if (action) conditions.push(like(auditLogs.action, `${action}%`));
if (from) conditions.push(gte(auditLogs.createdAt, parseInt(from)));
if (to) conditions.push(lte(auditLogs.createdAt, parseInt(to)));
const logs = await db
.select({
log: auditLogs,
userName: users.name,
userEmail: users.email,
})
.from(auditLogs)
.leftJoin(users, eq(auditLogs.userId, users.id))
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(auditLogs.createdAt))
.limit(limit)
.offset(offset);
return NextResponse.json({ logs, page, limit });
}

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { deleteBackup } from "@/lib/backup/manager";
import { db } from "@/lib/db";
import { backups } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import * as fs from "node:fs";
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
try {
await deleteBackup(id);
return NextResponse.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const backup = await db.select().from(backups).where(eq(backups.id, id)).get();
if (!backup) return NextResponse.json({ error: "Backup not found" }, { status: 404 });
if (backup.status !== "completed") {
return NextResponse.json({ error: "Backup not ready" }, { status: 400 });
}
if (!backup.path || !fs.existsSync(backup.path)) {
return NextResponse.json({ error: "Backup file not found on disk" }, { status: 404 });
}
const fileBuffer = fs.readFileSync(backup.path);
return new NextResponse(fileBuffer, {
headers: {
"Content-Disposition": `attachment; filename="${encodeURIComponent(backup.name)}"`,
"Content-Type": "application/zip",
"Content-Length": String(fileBuffer.length),
"X-Content-Type-Options": "nosniff",
},
});
}

48
app/api/backups/route.ts Normal file
View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
import { z } from "zod";
const CreateBackupSchema = z.object({
type: z.enum(["worlds", "plugins", "config", "full"]),
});
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const backups = await listBackups();
return NextResponse.json({ backups });
}
export async function POST(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 5); // Strict limit for backup creation
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof CreateBackupSchema>;
try {
body = CreateBackupSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
try {
const id = await createBackup(body.type as BackupType, session.user.id);
return NextResponse.json({ success: true, id }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Backup failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
import { nanoid } from "nanoid";
import { getClientIp } from "@/lib/security/rateLimit";
import * as fs from "node:fs";
import * as path from "node:path";
export async function DELETE(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const { filePath } = await req.json();
let resolvedPath: string;
try {
resolvedPath = sanitizeFilePath(filePath, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
if (!fs.existsSync(resolvedPath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const stat = fs.statSync(resolvedPath);
if (stat.isDirectory()) {
fs.rmSync(resolvedPath, { recursive: true });
} else {
fs.unlinkSync(resolvedPath);
}
const ip = getClientIp(req);
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id,
action: "file.delete", target: "file", targetId: path.relative(mcBase, resolvedPath),
details: null, ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import * as fs from "node:fs";
import * as path from "node:path";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const filePath = req.nextUrl.searchParams.get("path") ?? "";
let resolvedPath: string;
try {
resolvedPath = sanitizeFilePath(filePath, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
const fileName = path.basename(resolvedPath);
const fileBuffer = fs.readFileSync(resolvedPath);
return new NextResponse(fileBuffer, {
headers: {
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
"Content-Type": "application/octet-stream",
"Content-Length": String(fileBuffer.length),
// Prevent XSS via content sniffing
"X-Content-Type-Options": "nosniff",
},
});
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import * as fs from "node:fs";
import * as path from "node:path";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const relativePath = new URL(req.url).searchParams.get("path") ?? "/";
let resolvedPath: string;
try {
resolvedPath = sanitizeFilePath(relativePath, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
try {
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true });
const files = entries.map((entry) => {
const fullPath = path.join(resolvedPath, entry.name);
let size = 0;
let modifiedAt = 0;
try {
const stat = fs.statSync(fullPath);
size = stat.size;
modifiedAt = stat.mtimeMs;
} catch {}
return {
name: entry.name,
path: path.relative(mcBase, fullPath),
isDirectory: entry.isDirectory(),
size,
modifiedAt,
};
});
// Sort: directories first, then files, alphabetically
files.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
return NextResponse.json({
path: path.relative(mcBase, resolvedPath) || "/",
entries: files,
});
} catch (err) {
return NextResponse.json({ error: "Cannot read directory" }, { status: 500 });
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { sanitizeFilePath } from "@/lib/security/sanitize";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
import { nanoid } from "nanoid";
import * as fs from "node:fs";
import * as path from "node:path";
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
export async function POST(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 20);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
const targetDir = req.nextUrl.searchParams.get("path") ?? "/";
let resolvedDir: string;
try {
resolvedDir = sanitizeFilePath(targetDir, mcBase);
} catch {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
// Check file size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File too large (max 500 MB)" }, { status: 413 });
}
// Check extension
const ext = path.extname(file.name).toLowerCase();
if (BLOCKED_EXTENSIONS.has(ext)) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
}
// Sanitize filename: allow alphanumeric, dots, dashes, underscores, spaces
const safeName = file.name.replace(/[^a-zA-Z0-9._\- ]/g, "_");
const destPath = path.join(resolvedDir, safeName);
// Ensure destination is still within base
if (!destPath.startsWith(mcBase)) {
return NextResponse.json({ error: "Invalid destination" }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
fs.mkdirSync(resolvedDir, { recursive: true });
fs.writeFileSync(destPath, buffer);
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id,
action: "file.upload", target: "file", targetId: path.relative(mcBase, destPath),
details: JSON.stringify({ size: file.size, name: safeName }), ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true, path: path.relative(mcBase, destPath) });
}

9
app/api/health/route.ts Normal file
View File

@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
/** Health check endpoint used by Docker and monitoring. */
export async function GET() {
return NextResponse.json(
{ status: "ok", timestamp: Date.now() },
{ status: 200 },
);
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import * as os from "node:os";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const totalMemMb = Math.round(os.totalmem() / 1024 / 1024);
const freeMemMb = Math.round(os.freemem() / 1024 / 1024);
const usedMemMb = totalMemMb - freeMemMb;
// CPU usage (average across all cores, sampled over 100ms)
const cpuPercent = await getCpuPercent();
const serverStatus = mcProcessManager.getStatus();
return NextResponse.json({
system: {
cpuPercent,
totalMemMb,
usedMemMb,
freeMemMb,
loadAvg: os.loadavg(),
uptime: os.uptime(),
platform: os.platform(),
arch: os.arch(),
},
server: serverStatus,
timestamp: Date.now(),
});
}
function getCpuPercent(): Promise<number> {
return new Promise((resolve) => {
const cpus1 = os.cpus();
setTimeout(() => {
const cpus2 = os.cpus();
let totalIdle = 0;
let totalTick = 0;
for (let i = 0; i < cpus1.length; i++) {
const cpu1 = cpus1[i].times;
const cpu2 = cpus2[i].times;
const idle = cpu2.idle - cpu1.idle;
const total =
(cpu2.user - cpu1.user) +
(cpu2.nice - cpu1.nice) +
(cpu2.sys - cpu1.sys) +
(cpu2.idle - cpu1.idle) +
((cpu2.irq ?? 0) - (cpu1.irq ?? 0));
totalIdle += idle;
totalTick += total;
}
const percent = totalTick === 0 ? 0 : Math.round(((totalTick - totalIdle) / totalTick) * 100);
resolve(percent);
}, 100);
});
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { eq, desc } from "drizzle-orm";
import { rconClient } from "@/lib/minecraft/rcon";
import { sanitizeRconCommand } from "@/lib/security/sanitize";
import { z } from "zod";
import { nanoid } from "nanoid";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
const [bans, chatHistory, spawnPoints] = await Promise.all([
db.select().from(playerBans).where(eq(playerBans.playerId, id)).orderBy(desc(playerBans.bannedAt)),
db.select().from(playerChatHistory).where(eq(playerChatHistory.playerId, id)).orderBy(desc(playerChatHistory.timestamp)).limit(200),
db.select().from(playerSpawnPoints).where(eq(playerSpawnPoints.playerId, id)),
]);
return NextResponse.json({ player, bans, chatHistory, spawnPoints });
}
const BanSchema = z.object({
reason: z.string().min(1).max(500),
expiresAt: z.number().optional(),
});
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { id } = await params;
const { searchParams } = new URL(req.url);
const action = searchParams.get("action");
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
if (action === "ban") {
let body: z.infer<typeof BanSchema>;
try {
body = BanSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Insert ban record
await db.insert(playerBans).values({
id: nanoid(),
playerId: id,
reason: body.reason,
bannedBy: session.user.id,
bannedAt: Date.now(),
expiresAt: body.expiresAt ?? null,
isActive: true,
});
await db.update(mcPlayers).set({ isBanned: true }).where(eq(mcPlayers.id, id));
// Execute ban via RCON
try {
const cmd = sanitizeRconCommand(`ban ${player.username} ${body.reason}`);
await rconClient.sendCommand(cmd);
} catch { /* RCON might be unavailable */ }
// Audit
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: "player.ban",
target: "player",
targetId: id,
details: JSON.stringify({ reason: body.reason }),
ipAddress: ip,
createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}
if (action === "unban") {
await db.update(playerBans).set({ isActive: false, unbannedBy: session.user.id, unbannedAt: Date.now() }).where(eq(playerBans.playerId, id));
await db.update(mcPlayers).set({ isBanned: false }).where(eq(mcPlayers.id, id));
try {
const cmd = sanitizeRconCommand(`pardon ${player.username}`);
await rconClient.sendCommand(cmd);
} catch { /* RCON might be unavailable */ }
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id, action: "player.unban",
target: "player", targetId: id, details: null, ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}
if (action === "kick") {
const { reason } = await req.json();
try {
const cmd = sanitizeRconCommand(`kick ${player.username} ${reason ?? "Kicked by admin"}`);
await rconClient.sendCommand(cmd);
} catch (err) {
return NextResponse.json({ error: "RCON unavailable" }, { status: 503 });
}
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id, action: "player.kick",
target: "player", targetId: id, details: JSON.stringify({ reason }), ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}

42
app/api/players/route.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { mcPlayers } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { desc, like, or, eq } from "drizzle-orm";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { searchParams } = new URL(req.url);
const search = searchParams.get("q")?.trim() ?? "";
const onlineOnly = searchParams.get("online") === "true";
const bannedOnly = searchParams.get("banned") === "true";
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "50")));
const offset = (page - 1) * limit;
let query = db.select().from(mcPlayers);
const conditions = [];
if (search) {
conditions.push(like(mcPlayers.username, `%${search}%`));
}
if (onlineOnly) conditions.push(eq(mcPlayers.isOnline, true));
if (bannedOnly) conditions.push(eq(mcPlayers.isBanned, true));
const rows = await db
.select()
.from(mcPlayers)
.where(conditions.length ? (conditions.length === 1 ? conditions[0] : or(...conditions)) : undefined)
.orderBy(desc(mcPlayers.lastSeen))
.limit(limit)
.offset(offset);
return NextResponse.json({ players: rows, page, limit });
}

88
app/api/plugins/route.ts Normal file
View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { plugins, auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { rconClient } from "@/lib/minecraft/rcon";
import { sanitizeRconCommand } from "@/lib/security/sanitize";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import * as fs from "node:fs";
import * as path from "node:path";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
// Read plugins from DB + sync with filesystem
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
const pluginsDir = path.join(mcPath, "plugins");
const dbPlugins = await db.select().from(plugins);
// Try to read actual plugin jars from filesystem
let jarFiles: string[] = [];
try {
jarFiles = fs
.readdirSync(pluginsDir)
.filter((f) => f.endsWith(".jar"));
} catch { /* plugins dir might not exist */ }
return NextResponse.json({ plugins: dbPlugins, jarFiles });
}
export async function POST(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const { searchParams } = new URL(req.url);
const action = searchParams.get("action");
const pluginName = searchParams.get("name");
if (!pluginName || !/^[a-zA-Z0-9_\-]+$/.test(pluginName)) {
return NextResponse.json({ error: "Invalid plugin name" }, { status: 400 });
}
try {
if (action === "enable" || action === "disable") {
const cmd = sanitizeRconCommand(
`plugman ${action} ${pluginName}`,
);
const result = await rconClient.sendCommand(cmd);
await db
.update(plugins)
.set({ isEnabled: action === "enable" })
.where(eq(plugins.name, pluginName));
await db.insert(auditLogs).values({
id: nanoid(), userId: session.user.id,
action: `plugin.${action}`, target: "plugin", targetId: pluginName,
details: null, ipAddress: ip, createdAt: Date.now(),
});
return NextResponse.json({ success: true, result });
}
if (action === "reload") {
const cmd = sanitizeRconCommand(`plugman reload ${pluginName}`);
const result = await rconClient.sendCommand(cmd);
return NextResponse.json({ success: true, result });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 503 });
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { scheduledTasks } from "@/lib/db/schema";
import { scheduleTask, stopTask } from "@/lib/scheduler";
import { eq } from "drizzle-orm";
import { z } from "zod";
import cron from "node-cron";
const UpdateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
cronExpression: z.string().max(100).optional(),
command: z.string().min(1).max(500).optional(),
isEnabled: z.boolean().optional(),
});
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const task = await db.select().from(scheduledTasks).where(eq(scheduledTasks.id, id)).get();
if (!task) return NextResponse.json({ error: "Task not found" }, { status: 404 });
let body: z.infer<typeof UpdateSchema>;
try {
body = UpdateSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
if (body.cronExpression && !cron.validate(body.cronExpression)) {
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
}
const updated = { ...task, ...body, updatedAt: Date.now() };
await db.update(scheduledTasks).set(updated).where(eq(scheduledTasks.id, id));
// Reschedule
stopTask(id);
if (updated.isEnabled) {
scheduleTask(id, updated.cronExpression, updated.command);
}
return NextResponse.json({ success: true });
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
stopTask(id);
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { scheduledTasks } from "@/lib/db/schema";
import { scheduleTask, stopTask } from "@/lib/scheduler";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import cron from "node-cron";
const TaskSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
cronExpression: z.string().max(100),
command: z.string().min(1).max(500),
isEnabled: z.boolean().default(true),
});
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
return NextResponse.json({ tasks });
}
export async function POST(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof TaskSchema>;
try {
body = TaskSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
if (!cron.validate(body.cronExpression)) {
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
}
const id = nanoid();
await db.insert(scheduledTasks).values({
id,
name: body.name,
description: body.description ?? null,
cronExpression: body.cronExpression,
command: body.command,
isEnabled: body.isEnabled,
createdAt: Date.now(),
updatedAt: Date.now(),
});
if (body.isEnabled) {
scheduleTask(id, body.cronExpression, body.command);
}
return NextResponse.json({ success: true, id }, { status: 201 });
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { db } from "@/lib/db";
import { auditLogs } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { z } from "zod";
import { nanoid } from "nanoid";
const ActionSchema = z.object({
action: z.enum(["start", "stop", "restart"]),
force: z.boolean().optional().default(false),
});
export async function POST(req: NextRequest) {
// Auth
const session = await getAuthSession(req.headers);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Role check — only admin+
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Rate limiting
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 20); // stricter limit for control actions
if (!allowed) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
// Validate body
let body: z.infer<typeof ActionSchema>;
try {
body = ActionSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
const { action, force } = body;
try {
switch (action) {
case "start":
await mcProcessManager.start();
break;
case "stop":
await mcProcessManager.stop(force);
break;
case "restart":
await mcProcessManager.restart(force);
break;
}
// Audit log
await db.insert(auditLogs).values({
id: nanoid(),
userId: session.user.id,
action: `server.${action}${force ? ".force" : ""}`,
target: "server",
targetId: null,
details: JSON.stringify({ action, force }),
ipAddress: ip,
createdAt: Date.now(),
});
return NextResponse.json({ success: true, action });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { serverSettings } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { z } from "zod";
const UpdateSettingsSchema = z.object({
minecraftPath: z.string().optional(),
serverJar: z.string().optional(),
serverVersion: z.string().optional(),
serverType: z.enum(["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"]).optional(),
maxRam: z.number().min(512).max(32768).optional(),
minRam: z.number().min(256).max(32768).optional(),
rconEnabled: z.boolean().optional(),
rconPort: z.number().min(1).max(65535).optional(),
rconPassword: z.string().min(8).optional(),
javaArgs: z.string().max(1000).optional(),
autoStart: z.boolean().optional(),
restartOnCrash: z.boolean().optional(),
backupEnabled: z.boolean().optional(),
backupSchedule: z.string().optional(),
bluemapEnabled: z.boolean().optional(),
bluemapUrl: z.string().url().optional().or(z.literal("")),
});
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const settings = await db.select().from(serverSettings).get();
// Never return RCON password
if (settings) {
const { rconPassword: _, ...safe } = settings;
return NextResponse.json({ settings: safe });
}
return NextResponse.json({ settings: null });
}
export async function PATCH(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof UpdateSettingsSchema>;
try {
body = UpdateSettingsSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const existing = await db.select().from(serverSettings).get();
if (existing) {
await db.update(serverSettings).set({ ...body, updatedAt: Date.now() });
} else {
await db.insert(serverSettings).values({ id: 1, ...body, updatedAt: Date.now() });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { mcProcessManager } from "@/lib/minecraft/process";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip);
if (!allowed) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const status = mcProcessManager.getStatus();
return NextResponse.json(status);
}

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 20);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const type = req.nextUrl.searchParams.get("type") ?? "vanilla";
try {
let versionInfos: VersionInfo[];
switch (type) {
case "paper":
versionInfos = await fetchPaperVersions();
break;
case "fabric":
versionInfos = await fetchFabricVersions();
break;
case "vanilla":
default:
versionInfos = await fetchVanillaVersions();
}
const versions = versionInfos.map((v) => v.id);
return NextResponse.json({ versions, type });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch versions";
return NextResponse.json({ error: message }, { status: 503 });
}
}

91
app/api/team/route.ts Normal file
View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, getAuthSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { users, invitations } from "@/lib/db/schema";
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
import { sendInvitationEmail } from "@/lib/email";
import { z } from "zod";
import { nanoid } from "nanoid";
import { eq, ne } from "drizzle-orm";
const InviteSchema = z.object({
email: z.string().email().max(254),
role: z.enum(["admin", "moderator"]),
playerUuid: z.string().optional(),
});
export async function GET(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const teamUsers = await db
.select()
.from(users)
.where(ne(users.id, session.user.id));
const pendingInvites = await db
.select()
.from(invitations)
.where(eq(invitations.acceptedAt, null as unknown as number));
return NextResponse.json({ users: teamUsers, pendingInvites });
}
export async function POST(req: NextRequest) {
const session = await getAuthSession(req.headers);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "superadmin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const ip = getClientIp(req);
const { allowed } = checkRateLimit(ip, 10);
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let body: z.infer<typeof InviteSchema>;
try {
body = InviteSchema.parse(await req.json());
} catch {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Check if user already exists
const existing = await db.select().from(users).where(eq(users.email, body.email)).get();
if (existing) {
return NextResponse.json({ error: "User already exists" }, { status: 409 });
}
// Create invitation
const token = nanoid(48);
const expiresAt = Date.now() + 48 * 60 * 60 * 1000; // 48 hours
await db.insert(invitations).values({
id: nanoid(),
email: body.email,
role: body.role,
invitedBy: session.user.id,
token,
expiresAt,
createdAt: Date.now(),
});
const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`;
try {
await sendInvitationEmail({
to: body.email,
invitedByName: session.user.name ?? "An admin",
inviteUrl,
role: body.role,
});
} catch (err) {
// Log but don't fail — admin can resend
console.error("[Team] Failed to send invitation email:", err);
}
return NextResponse.json({ success: true, inviteUrl }, { status: 201 });
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

129
app/globals.css Normal file
View File

@@ -0,0 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0.00 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

81
app/layout.tsx Normal file
View File

@@ -0,0 +1,81 @@
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/layout/providers";
// ---------------------------------------------------------------------------
// Fonts
// ---------------------------------------------------------------------------
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
display: "swap",
});
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
export const metadata: Metadata = {
title: {
default: "CubeAdmin | Minecraft Server Manager",
template: "%s | CubeAdmin",
},
description:
"A professional, self-hosted admin panel for managing your Minecraft server. Monitor performance, manage players, control files, and more.",
keywords: [
"Minecraft",
"server admin",
"panel",
"management",
"console",
"monitoring",
],
authors: [{ name: "CubeAdmin" }],
creator: "CubeAdmin",
robots: {
index: false, // Admin panel should not be indexed
follow: false,
},
icons: {
icon: "/favicon.ico",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
],
width: "device-width",
initialScale: 1,
};
// ---------------------------------------------------------------------------
// Root Layout
// ---------------------------------------------------------------------------
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
// Apply dark class by default; next-themes will manage it from here
className="dark"
suppressHydrationWarning
>
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
<Providers>{children}</Providers>
</body>
</html>
);
}

5
app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

2314
bun.lock Normal file

File diff suppressed because it is too large Load Diff

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,72 @@
"use client";
import React, { useState } from "react";
import { ThemeProvider } from "next-themes";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
// ---------------------------------------------------------------------------
// QueryClient created once per client session
// ---------------------------------------------------------------------------
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Don't refetch on window focus in development
refetchOnWindowFocus: process.env.NODE_ENV === "production",
// 30 second stale time by default
staleTime: 30_000,
// Retry failed queries up to 2 times
retry: 2,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
},
mutations: {
// Don't retry mutations by default
retry: 0,
},
},
});
}
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
interface ProvidersProps {
children: React.ReactNode;
}
export function Providers({ children }: ProvidersProps) {
// useState ensures the QueryClient is only created once per component mount
// and is not shared between different users on SSR.
const [queryClient] = useState(() => makeQueryClient());
return (
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<TooltipProvider delay={400}>
{children}
<Toaster
position="bottom-right"
theme="dark"
richColors
closeButton
toastOptions={{
style: {
background: "oklch(0.205 0 0)",
border: "1px solid oklch(1 0 0 / 10%)",
color: "oklch(0.985 0 0)",
},
className: "cubeadmin-toast",
}}
/>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,488 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { authClient } from "@/lib/auth/client";
import {
LayoutDashboard,
Terminal,
Activity,
Clock,
Users,
Map,
Puzzle,
FolderOpen,
Archive,
Settings,
RefreshCw,
UserPlus,
ScrollText,
LogOut,
ChevronRight,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NavItem {
label: string;
href: string;
icon: React.ElementType;
}
interface NavSection {
title: string;
items: NavItem[];
}
interface ServerStatus {
online: boolean;
status: "online" | "offline" | "starting" | "stopping";
}
// ---------------------------------------------------------------------------
// Navigation structure
// ---------------------------------------------------------------------------
const NAV_SECTIONS: NavSection[] = [
{
title: "Overview",
items: [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
],
},
{
title: "Server",
items: [
{ label: "Console", href: "/console", icon: Terminal },
{ label: "Monitoring", href: "/monitoring", icon: Activity },
{ label: "Scheduler", href: "/scheduler", icon: Clock },
],
},
{
title: "World",
items: [
{ label: "Players", href: "/players", icon: Users },
{ label: "Map", href: "/map", icon: Map },
],
},
{
title: "Manage",
items: [
{ label: "Plugins", href: "/plugins", icon: Puzzle },
{ label: "Files", href: "/files", icon: FolderOpen },
{ label: "Backups", href: "/backups", icon: Archive },
{ label: "Settings", href: "/settings", icon: Settings },
{ label: "Updates", href: "/updates", icon: RefreshCw },
],
},
{
title: "Admin",
items: [
{ label: "Team", href: "/team", icon: UserPlus },
{ label: "Audit Log", href: "/audit", icon: ScrollText },
],
},
];
// ---------------------------------------------------------------------------
// CubeAdmin Logo SVG
// ---------------------------------------------------------------------------
function CubeIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
>
{/* Top face */}
<polygon
points="16,4 28,10 16,16 4,10"
fill="#059669"
opacity="0.9"
/>
{/* Left face */}
<polygon
points="4,10 16,16 16,28 4,22"
fill="#047857"
opacity="0.95"
/>
{/* Right face */}
<polygon
points="28,10 16,16 16,28 28,22"
fill="#10b981"
opacity="0.85"
/>
{/* Edge highlights */}
<polyline
points="4,10 16,4 28,10 16,16 4,10"
stroke="#34d399"
strokeWidth="0.75"
strokeLinejoin="round"
fill="none"
opacity="0.6"
/>
<line
x1="16" y1="16" x2="16" y2="28"
stroke="#34d399"
strokeWidth="0.75"
opacity="0.4"
/>
</svg>
);
}
// ---------------------------------------------------------------------------
// Server status indicator
// ---------------------------------------------------------------------------
function ServerStatusBadge({ collapsed }: { collapsed: boolean }) {
const { data: status } = useQuery<ServerStatus>({
queryKey: ["server-status"],
queryFn: async () => {
const res = await fetch("/api/server/status");
if (!res.ok) return { online: false, status: "offline" as const };
return res.json();
},
refetchInterval: 10_000,
staleTime: 8_000,
});
const isOnline = status?.online ?? false;
const label = status?.status
? status.status.charAt(0).toUpperCase() + status.status.slice(1)
: "Unknown";
const dotColor = {
online: "bg-emerald-500",
offline: "bg-red-500",
starting: "bg-yellow-500 animate-pulse",
stopping: "bg-orange-500 animate-pulse",
}[status?.status ?? "offline"] ?? "bg-zinc-500";
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger>
<div className="flex items-center justify-center p-1">
<span
className={cn("block h-2.5 w-2.5 rounded-full", dotColor)}
aria-label={`Server ${label}`}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<span>Server: {label}</span>
</TooltipContent>
</Tooltip>
);
}
return (
<div className="flex items-center gap-2 rounded-md bg-white/[0.04] px-2.5 py-1.5 ring-1 ring-white/[0.06]">
<span
className={cn("block h-2 w-2 flex-shrink-0 rounded-full", dotColor)}
/>
<span
className={cn(
"text-xs font-medium",
isOnline ? "text-emerald-400" : "text-zinc-400"
)}
>
{label}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Individual nav item
// ---------------------------------------------------------------------------
function NavLink({
item,
isActive,
collapsed,
}: {
item: NavItem;
isActive: boolean;
collapsed: boolean;
}) {
const Icon = item.icon;
const linkContent = (
<Link
href={item.href}
className={cn(
"group flex items-center gap-3 rounded-md px-2.5 py-2 text-sm font-medium transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50",
collapsed && "justify-center px-2",
isActive
? "bg-emerald-600/20 text-emerald-400 ring-1 ring-emerald-500/20"
: "text-zinc-400 hover:bg-white/[0.05] hover:text-zinc-100"
)}
aria-current={isActive ? "page" : undefined}
>
<Icon
className={cn(
"h-4 w-4 flex-shrink-0 transition-colors duration-150",
isActive
? "text-emerald-400"
: "text-zinc-500 group-hover:text-zinc-300"
)}
/>
{!collapsed && (
<span className="truncate leading-none">{item.label}</span>
)}
{!collapsed && isActive && (
<ChevronRight className="ml-auto h-3 w-3 text-emerald-500/60" />
)}
</Link>
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger>{linkContent}</TooltipTrigger>
<TooltipContent side="right">
<span>{item.label}</span>
</TooltipContent>
</Tooltip>
);
}
return linkContent;
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [mounted, setMounted] = useState(false);
// Responsive: auto-collapse on small screens
useEffect(() => {
setMounted(true);
const mq = window.matchMedia("(max-width: 1023px)");
const handler = (e: MediaQueryListEvent) => setCollapsed(e.matches);
setCollapsed(mq.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const { data: session } = useQuery({
queryKey: ["session"],
queryFn: async () => {
const { data } = await authClient.getSession();
return data;
},
staleTime: 5 * 60_000,
});
async function handleLogout() {
await authClient.signOut();
router.push("/login");
}
if (!mounted) return null;
const sidebarWidth = collapsed ? "w-[60px]" : "w-[240px]";
return (
<aside
className={cn(
"relative flex h-screen flex-shrink-0 flex-col border-r border-white/[0.06] bg-[#0a0a0a] transition-[width] duration-200 ease-in-out",
sidebarWidth
)}
aria-label="Main navigation"
>
{/* Logo */}
<div
className={cn(
"flex h-14 flex-shrink-0 items-center border-b border-white/[0.06]",
collapsed ? "justify-center px-0" : "gap-2.5 px-4"
)}
>
<CubeIcon className="h-7 w-7 flex-shrink-0" />
{!collapsed && (
<span className="text-sm font-semibold tracking-tight text-emerald-400">
CubeAdmin
</span>
)}
</div>
{/* Server status */}
<div
className={cn(
"flex-shrink-0 border-b border-white/[0.06]",
collapsed ? "px-2 py-2" : "px-3 py-2.5"
)}
>
<ServerStatusBadge collapsed={collapsed} />
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 scrollbar-thin">
<div className={cn("space-y-4", collapsed ? "px-1.5" : "px-2")}>
{NAV_SECTIONS.map((section) => {
const hasActiveItem = section.items.some(
(item) =>
pathname === item.href || pathname.startsWith(item.href + "/")
);
return (
<div key={section.title}>
{!collapsed && (
<p className="mb-1 px-2.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">
{section.title}
</p>
)}
{collapsed && hasActiveItem && (
<div className="mb-1 h-px w-full bg-emerald-500/20 rounded-full" />
)}
<div className="space-y-0.5">
{section.items.map((item) => {
const isActive =
pathname === item.href ||
pathname.startsWith(item.href + "/");
return (
<NavLink
key={item.href}
item={item}
isActive={isActive}
collapsed={collapsed}
/>
);
})}
</div>
</div>
);
})}
</div>
</nav>
{/* Collapse toggle */}
<button
onClick={() => setCollapsed((c) => !c)}
className={cn(
"flex-shrink-0 flex items-center gap-2 border-t border-white/[0.06] px-3 py-2 text-xs text-zinc-600 transition-colors hover:bg-white/[0.04] hover:text-zinc-400",
collapsed && "justify-center"
)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform duration-200",
!collapsed && "rotate-180"
)}
/>
{!collapsed && <span>Collapse</span>}
</button>
{/* User menu */}
<div
className={cn(
"flex-shrink-0 border-t border-white/[0.06]",
collapsed ? "p-2" : "p-3"
)}
>
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"flex w-full items-center gap-2.5 rounded-md p-1.5 text-left transition-colors hover:bg-white/[0.05] outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50",
collapsed && "justify-center"
)}
>
{/* Avatar */}
<div className="relative flex-shrink-0">
{session?.user?.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={session.user.image}
alt={session.user.name ?? "User avatar"}
className="h-7 w-7 rounded-full object-cover ring-1 ring-white/10"
/>
) : (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-600/30 ring-1 ring-emerald-500/20">
<span className="text-xs font-semibold text-emerald-400">
{(session?.user?.name ?? session?.user?.email ?? "?")
.charAt(0)
.toUpperCase()}
</span>
</div>
)}
{/* Online indicator */}
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500 ring-1 ring-[#0a0a0a]" />
</div>
{!collapsed && (
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-zinc-200">
{session?.user?.name ?? "Loading…"}
</p>
<p className="truncate text-[10px] text-zinc-500">
{(session?.user as { role?: string } | undefined)?.role ?? "Admin"}
</p>
</div>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
side={collapsed ? "right" : "top"}
align="start"
sideOffset={8}
className="w-52"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
{session?.user?.name ?? "—"}
</span>
<span className="text-xs text-muted-foreground truncate">
{session?.user?.email ?? "—"}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="h-4 w-4" />
Account Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
import React, { useState } from "react";
import { usePathname } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTheme } from "next-themes";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Moon,
Sun,
Bell,
Play,
Square,
RotateCcw,
Loader2,
ChevronDown,
Settings,
LogOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { authClient } from "@/lib/auth/client";
import { useRouter } from "next/navigation";
// ---------------------------------------------------------------------------
// Page title map
// ---------------------------------------------------------------------------
const PAGE_TITLES: Record<string, string> = {
"/dashboard": "Dashboard",
"/console": "Console",
"/monitoring": "Monitoring",
"/scheduler": "Scheduler",
"/players": "Players",
"/map": "World Map",
"/plugins": "Plugins",
"/files": "File Manager",
"/backups": "Backups",
"/settings": "Account Settings",
"/server": "Server Settings",
"/updates": "Updates",
"/team": "Team",
"/audit": "Audit Log",
};
function usePageTitle(): string {
const pathname = usePathname();
// Exact match first
if (PAGE_TITLES[pathname]) return PAGE_TITLES[pathname];
// Find the longest matching prefix
const match = Object.keys(PAGE_TITLES)
.filter((key) => pathname.startsWith(key + "/"))
.sort((a, b) => b.length - a.length)[0];
return match ? PAGE_TITLES[match] : "CubeAdmin";
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ServerStatus {
online: boolean;
status: "online" | "offline" | "starting" | "stopping";
playerCount?: number;
maxPlayers?: number;
}
type ServerAction = "start" | "stop" | "restart";
// ---------------------------------------------------------------------------
// Status badge
// ---------------------------------------------------------------------------
function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
if (!status) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700/50 bg-zinc-800/50 px-2 py-0.5 text-xs font-medium text-zinc-400">
<span className="h-1.5 w-1.5 rounded-full bg-zinc-500" />
Unknown
</span>
);
}
const statusConfigs = {
online: {
dot: "bg-emerald-500",
text: "Online",
className: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400",
},
offline: {
dot: "bg-red-500",
text: "Offline",
className: "border-red-500/20 bg-red-500/10 text-red-400",
},
starting: {
dot: "bg-yellow-500 animate-pulse",
text: "Starting…",
className: "border-yellow-500/20 bg-yellow-500/10 text-yellow-400",
},
stopping: {
dot: "bg-orange-500 animate-pulse",
text: "Stopping…",
className: "border-orange-500/20 bg-orange-500/10 text-orange-400",
},
};
const config = statusConfigs[status.status] ?? {
dot: "bg-zinc-500",
text: status.status,
className: "border-zinc-700/50 bg-zinc-800/50 text-zinc-400",
};
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium",
config.className
)}
>
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
{config.text}
{status.online && status.playerCount !== undefined && (
<span className="text-[10px] opacity-70">
{status.playerCount}/{status.maxPlayers}
</span>
)}
</span>
);
}
// ---------------------------------------------------------------------------
// Server action button with confirmation dialog
// ---------------------------------------------------------------------------
interface ActionButtonProps {
action: ServerAction;
disabled?: boolean;
isLoading?: boolean;
onConfirm: () => void;
serverStatus: ServerStatus | undefined;
}
const ACTION_CONFIG: Record<
ServerAction,
{
label: string;
icon: React.ElementType;
variant: "default" | "outline" | "destructive" | "ghost" | "secondary" | "link";
confirmTitle: string;
confirmDescription: string;
confirmLabel: string;
showWhen: (status: ServerStatus | undefined) => boolean;
}
> = {
start: {
label: "Start",
icon: Play,
variant: "outline",
confirmTitle: "Start the server?",
confirmDescription:
"This will start the Minecraft server. Players will be able to connect once it finishes booting.",
confirmLabel: "Start Server",
showWhen: (s) => !s || s.status === "offline",
},
stop: {
label: "Stop",
icon: Square,
variant: "outline",
confirmTitle: "Stop the server?",
confirmDescription:
"This will gracefully stop the server. All online players will be disconnected. Unsaved data will be saved first.",
confirmLabel: "Stop Server",
showWhen: (s) => s?.status === "online",
},
restart: {
label: "Restart",
icon: RotateCcw,
variant: "outline",
confirmTitle: "Restart the server?",
confirmDescription:
"This will gracefully restart the server. All online players will be temporarily disconnected.",
confirmLabel: "Restart Server",
showWhen: (s) => s?.status === "online",
},
};
function ServerActionButton({
action,
disabled,
isLoading,
onConfirm,
serverStatus,
}: ActionButtonProps) {
const config = ACTION_CONFIG[action];
const Icon = config.icon;
const [open, setOpen] = useState(false);
if (!config.showWhen(serverStatus)) return null;
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
disabled={disabled || isLoading}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-lg border text-xs font-medium h-7 gap-1.5 px-2.5 transition-all",
action === "stop" &&
"border-red-500/20 text-red-400 hover:bg-red-500/10 hover:border-red-500/30",
action === "restart" &&
"border-yellow-500/20 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/30",
action === "start" &&
"border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/10 hover:border-emerald-500/30"
)}
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Icon className="h-3 w-3" />
)}
{config.label}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{config.confirmTitle}</AlertDialogTitle>
<AlertDialogDescription>
{config.confirmDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setOpen(false);
onConfirm();
}}
className={cn(
action === "stop" &&
"bg-red-600 hover:bg-red-700 text-white border-0",
action === "restart" &&
"bg-yellow-600 hover:bg-yellow-700 text-white border-0",
action === "start" &&
"bg-emerald-600 hover:bg-emerald-700 text-white border-0"
)}
>
{config.confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
// Notifications bell
// ---------------------------------------------------------------------------
function NotificationBell() {
// TODO: fetch real notification count from /api/notifications
const count = 0;
return (
<Button
variant="ghost"
size="icon"
className="relative h-8 w-8 text-zinc-400 hover:text-zinc-100"
aria-label={`Notifications${count > 0 ? ` (${count} unread)` : ""}`}
>
<Bell className="h-4 w-4" />
{count > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-[9px] font-bold text-white ring-2 ring-[#0a0a0a]">
{count > 9 ? "9+" : count}
</span>
)}
</Button>
);
}
// ---------------------------------------------------------------------------
// Theme toggle
// ---------------------------------------------------------------------------
function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
// Render a placeholder until mounted to avoid SSR/client mismatch
if (!mounted) {
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400"
aria-label="Toggle theme"
disabled
>
<Moon className="h-4 w-4" />
</Button>
);
}
const isDark = resolvedTheme === "dark";
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-400 hover:text-zinc-100"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
);
}
// ---------------------------------------------------------------------------
// User avatar dropdown (topbar version)
// ---------------------------------------------------------------------------
function UserMenu() {
const router = useRouter();
const { data: session } = useQuery({
queryKey: ["session"],
queryFn: async () => {
const { data } = await authClient.getSession();
return data;
},
staleTime: 5 * 60_000,
});
async function handleLogout() {
await authClient.signOut();
router.push("/login");
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-white/[0.05] outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-600/30 ring-1 ring-emerald-500/20 flex-shrink-0">
{session?.user?.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={session.user.image}
alt=""
className="h-6 w-6 rounded-full object-cover"
/>
) : (
<span className="text-[10px] font-semibold text-emerald-400">
{(session?.user?.name ?? session?.user?.email ?? "?")
.charAt(0)
.toUpperCase()}
</span>
)}
</div>
<ChevronDown className="h-3 w-3 text-zinc-500" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
<span className="text-xs text-muted-foreground truncate">
{session?.user?.email ?? "—"}
</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
// ---------------------------------------------------------------------------
// Topbar
// ---------------------------------------------------------------------------
export function Topbar() {
const title = usePageTitle();
const queryClient = useQueryClient();
const { data: serverStatus } = useQuery<ServerStatus>({
queryKey: ["server-status"],
queryFn: async () => {
const res = await fetch("/api/server/status");
if (!res.ok) return { online: false, status: "offline" as const };
return res.json();
},
refetchInterval: 10_000,
staleTime: 8_000,
});
const controlMutation = useMutation({
mutationFn: async (action: ServerAction) => {
const res = await fetch("/api/server/control", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message ?? `Failed to ${action} server`);
}
return res.json();
},
onMutate: (action) => {
toast.loading(`${capitalize(action)}ing server…`, {
id: "server-control",
});
},
onSuccess: (_data, action) => {
toast.success(`Server ${action} command sent`, { id: "server-control" });
// Refetch status after a short delay
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["server-status"] });
}, 2000);
},
onError: (err: Error, action) => {
toast.error(`Failed to ${action} server: ${err.message}`, {
id: "server-control",
});
},
});
const isOperating = controlMutation.isPending;
return (
<header className="flex h-14 flex-shrink-0 items-center justify-between border-b border-white/[0.06] bg-[#0a0a0a]/80 px-6 backdrop-blur-sm">
{/* Left: page title */}
<div className="flex items-center gap-4">
<h1 className="text-sm font-semibold text-zinc-100">{title}</h1>
<ServerStatusBadge status={serverStatus} />
</div>
{/* Right: controls */}
<div className="flex items-center gap-1.5">
{/* Server quick-actions */}
<div className="mr-2 flex items-center gap-1.5">
{(["start", "stop", "restart"] as ServerAction[]).map((action) => (
<ServerActionButton
key={action}
action={action}
serverStatus={serverStatus}
disabled={isOperating}
isLoading={isOperating && controlMutation.variables === action}
onConfirm={() => controlMutation.mutate(action)}
/>
))}
</div>
<div className="h-5 w-px bg-white/[0.08]" />
<NotificationBell />
<ThemeToggle />
<UserMenu />
</div>
</header>
);
}
function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@@ -0,0 +1,74 @@
"use client"
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

76
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

109
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

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

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

60
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

103
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

196
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,271 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-lg"
side="right"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

157
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,271 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,51 @@
"use client"
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
import { cn } from "@/lib/utils"
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return (
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: PreviewCardPrimitive.Popup.Props &
Pick<
PreviewCardPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

20
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

20
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
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 }

283
components/ui/menubar.tsx Normal file
View File

@@ -0,0 +1,283 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border bg-background p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn(
"min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -0,0 +1,168 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg bg-background px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
)
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Icon>
)
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
}

90
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}

View File

@@ -0,0 +1,38 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

201
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

135
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

32
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

82
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

66
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

BIN
cubeadmin-logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
cubeadmin-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
data/cubeadmin.db Normal file

Binary file not shown.

BIN
data/cubeadmin.db-shm Normal file

Binary file not shown.

BIN
data/cubeadmin.db-wal Normal file

Binary file not shown.

45
docker/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM oven/bun:1.3-alpine AS builder
WORKDIR /app
# Install dependencies first (cache layer)
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
# Copy source
COPY . .
# Build Next.js
RUN bun run build
# ─── Stage 2: Production image ────────────────────────────────────────────────
FROM oven/bun:1.3-alpine AS runner
# Security: run as non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Copy built output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy server entrypoint
COPY --from=builder /app/server.ts ./server.ts
# Create data directory with correct permissions
RUN mkdir -p /app/data /app/backups && chown -R nextjs:nodejs /app/data /app/backups
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["bun", "--bun", "run", "server.ts"]

View File

@@ -0,0 +1,36 @@
################################################################################
# CubeAdmin — Development Docker Compose
#
# Usage: docker compose -f docker/docker-compose.dev.yml up
#
# This starts only the Minecraft server + optional BlueMap for development.
# The CubeAdmin panel runs on your host machine with `bun run dev`.
################################################################################
services:
minecraft:
image: itzg/minecraft-server:latest
container_name: minecraft_dev
restart: unless-stopped
tty: true
stdin_open: true
ports:
- "25565:25565"
- "25575:25575" # Expose RCON for local development
environment:
- TYPE=${MC_TYPE:-PAPER}
- VERSION=${MC_VERSION:-LATEST}
- EULA=TRUE
- MEMORY=2G
- ENABLE_RCON=true
- RCON_PORT=25575
- RCON_PASSWORD=${MC_RCON_PASSWORD:-devrcon123}
- USE_AIKAR_FLAGS=true
volumes:
- ./mc-data:/data
healthcheck:
test: ["CMD", "mc-health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s

129
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,129 @@
################################################################################
# CubeAdmin — Docker Compose
#
# Usage:
# 1. Copy .env.example to .env and fill in your values
# 2. docker compose up -d
#
# Services:
# - cubeadmin : The admin panel (Next.js + Bun)
# - minecraft : Minecraft server (optional — disable if you manage your own)
# - bluemap : BlueMap 3D map (optional)
#
################################################################################
services:
# ─── CubeAdmin panel ─────────────────────────────────────────────────────
cubeadmin:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: cubeadmin
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-CubeAdmin <noreply@example.com>}
- MC_SERVER_PATH=/mc-server
- MC_RCON_HOST=minecraft
- MC_RCON_PORT=${MC_RCON_PORT:-25575}
- MC_RCON_PASSWORD=${MC_RCON_PASSWORD:?MC_RCON_PASSWORD is required}
- BLUEMAP_URL=${BLUEMAP_URL:-}
- DATABASE_PATH=/app/data/cubeadmin.db
- BACKUPS_PATH=/app/backups
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@example.com}
- INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-Administrator}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-ChangeMe123!}
volumes:
# Persistent database
- cubeadmin_data:/app/data
# Persistent backups
- ${BACKUPS_PATH:-./backups}:/app/backups
# Mount Minecraft server directory (read-write for file management)
- ${MC_DATA_PATH:-./mc-data}:/mc-server
networks:
- cubeadmin_net
depends_on:
minecraft:
condition: service_healthy
required: false
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# ─── Minecraft Server (Paper MC) ─────────────────────────────────────────
# Remove or comment out this service if you manage your Minecraft server separately
minecraft:
image: itzg/minecraft-server:latest
container_name: minecraft
restart: unless-stopped
tty: true
stdin_open: true
ports:
- "${MC_PORT:-25565}:25565"
# Uncomment to expose RCON port externally (not recommended for security)
# - "25575:25575"
environment:
# Server type: VANILLA, PAPER, SPIGOT, FORGE, FABRIC, BUKKIT, BEDROCK
- TYPE=${MC_TYPE:-PAPER}
- VERSION=${MC_VERSION:-LATEST}
- EULA=TRUE
- MEMORY=${MC_MEMORY:-2G}
- MAX_PLAYERS=${MC_MAX_PLAYERS:-20}
- MOTD=${MC_MOTD:-Powered by CubeAdmin}
# RCON (required for CubeAdmin command execution)
- ENABLE_RCON=true
- RCON_PORT=25575
- RCON_PASSWORD=${MC_RCON_PASSWORD:?MC_RCON_PASSWORD is required}
# Performance
- USE_AIKAR_FLAGS=true
- VIEW_DISTANCE=10
- SIMULATION_DISTANCE=8
# Ops
- OPS=${MC_OPS:-}
- WHITELIST=${MC_WHITELIST:-}
- ENABLE_WHITELIST=false
volumes:
- ${MC_DATA_PATH:-./mc-data}:/data
networks:
- cubeadmin_net
healthcheck:
test: ["CMD", "mc-health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
# ─── BlueMap (optional 3D map) ───────────────────────────────────────────
# BlueMap is usually run as a plugin inside the MC server.
# This service provides a standalone BlueMap web viewer.
# Configure BLUEMAP_URL=http://localhost:8100 in your .env
#
# bluemap:
# image: nginx:alpine
# container_name: bluemap
# restart: unless-stopped
# ports:
# - "8100:80"
# volumes:
# - ${MC_DATA_PATH:-./mc-data}/plugins/BlueMap/web:/usr/share/nginx/html:ro
# networks:
# - cubeadmin_net
volumes:
cubeadmin_data:
name: cubeadmin_data
networks:
cubeadmin_net:
name: cubeadmin_net
driver: bridge

12
drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./lib/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_PATH ?? "./data/cubeadmin.db",
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,180 @@
CREATE TABLE `accounts` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`expires_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `audit_logs` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`action` text NOT NULL,
`target` text,
`target_id` text,
`details` text,
`ip_address` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `backups` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer,
`path` text,
`created_at` integer NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`triggered_by` text,
FOREIGN KEY (`triggered_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `invitations` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`role` text DEFAULT 'moderator' NOT NULL,
`invited_by` text NOT NULL,
`token` text NOT NULL,
`expires_at` integer NOT NULL,
`accepted_at` integer,
`created_at` integer NOT NULL,
FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `invitations_token_unique` ON `invitations` (`token`);--> statement-breakpoint
CREATE TABLE `mc_players` (
`id` text PRIMARY KEY NOT NULL,
`uuid` text NOT NULL,
`username` text NOT NULL,
`first_seen` integer,
`last_seen` integer,
`is_online` integer DEFAULT false NOT NULL,
`play_time` integer DEFAULT 0 NOT NULL,
`role` text,
`is_banned` integer DEFAULT false NOT NULL,
`notes` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `mc_players_uuid_idx` ON `mc_players` (`uuid`);--> statement-breakpoint
CREATE TABLE `player_bans` (
`id` text PRIMARY KEY NOT NULL,
`player_id` text NOT NULL,
`reason` text,
`banned_by` text,
`banned_at` integer NOT NULL,
`expires_at` integer,
`is_active` integer DEFAULT true NOT NULL,
`unbanned_by` text,
`unbanned_at` integer,
FOREIGN KEY (`player_id`) REFERENCES `mc_players`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`banned_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`unbanned_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `player_chat_history` (
`id` text PRIMARY KEY NOT NULL,
`player_id` text NOT NULL,
`message` text NOT NULL,
`channel` text,
`timestamp` integer NOT NULL,
`server_id` text,
FOREIGN KEY (`player_id`) REFERENCES `mc_players`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `player_spawn_points` (
`id` text PRIMARY KEY NOT NULL,
`player_id` text NOT NULL,
`name` text NOT NULL,
`world` text NOT NULL,
`x` real NOT NULL,
`y` real NOT NULL,
`z` real NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`player_id`) REFERENCES `mc_players`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `plugins` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`version` text,
`description` text,
`is_enabled` integer DEFAULT true NOT NULL,
`jar_file` text,
`config` text,
`installed_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `scheduled_tasks` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`cron_expression` text NOT NULL,
`command` text NOT NULL,
`is_enabled` integer DEFAULT true NOT NULL,
`last_run` integer,
`next_run` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `server_settings` (
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
`minecraft_path` text,
`server_jar` text,
`server_version` text,
`server_type` text,
`max_ram` integer DEFAULT 4096,
`min_ram` integer DEFAULT 1024,
`rcon_enabled` integer DEFAULT false NOT NULL,
`rcon_port` integer DEFAULT 25575,
`rcon_password` text,
`java_args` text,
`auto_start` integer DEFAULT false NOT NULL,
`restart_on_crash` integer DEFAULT false NOT NULL,
`backup_enabled` integer DEFAULT false NOT NULL,
`backup_schedule` text,
`bluemap_enabled` integer DEFAULT false NOT NULL,
`bluemap_url` text,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`token` text NOT NULL,
`expires_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`role` text DEFAULT 'moderator' NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE TABLE `verifications` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `accounts` ADD `id_token` text;--> statement-breakpoint
ALTER TABLE `accounts` ADD `password` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1772980984285,
"tag": "0000_overjoyed_thundra",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1772984147555,
"tag": "0001_gifted_loa",
"breakpoints": true
}
]
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

52
lib/auth/client.ts Normal file
View File

@@ -0,0 +1,52 @@
"use client";
import { createAuthClient } from "better-auth/react";
import { organizationClient } from "better-auth/client/plugins";
import { magicLinkClient } from "better-auth/client/plugins";
import type { Auth } from "./index";
/**
* Better Auth client instance for use in React components and client-side
* code. Mirrors the plugins registered on the server-side `auth` instance.
*/
export const authClient = createAuthClient({
// No baseURL — uses window.location.origin automatically, which always
// produces same-origin requests and avoids CSP connect-src issues.
...(process.env.NEXT_PUBLIC_BETTER_AUTH_URL
? { baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL }
: {}),
plugins: [
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
organizationClient(),
// Enables signIn.magicLink and magicLink.verify
magicLinkClient(),
],
});
// ---------------------------------------------------------------------------
// Convenience re-exports so consumers only need to import from this module
// ---------------------------------------------------------------------------
export const {
signIn,
signOut,
signUp,
useSession,
getSession,
} = authClient;
// ---------------------------------------------------------------------------
// Inferred client-side types
// ---------------------------------------------------------------------------
export type ClientSession = typeof authClient.$Infer.Session.session;
export type ClientUser = typeof authClient.$Infer.Session.user;
/**
* Infer server plugin types on the client side.
* Provides full type safety for plugin-specific methods without importing
* the server-only `auth` instance into a client bundle.
*/
export type { Auth };

147
lib/auth/index.ts Normal file
View File

@@ -0,0 +1,147 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { organization } from "better-auth/plugins";
import { magicLink } from "better-auth/plugins/magic-link";
import { count, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import * as schema from "@/lib/db/schema";
const isProduction = process.env.NODE_ENV === "production";
export const auth = betterAuth({
// -------------------------------------------------------------------------
// Core
// -------------------------------------------------------------------------
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
// -------------------------------------------------------------------------
// Database adapter (Drizzle + bun:sqlite)
// -------------------------------------------------------------------------
database: drizzleAdapter(db, {
provider: "sqlite",
// Keys must match Better Auth's internal model names (singular).
// usePlural: false (default) → "user", "session", "account", "verification"
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verifications,
},
}),
// -------------------------------------------------------------------------
// Custom user fields
// -------------------------------------------------------------------------
user: {
additionalFields: {
role: {
type: "string",
required: false,
defaultValue: "moderator",
input: false, // Not settable by the user directly
},
},
},
// -------------------------------------------------------------------------
// Email + password authentication
// -------------------------------------------------------------------------
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
minPasswordLength: 8,
maxPasswordLength: 128,
},
// -------------------------------------------------------------------------
// Database hooks — first registered user becomes admin automatically
// -------------------------------------------------------------------------
databaseHooks: {
user: {
create: {
after: async (user) => {
// Count all users; if this is the very first, promote to admin
const [{ total }] = await db
.select({ total: count() })
.from(schema.users);
if (total === 1) {
await db
.update(schema.users)
.set({ role: "admin" } as Record<string, unknown>)
.where(eq(schema.users.id, user.id));
console.log(`[Auth] First user ${user.id} (${user.email}) promoted to admin`);
}
},
},
},
},
// -------------------------------------------------------------------------
// Plugins
// -------------------------------------------------------------------------
plugins: [
// Organization / role support
organization(),
// Magic link — used for invitation acceptance flows
magicLink({
expiresIn: 60 * 60, // 1 hour
disableSignUp: true, // magic links are only for invited users
sendMagicLink: async ({ email, url, token }) => {
// Delegate to the application's email module. The email module is
// responsible for importing and calling Resend (or whichever mailer
// is configured). We do a dynamic import so that this file does not
// pull in email dependencies at auth-initialisation time on the edge.
const { sendMagicLinkEmail } = await import("@/lib/email/index");
await sendMagicLinkEmail({ email, url, token });
},
}),
],
// -------------------------------------------------------------------------
// Trusted origins — allow env-configured list plus localhost in dev
// -------------------------------------------------------------------------
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",").map((o) => o.trim())
: ["http://localhost:3000"],
// -------------------------------------------------------------------------
// Cookie / session security
// -------------------------------------------------------------------------
advanced: {
useSecureCookies: isProduction,
defaultCookieAttributes: {
httpOnly: true,
secure: isProduction,
sameSite: "strict",
path: "/",
},
},
});
// ---------------------------------------------------------------------------
// Type helpers for use across the application
// ---------------------------------------------------------------------------
export type Auth = typeof auth;
/** The server-side session type returned by auth.api.getSession */
export type Session = typeof auth.$Infer.Session.session;
/** The user type embedded in every session, with custom additionalFields */
export type User = typeof auth.$Infer.Session.user & {
role?: "superadmin" | "admin" | "moderator" | null;
};
type RawSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
/** Typed wrapper around auth.api.getSession that includes the role field */
export async function getAuthSession(
headers: Headers,
): Promise<(Omit<RawSession, "user"> & { user: User }) | null> {
return auth.api.getSession({ headers }) as Promise<
(Omit<RawSession, "user"> & { user: User }) | null
>;
}

141
lib/backup/manager.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* Backup manager: creates zip archives of worlds, plugins, or config.
* Uses the `archiver` package.
*/
import archiver from "archiver";
import * as fs from "node:fs";
import * as path from "node:path";
import { db } from "@/lib/db";
import { backups } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export type BackupType = "worlds" | "plugins" | "config" | "full";
const BACKUPS_DIR =
process.env.BACKUPS_PATH ?? path.join(process.cwd(), "backups");
/** Ensure the backups directory exists. */
function ensureBackupsDir(): void {
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
}
/**
* Create a backup archive of the specified type.
* Returns the backup record ID.
*/
export async function createBackup(
type: BackupType,
triggeredBy: string,
): Promise<string> {
ensureBackupsDir();
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
const id = nanoid();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = `backup-${type}-${timestamp}.zip`;
const filePath = path.join(BACKUPS_DIR, fileName);
// Insert pending record
await db.insert(backups).values({
id,
name: fileName,
type,
size: 0,
path: filePath,
createdAt: Date.now(),
status: "running",
triggeredBy,
});
try {
await archiveBackup(type, mcPath, filePath);
const stat = fs.statSync(filePath);
await db
.update(backups)
.set({ status: "completed", size: stat.size })
.where(eq(backups.id, id));
} catch (err) {
await db
.update(backups)
.set({ status: "failed" })
.where(eq(backups.id, id));
throw err;
}
return id;
}
function archiveBackup(
type: BackupType,
mcPath: string,
outPath: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outPath);
const archive = archiver("zip", { zlib: { level: 6 } });
output.on("close", resolve);
archive.on("error", reject);
archive.pipe(output);
const dirsToArchive: { src: string; name: string }[] = [];
if (type === "worlds" || type === "full") {
// Common world directory names
for (const dir of ["world", "world_nether", "world_the_end"]) {
const p = path.join(mcPath, dir);
if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: dir });
}
}
if (type === "plugins" || type === "full") {
const p = path.join(mcPath, "plugins");
if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: "plugins" });
}
if (type === "config" || type === "full") {
// Config files at server root
for (const file of [
"server.properties",
"ops.json",
"whitelist.json",
"banned-players.json",
"banned-ips.json",
"eula.txt",
"spigot.yml",
"paper.yml",
"bukkit.yml",
]) {
const p = path.join(mcPath, file);
if (fs.existsSync(p)) archive.file(p, { name: `config/${file}` });
}
}
for (const { src, name } of dirsToArchive) {
archive.directory(src, name);
}
archive.finalize();
});
}
/** Delete a backup file and its DB record. */
export async function deleteBackup(id: string): Promise<void> {
const record = await db
.select()
.from(backups)
.where(eq(backups.id, id))
.get();
if (!record) throw new Error("Backup not found");
if (record.path && fs.existsSync(record.path)) {
fs.unlinkSync(record.path);
}
await db.delete(backups).where(eq(backups.id, id));
}
/** List all backups from the DB. */
export async function listBackups() {
return db.select().from(backups).orderBy(backups.createdAt);
}

102
lib/db/index.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import * as schema from "./schema";
const DB_PATH = "./data/cubeadmin.db";
// Ensure the data directory exists before opening the database
mkdirSync(dirname(DB_PATH), { recursive: true });
const sqlite = new Database(DB_PATH, { create: true });
// Enable WAL mode for better concurrent read performance
sqlite.exec("PRAGMA journal_mode = WAL;");
sqlite.exec("PRAGMA foreign_keys = ON;");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
// Re-export all schema tables for convenient imports from a single location
export {
users,
sessions,
accounts,
verifications,
invitations,
mcPlayers,
playerBans,
playerChatHistory,
playerSpawnPoints,
plugins,
backups,
scheduledTasks,
auditLogs,
serverSettings,
} from "./schema";
// Re-export Zod schemas
export {
insertUserSchema,
selectUserSchema,
insertSessionSchema,
selectSessionSchema,
insertAccountSchema,
selectAccountSchema,
insertVerificationSchema,
selectVerificationSchema,
insertInvitationSchema,
selectInvitationSchema,
insertMcPlayerSchema,
selectMcPlayerSchema,
insertPlayerBanSchema,
selectPlayerBanSchema,
insertPlayerChatHistorySchema,
selectPlayerChatHistorySchema,
insertPlayerSpawnPointSchema,
selectPlayerSpawnPointSchema,
insertPluginSchema,
selectPluginSchema,
insertBackupSchema,
selectBackupSchema,
insertScheduledTaskSchema,
selectScheduledTaskSchema,
insertAuditLogSchema,
selectAuditLogSchema,
insertServerSettingsSchema,
selectServerSettingsSchema,
} from "./schema";
// Re-export inferred types
export type {
User,
NewUser,
Session,
NewSession,
Account,
NewAccount,
Verification,
NewVerification,
Invitation,
NewInvitation,
McPlayer,
NewMcPlayer,
PlayerBan,
NewPlayerBan,
PlayerChatHistory,
NewPlayerChatHistory,
PlayerSpawnPoint,
NewPlayerSpawnPoint,
Plugin,
NewPlugin,
Backup,
NewBackup,
ScheduledTask,
NewScheduledTask,
AuditLog,
NewAuditLog,
ServerSettings,
NewServerSettings,
} from "./schema";

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