feat: implement rVote.online - quadratic voting platform

Complete implementation of a Reddit-style proposal ranking system with:

- Quadratic voting with credit decay (10 credits/day, 30-60 day decay)
- Two-stage voting: Ranking (quadratic) → Pass/Fail (time-boxed)
- Auto-promotion at score ≥100 to 7-day voting period
- NextAuth.js authentication with email/password
- PostgreSQL database with Prisma ORM
- shadcn/ui components with Tailwind CSS
- Docker configuration for deployment

Features:
- User registration/login with credit system
- Proposal creation, editing, deletion
- Upvote/downvote with quadratic cost (weight² credits)
- Vote decay returning credits over time
- Pass/fail voting with Yes/No/Abstain
- User profile with voting history and credit tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-05 04:48:20 +00:00
parent 8804057a7d
commit c6b7f5d899
52 changed files with 7638 additions and 87 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Database
DATABASE_URL="postgresql://rvote:your_password@localhost:5432/rvote"
DB_PASSWORD="your_secure_password_here"
# NextAuth
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
NEXTAUTH_URL="http://localhost:3000"
# Resend (for magic link emails)
RESEND_API_KEY="re_your_api_key_here"

9
.gitignore vendored
View File

@ -30,8 +30,11 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# env files
.env
.env.local
.env.production
!.env.example
# vercel
.vercel
@ -39,3 +42,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install dependencies
RUN npm ci
# Copy source files
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Set ownership
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

23
components.json Normal file
View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

47
docker-compose.yml Normal file
View File

@ -0,0 +1,47 @@
services:
rvote:
build: .
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.rvote.rule=Host(`rvote.online`) || Host(`www.rvote.online`)"
- "traefik.http.routers.rvote.entrypoints=web,websecure"
- "traefik.http.routers.rvote.tls=true"
- "traefik.http.services.rvote.loadbalancer.server.port=3000"
environment:
- DATABASE_URL=postgresql://rvote:${DB_PASSWORD}@postgres:5432/rvote
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=https://rvote.online
- RESEND_API_KEY=${RESEND_API_KEY}
networks:
- traefik-public
- rvote-internal
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=rvote
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=rvote
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- rvote-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rvote -d rvote"]
interval: 5s
timeout: 5s
retries: 5
networks:
traefik-public:
external: true
rvote-internal:
internal: true
volumes:
postgres_data:

View File

@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

2787
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,18 +9,34 @@
"lint": "eslint"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6",
"prisma": "^6.19.2",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"resend": "^6.9.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

117
prisma/schema.prisma Normal file
View File

@ -0,0 +1,117 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String?
name String?
credits Int @default(0)
lastCreditAt DateTime @default(now())
emailVerified DateTime?
votes Vote[]
finalVotes FinalVote[]
proposals Proposal[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// NextAuth fields
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Proposal {
id String @id @default(cuid())
title String
description String @db.Text
authorId String
author User @relation(fields: [authorId], references: [id])
status ProposalStatus @default(RANKING)
score Int @default(0)
votes Vote[]
finalVotes FinalVote[]
votingEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ProposalStatus {
RANKING
VOTING
PASSED
FAILED
ARCHIVED
}
model Vote {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
proposalId String
proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)
weight Int
creditCost Int
createdAt DateTime @default(now())
decaysAt DateTime
@@unique([userId, proposalId])
}
model FinalVote {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
proposalId String
proposal Proposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)
vote VoteChoice
createdAt DateTime @default(now())
@@unique([userId, proposalId])
}
enum VoteChoice {
YES
NO
ABSTAIN
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@ -0,0 +1,73 @@
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const { email, password, name } = await req.json();
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 }
);
}
// Validate password strength
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters" },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existingUser) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 400 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Create user with initial credits
const user = await prisma.user.create({
data: {
email: email.toLowerCase(),
passwordHash,
name: name || null,
credits: 50, // Starting credits
emailVerified: new Date(), // Auto-verify for now
},
select: {
id: true,
email: true,
name: true,
credits: true,
},
});
return NextResponse.json({ user }, { status: 201 });
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Failed to create account" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,146 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { VoteChoice } from "@prisma/client";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id: proposalId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { vote } = await req.json();
if (!vote || !["YES", "NO", "ABSTAIN"].includes(vote)) {
return NextResponse.json(
{ error: "Vote must be YES, NO, or ABSTAIN" },
{ status: 400 }
);
}
// Check proposal exists and is in voting stage
const proposal = await prisma.proposal.findUnique({
where: { id: proposalId },
select: { status: true, votingEndsAt: true },
});
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.status !== "VOTING") {
return NextResponse.json(
{ error: "This proposal is not in the voting stage" },
{ status: 400 }
);
}
// Check if voting has ended
if (proposal.votingEndsAt && new Date() > proposal.votingEndsAt) {
return NextResponse.json(
{ error: "Voting has ended for this proposal" },
{ status: 400 }
);
}
// Upsert the vote
await prisma.finalVote.upsert({
where: {
userId_proposalId: {
userId: session.user.id,
proposalId,
},
},
create: {
userId: session.user.id,
proposalId,
vote: vote as VoteChoice,
},
update: {
vote: vote as VoteChoice,
},
});
// Get updated vote counts
const voteCounts = await prisma.finalVote.groupBy({
by: ["vote"],
where: { proposalId },
_count: true,
});
const votes = {
yes: 0,
no: 0,
abstain: 0,
total: 0,
};
voteCounts.forEach((vc) => {
const key = vc.vote.toLowerCase() as "yes" | "no" | "abstain";
votes[key] = vc._count;
votes.total += vc._count;
});
return NextResponse.json({
success: true,
votes,
userVote: vote,
});
} catch (error) {
console.error("Final vote error:", error);
return NextResponse.json({ error: "Failed to cast vote" }, { status: 500 });
}
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id: proposalId } = await params;
// Get vote counts
const voteCounts = await prisma.finalVote.groupBy({
by: ["vote"],
where: { proposalId },
_count: true,
});
const votes = {
yes: 0,
no: 0,
abstain: 0,
total: 0,
};
voteCounts.forEach((vc) => {
const key = vc.vote.toLowerCase() as "yes" | "no" | "abstain";
votes[key] = vc._count;
votes.total += vc._count;
});
// Get user's vote if authenticated
let userVote: VoteChoice | null = null;
if (session?.user?.id) {
const existingVote = await prisma.finalVote.findUnique({
where: {
userId_proposalId: {
userId: session.user.id,
proposalId,
},
},
});
userVote = existingVote?.vote || null;
}
return NextResponse.json({
votes,
userVote,
});
}

View File

