V1 bingo
Some checks failed
Deploy / build-and-deploy (push) Failing after 2m53s

This commit is contained in:
2026-06-14 21:29:43 +03:00
commit 05677924b5
55 changed files with 10816 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Badge } from "./ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "./ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Separator } from "./ui/separator";
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
createdAt: string;
};
export function AdminDashboard() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newGridSize, setNewGridSize] = useState("5");
const [creating, setCreating] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchCampaigns = async () => {
const res = await fetch("/api/campaigns");
const data = await res.json();
setCampaigns(data);
setLoading(false);
};
useEffect(() => { fetchCampaigns(); }, []);
const createCampaign = async () => {
if (!newName) return;
setCreating(true);
await fetch("/api/campaigns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName, gridSize: parseInt(newGridSize) }),
});
setNewName("");
setDialogOpen(false);
await fetchCampaigns();
setCreating(false);
};
const deleteCampaign = async (id: string) => {
await fetch(`/api/campaigns/${id}`, { method: "DELETE" });
await fetchCampaigns();
};
const statusColors: Record<string, "success" | "warning" | "secondary"> = {
active: "success",
completed: "warning",
archived: "secondary",
};
return (
<div className="space-y-6 w-full max-w-2xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-mono text-cyan-300 uppercase tracking-wider"> Command Center</h2>
<p className="text-xs text-slate-500 font-mono">Admin terminal v1.0</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="font-mono text-xs">
+ NEW CAMPAIGN
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="font-mono text-cyan-300">New Campaign</DialogTitle>
<DialogDescription className="font-mono text-xs text-slate-500">
Deploy a new bingo operation
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase">Mission Name</label>
<Input
placeholder='e.g. "Traitor Hunt #42"'
value={newName}
onChange={e => setNewName(e.target.value)}
className="font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase">Grid Size</label>
<Select value={newGridSize} onValueChange={setNewGridSize}>
<SelectTrigger className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3×3 (Quick)</SelectItem>
<SelectItem value="4">4×4</SelectItem>
<SelectItem value="5">5×5 (Classic)</SelectItem>
<SelectItem value="6">6×6 (Chaotic)</SelectItem>
<SelectItem value="7">7×7 (Meltdown)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} className="font-mono">Cancel</Button>
<Button onClick={createCampaign} disabled={creating || !newName} className="font-mono">
{creating ? "DEPLOYING..." : "▶ DEPLOY"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Separator />
{loading ? (
<div className="text-center text-slate-500 font-mono text-sm py-8">
Loading submarine manifests...
</div>
) : campaigns.length === 0 ? (
<Card className="border-dashed border-slate-700/30">
<CardContent className="py-12 text-center">
<div className="text-4xl mb-3">🗺💀</div>
<p className="text-slate-500 font-mono text-sm">No campaigns deployed</p>
<p className="text-slate-600 font-mono text-xs mt-1">Click &ldquo;New Campaign&rdquo; to start the chaos</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{campaigns.map(c => (
<Card key={c.id} className="border-slate-700/30 hover:border-cyan-800/30 transition-colors">
<CardContent className="p-4 flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-200">{c.name}</span>
<Badge variant={statusColors[c.status] || "secondary"} className="text-[9px] uppercase">
{c.status}
</Badge>
</div>
<div className="flex gap-3 mt-1 text-[10px] text-slate-600 font-mono">
<span>{c.gridSize}×{c.gridSize}</span>
<span>{new Date(c.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
<a href={`/admin/campaigns/${c.id}`}>
<Button variant="ghost" size="sm" className="text-xs font-mono">EDIT</Button>
</a>
<Button variant="destructive" size="sm" className="text-xs font-mono" onClick={() => deleteCampaign(c.id)}>
DEL
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
type User = { id: string; nickname: string; isAdmin: boolean } | null;
type AuthContext = {
user: User;
login: (nickname: string, password: string) => Promise<string | null>;
register: (nickname: string, password: string) => Promise<string | null>;
logout: () => Promise<void>;
loading: boolean;
refetch: () => Promise<void>;
};
const ctx = createContext<AuthContext>({
user: null,
login: async () => null,
register: async () => null,
logout: async () => {},
loading: true,
refetch: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User>(null);
const [loading, setLoading] = useState(true);
const refetch = useCallback(async () => {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
setUser(data.user);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refetch(); }, [refetch]);
const login = async (nickname: string, password: string): Promise<string | null> => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname, password }),
});
const data = await res.json();
if (data.error) return data.error;
setUser(data);
return null;
};
const register = async (nickname: string, password: string): Promise<string | null> => {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname, password }),
});
const data = await res.json();
if (data.error) return data.error;
const loginErr = await login(nickname, password);
return loginErr;
};
const logout = async () => {
await fetch("/api/auth/login", { method: "DELETE" });
setUser(null);
};
return (
<ctx.Provider value={{ user, login, register, logout, loading, refetch }}>
{children}
</ctx.Provider>
);
}
export const useAuth = () => useContext(ctx);

299
components/BingoCard.tsx Normal file
View File

@@ -0,0 +1,299 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { BingoCell } from "./BingoCell";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { playSound, playBingo } from "@/lib/sounds";
import { cn } from "@/lib/utils";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
soundUrl?: string | null;
gridIndex: number;
};
type GridCell = {
index: number;
item: Item | null;
marked: boolean;
markedBy: string[];
markCount: number;
};
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
};
type Props = {
campaign: Campaign;
initialGrid: GridCell[];
currentUserNickname: string;
isAdmin: boolean;
};
function checkBingo(grid: GridCell[], gridSize: number): number[][] {
const lines: number[][] = [];
const size = gridSize;
for (let row = 0; row < size; row++) {
const indices = Array.from({ length: size }, (_, c) => row * size + c);
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
}
for (let col = 0; col < size; col++) {
const indices = Array.from({ length: size }, (_, r) => r * size + col);
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
}
const diag1 = Array.from({ length: size }, (_, i) => i * size + i);
if (diag1.every(i => grid[i]?.marked)) lines.push(diag1);
const diag2 = Array.from({ length: size }, (_, i) => (i + 1) * size - i - 1);
if (diag2.every(i => grid[i]?.marked)) lines.push(diag2);
return lines;
}
export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin }: Props) {
const [grid, setGrid] = useState<GridCell[]>(initialGrid);
const [bingoLines, setBingoLines] = useState<number[][]>([]);
const [marking, setMarking] = useState<string | null>(null);
const [chaosLevel, setChaosLevel] = useState(0);
const [showBingo, setShowBingo] = useState(false);
const [activityLog, setActivityLog] = useState<string[]>([]);
const bingoNotified = useRef(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchState = useCallback(async () => {
try {
const res = await fetch(`/api/game/${campaign.id}/state`);
const data = await res.json();
if (data.grid) {
setGrid(data.grid);
const lines = checkBingo(data.grid, campaign.gridSize);
setBingoLines(lines);
const markedCount = data.grid.filter((c: GridCell) => c.marked).length;
const totalItems = data.grid.filter((c: GridCell) => c.item).length;
setChaosLevel(totalItems > 0 ? Math.min(markedCount / totalItems, 1) : 0);
if (lines.length > 0 && !bingoNotified.current) {
bingoNotified.current = true;
setShowBingo(true);
playBingo();
}
if (data.activityLog) {
setActivityLog(data.activityLog);
}
}
} catch {}
}, [campaign.id, campaign.gridSize]);
useEffect(() => {
fetchState();
pollingRef.current = setInterval(fetchState, 3000);
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, [fetchState]);
const handleMark = async (itemId: string) => {
if (marking) return;
setMarking(itemId);
try {
const res = await fetch("/api/game/mark", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campaignId: campaign.id, itemId }),
});
if (res.ok) {
const item = grid.find(c => c.item?.id === itemId);
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
await fetchState();
} else if (res.status === 409) {
await fetchState();
}
} catch {}
setMarking(null);
};
const markedCount = grid.filter(c => c.marked).length;
const totalItems = grid.filter(c => c.item).length;
const hullHealth = Math.max(0, 100 - chaosLevel * 100);
const reactorTemp = 80 + chaosLevel * 120;
const o2Level = Math.max(0, 100 - chaosLevel * 130);
const stageLabel =
chaosLevel < 0.25 ? "✅ Sub stable" :
chaosLevel < 0.5 ? "⚠️ Leaking" :
chaosLevel < 0.75 ? "🚨 MELTDOWN" : "☢️ SUB DESTROYED";
const chaosStageClass =
chaosLevel < 0.3 ? "from-cyan-600 to-cyan-500" :
chaosLevel < 0.6 ? "from-amber-600 to-orange-500" :
"from-red-600 to-red-500";
return (
<div className="flex flex-col items-center gap-4 w-full max-w-5xl mx-auto">
{/* Header */}
<div className="text-center w-full">
<h2 className="text-2xl font-bold text-cyan-300 font-mono tracking-wide uppercase">
{campaign.name}
</h2>
<p className="text-sm text-slate-500 font-mono mt-1">
{markedCount}/{totalItems} cells marked
</p>
</div>
{/* Chaos Meter */}
<div className="w-full space-y-1">
<div className="flex justify-between text-xs font-mono text-slate-500">
<span>{stageLabel}</span>
<span>{Math.round(chaosLevel * 100)}% chaos</span>
</div>
<div className="w-full bg-slate-900/60 rounded-full h-3 overflow-hidden border border-slate-700/30">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r",
chaosStageClass,
chaosLevel >= 0.6 && "animate-pulse"
)}
style={{ width: `${chaosLevel * 100}%` }}
/>
</div>
</div>
{/* Sub Status + Grid Row */}
<div className="flex gap-4 w-full">
{/* Sub Status Panel */}
<div className="hidden md:flex flex-col gap-2 w-48 shrink-0">
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 space-y-2">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider">Sub Status</h3>
<div className="space-y-2">
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">Hull</span>
<span className={cn(hullHealth < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(hullHealth)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", hullHealth < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${hullHealth}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">Reactor</span>
<span className={cn(reactorTemp > 120 ? "text-orange-400" : "text-slate-400")}>{Math.round(reactorTemp)}°C</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", reactorTemp > 120 ? "bg-orange-500 animate-pulse" : "bg-cyan-500")} style={{ width: `${Math.min(reactorTemp / 2, 100)}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">O</span>
<span className={cn(o2Level < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(o2Level)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", o2Level < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${o2Level}%` }} />
</div>
</div>
</div>
<div className="text-[8px] font-mono text-slate-700 pt-1 border-t border-slate-700/30">
{chaosLevel < 0.25 && "All systems nominal"}
{chaosLevel >= 0.25 && chaosLevel < 0.5 && "⚠️ Minor breaches detected"}
{chaosLevel >= 0.5 && chaosLevel < 0.75 && "🚨 EVACUATE! SUB COMPROMISED"}
{chaosLevel >= 0.75 && "☢️ MELTDOWN IN PROGRESS"}
</div>
</div>
{/* Activity Log */}
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 flex-1 min-h-0 max-h-60 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2 sticky top-0 bg-slate-900/80 pb-1">Crew Log</h3>
<div className="space-y-1">
{activityLog.length === 0 && (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
)}
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
</div>
</div>
{/* Bingo Grid */}
<div className="flex-1 min-w-0">
<div
className="grid gap-1.5 w-full"
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
>
{grid.map((cell) => (
<BingoCell
key={cell.index}
item={cell.item}
index={cell.index}
marked={cell.marked}
markCount={cell.markCount}
markedBy={cell.markedBy}
gridSize={campaign.gridSize}
onMark={handleMark}
disabled={marking !== null}
/>
))}
</div>
</div>
</div>
{/* Bingo Lines */}
{bingoLines.length > 0 && (
<div className="flex flex-wrap gap-2 items-center justify-center">
<Badge variant="destructive" className="text-sm px-4 py-1 animate-pulse shadow-lg shadow-red-600/30">
🏆 BINGO! {bingoLines.length} line(s)
</Badge>
</div>
)}
{/* Bingo Celebration Modal */}
{showBingo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setShowBingo(false)}>
<div className="bg-slate-900 border-2 border-amber-500/50 rounded-2xl p-8 text-center shadow-2xl shadow-amber-900/30 animate-[bounce_1s_ease-in-out_infinite] max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="text-6xl mb-4">🏆🔥💀</div>
<h2 className="text-3xl font-bold text-amber-400 font-mono mb-2">БИНГО!</h2>
<p className="text-slate-300 mb-4">Хаос победил! Суб взорван, экипаж мёртв, всем весело!</p>
<div className="text-4xl mb-4 animate-pulse">🎉💥🌊🤡</div>
<Button variant="outline" onClick={() => setShowBingo(false)}>
Продолжить хаос
</Button>
</div>
</div>
)}
{/* Mobile Activity Log */}
<div className="md:hidden w-full bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 max-h-40 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2">Crew Log</h3>
<div className="space-y-1">
{activityLog.length === 0 && (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
)}
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2 justify-center text-[10px] text-slate-600 font-mono">
<span>🎺 horn</span>
<span>🚨 alarm</span>
<span>🌊 flood</span>
<span>💥 boom</span>
<span>👹 monster</span>
<span>💀 death</span>
<span>🔥 chaos</span>
</div>
</div>
);
}

113
components/BingoCell.tsx Normal file
View File

@@ -0,0 +1,113 @@
"use client";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
gridIndex: number;
};
type Props = {
item: Item | null;
index: number;
marked: boolean;
markCount: number;
markedBy: string[];
gridSize: number;
onMark: (itemId: string) => void;
disabled?: boolean;
isFreeSpace?: boolean;
};
function getSoundEmoji(cat: string) {
switch (cat) {
case "horn": return "🎺";
case "alarm": return "🚨";
case "flood": return "🌊";
case "explosion": return "💥";
case "monster": return "👹";
case "death": return "💀";
case "chaos": return "🔥";
default: return "🔔";
}
}
export function BingoCell({ item, index, marked, markCount, markedBy, gridSize, onMark, disabled, isFreeSpace }: Props) {
const [shake, setShake] = useState(false);
const [glow, setGlow] = useState(false);
useEffect(() => {
if (marked) {
setShake(true);
setGlow(true);
const t1 = setTimeout(() => setShake(false), 300);
const t2 = setTimeout(() => setGlow(false), 2000);
return () => { clearTimeout(t1); clearTimeout(t2); };
}
}, [marked]);
if (!item) {
return (
<div className={cn(
"flex items-center justify-center rounded-lg border border-dashed border-slate-700/30 bg-slate-900/20",
"text-slate-600 text-sm",
gridSize > 5 ? "p-1" : "p-2"
)}>
</div>
);
}
const isFree = isFreeSpace || item.text.startsWith("FREE SPACE");
return (
<button
onClick={() => !disabled && !marked && onMark(item.id)}
disabled={disabled || marked}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all duration-200 overflow-hidden",
"select-none",
gridSize > 5 ? "p-1 gap-0" : "p-2 gap-1",
marked
? "border-cyan-500/60 bg-cyan-900/40 cursor-default"
: "border-slate-700/40 bg-slate-800/40 hover:bg-slate-700/40 hover:border-cyan-600/40 hover:shadow-lg hover:shadow-cyan-900/20 cursor-pointer active:scale-95",
shake && "animate-shake",
glow && "animate-glow-pulse",
isFree && "border-amber-500/40 bg-amber-900/20",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
)}
title={markedBy.length > 0 ? `Marked by: ${markedBy.join(", ")}` : item.text}
>
<span className={cn(gridSize > 5 ? "text-lg" : "text-2xl", "leading-none")}>
{item.emoji || "💀"}
</span>
<span className={cn(
"text-center font-medium leading-tight text-slate-200",
gridSize > 5 ? "text-[10px]" : "text-xs",
"line-clamp-2"
)}>
{item.text}
</span>
{marked && markCount > 0 && (
<span className={cn(
"absolute top-0.5 right-1 font-bold text-cyan-400",
gridSize > 5 ? "text-[9px]" : "text-xs"
)}>
{markCount}
</span>
)}
{!marked && !isFree && (
<span className="text-[9px] text-slate-600 mt-0.5">{getSoundEmoji(item.soundCategory)}</span>
)}
{isFree && !marked && (
<span className={cn("text-[9px] text-amber-500/60 mt-0.5", gridSize > 5 ? "hidden" : "")}>
🎯 mark me
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "./ui/card";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { useAuth } from "./AuthProvider";
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
createdAt: string;
};
export function CampaignList({ onSelect }: { onSelect: (id: string) => void }) {
const { user } = useAuth();
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/campaigns")
.then(r => r.json())
.then(data => setCampaigns(data))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="text-center text-slate-500 font-mono text-sm py-8">
Scanning submarine network...
</div>
);
}
if (campaigns.length === 0) {
return (
<Card className="border-slate-700/30">
<CardContent className="py-8 text-center">
<div className="text-3xl mb-2">🗺</div>
<p className="text-slate-500 font-mono text-sm">No active campaigns</p>
<p className="text-slate-600 font-mono text-xs mt-1">Admin can create one</p>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-3 w-full max-w-md">
{campaigns.map(c => (
<Card key={c.id} className="border-slate-700/30 hover:border-cyan-700/30 transition-all cursor-pointer group" onClick={() => onSelect(c.id)}>
<CardContent className="p-4 flex items-center justify-between">
<div>
<h3 className="font-mono text-sm text-slate-200 group-hover:text-cyan-300 transition-colors">
{c.name}
</h3>
<div className="flex gap-2 mt-1">
<Badge variant={c.status === "active" ? "success" : "secondary"} className="text-[9px]">
{c.status}
</Badge>
<span className="text-[10px] text-slate-600 font-mono">{c.gridSize}×{c.gridSize}</span>
</div>
</div>
<Button variant="ghost" size="sm" className="font-mono text-xs">
JOIN
</Button>
</CardContent>
</Card>
))}
</div>
);
}

