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 (
|
||||
<div className="space-y-16">
|
||||
{/* 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 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" />
|
||||
|
|
@ -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">
|
||||
Part of the rSpace Ecosystem
|
||||
</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 />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent">Backlog Prioritization</span>
|
||||
</h1>
|
||||
|
|
@ -196,11 +196,11 @@ export default function HomePage() {
|
|||
<h3 className="text-lg font-semibold text-center mb-4">
|
||||
Vote Cost Calculator
|
||||
</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) => (
|
||||
<div
|
||||
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-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,52 +1,76 @@
|
|||
"use client";
|
||||
|
||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const slug = params.slug as string;
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [inviteInfo, setInviteInfo] = useState<{ spaceName: string; uses: number; maxUses?: number } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [state, setState] = useState<PageState>({ type: "loading" });
|
||||
const [joining, setJoining] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetch(`/api/spaces/join/${token}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
setError(data.error);
|
||||
} else {
|
||||
setInviteInfo(data);
|
||||
}
|
||||
})
|
||||
.catch(() => setError("Failed to load invite"));
|
||||
} else {
|
||||
setError("No invite token provided");
|
||||
if (!token) {
|
||||
setState({ type: "error", message: "No invite token provided.", icon: AlertCircle });
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/spaces/join/${token}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (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 {
|
||||
setState({ type: "error", message: "This invite link is not valid.", icon: AlertCircle });
|
||||
}
|
||||
})
|
||||
.catch(() => setState({ type: "error", message: "Failed to load invite. Please try again.", icon: AlertCircle }));
|
||||
}, [token]);
|
||||
|
||||
async function acceptInvite() {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await fetch(`/api/spaces/join/${token}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
toast.success("Welcome to the space!");
|
||||
router.push("/");
|
||||
if (res.ok && data.alreadyMember) {
|
||||
setState({ type: "already_member", spaceName: data.space.name });
|
||||
} else if (res.ok) {
|
||||
setState({
|
||||
type: "joined",
|
||||
spaceName: data.space.name,
|
||||
startingCredits: data.space.startingCredits ?? 0,
|
||||
});
|
||||
} 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 protocol = window.location.protocol;
|
||||
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");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setJoining(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-16">
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
<Link href="/">Go Home</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
if (!inviteInfo) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-16 text-center text-muted-foreground">
|
||||
Loading invite...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{state.type === "invite" && (
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<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" />
|
||||
</div>
|
||||
<CardTitle className="text-xl sm:text-2xl">Join {state.info.space.name}</CardTitle>
|
||||
<CardDescription>
|
||||
You've been invited to join this space.
|
||||
</CardDescription>
|
||||
{state.info.space.description && (
|
||||
<p className="text-sm text-muted-foreground mt-2">{state.info.space.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-4">
|
||||
<Button onClick={acceptInvite} disabled={joining} size="lg" className="w-full">
|
||||
<LogIn className="h-4 w-4 mr-2" />
|
||||
{joining ? "Joining..." : "Accept Invite"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-16">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<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" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Join {inviteInfo.spaceName}</CardTitle>
|
||||
<CardDescription>
|
||||
You've been invited to join this space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-4">
|
||||
<Button onClick={acceptInvite} disabled={loading} size="lg" className="w-full">
|
||||
<LogIn className="h-4 w-4 mr-2" />
|
||||
{loading ? "Joining..." : "Accept Invite"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,14 +52,14 @@ export default async function SpaceLayout({
|
|||
>
|
||||
<div className="border-b bg-card">
|
||||
<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 && (
|
||||
<p className="text-sm text-muted-foreground">{space.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SpaceNav />
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="container mx-auto px-4 py-4 sm:py-6">
|
||||
{children}
|
||||
</div>
|
||||
</SpaceProvider>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma";
|
|||
import { notFound, redirect } from "next/navigation";
|
||||
import { MemberList } from "@/components/MemberList";
|
||||
import { InviteDialog } from "@/components/InviteDialog";
|
||||
import { InviteList } from "@/components/InviteList";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default async function SpaceMembersPage({
|
||||
|
|
@ -38,7 +39,7 @@ export default async function SpaceMembersPage({
|
|||
|
||||
return (
|
||||
<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>
|
||||
<h2 className="text-2xl font-bold">Members</h2>
|
||||
<p className="text-muted-foreground">
|
||||
|
|
@ -54,6 +55,13 @@ export default async function SpaceMembersPage({
|
|||
isAdmin={membership.role === "ADMIN"}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,25 +36,25 @@ export default async function SpaceDashboard({
|
|||
<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">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -62,7 +62,7 @@ export default async function SpaceDashboard({
|
|||
|
||||
{/* Top proposals */}
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default async function SpaceProposalDetailPage({
|
|||
|
||||
return (
|
||||
<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" && (
|
||||
<div className="pt-1">
|
||||
<VoteButtons
|
||||
|
|
@ -92,7 +92,7 @@ export default async function SpaceProposalDetailPage({
|
|||
</div>
|
||||
)}
|
||||
<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>
|
||||
{proposal.votingEndsAt && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
|
|
@ -100,7 +100,7 @@ export default async function SpaceProposalDetailPage({
|
|||
</span>
|
||||
)}
|
||||
</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">
|
||||
by {proposal.author.name || proposal.author.email} ·{" "}
|
||||
{formatDistanceToNow(new Date(proposal.createdAt), { addSuffix: true })}
|
||||
|
|
|
|||
|
|
@ -70,9 +70,9 @@ export default async function SpaceProposalsPage({
|
|||
|
||||
return (
|
||||
<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>
|
||||
<Button asChild>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/proposals/new">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Proposal
|
||||
|
|
@ -83,13 +83,13 @@ export default async function SpaceProposalsPage({
|
|||
<Tabs defaultValue="ranking">
|
||||
<TabsList>
|
||||
<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 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 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>
|
||||
</TabsList>
|
||||
<TabsContent value="ranking">
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default function SpaceSettingsPage() {
|
|||
<CardTitle>Voting Configuration</CardTitle>
|
||||
<CardDescription>Controls how proposals are ranked and promoted</CardDescription>
|
||||
</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">
|
||||
<Label htmlFor="threshold">Promotion Threshold</Label>
|
||||
<Input id="threshold" type="number" value={promotionThreshold} onChange={(e) => setPromotionThreshold(e.target.value)} />
|
||||
|
|
@ -107,7 +107,7 @@ export default function SpaceSettingsPage() {
|
|||
<CardTitle>Credits</CardTitle>
|
||||
<CardDescription>Controls credit allocation for members</CardDescription>
|
||||
</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">
|
||||
<Label htmlFor="cpd">Credits Per Day</Label>
|
||||
<Input id="cpd" type="number" value={creditsPerDay} onChange={(e) => setCreditsPerDay(e.target.value)} />
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ export default async function SpacesPage() {
|
|||
|
||||
return (
|
||||
<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>
|
||||
<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">
|
||||
Communities you belong to
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export function FinalVotePanel({
|
|||
|
||||
<CardContent className="space-y-4">
|
||||
{/* 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 className="text-2xl font-bold text-green-500">{votes.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>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<Button
|
||||
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")}
|
||||
disabled={isVoting}
|
||||
>
|
||||
|
|
@ -154,7 +154,7 @@ export function FinalVotePanel({
|
|||
</Button>
|
||||
<Button
|
||||
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")}
|
||||
disabled={isVoting}
|
||||
>
|
||||
|
|
@ -167,7 +167,7 @@ export function FinalVotePanel({
|
|||
</Button>
|
||||
<Button
|
||||
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")}
|
||||
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">
|
||||
<CardContent className="py-4">
|
||||
<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">
|
||||
<Coins className="h-6 w-6 text-orange-500" />
|
||||
<span className="font-bold text-2xl text-orange-600">{credits}</span>
|
||||
<span className="text-muted-foreground">credits</span>
|
||||
<Coins className="h-5 w-5 sm:h-6 sm:w-6 text-orange-500" />
|
||||
<span className="font-bold text-xl sm:text-2xl text-orange-600">{credits}</span>
|
||||
<span className="text-muted-foreground text-sm sm:text-base">credits</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
|
||||
Max vote: ±{maxWeight}
|
||||
|
|
@ -187,11 +187,11 @@ export function InteractiveDemo() {
|
|||
<CardDescription>Each additional vote costs exponentially more credits</CardDescription>
|
||||
</CardHeader>
|
||||
<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) => (
|
||||
<div
|
||||
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
|
||||
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
|
||||
: "bg-muted/50 border-muted text-muted-foreground"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [maxUses, setMaxUses] = useState("");
|
||||
const [expiresIn, setExpiresIn] = useState("");
|
||||
const [inviteUrl, setInviteUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
@ -33,6 +34,10 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
|||
const body: Record<string, unknown> = {};
|
||||
if (email) body.email = email;
|
||||
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`, {
|
||||
method: "POST",
|
||||
|
|
@ -61,6 +66,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
|||
function reset() {
|
||||
setEmail("");
|
||||
setMaxUses("");
|
||||
setExpiresIn("");
|
||||
setInviteUrl("");
|
||||
setOpen(false);
|
||||
}
|
||||
|
|
@ -68,7 +74,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button size="sm" className="sm:size-default">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Create Invite
|
||||
</Button>
|
||||
|
|
@ -84,7 +90,7 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
|||
{!inviteUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Restrict to email (optional)</Label>
|
||||
<Label htmlFor="email">Restrict to email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
|
|
@ -92,16 +98,33 @@ export function InviteDialog({ spaceSlug }: InviteDialogProps) {
|
|||
value={email}
|
||||
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>
|
||||
<Label htmlFor="maxUses">Max uses (optional)</Label>
|
||||
<Input
|
||||
id="maxUses"
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="maxUses">Max uses</Label>
|
||||
<Input
|
||||
id="maxUses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Unlimited"
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
<Button onClick={createInvite} disabled={loading}>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<Card key={member.id}>
|
||||
<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">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>
|
||||
|
|
@ -109,7 +109,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
|
|||
</Badge>
|
||||
)}
|
||||
</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 className="flex items-center gap-1">
|
||||
<Coins className="h-3 w-3 text-orange-500" />
|
||||
|
|
@ -120,7 +120,7 @@ export function MemberList({ members: initialMembers, spaceSlug, isAdmin, curren
|
|||
</div>
|
||||
|
||||
{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">
|
||||
<Input
|
||||
type="number"
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function ProposalCard({
|
|||
isUpvoted ? "ring-1 ring-orange-500/30" : isDownvoted ? "ring-1 ring-blue-500/30" : ""
|
||||
}`}>
|
||||
{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
|
||||
proposalId={proposal.id}
|
||||
currentScore={score}
|
||||
|
|
@ -113,7 +113,7 @@ export function ProposalCard({
|
|||
href={`/proposals/${proposal.id}`}
|
||||
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}
|
||||
</h3>
|
||||
</Link>
|
||||
|
|
@ -159,7 +159,7 @@ export function ProposalCard({
|
|||
</CardContent>
|
||||
|
||||
<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">
|
||||
<User className="h-3 w-3" />
|
||||
{proposal.author.name || proposal.author.email.split("@")[0]}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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() {
|
||||
const pathname = usePathname();
|
||||
const { space, membership } = useSpace();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const links = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
|
|
@ -26,7 +34,8 @@ export function SpaceNav() {
|
|||
<div className="border-b bg-card/50">
|
||||
<div className="container mx-auto px-4">
|
||||
<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) => {
|
||||
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
|
||||
return (
|
||||
|
|
@ -45,13 +54,65 @@ export function SpaceNav() {
|
|||
);
|
||||
})}
|
||||
</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 && (
|
||||
<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" />
|
||||
<span className="font-medium text-orange-600">{membership.credits}</span>
|
||||
<span>credits</span>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Desktop credits */}
|
||||
{membership && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -226,15 +226,15 @@ export function VoteButtons({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={cancelPending}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${
|
||||
className={`h-8 px-3 text-xs ${
|
||||
pendingWeight > 0
|
||||
? "bg-orange-500 hover:bg-orange-600"
|
||||
: "bg-blue-500 hover:bg-blue-600"
|
||||
|
|
@ -244,10 +244,10 @@ export function VoteButtons({
|
|||
title="Confirm vote"
|
||||
>
|
||||
{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
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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