@ -0,0 +1,168 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { getEffectiveWeight } from "@/lib/voting";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const proposal = await prisma.proposal.findUnique({
where: { id },
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
votes: {
include: {
user: {
select: {
id: true,
name: true,
},
},
},
},
finalVotes: {
select: {
vote: true,
},
},
},
});
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
// Calculate effective scores with decay
const effectiveScore = proposal.votes.reduce((sum, vote) => {
return sum + getEffectiveWeight(vote.weight, vote.createdAt);
}, 0);
// Calculate final vote counts
const finalVoteCounts = proposal.finalVotes.reduce(
(acc, fv) => {
acc[fv.vote.toLowerCase() as "yes" | "no" | "abstain"]++;
acc.total++;
return acc;
},
{ yes: 0, no: 0, abstain: 0, total: 0 }
);
return NextResponse.json({
...proposal,
effectiveScore,
finalVoteCounts,
});
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const proposal = await prisma.proposal.findUnique({
where: { id },
select: { authorId: true, status: true },
});
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.authorId !== session.user.id) {
return NextResponse.json(
{ error: "You can only edit your own proposals" },
{ status: 403 }
);
}
if (proposal.status !== "RANKING") {
return NextResponse.json(
{ error: "Cannot edit proposals after they enter voting" },
{ status: 400 }
);
}
try {
const { title, description } = await req.json();
const updateData: { title?: string; description?: string } = {};
if (title) updateData.title = title;
if (description) updateData.description = description;
const updated = await prisma.proposal.update({
where: { id },
data: updateData,
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return NextResponse.json({ proposal: updated });
} catch (error) {
console.error("Update proposal error:", error);
return NextResponse.json(
{ error: "Failed to update proposal" },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const proposal = await prisma.proposal.findUnique({
where: { id },
select: { authorId: true, status: true },
});
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.authorId !== session.user.id) {
return NextResponse.json(
{ error: "You can only delete your own proposals" },
{ status: 403 }
);
}
if (proposal.status !== "RANKING") {
return NextResponse.json(
{ error: "Cannot delete proposals after they enter voting" },
{ status: 400 }
);
}
await prisma.proposal.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,236 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { calculateAvailableCredits, calculateVoteCost } from "@/lib/credits";
import { getEffectiveWeight, shouldPromote, getVotingEndDate, DECAY_START_DAYS } from "@/lib/voting";
import { NextRequest, NextResponse } from "next/server";
import { addDays } from "date-fns";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id: proposalId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { weight } = await req.json();
if (typeof weight !== "number" || weight === 0) {
return NextResponse.json(
{ error: "Weight must be a non-zero number" },
{ status: 400 }
);
}
// Check proposal exists and is in ranking stage
const proposal = await prisma.proposal.findUnique({
where: { id: proposalId },
select: { status: true },
});
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.status !== "RANKING") {
return NextResponse.json(
{ error: "This proposal is no longer accepting ranking votes" },
{ status: 400 }
);
}
// Get user's current credits
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
const creditCost = calculateVoteCost(weight);
// Check for existing vote
const existingVote = await prisma.vote.findUnique({
where: {
userId_proposalId: {
userId: session.user.id,
proposalId,
},
},
});
// Calculate total credits needed (new cost minus returned credits from old vote)
const returnedCredits = existingVote ? existingVote.creditCost : 0;
const netCost = creditCost - returnedCredits;
if (netCost > availableCredits) {
return NextResponse.json(
{ error: `Not enough credits. Need ${netCost}, have ${availableCredits}` },
{ status: 400 }
);
}
// Perform the vote in a transaction
const result = await prisma.$transaction(async (tx) => {
// Update or create vote
if (existingVote) {
await tx.vote.update({
where: { id: existingVote.id },
data: {
weight,
creditCost,
createdAt: new Date(), // Reset creation time on re-vote
decaysAt: addDays(new Date(), DECAY_START_DAYS),
},
});
} else {
await tx.vote.create({
data: {
userId: session.user.id,
proposalId,
weight,
creditCost,
decaysAt: addDays(new Date(), DECAY_START_DAYS),
},
});
}
// Update user credits
const newCredits = availableCredits - netCost;
await tx.user.update({
where: { id: session.user.id },
data: {
credits: newCredits,
lastCreditAt: new Date(),
},
});
// Calculate new proposal score
const allVotes = await tx.vote.findMany({
where: { proposalId },
});
const newScore = allVotes.reduce((sum, v) => {
return sum + getEffectiveWeight(v.weight, v.createdAt);
}, 0);
// Update proposal score and check for promotion
const updateData: { score: number; status?: "VOTING"; votingEndsAt?: Date } = {
score: newScore,
};
if (shouldPromote(newScore)) {
updateData.status = "VOTING";
updateData.votingEndsAt = getVotingEndDate();
}
await tx.proposal.update({
where: { id: proposalId },
data: updateData,
});
return { newScore, promoted: shouldPromote(newScore) };
});
return NextResponse.json({
success: true,
newScore: result.newScore,
promoted: result.promoted,
creditCost,
});
} catch (error) {
console.error("Vote error:", error);
return NextResponse.json({ error: "Failed to cast vote" }, { status: 500 });
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
const { id: proposalId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check proposal is still in ranking
const proposal = await prisma.proposal.findUnique({
where: { id: proposalId },
select: { status: true },
});
if (!proposal || proposal.status !== "RANKING") {
return NextResponse.json(
{ error: "Cannot remove vote from this proposal" },
{ status: 400 }
);
}
// Find existing vote
const existingVote = await prisma.vote.findUnique({
where: {
userId_proposalId: {
userId: session.user.id,
proposalId,
},
},
});
if (!existingVote) {
return NextResponse.json({ error: "No vote to remove" }, { status: 404 });
}
// Remove vote and return credits
const result = await prisma.$transaction(async (tx) => {
// Delete vote
await tx.vote.delete({
where: { id: existingVote.id },
});
// Return credits to user
const user = await tx.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
const currentCredits = calculateAvailableCredits(user!.credits, user!.lastCreditAt);
await tx.user.update({
where: { id: session.user.id },
data: {
credits: currentCredits + existingVote.creditCost,
lastCreditAt: new Date(),
},
});
// Recalculate proposal score
const allVotes = await tx.vote.findMany({
where: { proposalId },
});
const newScore = allVotes.reduce((sum, v) => {
return sum + getEffectiveWeight(v.weight, v.createdAt);
}, 0);
await tx.proposal.update({
where: { id: proposalId },
data: { score: newScore },
});
return { newScore, returnedCredits: existingVote.creditCost };
});
return NextResponse.json({
success: true,
newScore: result.newScore,
returnedCredits: result.returnedCredits,
});
}

View File

@ -0,0 +1,118 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { ProposalStatus } from "@prisma/client";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const status = searchParams.get("status") as ProposalStatus | null;
const sortBy = searchParams.get("sortBy") || "createdAt";
const sortOrder = searchParams.get("sortOrder") || "desc";
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const where: { status?: ProposalStatus } = {};
if (status) {
where.status = status;
}
const orderBy: Record<string, string> = {};
if (sortBy === "score" || sortBy === "createdAt" || sortBy === "votingEndsAt") {
orderBy[sortBy] = sortOrder;
} else {
orderBy.createdAt = "desc";
}
const [proposals, total] = await Promise.all([
prisma.proposal.findMany({
where,
orderBy,
skip: (page - 1) * limit,
take: limit,
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
_count: {
select: {
votes: true,
finalVotes: true,
},
},
},
}),
prisma.proposal.count({ where }),
]);
return NextResponse.json({
proposals,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { title, description } = await req.json();
if (!title || !description) {
return NextResponse.json(
{ error: "Title and description are required" },
{ status: 400 }
);
}
if (title.length > 200) {
return NextResponse.json(
{ error: "Title must be 200 characters or less" },
{ status: 400 }
);
}
if (description.length > 10000) {
return NextResponse.json(
{ error: "Description must be 10,000 characters or less" },
{ status: 400 }
);
}
const proposal = await prisma.proposal.create({
data: {
title,
description,
authorId: session.user.id,
},
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return NextResponse.json({ proposal }, { status: 201 });
} catch (error) {
console.error("Create proposal error:", error);
return NextResponse.json(
{ error: "Failed to create proposal" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,73 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { calculateAvailableCredits, maxAffordableWeight } from "@/lib/credits";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
credits: true,
lastCreditAt: true,
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const available = calculateAvailableCredits(user.credits, user.lastCreditAt);
return NextResponse.json({
stored: user.credits,
available,
maxAffordableVote: maxAffordableWeight(available),
});
}
// Claim accumulated credits
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
credits: true,
lastCreditAt: true,
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const available = calculateAvailableCredits(user.credits, user.lastCreditAt);
// Update stored credits and reset claim time
const updatedUser = await prisma.user.update({
where: { id: session.user.id },
data: {
credits: available,
lastCreditAt: new Date(),
},
select: {
credits: true,
},
});
return NextResponse.json({
stored: updatedUser.credits,
available: updatedUser.credits,
maxAffordableVote: maxAffordableWeight(updatedUser.credits),
});
}

View File

@ -0,0 +1,107 @@
"use client";
import { useState, Suspense } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
function SignInForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
toast.error("Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
} catch (error) {
toast.error("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
}
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
<CardDescription>
Enter your email and password to access your account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
<p className="text-sm text-muted-foreground text-center">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}
export default function SignInPage() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Suspense fallback={<div className="animate-pulse">Loading...</div>}>
<SignInForm />
</Suspense>
</div>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
setIsLoading(true);
try {
// Register the user
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create account");
}
// Sign in automatically
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
toast.error("Account created but failed to sign in. Please try signing in manually.");
router.push("/auth/signin");
} else {
toast.success("Account created! Welcome to rVote.");
router.push("/");
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "An error occurred");
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">Create an account</CardTitle>
<CardDescription>
Join rVote to start ranking and voting on proposals
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name (optional)</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<p className="text-sm text-muted-foreground">
You&apos;ll start with <strong>50 credits</strong> and earn 10 more each day.
</p>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
<p className="text-sm text-muted-foreground text-center">
Already have an account?{" "}
<Link href="/auth/signin" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -1,26 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { Navbar } from "@/components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +15,10 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "rVote - Quadratic Voting for Community Governance",
description:
"A Reddit-style proposal ranking platform with quadratic voting. Proposals are ranked by members, and top proposals advance to pass/fail voting.",
keywords: ["voting", "governance", "quadratic voting", "proposals", "community"],
};
export default function RootLayout({
@ -23,11 +27,14 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-background`}
>
{children}
<Providers>
<Navbar />
<main className="container mx-auto px-4 py-8">{children}</main>
</Providers>
</body>
</html>
);

View File

@ -1,65 +1,210 @@
import Image from "next/image";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ProposalList } from "@/components/ProposalList";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import Link from "next/link";
import { ArrowRight, TrendingUp, Vote, Zap } from "lucide-react";
export default async function HomePage() {
const session = await auth();
// Get top ranking proposals
const proposals = await prisma.proposal.findMany({
where: { status: "RANKING" },
orderBy: { score: "desc" },
take: 10,
include: {
author: {
select: { id: true, name: true, email: true },
},
votes: true,
},
});
// Get user's votes and credits if logged in
let availableCredits = 0;
let userVotes: { proposalId: string; weight: number; effectiveWeight: number }[] = [];
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (user) {
availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
}
const votes = await prisma.vote.findMany({
where: {
userId: session.user.id,
proposalId: { in: proposals.map((p) => p.id) },
},
});
userVotes = votes.map((v) => ({
proposalId: v.proposalId,
weight: v.weight,
effectiveWeight: getEffectiveWeight(v.weight, v.createdAt),
}));
}
// Get counts for stats
const [rankingCount, votingCount, passedCount] = await Promise.all([
prisma.proposal.count({ where: { status: "RANKING" } }),
prisma.proposal.count({ where: { status: "VOTING" } }),
prisma.proposal.count({ where: { status: "PASSED" } }),
]);
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="space-y-8">
{/* Hero section */}
<section className="text-center py-12 space-y-4">
<h1 className="text-4xl font-bold tracking-tight">
Community-Driven Governance
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Rank proposals using quadratic voting. Top proposals advance to pass/fail voting.
Your voice matters more when you concentrate your votes.
</p>
{!session?.user && (
<div className="flex justify-center gap-4 pt-4">
<Button asChild size="lg">
<Link href="/auth/signup">Get Started</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/proposals">Browse Proposals</Link>
</Button>
</div>
)}
{session?.user && (
<div className="flex justify-center gap-4 pt-4">
<Button asChild size="lg">
<Link href="/proposals/new">Create Proposal</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/voting">View Active Votes</Link>
</Button>
</div>
)}
</section>
{/* Stats */}
<section className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Ranking</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rankingCount}</div>
<p className="text-xs text-muted-foreground">proposals being ranked</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Voting</CardTitle>
<Vote className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{votingCount}</div>
<p className="text-xs text-muted-foreground">proposals in pass/fail voting</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Passed</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{passedCount}</div>
<p className="text-xs text-muted-foreground">proposals approved</p>
</CardContent>
</Card>
</section>
{/* How it works */}
<section className="py-8">
<h2 className="text-2xl font-bold mb-6">How It Works</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader>
<Badge className="w-fit mb-2">Stage 1</Badge>
<CardTitle>Ranking</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground">
<p>
Proposals start in the ranking stage. Use your credits to upvote or
downvote. <strong>Quadratic voting</strong>: 1 vote costs 1 credit,
2 votes cost 4 credits, 3 votes cost 9 credits.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<Badge className="w-fit mb-2" variant="secondary">
Threshold
</Badge>
<CardTitle>Score +100</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground">
<p>
When a proposal reaches a score of <strong>+100</strong>, it
automatically advances to the pass/fail voting stage. Old votes
decay over time, keeping rankings fresh.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<Badge className="w-fit mb-2" variant="outline">
Stage 2
</Badge>
<CardTitle>Pass/Fail</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground">
<p>
In the final stage, members vote <strong>Yes, No, or Abstain</strong>.
Voting is open for 7 days. Simple majority wins. One member = one vote.
</p>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</section>
{/* Top proposals */}
<section className="py-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Top Proposals</h2>
<Button asChild variant="ghost">
<Link href="/proposals">
View All <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</main>
{proposals.length > 0 ? (
<ProposalList
proposals={proposals}
userVotes={userVotes}
availableCredits={availableCredits}
/>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<p>No proposals yet. Be the first to create one!</p>
{session?.user && (
<Button asChild className="mt-4">
<Link href="/proposals/new">Create Proposal</Link>
</Button>
)}
</CardContent>
</Card>
)}
</section>
</div>
);
}

323
src/app/profile/page.tsx Normal file
View File

@ -0,0 +1,323 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { calculateAvailableCredits, CREDITS_PER_DAY, MAX_CREDITS } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import { format, formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { Coins, FileText, Vote, TrendingUp, Clock } from "lucide-react";
export default async function ProfilePage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/auth/signin");
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
proposals: {
orderBy: { createdAt: "desc" },
take: 5,
select: {
id: true,
title: true,
status: true,
score: true,
createdAt: true,
},
},
votes: {
orderBy: { createdAt: "desc" },
take: 10,
include: {
proposal: {
select: {
id: true,
title: true,
status: true,
},
},
},
},
finalVotes: {
orderBy: { createdAt: "desc" },
take: 10,
include: {
proposal: {
select: {
id: true,
title: true,
status: true,
},
},
},
},
},
});
if (!user) {
redirect("/auth/signin");
}
const availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
const creditProgress = (availableCredits / MAX_CREDITS) * 100;
// Calculate total credits spent
const totalCreditsSpent = user.votes.reduce((sum, v) => sum + v.creditCost, 0);
// Count stats
const proposalCount = user.proposals.length;
const rankingVotes = user.votes.length;
const finalVotes = user.finalVotes.length;
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold">Profile</h1>
<p className="text-muted-foreground">
{user.name || user.email} Member since{" "}
{format(user.createdAt, "MMMM yyyy")}
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Coins className="h-4 w-4" />
Credits
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{availableCredits}</div>
<Progress value={creditProgress} className="mt-2 h-1" />
<p className="text-xs text-muted-foreground mt-1">
+{CREDITS_PER_DAY}/day (max {MAX_CREDITS})
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Proposals
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{proposalCount}</div>
<p className="text-xs text-muted-foreground">created</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Ranking Votes
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rankingVotes}</div>
<p className="text-xs text-muted-foreground">
{totalCreditsSpent} credits spent
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Vote className="h-4 w-4" />
Final Votes
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{finalVotes}</div>
<p className="text-xs text-muted-foreground">pass/fail votes</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Your proposals */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Your Proposals</CardTitle>
<CardDescription>Proposals you&apos;ve created</CardDescription>
</CardHeader>
<CardContent>
{user.proposals.length === 0 ? (
<p className="text-sm text-muted-foreground">
You haven&apos;t created any proposals yet.
</p>
) : (
<div className="space-y-3">
{user.proposals.map((proposal) => (
<Link
key={proposal.id}
href={`/proposals/${proposal.id}`}
className="block p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium text-sm line-clamp-1">
{proposal.title}
</span>
<Badge variant="outline" className="text-xs shrink-0">
{proposal.status}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>Score: {proposal.score}</span>
<span></span>
<span>
{formatDistanceToNow(proposal.createdAt, {
addSuffix: true,
})}
</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent votes */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Votes</CardTitle>
<CardDescription>Your voting activity</CardDescription>
</CardHeader>
<CardContent>
{user.votes.length === 0 && user.finalVotes.length === 0 ? (
<p className="text-sm text-muted-foreground">
You haven&apos;t voted on any proposals yet.
</p>
) : (
<div className="space-y-3">
{user.votes.slice(0, 5).map((vote) => {
const effectiveWeight = getEffectiveWeight(
vote.weight,
vote.createdAt
);
return (
<Link
key={vote.id}
href={`/proposals/${vote.proposalId}`}
className="block p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm line-clamp-1">
{vote.proposal.title}
</span>
<Badge
variant={vote.weight > 0 ? "default" : "destructive"}
>
{vote.weight > 0 ? "+" : ""}
{effectiveWeight}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>Cost: {vote.creditCost} credits</span>
<span></span>
<span>
{formatDistanceToNow(vote.createdAt, {
addSuffix: true,
})}
</span>
</div>
</Link>
);
})}
{user.finalVotes.slice(0, 5).map((vote) => (
<Link
key={vote.id}
href={`/proposals/${vote.proposalId}`}
className="block p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm line-clamp-1">
{vote.proposal.title}
</span>
<Badge
variant={
vote.vote === "YES"
? "default"
: vote.vote === "NO"
? "destructive"
: "secondary"
}
>
{vote.vote}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span>Final vote</span>
<span></span>
<span>
{formatDistanceToNow(vote.createdAt, { addSuffix: true })}
</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Credit info */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Coins className="h-5 w-5" />
Credit System
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Stored credits</p>
<p className="font-mono text-lg">{user.credits}</p>
</div>
<div>
<p className="text-muted-foreground">Available credits</p>
<p className="font-mono text-lg">{availableCredits}</p>
</div>
<div>
<p className="text-muted-foreground">Earn rate</p>
<p className="font-mono text-lg">{CREDITS_PER_DAY}/day</p>
</div>
<div>
<p className="text-muted-foreground">Last claimed</p>
<p className="text-lg">
{formatDistanceToNow(user.lastCreditAt, { addSuffix: true })}
</p>
</div>
</div>
<div className="bg-muted p-4 rounded-lg text-sm">
<p className="font-medium mb-2">How voting costs work:</p>
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>1 vote = 1 credit</li>
<li>2 votes = 4 credits (2²)</li>
<li>3 votes = 9 credits (3²)</li>
<li>4 votes = 16 credits (4²)</li>
<li>
Max affordable: {Math.floor(Math.sqrt(availableCredits))} votes
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,330 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { VoteButtons } from "@/components/VoteButtons";
import { FinalVotePanel } from "@/components/FinalVotePanel";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight, getDecayPercentage } from "@/lib/voting";
import { formatDistanceToNow, format } from "date-fns";
import Link from "next/link";
import { ArrowLeft, User, Clock, TrendingUp } from "lucide-react";
import { ProposalStatus } from "@prisma/client";
const statusColors: Record<ProposalStatus, string> = {
RANKING: "bg-blue-500/10 text-blue-500 border-blue-500/20",
VOTING: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
PASSED: "bg-green-500/10 text-green-500 border-green-500/20",
FAILED: "bg-red-500/10 text-red-500 border-red-500/20",
ARCHIVED: "bg-gray-500/10 text-gray-500 border-gray-500/20",
};
export default async function ProposalPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await auth();
const proposal = await prisma.proposal.findUnique({
where: { id },
include: {
author: { select: { id: true, name: true, email: true } },
votes: {
include: {
user: { select: { id: true, name: true } },
},
orderBy: { weight: "desc" },
},
finalVotes: true,
},
});
if (!proposal) {
notFound();
}
// Get user's credits and vote
let availableCredits = 0;
let userVote: { weight: number; effectiveWeight: number } | undefined;
let userFinalVote: "YES" | "NO" | "ABSTAIN" | undefined;
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (user) {
availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
}
const existingVote = proposal.votes.find((v) => v.userId === session.user.id);
if (existingVote) {
userVote = {
weight: existingVote.weight,
effectiveWeight: getEffectiveWeight(existingVote.weight, existingVote.createdAt),
};
}
const existingFinalVote = proposal.finalVotes.find(
(v) => v.userId === session.user.id
);
if (existingFinalVote) {
userFinalVote = existingFinalVote.vote;
}
}
// Calculate final vote counts
const finalVoteCounts = proposal.finalVotes.reduce(
(acc, fv) => {
acc[fv.vote.toLowerCase() as "yes" | "no" | "abstain"]++;
acc.total++;
return acc;
},
{ yes: 0, no: 0, abstain: 0, total: 0 }
);
// Calculate effective score
const effectiveScore = proposal.votes.reduce((sum, v) => {
return sum + getEffectiveWeight(v.weight, v.createdAt);
}, 0);
const isRanking = proposal.status === "RANKING";
const isVoting = proposal.status === "VOTING";
const isCompleted = proposal.status === "PASSED" || proposal.status === "FAILED";
const progressToVoting = Math.min((effectiveScore / 100) * 100, 100);
return (
<div className="max-w-4xl mx-auto space-y-6">
<Button variant="ghost" asChild>
<Link href="/proposals">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Proposals
</Link>
</Button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Main content */}
<div className="md:col-span-2 space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<CardTitle className="text-2xl">{proposal.title}</CardTitle>
<Badge variant="outline" className={statusColors[proposal.status]}>
{proposal.status}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
{proposal.author.name || proposal.author.email.split("@")[0]}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatDistanceToNow(proposal.createdAt, { addSuffix: true })}
</span>
</div>
</CardHeader>
<CardContent>
<div className="prose dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap">{proposal.description}</p>
</div>
</CardContent>
</Card>
{/* Votes list for ranking stage */}
{isRanking && proposal.votes.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Votes</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{proposal.votes.slice(0, 10).map((vote) => {
const effectiveWeight = getEffectiveWeight(
vote.weight,
vote.createdAt
);
const decayPct = getDecayPercentage(vote.createdAt);
return (
<div
key={vote.id}
className="flex items-center justify-between py-2 border-b last:border-0"
>
<div className="flex items-center gap-2">
<Badge
variant={vote.weight > 0 ? "default" : "destructive"}
>
{vote.weight > 0 ? "+" : ""}
{effectiveWeight}
</Badge>
<span className="text-sm">
{vote.user.name || "Anonymous"}
</span>
</div>
<div className="text-xs text-muted-foreground">
{decayPct > 0 && (
<span className="text-yellow-600">
{decayPct}% decayed {" "}
</span>
)}
{formatDistanceToNow(vote.createdAt, {
addSuffix: true,
})}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Ranking vote panel */}
{isRanking && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Ranking</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<VoteButtons
proposalId={proposal.id}
currentScore={effectiveScore}
userVote={userVote}
availableCredits={availableCredits}
disabled={!session?.user}
/>
</div>
{!session?.user && (
<p className="text-sm text-muted-foreground text-center">
<Link href="/auth/signin" className="text-primary hover:underline">
Sign in
</Link>{" "}
to vote
</p>
)}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<TrendingUp className="h-4 w-4" />
Progress to voting
</span>
<span className="font-mono">{effectiveScore}/100</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressToVoting}%` }}
/>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>{proposal.votes.length}</strong> votes cast
</p>
{session?.user && (
<p>
You have <strong>{availableCredits}</strong> credits
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Pass/fail vote panel */}
{isVoting && proposal.votingEndsAt && (
<FinalVotePanel
proposalId={proposal.id}
votingEndsAt={proposal.votingEndsAt}
votes={finalVoteCounts}
userVote={userFinalVote}
/>
)}
{/* Completed result */}
{isCompleted && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Result</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<Badge
variant={proposal.status === "PASSED" ? "default" : "destructive"}
className="text-lg px-4 py-1"
>
{proposal.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<div className="font-bold text-green-500">
{finalVoteCounts.yes}
</div>
<div className="text-muted-foreground">Yes</div>
</div>
<div>
<div className="font-bold text-red-500">
{finalVoteCounts.no}
</div>
<div className="text-muted-foreground">No</div>
</div>
<div>
<div className="font-bold text-gray-500">
{finalVoteCounts.abstain}
</div>
<div className="text-muted-foreground">Abstain</div>
</div>
</div>
{proposal.votingEndsAt && (
<p className="text-xs text-muted-foreground text-center">
Voting ended {format(proposal.votingEndsAt, "PPP")}
</p>
)}
</CardContent>
</Card>
)}
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Details</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span>{format(proposal.createdAt, "PPP")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last updated</span>
<span>{format(proposal.updatedAt, "PPP")}</span>
</div>
{proposal.votingEndsAt && (
<div className="flex justify-between">
<span className="text-muted-foreground">
{isVoting ? "Voting ends" : "Voting ended"}
</span>
<span>{format(proposal.votingEndsAt, "PPP")}</span>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
export default function NewProposalPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
toast.error("Title is required");
return;
}
if (!description.trim()) {
toast.error("Description is required");
return;
}
setIsLoading(true);
try {
const res = await fetch("/api/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create proposal");
}
toast.success("Proposal created successfully!");
router.push(`/proposals/${data.proposal.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "An error occurred");
} finally {
setIsLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto">
<Button variant="ghost" asChild className="mb-4">
<Link href="/proposals">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Proposals
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Create New Proposal</CardTitle>
<CardDescription>
Submit a proposal for the community to rank and vote on. Proposals
start in the ranking stage and advance to pass/fail voting when they
reach a score of +100.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="A clear, concise title for your proposal"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
required
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
{title.length}/200 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe your proposal in detail. What problem does it solve? How would it be implemented? What are the benefits?"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={10000}
rows={10}
required
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
{description.length}/10,000 characters
</p>
</div>
<div className="bg-muted p-4 rounded-lg text-sm space-y-2">
<p className="font-medium">What happens next?</p>
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>Your proposal starts in the <strong>Ranking</strong> stage</li>
<li>Community members can upvote or downvote using their credits</li>
<li>When the score reaches <strong>+100</strong>, it advances to voting</li>
<li>In the voting stage, members vote Yes/No for 7 days</li>
</ul>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Proposal
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

138
src/app/proposals/page.tsx Normal file
View File

@ -0,0 +1,138 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ProposalList } from "@/components/ProposalList";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import Link from "next/link";
import { Plus } from "lucide-react";
export default async function ProposalsPage() {
const session = await auth();
// Get all proposals grouped by status
const [rankingProposals, votingProposals, completedProposals] = await Promise.all([
prisma.proposal.findMany({
where: { status: "RANKING" },
orderBy: { score: "desc" },
include: {
author: { select: { id: true, name: true, email: true } },
votes: true,
},
}),
prisma.proposal.findMany({
where: { status: "VOTING" },
orderBy: { votingEndsAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true } },
votes: true,
},
}),
prisma.proposal.findMany({
where: { status: { in: ["PASSED", "FAILED"] } },
orderBy: { updatedAt: "desc" },
take: 20,
include: {
author: { select: { id: true, name: true, email: true } },
votes: true,
},
}),
]);
// Get user's votes and credits if logged in
let availableCredits = 0;
let userVotes: { proposalId: string; weight: number; effectiveWeight: number }[] = [];
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (user) {
availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
}
const allProposalIds = [
...rankingProposals.map((p) => p.id),
...votingProposals.map((p) => p.id),
...completedProposals.map((p) => p.id),
];
const votes = await prisma.vote.findMany({
where: {
userId: session.user.id,
proposalId: { in: allProposalIds },
},
});
userVotes = votes.map((v) => ({
proposalId: v.proposalId,
weight: v.weight,
effectiveWeight: getEffectiveWeight(v.weight, v.createdAt),
}));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Proposals</h1>
<p className="text-muted-foreground">
Browse, rank, and vote on community proposals
</p>
</div>
{session?.user && (
<Button asChild>
<Link href="/proposals/new">
<Plus className="h-4 w-4 mr-2" />
New Proposal
</Link>
</Button>
)}
</div>
<Tabs defaultValue="ranking" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="ranking">
Ranking ({rankingProposals.length})
</TabsTrigger>
<TabsTrigger value="voting">
Voting ({votingProposals.length})
</TabsTrigger>
<TabsTrigger value="completed">
Completed ({completedProposals.length})
</TabsTrigger>
</TabsList>
<TabsContent value="ranking" className="mt-6">
<ProposalList
proposals={rankingProposals}
userVotes={userVotes}
availableCredits={availableCredits}
emptyMessage="No proposals are currently being ranked."
/>
</TabsContent>
<TabsContent value="voting" className="mt-6">
<ProposalList
proposals={votingProposals}
userVotes={userVotes}
availableCredits={availableCredits}
emptyMessage="No proposals are currently in voting."
/>
</TabsContent>
<TabsContent value="completed" className="mt-6">
<ProposalList
proposals={completedProposals}
userVotes={userVotes}
availableCredits={availableCredits}
emptyMessage="No proposals have been completed yet."
/>
</TabsContent>
</Tabs>
</div>
);
}

172
src/app/voting/page.tsx Normal file
View File

@ -0,0 +1,172 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { Clock, ArrowRight, CheckCircle, XCircle } from "lucide-react";
export default async function VotingPage() {
const session = await auth();
// Get proposals in voting stage
const votingProposals = await prisma.proposal.findMany({
where: { status: "VOTING" },
orderBy: { votingEndsAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true } },
finalVotes: {
select: { vote: true },
},
},
});
// Get user's final votes if logged in
let userVotes: Record<string, string> = {};
if (session?.user?.id) {
const votes = await prisma.finalVote.findMany({
where: {
userId: session.user.id,
proposalId: { in: votingProposals.map((p) => p.id) },
},
});
userVotes = Object.fromEntries(votes.map((v) => [v.proposalId, v.vote]));
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Active Voting</h1>
<p className="text-muted-foreground">
Proposals in the pass/fail voting stage. Vote Yes, No, or Abstain.
</p>
</div>
{!session?.user && (
<Card className="border-yellow-500/50 bg-yellow-500/5">
<CardContent className="py-4">
<p className="text-sm">
<Link href="/auth/signin" className="text-primary hover:underline">
Sign in
</Link>{" "}
to cast your vote on these proposals.
</p>
</CardContent>
</Card>
)}
{votingProposals.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<p>No proposals are currently in the voting stage.</p>
<Button asChild className="mt-4" variant="outline">
<Link href="/proposals">Browse Proposals</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{votingProposals.map((proposal) => {
const voteCounts = proposal.finalVotes.reduce(
(acc, fv) => {
acc[fv.vote.toLowerCase() as "yes" | "no" | "abstain"]++;
acc.total++;
return acc;
},
{ yes: 0, no: 0, abstain: 0, total: 0 }
);
const yesPercentage =
voteCounts.total > 0
? Math.round((voteCounts.yes / voteCounts.total) * 100)
: 50;
const hasVoted = userVotes[proposal.id];
const timeRemaining = proposal.votingEndsAt
? formatDistanceToNow(proposal.votingEndsAt, { addSuffix: true })
: "Unknown";
return (
<Card key={proposal.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<Link
href={`/proposals/${proposal.id}`}
className="hover:underline"
>
<CardTitle className="text-lg">
{proposal.title}
</CardTitle>
</Link>
<p className="text-sm text-muted-foreground mt-1">
by{" "}
{proposal.author.name ||
proposal.author.email.split("@")[0]}
</p>
</div>
<div className="flex items-center gap-2 text-sm text-yellow-600">
<Clock className="h-4 w-4" />
Ends {timeRemaining}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground line-clamp-2">
{proposal.description}
</p>
{/* Vote progress bar */}
<div className="space-y-2">
<div className="h-3 rounded-full overflow-hidden bg-red-500/20 flex">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${yesPercentage}%` }}
/>
<div
className="h-full bg-red-500 transition-all duration-300"
style={{ width: `${100 - yesPercentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle className="h-3 w-3 text-green-500" />
{voteCounts.yes} Yes ({yesPercentage}%)
</span>
<span>{voteCounts.total} total votes</span>
<span className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-red-500" />
{voteCounts.no} No ({100 - yesPercentage}%)
</span>
</div>
</div>
<div className="flex items-center justify-between">
{hasVoted ? (
<Badge variant="secondary">
You voted: {hasVoted}
</Badge>
) : (
<span className="text-sm text-muted-foreground">
{session?.user
? "You haven't voted yet"
: "Sign in to vote"}
</span>
)}
<Button asChild variant="outline" size="sm">
<Link href={`/proposals/${proposal.id}`}>
{hasVoted ? "Change Vote" : "Vote Now"}
<ArrowRight className="h-4 w-4 ml-2" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Coins } from "lucide-react";
interface CreditInfo {
available: number;
stored: number;
}
export function CreditDisplay() {
const [credits, setCredits] = useState<CreditInfo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchCredits() {
try {
const res = await fetch("/api/user/credits");
if (res.ok) {
const data = await res.json();
setCredits(data);
}
} catch (error) {
console.error("Failed to fetch credits:", error);
} finally {
setLoading(false);
}
}
fetchCredits();
// Refresh every minute to show earned credits
const interval = setInterval(fetchCredits, 60000);
return () => clearInterval(interval);
}, []);
if (loading) {
return (
<Badge variant="secondary" className="h-7 px-3 animate-pulse">
<Coins className="h-3 w-3 mr-1" />
...
</Badge>
);
}
if (!credits) {
return null;
}
return (
<Badge
variant="secondary"
className="h-7 px-3 font-mono"
title={`${credits.available} credits available (${credits.stored} stored)`}
>
<Coins className="h-3 w-3 mr-1" />
{credits.available}
</Badge>
);
}

View File

@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { VoteChoice } from "@prisma/client";
import { Check, X, Minus, Loader2, Clock } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
interface FinalVotePanelProps {
proposalId: string;
votingEndsAt: Date | string;
votes: {
yes: number;
no: number;
abstain: number;
total: number;
};
userVote?: VoteChoice;
result?: "PASSED" | "FAILED" | null;
onVote?: (newVotes: { yes: number; no: number; abstain: number; total: number }, userVote: VoteChoice) => void;
}
export function FinalVotePanel({
proposalId,
votingEndsAt,
votes: initialVotes,
userVote: initialUserVote,
result,
onVote,
}: FinalVotePanelProps) {
const [votes, setVotes] = useState(initialVotes);
const [userVote, setUserVote] = useState(initialUserVote);
const [isVoting, setIsVoting] = useState(false);
const endDate = typeof votingEndsAt === "string" ? new Date(votingEndsAt) : votingEndsAt;
const isEnded = endDate < new Date();
const canVote = !isEnded && !result;
const yesPercentage = votes.total > 0 ? (votes.yes / votes.total) * 100 : 50;
const noPercentage = votes.total > 0 ? (votes.no / votes.total) * 100 : 50;
async function castVote(vote: VoteChoice) {
setIsVoting(true);
try {
const res = await fetch(`/api/proposals/${proposalId}/final-vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vote }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Failed to vote");
}
const data = await res.json();
setVotes(data.votes);
setUserVote(vote);
onVote?.(data.votes, vote);
toast.success("Vote cast successfully!");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to vote");
} finally {
setIsVoting(false);
}
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Pass/Fail Vote</CardTitle>
{result ? (
<Badge variant={result === "PASSED" ? "default" : "destructive"}>
{result}
</Badge>
) : (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{isEnded ? "Voting ended" : `Ends ${formatDistanceToNow(endDate, { addSuffix: true })}`}
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Vote counts */}
<div className="grid grid-cols-3 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>
</div>
<div>
<div className="text-2xl font-bold text-red-500">{votes.no}</div>
<div className="text-sm text-muted-foreground">No</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-500">{votes.abstain}</div>
<div className="text-sm text-muted-foreground">Abstain</div>
</div>
</div>
{/* Progress bar */}
{votes.total > 0 && (
<div className="space-y-1">
<div className="h-3 rounded-full overflow-hidden bg-red-500/20 flex">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${yesPercentage}%` }}
/>
<div
className="h-full bg-red-500 transition-all duration-300"
style={{ width: `${noPercentage}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{Math.round(yesPercentage)}% Yes</span>
<span>{Math.round(noPercentage)}% No</span>
</div>
</div>
)}
{/* Vote buttons */}
{canVote && (
<div className="space-y-2">
{userVote && (
<p className="text-sm text-muted-foreground text-center">
You voted: <Badge variant="outline">{userVote}</Badge>
</p>
)}
<div className="grid grid-cols-3 gap-2">
<Button
variant={userVote === "YES" ? "default" : "outline"}
className="flex-col h-auto py-3"
onClick={() => castVote("YES")}
disabled={isVoting}
>
{isVoting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Check className="h-5 w-5" />
)}
<span className="text-xs mt-1">Yes</span>
</Button>
<Button
variant={userVote === "NO" ? "destructive" : "outline"}
className="flex-col h-auto py-3"
onClick={() => castVote("NO")}
disabled={isVoting}
>
{isVoting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<X className="h-5 w-5" />
)}
<span className="text-xs mt-1">No</span>
</Button>
<Button
variant={userVote === "ABSTAIN" ? "secondary" : "outline"}
className="flex-col h-auto py-3"
onClick={() => castVote("ABSTAIN")}
disabled={isVoting}
>
{isVoting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Minus className="h-5 w-5" />
)}
<span className="text-xs mt-1">Abstain</span>
</Button>
</div>
<p className="text-xs text-muted-foreground text-center">
One member = one vote. You can change your vote until voting ends.
</p>
</div>
)}
{!canVote && !result && (
<p className="text-sm text-muted-foreground text-center">
Voting has ended. Results are being tallied.
</p>
)}
</CardContent>
</Card>
);
}

