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*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files
|
||||||
.env*
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
@ -39,3 +42,5 @@ yarn-error.log*
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
output: "standalone",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "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": "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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"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 "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
:root {
|
@custom-variant dark (&:is(.dark *));
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--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 {
|
:root {
|
||||||
--background: #0a0a0a;
|
--radius: 0.625rem;
|
||||||
--foreground: #ededed;
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.dark {
|
||||||
background: var(--background);
|
--background: oklch(0.145 0 0);
|
||||||
color: var(--foreground);
|
--foreground: oklch(0.985 0 0);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/components/Providers";
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -13,8 +15,10 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "rVote - Quadratic Voting for Community Governance",
|
||||||
description: "Generated by create next app",
|
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({
|
export default function RootLayout({
|
||||||
|
|
@ -23,11 +27,14 @@ export default function RootLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
257
src/app/page.tsx
257
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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="space-y-8">
|
||||||
<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">
|
{/* Hero section */}
|
||||||
<Image
|
<section className="text-center py-12 space-y-4">
|
||||||
className="dark:invert"
|
<h1 className="text-4xl font-bold tracking-tight">
|
||||||
src="/next.svg"
|
Community-Driven Governance
|
||||||
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>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
Rank proposals using quadratic voting. Top proposals advance to pass/fail voting.
|
||||||
<a
|
Your voice matters more when you concentrate your votes.
|
||||||
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>
|
</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>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
)}
|
||||||
<a
|
{session?.user && (
|
||||||
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]"
|
<div className="flex justify-center gap-4 pt-4">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<Button asChild size="lg">
|
||||||
target="_blank"
|
<Link href="/proposals/new">Create Proposal</Link>
|
||||||
rel="noopener noreferrer"
|
</Button>
|
||||||
>
|
<Button asChild variant="outline" size="lg">
|
||||||
<Image
|
<Link href="/voting">View Active Votes</Link>
|
||||||
className="dark:invert"
|
</Button>
|
||||||
src="/vercel.svg"
|
</div>
|
||||||
alt="Vercel logomark"
|
)}
|
||||||
width={16}
|
</section>
|
||||||
height={16}
|
|
||||||
|
{/* 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>
|
||||||
|
</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>
|
||||||
|
{proposals.length > 0 ? (
|
||||||
|
<ProposalList
|
||||||
|
proposals={proposals}
|
||||||
|
userVotes={userVotes}
|
||||||
|
availableCredits={availableCredits}
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
) : (
|
||||||
</a>
|
<Card>
|
||||||
<a
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
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]"
|
<p>No proposals yet. Be the first to create one!</p>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{session?.user && (
|
||||||
target="_blank"
|
<Button asChild className="mt-4">
|
||||||
rel="noopener noreferrer"
|
<Link href="/proposals/new">Create Proposal</Link>
|
||||||
>
|
</Button>
|
||||||
Documentation
|
)}
|
||||||
</a>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</main>
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</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