This commit is contained in:
164
components/AdminDashboard.tsx
Normal file
164
components/AdminDashboard.tsx
Normal 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 “New Campaign” 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>
|
||||
);
|
||||
}
|
||||
79
components/AuthProvider.tsx
Normal file
79
components/AuthProvider.tsx
Normal 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
299
components/BingoCard.tsx
Normal 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
113
components/BingoCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
components/CampaignList.tsx
Normal file
73
components/CampaignList.tsx
Normal 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
57
components/ChaosMeter.tsx
Normal 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
303
components/ItemEditor.tsx
Normal 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
83
components/LoginForm.tsx
Normal 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
47
components/Navbar.tsx
Normal 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
30
components/ui/badge.tsx
Normal 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
48
components/ui/button.tsx
Normal 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
53
components/ui/card.tsx
Normal 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
95
components/ui/dialog.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
137
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
15
components/ui/separator.tsx
Normal file
15
components/ui/separator.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user