109
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,109 @@
"use client";
import Link from "next/link";
import { useSession, signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { CreditDisplay } from "./CreditDisplay";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
export function Navbar() {
const { data: session, status } = useSession();
return (
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-6">
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl font-bold text-primary">rVote</span>
</Link>
<div className="hidden md:flex items-center gap-4">
<Link
href="/proposals"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Proposals
</Link>
<Link
href="/voting"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Voting
</Link>
</div>
</div>
<div className="flex items-center gap-4">
{status === "loading" ? (
<div className="h-8 w-20 animate-pulse bg-muted rounded" />
) : session?.user ? (
<>
<CreditDisplay />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="relative h-8 w-8 rounded-full"
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{session.user.name?.[0]?.toUpperCase() ||
session.user.email?.[0]?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
{session.user.name && (
<p className="font-medium">{session.user.name}</p>
)}
{session.user.email && (
<p className="w-[200px] truncate text-sm text-muted-foreground">
{session.user.email}
</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/proposals/new">New Proposal</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => signOut()}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<div className="flex items-center gap-2">
<Button variant="ghost" asChild>
<Link href="/auth/signin">Sign in</Link>
</Button>
<Button asChild>
<Link href="/auth/signup">Sign up</Link>
</Button>
</div>
)}
</div>
</div>
</div>
</nav>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { VoteButtons } from "./VoteButtons";
import { formatDistanceToNow } from "date-fns";
import { ProposalStatus } from "@prisma/client";
import { Clock, User, TrendingUp } from "lucide-react";
interface ProposalCardProps {
proposal: {
id: string;
title: string;
description: string;
status: ProposalStatus;
score: number;
createdAt: Date | string;
votingEndsAt?: Date | string | null;
author: {
id: string;
name: string | null;
email: string;
};
};
userVote?: {
weight: number;
effectiveWeight: number;
};
availableCredits: number;
showVoting?: boolean;
}
const statusColors: Record<ProposalStatus, string> = {
RANKING: "bg-blue-500/10 text-blue-500 border-blue-500/20",
VOTING: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
PASSED: "bg-green-500/10 text-green-500 border-green-500/20",
FAILED: "bg-red-500/10 text-red-500 border-red-500/20",
ARCHIVED: "bg-gray-500/10 text-gray-500 border-gray-500/20",
};
const statusLabels: Record<ProposalStatus, string> = {
RANKING: "Ranking",
VOTING: "Voting",
PASSED: "Passed",
FAILED: "Failed",
ARCHIVED: "Archived",
};
export function ProposalCard({
proposal,
userVote,
availableCredits,
showVoting = true,
}: ProposalCardProps) {
const [score, setScore] = useState(proposal.score);
const [currentVote, setCurrentVote] = useState(userVote);
const createdAt =
typeof proposal.createdAt === "string"
? new Date(proposal.createdAt)
: proposal.createdAt;
const votingEndsAt = proposal.votingEndsAt
? typeof proposal.votingEndsAt === "string"
? new Date(proposal.votingEndsAt)
: proposal.votingEndsAt
: null;
function handleVote(newScore: number, newWeight: number) {
setScore(newScore);
setCurrentVote(
newWeight !== 0
? { weight: newWeight, effectiveWeight: newWeight }
: undefined
);
}
const isRanking = proposal.status === "RANKING";
const isVoting = proposal.status === "VOTING";
const progressToVoting = isRanking ? Math.min((score / 100) * 100, 100) : 100;
return (
<Card className="flex">
{showVoting && isRanking && (
<div className="flex items-center justify-center px-4 border-r bg-muted/30">
<VoteButtons
proposalId={proposal.id}
currentScore={score}
userVote={currentVote}
availableCredits={availableCredits}
onVote={handleVote}
/>
</div>
)}
<div className="flex-1 min-w-0">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<Link
href={`/proposals/${proposal.id}`}
className="hover:underline"
>
<h3 className="font-semibold text-lg leading-tight line-clamp-2">
{proposal.title}
</h3>
</Link>
<Badge variant="outline" className={statusColors[proposal.status]}>
{statusLabels[proposal.status]}
</Badge>
</div>
</CardHeader>
<CardContent className="pb-2">
<p className="text-muted-foreground text-sm line-clamp-2">
{proposal.description}
</p>
{isRanking && (
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span className="flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
Progress to voting
</span>
<span>{score}/100</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressToVoting}%` }}
/>
</div>
</div>
)}
{isVoting && votingEndsAt && (
<div className="mt-3 flex items-center gap-1 text-sm text-yellow-600">
<Clock className="h-4 w-4" />
Voting ends {formatDistanceToNow(votingEndsAt, { addSuffix: true })}
</div>
)}
</CardContent>
<CardFooter className="pt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{proposal.author.name || proposal.author.email.split("@")[0]}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDistanceToNow(createdAt, { addSuffix: true })}
</span>
</div>
</CardFooter>
</div>
</Card>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { ProposalCard } from "./ProposalCard";
import { ProposalStatus } from "@prisma/client";
interface Proposal {
id: string;
title: string;
description: string;
status: ProposalStatus;
score: number;
createdAt: Date | string;
votingEndsAt?: Date | string | null;
author: {
id: string;
name: string | null;
email: string;
};
}
interface UserVote {
proposalId: string;
weight: number;
effectiveWeight: number;
}
interface ProposalListProps {
proposals: Proposal[];
userVotes?: UserVote[];
availableCredits: number;
emptyMessage?: string;
}
export function ProposalList({
proposals,
userVotes = [],
availableCredits,
emptyMessage = "No proposals found.",
}: ProposalListProps) {
if (proposals.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<p>{emptyMessage}</p>
</div>
);
}
const voteMap = new Map(userVotes.map((v) => [v.proposalId, v]));
return (
<div className="space-y-4">
{proposals.map((proposal) => (
<ProposalCard
key={proposal.id}
proposal={proposal}
userVote={voteMap.get(proposal.id)}
availableCredits={availableCredits}
/>
))}
</div>
);
}

View File

@ -0,0 +1,13 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { Toaster } from "sonner";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
<Toaster position="bottom-right" />
</SessionProvider>
);
}

View File

@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ChevronUp, ChevronDown, Loader2 } from "lucide-react";
import { calculateVoteCost, maxAffordableWeight } from "@/lib/credits";
import { toast } from "sonner";
interface VoteButtonsProps {
proposalId: string;
currentScore: number;
userVote?: {
weight: number;
effectiveWeight: number;
};
availableCredits: number;
onVote?: (newScore: number, newWeight: number) => void;
disabled?: boolean;
}
export function VoteButtons({
proposalId,
currentScore,
userVote,
availableCredits,
onVote,
disabled = false,
}: VoteButtonsProps) {
const [isVoting, setIsVoting] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [voteDirection, setVoteDirection] = useState<"up" | "down">("up");
const [voteWeight, setVoteWeight] = useState(1);
const maxWeight = maxAffordableWeight(availableCredits);
const voteCost = calculateVoteCost(voteWeight);
async function submitVote() {
setIsVoting(true);
try {
const weight = voteDirection === "up" ? voteWeight : -voteWeight;
const res = await fetch(`/api/proposals/${proposalId}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weight }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Failed to vote");
}
const data = await res.json();
toast.success(`Vote cast! Cost: ${voteCost} credits`);
onVote?.(data.newScore, weight);
setDialogOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to vote");
} finally {
setIsVoting(false);
}
}
async function removeVote() {
setIsVoting(true);
try {
const res = await fetch(`/api/proposals/${proposalId}/vote`, {
method: "DELETE",
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "Failed to remove vote");
}
const data = await res.json();
toast.success("Vote removed, credits returned");
onVote?.(data.newScore, 0);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to remove vote"
);
} finally {
setIsVoting(false);
}
}
function openVoteDialog(direction: "up" | "down") {
setVoteDirection(direction);
setVoteWeight(1);
setDialogOpen(true);
}
const hasVoted = userVote && userVote.weight !== 0;
const votedUp = hasVoted && userVote.weight > 0;
const votedDown = hasVoted && userVote.weight < 0;
return (
<div className="flex flex-col items-center gap-1">
<Button
variant={votedUp ? "default" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => (votedUp ? removeVote() : openVoteDialog("up"))}
disabled={disabled || isVoting || (!votedUp && maxWeight < 1)}
title={votedUp ? "Remove upvote" : "Upvote"}
>
{isVoting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ChevronUp className="h-5 w-5" />
)}
</Button>
<Badge variant="outline" className="font-mono text-lg px-2 min-w-[3rem] justify-center">
{currentScore}
</Badge>
<Button
variant={votedDown ? "destructive" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => (votedDown ? removeVote() : openVoteDialog("down"))}
disabled={disabled || isVoting || (!votedDown && maxWeight < 1)}
title={votedDown ? "Remove downvote" : "Downvote"}
>
{isVoting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</Button>
{hasVoted && (
<span className="text-xs text-muted-foreground">
Your vote: {userVote.effectiveWeight > 0 ? "+" : ""}
{userVote.effectiveWeight}
</span>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{voteDirection === "up" ? "Upvote" : "Downvote"} Proposal
</DialogTitle>
<DialogDescription>
Choose your vote weight. Cost increases quadratically (weight² credits).
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="weight" className="text-right">
Weight
</Label>
<Input
id="weight"
type="number"
min={1}
max={maxWeight}
value={voteWeight}
onChange={(e) =>
setVoteWeight(
Math.max(1, Math.min(maxWeight, parseInt(e.target.value) || 1))
)
}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="text-right text-sm text-muted-foreground">
Cost
</span>
<span className="col-span-3 font-mono">
{voteCost} credits (you have {availableCredits})
</span>
</div>
<div className="text-sm text-muted-foreground">
<p>Quick reference:</p>
<ul className="list-disc list-inside mt-1">
<li>1 vote = 1 credit</li>
<li>2 votes = 4 credits</li>
<li>3 votes = 9 credits</li>
<li>Max you can afford: {maxWeight} votes ({calculateVoteCost(maxWeight)} credits)</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={submitVote}
disabled={isVoting || voteWeight < 1 || voteCost > availableCredits}
variant={voteDirection === "up" ? "default" : "destructive"}
>
{isVoting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{voteDirection === "up" ? "Upvote" : "Downvote"} ({voteCost} credits)
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

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

View File

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

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

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

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

View File

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

67
src/lib/auth.ts Normal file
View File

@ -0,0 +1,67 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./prisma";
import bcrypt from "bcryptjs";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt",
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});

44
src/lib/credits.ts Normal file
View File

@ -0,0 +1,44 @@
import { differenceInDays, differenceInHours } from "date-fns";
// Credits earned per day
export const CREDITS_PER_DAY = 10;
// Maximum credits a user can accumulate
export const MAX_CREDITS = 500;
/**
* Calculate total available credits for a user
* Includes stored credits plus earned credits since last claim
*/
export function calculateAvailableCredits(
storedCredits: number,
lastCreditAt: Date
): number {
const hoursSinceLastClaim = differenceInHours(new Date(), lastCreditAt);
const earnedCredits = Math.floor((hoursSinceLastClaim / 24) * CREDITS_PER_DAY);
const totalCredits = storedCredits + earnedCredits;
return Math.min(totalCredits, MAX_CREDITS);
}
/**
* Calculate the quadratic cost of a vote
* Cost = weight^2 (1 vote = 1 credit, 2 votes = 4, 3 votes = 9, etc.)
*/
export function calculateVoteCost(weight: number): number {
return Math.pow(Math.abs(weight), 2);
}
/**
* Calculate the maximum vote weight a user can afford
*/
export function maxAffordableWeight(availableCredits: number): number {
return Math.floor(Math.sqrt(availableCredits));
}
/**
* Calculate credits to return when a vote fully decays
*/
export function calculateDecayedCredits(creditCost: number): number {
// Return the full credit cost when vote decays
return creditCost;
}

13
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

102
src/lib/voting.ts Normal file
View File

@ -0,0 +1,102 @@
import { differenceInDays, addDays } from "date-fns";
// Votes start decaying after this many days
export const DECAY_START_DAYS = 30;
// Votes are fully decayed (0 weight) after this many days
export const DECAY_END_DAYS = 60;
// Score threshold to promote proposal to voting stage
export const PROMOTION_THRESHOLD = 100;
// Voting period duration in days
export const VOTING_PERIOD_DAYS = 7;
/**
* Calculate the effective weight of a vote after decay
* Returns a value between 0 and the original weight
*/
export function getEffectiveWeight(
originalWeight: number,
createdAt: Date
): number {
const age = differenceInDays(new Date(), createdAt);
// No decay if vote is younger than decay start
if (age < DECAY_START_DAYS) {
return originalWeight;
}
// Fully decayed if older than decay end
if (age >= DECAY_END_DAYS) {
return 0;
}
// Linear decay between start and end
const decayProgress =
(age - DECAY_START_DAYS) / (DECAY_END_DAYS - DECAY_START_DAYS);
return Math.round(originalWeight * (1 - decayProgress));
}
/**
* Calculate the decay percentage (0-100) for display
*/
export function getDecayPercentage(createdAt: Date): number {
const age = differenceInDays(new Date(), createdAt);
if (age < DECAY_START_DAYS) {
return 0;
}
if (age >= DECAY_END_DAYS) {
return 100;
}
const decayProgress =
(age - DECAY_START_DAYS) / (DECAY_END_DAYS - DECAY_START_DAYS);
return Math.round(decayProgress * 100);
}
/**
* Calculate when a vote will start decaying
*/
export function getDecayStartDate(createdAt: Date): Date {
return addDays(createdAt, DECAY_START_DAYS);
}
/**
* Calculate when a vote will be fully decayed
*/
export function getFullDecayDate(createdAt: Date): Date {
return addDays(createdAt, DECAY_END_DAYS);
}
/**
* Check if a proposal should be promoted to voting stage
*/
export function shouldPromote(score: number): boolean {
return score >= PROMOTION_THRESHOLD;
}
/**
* Calculate the voting end date from promotion time
*/
export function getVotingEndDate(promotedAt: Date = new Date()): Date {
return addDays(promotedAt, VOTING_PERIOD_DAYS);
}
/**
* Calculate the result of a pass/fail vote
*/
export function calculateVoteResult(
yesVotes: number,
noVotes: number
): "PASSED" | "FAILED" | "TIE" {
if (yesVotes > noVotes) {
return "PASSED";
}
if (noVotes > yesVotes) {
return "FAILED";
}
return "TIE";
}

79
src/types/index.ts Normal file
View File

@ -0,0 +1,79 @@
import { Proposal, User, Vote, FinalVote, ProposalStatus, VoteChoice } from "@prisma/client";
// Extended types with relations
export type ProposalWithAuthor = Proposal & {
author: Pick<User, "id" | "name" | "email">;
};
export type ProposalWithVotes = Proposal & {
votes: Vote[];
finalVotes: FinalVote[];
author: Pick<User, "id" | "name" | "email">;
};
export type VoteWithUser = Vote & {
user: Pick<User, "id" | "name">;
};
// API response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// Vote request types
export interface CastVoteRequest {
weight: number; // Positive for upvote, negative for downvote
}
export interface CastFinalVoteRequest {
vote: VoteChoice;
}
// User credit info
export interface UserCredits {
stored: number;
available: number;
earnedSinceLastClaim: number;
maxAffordableVote: number;
}
// Proposal list filters
export interface ProposalFilters {
status?: ProposalStatus;
authorId?: string;
sortBy?: "score" | "createdAt" | "votingEndsAt";
sortOrder?: "asc" | "desc";
page?: number;
limit?: number;
}
// Vote summary for a proposal
export interface VoteSummary {
totalScore: number;
upvotes: number;
downvotes: number;
voterCount: number;
userVote?: {
weight: number;
creditCost: number;
effectiveWeight: number;
decayPercentage: number;
};
}
// Final vote summary
export interface FinalVoteSummary {
yes: number;
no: number;
abstain: number;
total: number;
userVote?: VoteChoice;
timeRemaining?: number; // milliseconds
result?: "PASSED" | "FAILED" | "TIE" | "PENDING";
}
// Re-export Prisma types for convenience
export { ProposalStatus, VoteChoice } from "@prisma/client";
export type { Proposal, User, Vote, FinalVote };

9
src/types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}