57
components/ChaosMeter.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { playChaosRiser } from "@/lib/sounds";
type Props = {
markedCount: number;
totalItems: number;
};
export function ChaosMeter({ markedCount, totalItems }: Props) {
const [prevCount, setPrevCount] = useState(markedCount);
const ratio = totalItems > 0 ? markedCount / totalItems : 0;
const percent = Math.round(ratio * 100);
useEffect(() => {
if (markedCount > prevCount && markedCount % 3 === 0) {
playChaosRiser();
}
setPrevCount(markedCount);
}, [markedCount, prevCount]);
const stage =
ratio < 0.25 ? "stable" :
ratio < 0.5 ? "unstable" :
ratio < 0.75 ? "critical" : "meltdown";
const stageLabels: Record<string, string> = {
stable: "✅ Sub stable",
unstable: "⚠️ Leaking",
critical: "🚨 MELTDOWN",
meltdown: "☢️ NUKE",
};
return (
<div className="w-full space-y-1">
<div className="flex justify-between text-xs font-mono text-slate-500">
<span>{stageLabels[stage]}</span>
<span>{percent}% chaos</span>
</div>
<div className="w-full bg-slate-900/60 rounded-full h-3 overflow-hidden border border-slate-700/30">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out",
stage === "stable" && "bg-gradient-to-r from-cyan-600 to-cyan-400",
stage === "unstable" && "bg-gradient-to-r from-amber-600 to-orange-400",
stage === "critical" && "bg-gradient-to-r from-orange-600 to-red-500 animate-pulse",
stage === "meltdown" && "bg-gradient-to-r from-red-600 to-purple-600 animate-pulse shadow-lg shadow-red-500/50"
)}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}

303
components/ItemEditor.tsx Normal file
View File

@@ -0,0 +1,303 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Card, CardContent } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { SOUND_CATEGORIES, EMOJIS } from "@/lib/bingo-data";
import { Badge } from "./ui/badge";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
soundUrl?: string | null;
gridIndex: number;
};
type Campaign = {
id: string;
name: string;
gridSize: number;
};
function EmojiPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="w-10 h-10 flex items-center justify-center text-xl rounded border border-slate-700/50 bg-slate-800/50 hover:bg-slate-700/50 cursor-pointer"
>
{value || "?"}
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 bg-slate-900 border border-slate-700/50 rounded-lg p-2 shadow-xl w-[280px]">
<div className="grid grid-cols-8 gap-1">
{EMOJIS.map(e => (
<button
key={e}
type="button"
onClick={() => { onChange(e); setOpen(false); }}
className={`w-8 h-8 flex items-center justify-center text-base rounded hover:bg-slate-700 cursor-pointer ${e === value ? "bg-cyan-700/50 ring-1 ring-cyan-400" : ""}`}
>
{e}
</button>
))}
</div>
</div>
)}
</div>
);
}
export function ItemEditor({ campaign }: { campaign: Campaign }) {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [editText, setEditText] = useState("");
const [editEmoji, setEditEmoji] = useState("💀");
const [editSound, setEditSound] = useState("horn");
const [editSoundUrl, setEditSoundUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchItems = async () => {
const res = await fetch(`/api/campaigns/${campaign.id}/items`);
const data = await res.json();
const sorted = [...data].sort((a: Item, b: Item) => a.gridIndex - b.gridIndex);
setItems(sorted);
setLoading(false);
};
useEffect(() => { fetchItems(); }, [campaign.id]);
const updateItem = async (item: Item) => {
await fetch(`/api/campaigns/${campaign.id}/items`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: item.id,
text: item.text,
emoji: item.emoji,
soundCategory: item.soundCategory,
soundUrl: item.soundUrl,
gridIndex: item.gridIndex,
}),
});
await fetchItems();
};
const deleteItem = async (itemId: string) => {
await fetch(`/api/campaigns/${campaign.id}/items?itemId=${itemId}`, { method: "DELETE" });
await fetchItems();
};
const addItem = async () => {
if (!editText) return;
const maxIdx = items.reduce((max, it) => Math.max(max, it.gridIndex), -1);
const totalCells = campaign.gridSize * campaign.gridSize;
if (maxIdx + 1 >= totalCells) {
alert("Grid is full! Delete some items first.");
return;
}
await fetch(`/api/campaigns/${campaign.id}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: editText,
emoji: editEmoji,
soundCategory: editSound,
soundUrl: editSoundUrl,
gridIndex: maxIdx + 1,
}),
});
setEditText("");
setEditEmoji("💀");
setEditSound("horn");
setEditSoundUrl(null);
await fetchItems();
};
const uploadSound = async (file: File) => {
setUploading(true);
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload/sound", { method: "POST", body: formData });
const data = await res.json();
if (data.url) setEditSoundUrl(data.url);
setUploading(false);
};
const totalCells = campaign.gridSize * campaign.gridSize;
if (loading) {
return <div className="text-center text-slate-500 font-mono text-sm py-8">Loading items...</div>;
}
return (
<div className="space-y-4">
<Card className="border-cyan-800/30">
<CardContent className="p-4 space-y-3">
<h3 className="text-xs font-mono text-cyan-400 uppercase">+ Add New Cell</h3>
<div className="flex flex-wrap gap-2 items-end">
<div className="flex-1 min-w-[150px]">
<label className="text-[9px] font-mono text-slate-600 uppercase">Text</label>
<Input
placeholder="Reactor explodes"
value={editText}
onChange={e => setEditText(e.target.value)}
className="font-mono text-sm"
/>
</div>
<div>
<label className="text-[9px] font-mono text-slate-600 uppercase">Emoji</label>
<EmojiPicker value={editEmoji} onChange={setEditEmoji} />
</div>
<div className="w-32">
<label className="text-[9px] font-mono text-slate-600 uppercase">Sound</label>
<Select
value={editSound}
onValueChange={v => { setEditSound(v); if (v !== "custom") setEditSoundUrl(null); }}
>
<SelectTrigger className="font-mono text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_CATEGORIES.map(cat => (
<SelectItem key={cat.value} value={cat.value} className="font-mono text-xs">
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editSound === "custom" && (
<div>
<label className="text-[9px] font-mono text-slate-600 uppercase">File</label>
<div className="flex gap-1 items-center">
<input
ref={fileInputRef}
type="file"
accept=".ogg"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) uploadSound(f); }}
/>
<Button
variant="outline"
size="sm"
className="font-mono text-[9px] h-10"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? "..." : editSoundUrl ? "✓" : "OGG"}
</Button>
{editSoundUrl && (
<button
type="button"
onClick={() => { const a = new Audio(editSoundUrl); a.volume = 0.4; a.play(); }}
className="text-sm cursor-pointer hover:text-cyan-300"
>
</button>
)}
</div>
</div>
)}
<Button size="sm" onClick={addItem} disabled={!editText} className="font-mono text-xs">
ADD
</Button>
</div>
</CardContent>
</Card>
<div
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
>
{Array.from({ length: totalCells }).map((_, idx) => {
const item = items.find(it => it.gridIndex === idx);
if (!item) {
return (
<div key={idx} className="aspect-square rounded border border-dashed border-slate-700/20 bg-slate-900/20 flex items-center justify-center text-slate-700 text-[10px] font-mono">
</div>
);
}
return (
<Card key={item.id} className="border-slate-700/30 aspect-square group">
<CardContent className="p-1.5 h-full flex flex-col items-center justify-center gap-0.5 relative">
<span className="text-lg">{item.emoji}</span>
<span className="text-[8px] text-center font-mono text-slate-300 leading-tight line-clamp-2">
{item.text}
</span>
<Badge variant="outline" className="text-[6px] px-1 py-0 absolute top-0.5 right-0.5">
{item.soundUrl ? "🔊" : item.soundCategory}
</Badge>
<div className="absolute bottom-0.5 left-0.5 right-0.5 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="destructive"
size="sm"
className="h-4 text-[8px] px-1 py-0 flex-1"
onClick={() => deleteItem(item.id)}
>
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
<div className="space-y-1">
<h4 className="text-[10px] font-mono text-slate-600 uppercase">All Items</h4>
{items.map(item => (
<div key={item.id} className="flex items-center gap-2 bg-slate-800/30 rounded p-2 border border-slate-700/20">
<span className="text-sm w-6">{item.emoji}</span>
<input
className="flex-1 bg-transparent text-xs font-mono text-slate-200 border-b border-slate-700/30 focus:border-cyan-500/50 outline-none px-1"
value={item.text}
onChange={e => {
setItems(prev => prev.map(it => it.id === item.id ? { ...it, text: e.target.value } : it));
}}
onBlur={() => updateItem(item)}
/>
<Select
value={item.soundCategory}
onValueChange={val => {
const updated = { ...item, soundCategory: val, soundUrl: val !== "custom" ? null : item.soundUrl };
setItems(prev => prev.map(it => it.id === item.id ? updated : it));
updateItem(updated);
}}
>
<SelectTrigger className="h-6 text-[9px] w-20 font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_CATEGORIES.map(cat => (
<SelectItem key={cat.value} value={cat.value} className="text-[10px] font-mono">
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[9px] text-slate-600 font-mono w-6 text-right">#{item.gridIndex}</span>
</div>
))}
</div>
</div>
);
}

83
components/LoginForm.tsx Normal file
View File

@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { useAuth } from "./AuthProvider";
export function LoginForm() {
const { login, register } = useAuth();
const [nickname, setNickname] = useState("");
const [password, setPassword] = useState("");
const [isRegister, setIsRegister] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
const fn = isRegister ? register : login;
const err = await fn(nickname, password);
if (err) setError(err);
setLoading(false);
};
return (
<Card className="w-full max-w-md mx-auto border-cyan-700/30 shadow-2xl shadow-cyan-900/20">
<CardHeader className="text-center">
<div className="text-4xl mb-2">🤡💥</div>
<CardTitle className="text-2xl font-mono text-cyan-300 tracking-wider uppercase">
BaraBingo
</CardTitle>
<CardDescription className="text-slate-500 font-mono text-xs">
{isRegister ? "SUB CREW REGISTRATION" : "SUB NETWORK LOGIN"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase tracking-wider">Nickname</label>
<Input
placeholder="e.g. DrunkEngineer69"
value={nickname}
onChange={e => setNickname(e.target.value)}
required
minLength={2}
maxLength={20}
className="font-mono"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-mono text-slate-500 uppercase tracking-wider">Password</label>
<Input
type="password"
placeholder="••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
minLength={4}
className="font-mono"
/>
</div>
{error && (
<div className="text-red-400 text-xs font-mono bg-red-950/30 border border-red-800/30 rounded-md p-2">
{error}
</div>
)}
<Button type="submit" className="w-full font-mono" disabled={loading}>
{loading ? "Connecting..." : isRegister ? "▶ REGISTER" : "▶ LOGIN"}
</Button>
<button
type="button"
onClick={() => { setIsRegister(!isRegister); setError(""); }}
className="w-full text-center text-xs text-slate-600 hover:text-cyan-400 transition-colors font-mono cursor-pointer"
>
{isRegister ? "Already have clearance? Login" : "Need a badge? Register"}
</button>
</form>
</CardContent>
</Card>
);
}

47
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,47 @@
"use client";
import { useAuth } from "./AuthProvider";
import { Button } from "./ui/button";
import { Badge } from "./ui/badge";
export function Navbar() {
const { user, loading, logout } = useAuth();
return (
<nav className="border-b border-slate-800/50 bg-slate-950/50 backdrop-blur-sm sticky top-0 z-50">
<div className="container mx-auto px-4 h-12 flex items-center justify-between max-w-5xl">
<a href="/" className="flex items-center gap-2 font-mono text-sm text-cyan-300 hover:text-cyan-200 transition-colors no-underline">
<span>🤡</span>
<span className="font-bold tracking-wider uppercase hidden sm:inline">BaraBingo</span>
</a>
{!loading && (
<div className="flex items-center gap-3">
{user ? (
<>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400">{user.nickname}</span>
{user.isAdmin && (
<Badge variant="default" className="text-[8px] px-1.5 py-0 uppercase">Admin</Badge>
)}
</div>
{user.isAdmin && (
<a href="/admin">
<Button variant="ghost" size="sm" className="text-xs font-mono h-7"></Button>
</a>
)}
<Button variant="ghost" size="sm" className="text-xs font-mono text-slate-600 h-7" onClick={logout}>
EXIT
</Button>
</>
) : (
<a href="/">
<Button variant="outline" size="sm" className="text-xs font-mono h-7">LOGIN</Button>
</a>
)}
</div>
)}
</div>
</nav>
);
}

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

@@ -0,0 +1,30 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-cyan-600/80 text-cyan-50",
secondary: "border-transparent bg-slate-700 text-slate-200",
destructive: "border-transparent bg-red-600/80 text-red-50",
success: "border-transparent bg-emerald-600/80 text-emerald-50",
warning: "border-transparent bg-amber-600/80 text-amber-50",
outline: "text-slate-300 border-slate-600",
},
},
defaultVariants: {
variant: "default",
},
}
)
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

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

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer",
{
variants: {
variant: {
default: "bg-cyan-600 text-white hover:bg-cyan-500 shadow-lg shadow-cyan-900/30",
destructive: "bg-red-700 text-white hover:bg-red-600 shadow-lg shadow-red-900/30",
outline: "border border-cyan-700/50 bg-transparent text-cyan-300 hover:bg-cyan-950/50",
secondary: "bg-slate-800 text-slate-200 hover:bg-slate-700",
ghost: "text-slate-300 hover:bg-slate-800/50 hover:text-white",
link: "text-cyan-400 underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

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

@@ -0,0 +1,53 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-slate-700/50 bg-slate-900/80 text-slate-100 shadow-xl backdrop-blur-sm",
className
)}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-slate-400", className)} {...props} />
)
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

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

@@ -0,0 +1,95 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-700/50 bg-slate-900 p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4 text-slate-400" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight text-slate-100", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

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

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-700/50 bg-slate-800/50 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/50 focus-visible:border-cyan-500/50 disabled:cursor-not-allowed disabled:opacity-50 transition-all",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

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

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-slate-700/50 bg-slate-800/50 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 transition-all",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-slate-700/50 bg-slate-900 text-slate-100 shadow-md animate-in fade-in-80",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-400", className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-slate-800 focus:text-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-cyan-400" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-slate-700/50", className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,15 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 bg-slate-700/50 h-[1px] w-full", className)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }