Polish invite flow and improve mobile responsiveness
- Add Sheet UI component for mobile navigation drawer - SpaceNav: hamburger menu on mobile, horizontal tabs on desktop - Join page: specific error messages (expired/maxed/invalid), inline success state with credits info, already-member handling, skeleton loading - InviteDialog: add expiry hours input, helper text for email field - InviteList: new component for admin invite management (revoke, copy link) - Mobile responsiveness sweep across all space pages, proposals, voting buttons, member list, settings, demo page, and final vote panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9456c6f73f
commit
90865039f5
|
|
@ -24,7 +24,7 @@ export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
{/* Hero section */}
|
{/* Hero section */}
|
||||||
<section className="relative text-center py-16 space-y-6 overflow-hidden">
|
<section className="relative text-center py-8 sm:py-16 space-y-6 overflow-hidden">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-accent/10 -z-10" />
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-accent/10 -z-10" />
|
||||||
<div className="absolute top-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl -z-10" />
|
<div className="absolute top-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl -z-10" />
|
||||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-accent/5 rounded-full blur-3xl -z-10" />
|
<div className="absolute bottom-0 left-0 w-96 h-96 bg-accent/5 rounded-full blur-3xl -z-10" />
|
||||||
|
|
@ -32,7 +32,7 @@ export default function HomePage() {
|
||||||
<Badge variant="secondary" className="text-sm px-4 py-1 bg-primary/10 text-primary border-primary/20">
|
<Badge variant="secondary" className="text-sm px-4 py-1 bg-primary/10 text-primary border-primary/20">
|
||||||
Part of the rSpace Ecosystem
|
Part of the rSpace Ecosystem
|
||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight max-w-4xl mx-auto leading-tight">
|
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight max-w-4xl mx-auto leading-tight">
|
||||||
Democratic<br />
|
Democratic<br />
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent">Backlog Prioritization</span>
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent">Backlog Prioritization</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -196,11 +196,11 @@ export default function HomePage() {
|
||||||
<h3 className="text-lg font-semibold text-center mb-4">
|
<h3 className="text-lg font-semibold text-center mb-4">
|
||||||
Vote Cost Calculator
|
Vote Cost Calculator
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-5 gap-2 text-center">
|
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 text-center">
|
||||||
{[1, 2, 3, 4, 5].map((votes) => (
|
{[1, 2, 3, 4, 5].map((votes) => (
|
||||||
<div
|
<div
|
||||||
key={votes}
|
key={votes}
|
||||||
className="p-4 rounded-lg border-2 bg-card hover:border-primary/50 hover:bg-primary/5 transition-all duration-200"
|
className="p-3 sm:p-4 rounded-lg border-2 bg-card hover:border-primary/50 hover:bg-primary/5 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold text-primary">{votes}</div>
|
<div className="text-2xl font-bold text-primary">{votes}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,76 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Users, LogIn } from "lucide-react";
|
import { Users, LogIn, CheckCircle2, AlertCircle, Clock, UserCheck } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface InviteInfo {
|
||||||
|
space: { name: string; slug: string; description: string | null; startingCredits?: number };
|
||||||
|
expired: boolean;
|
||||||
|
maxedOut: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageState =
|
||||||
|
| { type: "loading" }
|
||||||
|
| { type: "error"; message: string; icon: typeof AlertCircle }
|
||||||
|
| { type: "invite"; info: InviteInfo }
|
||||||
|
| { type: "joined"; spaceName: string; startingCredits: number }
|
||||||
|
| { type: "already_member"; spaceName: string };
|
||||||
|
|
||||||
export default function JoinSpacePage() {
|
export default function JoinSpacePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
|
||||||
const slug = params.slug as string;
|
const slug = params.slug as string;
|
||||||
const token = searchParams.get("token");
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
const [inviteInfo, setInviteInfo] = useState<{ spaceName: string; uses: number; maxUses?: number } | null>(null);
|
const [state, setState] = useState<PageState>({ type: "loading" });
|
||||||
const [loading, setLoading] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (!token) {
|
||||||
|
setState({ type: "error", message: "No invite token provided.", icon: AlertCircle });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch(`/api/spaces/join/${token}`)
|
fetch(`/api/spaces/join/${token}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setError(data.error);
|
setState({ type: "error", message: data.error, icon: AlertCircle });
|
||||||
|
} else if (data.expired) {
|
||||||
|
setState({ type: "error", message: "This invite link has expired.", icon: Clock });
|
||||||
|
} else if (data.maxedOut) {
|
||||||
|
setState({ type: "error", message: "This invite link has reached its maximum number of uses.", icon: Users });
|
||||||
|
} else if (data.valid) {
|
||||||
|
setState({ type: "invite", info: data });
|
||||||
} else {
|
} else {
|
||||||
setInviteInfo(data);
|
setState({ type: "error", message: "This invite link is not valid.", icon: AlertCircle });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setError("Failed to load invite"));
|
.catch(() => setState({ type: "error", message: "Failed to load invite. Please try again.", icon: AlertCircle }));
|
||||||
} else {
|
|
||||||
setError("No invite token provided");
|
|
||||||
}
|
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
async function acceptInvite() {
|
async function acceptInvite() {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoading(true);
|
setJoining(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/spaces/join/${token}`, { method: "POST" });
|
const res = await fetch(`/api/spaces/join/${token}`, { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok && data.alreadyMember) {
|
||||||
toast.success("Welcome to the space!");
|
setState({ type: "already_member", spaceName: data.space.name });
|
||||||
router.push("/");
|
} else if (res.ok) {
|
||||||
|
setState({
|
||||||
|
type: "joined",
|
||||||
|
spaceName: data.space.name,
|
||||||
|
startingCredits: data.space.startingCredits ?? 0,
|
||||||
|
});
|
||||||
} else if (res.status === 401) {
|
} else if (res.status === 401) {
|
||||||
// Not logged in - redirect to signin with invite token preserved
|
// Not logged in — redirect to signin with invite URL preserved
|
||||||
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
|
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
window.location.href = `${protocol}//${rootDomain}/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`;
|
window.location.href = `${protocol}//${rootDomain}/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`;
|
||||||
|
|
@ -54,52 +78,94 @@ export default function JoinSpacePage() {
|
||||||
toast.error(data.error || "Failed to join space");
|
toast.error(data.error || "Failed to join space");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setJoining(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto mt-16">
|
<div className="max-w-md mx-auto mt-8 sm:mt-16 px-4">
|
||||||
|
{state.type === "loading" && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-muted" />
|
||||||
|
<div className="h-6 bg-muted rounded w-48 mx-auto" />
|
||||||
|
<div className="h-4 bg-muted rounded w-64 mx-auto" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.type === "error" && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-8 text-center space-y-4">
|
<CardContent className="py-8 text-center space-y-4">
|
||||||
<p className="text-lg text-muted-foreground">{error}</p>
|
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||||
|
<state.icon className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-muted-foreground">{state.message}</p>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/">Go Home</Link>
|
<Link href="/">Go Home</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
)}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inviteInfo) {
|
{state.type === "invite" && (
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto mt-16 text-center text-muted-foreground">
|
|
||||||
Loading invite...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto mt-16">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||||
<Users className="h-8 w-8 text-primary" />
|
<Users className="h-8 w-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-2xl">Join {inviteInfo.spaceName}</CardTitle>
|
<CardTitle className="text-xl sm:text-2xl">Join {state.info.space.name}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
You've been invited to join this space.
|
You've been invited to join this space.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
{state.info.space.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">{state.info.space.description}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-center space-y-4">
|
<CardContent className="text-center space-y-4">
|
||||||
<Button onClick={acceptInvite} disabled={loading} size="lg" className="w-full">
|
<Button onClick={acceptInvite} disabled={joining} size="lg" className="w-full">
|
||||||
<LogIn className="h-4 w-4 mr-2" />
|
<LogIn className="h-4 w-4 mr-2" />
|
||||||
{loading ? "Joining..." : "Accept Invite"}
|
{joining ? "Joining..." : "Accept Invite"}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.type === "joined" && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold">Welcome to {state.spaceName}!</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You've been given <span className="font-semibold text-orange-600">{state.startingCredits} credits</span> to start voting on proposals.
|
||||||
|
</p>
|
||||||
|
<Button asChild size="lg" className="w-full">
|
||||||
|
<Link href="/">Go to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.type === "already_member" && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center space-y-4">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-full bg-blue-500/10 flex items-center justify-center">
|
||||||
|
<UserCheck className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold">Already a member</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
You're already a member of {state.spaceName}.
|
||||||
|
</p>
|
||||||
|
<Button asChild size="lg" className="w-full">
|
||||||
|
<Link href="/">Go to Dashboard</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,14 @@ export default async function SpaceLayout({
|
||||||
>
|
>
|
||||||
<div className="border-b bg-card">
|
<div className="border-b bg-card">
|
||||||
<div className="container mx-auto px-4 py-3">
|
<div className="container mx-auto px-4 py-3">
|
||||||
<h1 className="text-xl font-bold">{space.name}</h1>
|
<h1 className="text-lg sm:text-xl font-bold">{space.name}</h1>
|
||||||
{space.description && (
|
{space.description && (
|
||||||
<p className="text-sm text-muted-foreground">{space.description}</p>
|
<p className="text-sm text-muted-foreground">{space.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SpaceNav />
|
<SpaceNav />
|
||||||
<div className="container mx-auto px-4 py-6">
|
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</SpaceProvider>
|
</SpaceProvider>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { MemberList } from "@/components/MemberList";
|
import { MemberList } from "@/components/MemberList";
|
||||||
import { InviteDialog } from "@/components/InviteDialog";
|
import { InviteDialog } from "@/components/InviteDialog";
|
||||||
|
import { InviteList } from "@/components/InviteList";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export default async function SpaceMembersPage({
|
export default async function SpaceMembersPage({
|
||||||
|
|
@ -38,7 +39,7 @@ export default async function SpaceMembersPage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold">Members</h2>
|
<h2 className="text-2xl font-bold">Members</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
|
@ -54,6 +55,13 @@ export default async function SpaceMembersPage({
|
||||||
isAdmin={membership.role === "ADMIN"}
|
isAdmin={membership.role === "ADMIN"}
|
||||||
currentUserId={session.user.id}
|
currentUserId={session.user.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{membership.role === "ADMIN" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold">Active Invites</h3>
|
||||||
|
<InviteList spaceSlug={slug} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,25 +36,25 @@ export default async function SpaceDashboard({
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<Card className="bg-gradient-to-br from-orange-500/10 to-transparent border-orange-500/20">
|
<Card className="bg-gradient-to-br from-orange-500/10 to-transparent border-orange-500/20">
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
<div className="text-3xl font-bold text-orange-600">{rankingCount}</div>
|
<div className="text-2xl sm:text-3xl font-bold text-orange-600">{rankingCount}</div>
|
||||||
<p className="text-sm text-muted-foreground">Being Ranked</p>
|
<p className="text-sm text-muted-foreground">Being Ranked</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
|
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
<div className="text-3xl font-bold text-blue-600">{votingCount}</div>
|
<div className="text-2xl sm:text-3xl font-bold text-blue-600">{votingCount}</div>
|
||||||
<p className="text-sm text-muted-foreground">In Voting</p>
|
<p className="text-sm text-muted-foreground">In Voting</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20">
|
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20">
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
<div className="text-3xl font-bold text-green-600">{passedCount}</div>
|
<div className="text-2xl sm:text-3xl font-bold text-green-600">{passedCount}</div>
|
||||||
<p className="text-sm text-muted-foreground">Passed</p>
|
<p className="text-sm text-muted-foreground">Passed</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
|
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
|
||||||
<CardContent className="pt-6 text-center">
|
<CardContent className="pt-6 text-center">
|
||||||
<div className="text-3xl font-bold text-purple-600">{memberCount}</div>
|
<div className="text-2xl sm:text-3xl font-bold text-purple-600">{memberCount}</div>
|
||||||
<p className="text-sm text-muted-foreground">Members</p>
|
<p className="text-sm text-muted-foreground">Members</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -62,7 +62,7 @@ export default async function SpaceDashboard({
|
||||||
|
|
||||||
{/* Top proposals */}
|
{/* Top proposals */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
|
||||||
<h2 className="text-xl font-bold">Top Proposals</h2>
|
<h2 className="text-xl font-bold">Top Proposals</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export default async function SpaceProposalDetailPage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
{proposal.status === "RANKING" && (
|
{proposal.status === "RANKING" && (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<VoteButtons
|
<VoteButtons
|
||||||
|
|
@ -92,7 +92,7 @@ export default async function SpaceProposalDetailPage({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
<Badge className={statusColors[proposal.status]}>{proposal.status}</Badge>
|
<Badge className={statusColors[proposal.status]}>{proposal.status}</Badge>
|
||||||
{proposal.votingEndsAt && (
|
{proposal.votingEndsAt && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
|
|
@ -100,7 +100,7 @@ export default async function SpaceProposalDetailPage({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold">{proposal.title}</h1>
|
<h1 className="text-xl sm:text-2xl font-bold">{proposal.title}</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
by {proposal.author.name || proposal.author.email} ·{" "}
|
by {proposal.author.name || proposal.author.email} ·{" "}
|
||||||
{formatDistanceToNow(new Date(proposal.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(new Date(proposal.createdAt), { addSuffix: true })}
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,9 @@ export default async function SpaceProposalsPage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<h2 className="text-2xl font-bold">Proposals</h2>
|
<h2 className="text-2xl font-bold">Proposals</h2>
|
||||||
<Button asChild>
|
<Button asChild size="sm">
|
||||||
<Link href="/proposals/new">
|
<Link href="/proposals/new">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
New Proposal
|
New Proposal
|
||||||
|
|
@ -83,13 +83,13 @@ export default async function SpaceProposalsPage({
|
||||||
<Tabs defaultValue="ranking">
|
<Tabs defaultValue="ranking">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="ranking">
|
<TabsTrigger value="ranking">
|
||||||
Ranking <Badge variant="secondary" className="ml-2 text-xs">{rankingProposals.length}</Badge>
|
Ranking <Badge variant="secondary" className="ml-2 text-xs hidden sm:inline">{rankingProposals.length}</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="voting">
|
<TabsTrigger value="voting">
|
||||||
Voting <Badge variant="secondary" className="ml-2 text-xs">{votingProposals.length}</Badge>
|
Voting <Badge variant="secondary" className="ml-2 text-xs hidden sm:inline">{votingProposals.length}</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="completed">
|
<TabsTrigger value="completed">
|
||||||
Completed <Badge variant="secondary" className="ml-2 text-xs">{completedProposals.length}</Badge>
|
Completed <Badge variant="secondary" className="ml-2 text-xs hidden sm:inline">{completedProposals.length}</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="ranking">
|
<TabsContent value="ranking">
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export default function SpaceSettingsPage() {
|
||||||
<CardTitle>Voting Configuration</CardTitle>
|
<CardTitle>Voting Configuration</CardTitle>
|
||||||
<CardDescription>Controls how proposals are ranked and promoted</CardDescription>
|
<CardDescription>Controls how proposals are ranked and promoted</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-2 gap-4">
|
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="threshold">Promotion Threshold</Label>
|
<Label htmlFor="threshold">Promotion Threshold</Label>
|
||||||
<Input id="threshold" type="number" value={promotionThreshold} onChange={(e) => setPromotionThreshold(e.target.value)} />
|
<Input id="threshold" type="number" value={promotionThreshold} onChange={(e) => setPromotionThreshold(e.target.value)} />
|
||||||
|
|
@ -107,7 +107,7 @@ export default function SpaceSettingsPage() {
|
||||||
<CardTitle>Credits</CardTitle>
|
<CardTitle>Credits</CardTitle>
|
||||||
<CardDescription>Controls credit allocation for members</CardDescription>
|
<CardDescription>Controls credit allocation for members</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-3 gap-4">
|
<CardContent className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cpd">Credits Per Day</Label>
|
<Label htmlFor="cpd">Credits Per Day</Label>
|
||||||
<Input id="cpd" type="number" value={creditsPerDay} onChange={(e) => setCreditsPerDay(e.target.value)} />
|
<Input id="cpd" type="number" value={creditsPerDay} onChange={(e) => setCreditsPerDay(e.target.value)} />
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,9 @@ export default async function SpacesPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Your Spaces</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold">Your Spaces</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Communities you belong to
|
Communities you belong to
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export function FinalVotePanel({
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Vote counts */}
|
{/* Vote counts */}
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-2 sm:gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold text-green-500">{votes.yes}</div>
|
<div className="text-2xl font-bold text-green-500">{votes.yes}</div>
|
||||||
<div className="text-sm text-muted-foreground">Yes</div>
|
<div className="text-sm text-muted-foreground">Yes</div>
|
||||||
|
|
@ -138,10 +138,10 @@ export function FinalVotePanel({
|
||||||
You voted: <Badge variant="outline">{userVote}</Badge>
|
You voted: <Badge variant="outline">{userVote}</Badge>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={userVote === "YES" ? "default" : "outline"}
|
variant={userVote === "YES" ? "default" : "outline"}
|
||||||
className="flex-col h-auto py-3"
|
className="flex-col h-auto py-2 sm:py-3"
|
||||||
onClick={() => castVote("YES")}
|
onClick={() => castVote("YES")}
|
||||||
disabled={isVoting}
|
disabled={isVoting}
|
||||||
>
|
>
|
||||||
|
|
@ -154,7 +154,7 @@ export function FinalVotePanel({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={userVote === "NO" ? "destructive" : "outline"}
|
variant={userVote === "NO" ? "destructive" : "outline"}
|
||||||
className="flex-col h-auto py-3"
|
className="flex-col h-auto py-2 sm:py-3"
|
||||||
onClick={() => castVote("NO")}
|
onClick={() => castVote("NO")}
|
||||||
disabled={isVoting}
|
disabled={isVoting}
|
||||||
>
|
>
|
||||||
|
|
@ -167,7 +167,7 @@ export function FinalVotePanel({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={userVote === "ABSTAIN" ? "secondary" : "outline"}
|
variant={userVote === "ABSTAIN" ? "secondary" : "outline"}
|
||||||
className="flex-col h-auto py-3"
|
className="flex-col h-auto py-2 sm:py-3"
|
||||||
onClick={() => castVote("ABSTAIN")}
|
onClick={() => castVote("ABSTAIN")}
|
||||||
disabled={isVoting}
|
disabled={isVoting}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -159,11 +159,11 @@ export function InteractiveDemo() {
|
||||||
<Card className="border-2 border-orange-500/30 bg-gradient-to-r from-orange-500/10 to-amber-500/10">
|
<Card className="border-2 border-orange-500/30 bg-gradient-to-r from-orange-500/10 to-amber-500/10">
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Coins className="h-6 w-6 text-orange-500" />
|
<Coins className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500" />
|
||||||
<span className="font-bold text-2xl text-orange-600">{credits}</span>
|
<span className="font-bold text-xl sm:text-2xl text-orange-600">{credits}</span>
|
||||||
<span className="text-muted-foreground">credits</span>
|
<span className="text-muted-foreground text-sm sm:text-base">credits</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
|
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
|
||||||
Max vote: ±{maxWeight}
|
Max vote: ±{maxWeight}
|
||||||
|
|
@ -187,11 +187,11 @@ export function InteractiveDemo() {
|
||||||
<CardDescription>Each additional vote costs exponentially more credits</CardDescription>
|
<CardDescription>Each additional vote costs exponentially more credits</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-5 gap-2 text-center text-sm">
|
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 text-center text-sm">
|
||||||
{[1, 2, 3, 4, 5].map((w) => (
|
{[1, 2, 3, 4, 5].map((w) => (
|
||||||
<div
|
<div
|
||||||
key={w}
|
key={w}
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
className={`p-2 sm:p-3 rounded-lg border-2 transition-all ${
|
||||||
w <= maxWeight
|
w <= maxWeight
|
||||||
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
|
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
|
||||||
: "bg-muted/50 border-muted text-muted-foreground"
|
: "bg-muted/50 border-muted text-muted-foreground"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [maxUses, setMaxUses] = useState("");
|
const [maxUses, setMaxUses] = useState("");
|
||||||
|
const [expiresIn, setExpiresIn] = useState("");
|
||||||
const [inviteUrl, setInviteUrl] = useState("");
|
const [inviteUrl, setInviteUrl] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -33,6 +34,10 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
||||||
const body: Record<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (email) body.email = email;
|
if (email) body.email = email;
|
||||||
if (maxUses) body.maxUses = parseInt(maxUses);
|
if (maxUses) body.maxUses = parseInt(maxUses);
|
||||||
|
if (expiresIn) {
|
||||||
|
const hours = parseInt(expiresIn);
|
||||||
|
body.expiresAt = new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/spaces/${spaceSlug}/invites`, {
|
const res = await fetch(`/api/spaces/${spaceSlug}/invites`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -61,6 +66,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
||||||
function reset() {
|
function reset() {
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setMaxUses("");
|
setMaxUses("");
|
||||||
|
setExpiresIn("");
|
||||||
setInviteUrl("");
|
setInviteUrl("");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +74,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button>
|
<Button size="sm" className="sm:size-default">
|
||||||
<UserPlus className="h-4 w-4 mr-2" />
|
<UserPlus className="h-4 w-4 mr-2" />
|
||||||
Create Invite
|
Create Invite
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -84,7 +90,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
||||||
{!inviteUrl ? (
|
{!inviteUrl ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email">Restrict to email (optional)</Label>
|
<Label htmlFor="email">Restrict to email</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -92,17 +98,34 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
If set, only this email address can use the invite link.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="maxUses">Max uses (optional)</Label>
|
<Label htmlFor="maxUses">Max uses</Label>
|
||||||
<Input
|
<Input
|
||||||
id="maxUses"
|
id="maxUses"
|
||||||
type="number"
|
type="number"
|
||||||
|
min="1"
|
||||||
placeholder="Unlimited"
|
placeholder="Unlimited"
|
||||||
value={maxUses}
|
value={maxUses}
|
||||||
onChange={(e) => setMaxUses(e.target.value)}
|
onChange={(e) => setMaxUses(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="expiresIn">Expires in (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="expiresIn"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Never"
|
||||||
|
value={expiresIn}
|
||||||
|
onChange={(e) => setExpiresIn(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={createInvite} disabled={loading}>
|
<Button onClick={createInvite} disabled={loading}>
|
||||||
{loading ? "Creating..." : "Create Invite"}
|
{loading ? "Creating..." : "Create Invite"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Copy, Trash2, Mail, Clock, Hash } from "lucide-react";
|
||||||
|
|
||||||
|
interface Invite {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
email: string | null;
|
||||||
|
maxUses: number | null;
|
||||||
|
uses: number;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InviteListProps {
|
||||||
|
spaceSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteList({ spaceSlug }: InviteListProps) {
|
||||||
|
const [invites, setInvites] = useState<Invite[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/spaces/${spaceSlug}/invites`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) setInvites(data);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error("Failed to load invites"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [spaceSlug]);
|
||||||
|
|
||||||
|
async function revokeInvite(id: string) {
|
||||||
|
const res = await fetch(`/api/spaces/${spaceSlug}/invites/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
setInvites((prev) => prev.filter((i) => i.id !== id));
|
||||||
|
toast.success("Invite revoked");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to revoke invite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink(token: string) {
|
||||||
|
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const url = `${protocol}//${spaceSlug}.${rootDomain}/join?token=${token}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(invite: Invite) {
|
||||||
|
return invite.expiresAt ? new Date(invite.expiresAt) < new Date() : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMaxedOut(invite: Invite) {
|
||||||
|
return invite.maxUses !== null ? invite.uses >= invite.maxUses : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-sm text-muted-foreground animate-pulse">Loading invites...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invites.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No active invites. Use the button above to create one.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{invites.map((invite) => {
|
||||||
|
const expired = isExpired(invite);
|
||||||
|
const maxed = isMaxedOut(invite);
|
||||||
|
const inactive = expired || maxed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={invite.id} className={inactive ? "opacity-60" : ""}>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
|
||||||
|
{invite.token.slice(0, 8)}...
|
||||||
|
</code>
|
||||||
|
{invite.email && (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
{invite.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Hash className="h-3 w-3" />
|
||||||
|
{invite.uses}{invite.maxUses !== null ? `/${invite.maxUses}` : ""} uses
|
||||||
|
</span>
|
||||||
|
{invite.expiresAt && (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{expired ? "Expired" : `Expires ${new Date(invite.expiresAt).toLocaleDateString()}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{expired && <Badge variant="destructive" className="text-xs">Expired</Badge>}
|
||||||
|
{maxed && <Badge variant="secondary" className="text-xs">Max uses reached</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!inactive && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => copyLink(invite.token)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => revokeInvite(invite.id)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -92,7 +92,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<Card key={member.id}>
|
<Card key={member.id}>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
|
|
@ -109,7 +109,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground flex items-center gap-3">
|
<div className="text-sm text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span>{member.user.email}</span>
|
<span>{member.user.email}</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Coins className="h-3 w-3 text-orange-500" />
|
<Coins className="h-3 w-3 text-orange-500" />
|
||||||
|
|
@ -120,7 +120,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin && member.user.id !== currentUserId && (
|
{isAdmin && member.user.id !== currentUserId && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export function ProposalCard({
|
||||||
isUpvoted ? "ring-1 ring-orange-500/30" : isDownvoted ? "ring-1 ring-blue-500/30" : ""
|
isUpvoted ? "ring-1 ring-orange-500/30" : isDownvoted ? "ring-1 ring-blue-500/30" : ""
|
||||||
}`}>
|
}`}>
|
||||||
{showVoting && isRanking && (
|
{showVoting && isRanking && (
|
||||||
<div className="flex items-center justify-center py-3 px-4 bg-muted/50 border-r min-w-[80px]">
|
<div className="flex items-center justify-center py-2 px-2 sm:py-3 sm:px-4 bg-muted/50 border-r min-w-[60px] sm:min-w-[80px]">
|
||||||
<VoteButtons
|
<VoteButtons
|
||||||
proposalId={proposal.id}
|
proposalId={proposal.id}
|
||||||
currentScore={score}
|
currentScore={score}
|
||||||
|
|
@ -113,7 +113,7 @@ export function ProposalCard({
|
||||||
href={`/proposals/${proposal.id}`}
|
href={`/proposals/${proposal.id}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-lg leading-tight line-clamp-2">
|
<h3 className="font-semibold text-base sm:text-lg leading-tight line-clamp-2">
|
||||||
{proposal.title}
|
{proposal.title}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -159,7 +159,7 @@ export function ProposalCard({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="pt-2 pb-4 text-xs text-muted-foreground">
|
<CardFooter className="pt-2 pb-4 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<User className="h-3 w-3" />
|
<User className="h-3 w-3" />
|
||||||
{proposal.author.name || proposal.author.email.split("@")[0]}
|
{proposal.author.name || proposal.author.email.split("@")[0]}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { useSpace } from "@/components/SpaceProvider";
|
import { useSpace } from "@/components/SpaceProvider";
|
||||||
import { FileText, Users, Settings, LayoutDashboard, Coins } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { FileText, Users, Settings, LayoutDashboard, Coins, Menu } from "lucide-react";
|
||||||
|
|
||||||
export function SpaceNav() {
|
export function SpaceNav() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { space, membership } = useSpace();
|
const { space, membership } = useSpace();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
|
@ -26,7 +34,8 @@ export function SpaceNav() {
|
||||||
<div className="border-b bg-card/50">
|
<div className="border-b bg-card/50">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex items-center justify-between h-12">
|
<div className="flex items-center justify-between h-12">
|
||||||
<div className="flex items-center gap-1">
|
{/* Desktop nav */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1">
|
||||||
{links.map((link) => {
|
{links.map((link) => {
|
||||||
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
|
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
|
||||||
return (
|
return (
|
||||||
|
|
@ -45,13 +54,65 @@ export function SpaceNav() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile hamburger */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setOpen(true)} className="gap-1.5">
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">{space.name}</span>
|
||||||
|
</Button>
|
||||||
|
<SheetContent side="left" className="w-64">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{space.name}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<nav className="flex flex-col gap-1 px-6">
|
||||||
|
{links.map((link) => {
|
||||||
|
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-3 rounded-md text-base transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<link.icon className="h-5 w-5" />
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
{membership && (
|
{membership && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="mt-auto px-6 pb-6 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Coins className="h-4 w-4 text-orange-500" />
|
<Coins className="h-4 w-4 text-orange-500" />
|
||||||
<span className="font-medium text-orange-600">{membership.credits}</span>
|
<span className="font-medium text-orange-600">{membership.credits}</span>
|
||||||
<span>credits</span>
|
<span>credits</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop credits */}
|
||||||
|
{membership && (
|
||||||
|
<div className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Coins className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="font-medium text-orange-600">{membership.credits}</span>
|
||||||
|
<span>credits</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile credits (compact) */}
|
||||||
|
{membership && (
|
||||||
|
<div className="sm:hidden flex items-center gap-1.5 text-sm">
|
||||||
|
<Coins className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="font-medium text-orange-600">{membership.credits}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -226,15 +226,15 @@ export function VoteButtons({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0"
|
className="h-8 w-8 p-0"
|
||||||
onClick={cancelPending}
|
onClick={cancelPending}
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-6 px-2 text-xs ${
|
className={`h-8 px-3 text-xs ${
|
||||||
pendingWeight > 0
|
pendingWeight > 0
|
||||||
? "bg-orange-500 hover:bg-orange-600"
|
? "bg-orange-500 hover:bg-orange-600"
|
||||||
: "bg-blue-500 hover:bg-blue-600"
|
: "bg-blue-500 hover:bg-blue-600"
|
||||||
|
|
@ -244,10 +244,10 @@ export function VoteButtons({
|
||||||
title="Confirm vote"
|
title="Confirm vote"
|
||||||
>
|
>
|
||||||
{isVoting ? (
|
{isVoting ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="h-3 w-3 mr-1" />
|
<Check className="h-4 w-4 mr-1" />
|
||||||
Cast
|
Cast
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
side?: "top" | "bottom" | "left" | "right"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background fixed z-50 flex flex-col gap-4 shadow-lg outline-none transition-transform duration-300 ease-in-out",
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 max-w-sm border-l",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 max-w-sm border-r",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-2 p-6 pb-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetPortal,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue