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:
parent
8804057a7d
commit
c6b7f5d899
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
261
src/app/page.tsx
261
src/app/page.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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've created</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.proposals.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You haven'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue