initial project setup
Fastify + Prisma backend, React + Vite frontend, Docker deployment. Multi-board feedback platform with anonymous cookie auth, passkey upgrade path, ALTCHA spam protection, plugin system, and full privacy-first architecture.
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Database
|
||||||
|
DB_PASSWORD=change-me-to-a-random-string
|
||||||
|
DATABASE_URL=postgresql://echoboard:change-me-to-a-random-string@db:5432/echoboard
|
||||||
|
|
||||||
|
# Encryption (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
|
||||||
|
APP_MASTER_KEY=
|
||||||
|
APP_BLIND_INDEX_KEY=
|
||||||
|
|
||||||
|
# Auth secrets (generate each the same way as above)
|
||||||
|
TOKEN_SECRET=
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# ALTCHA spam protection
|
||||||
|
ALTCHA_HMAC_KEY=
|
||||||
|
|
||||||
|
# WebAuthn / Passkey
|
||||||
|
WEBAUTHN_RP_NAME=Echoboard
|
||||||
|
WEBAUTHN_RP_ID=localhost
|
||||||
|
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# Web Push (generate with: npx web-push generate-vapid-keys)
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_CONTACT=mailto:admin@example.com
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY packages/api/package.json packages/api/
|
||||||
|
COPY packages/web/package.json packages/web/
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY packages/api/ packages/api/
|
||||||
|
COPY packages/web/ packages/web/
|
||||||
|
COPY echoboard.plugins.ts ./
|
||||||
|
|
||||||
|
RUN npx prisma generate --schema=packages/api/prisma/schema.prisma
|
||||||
|
RUN npm run build:web
|
||||||
|
RUN npm run build:api
|
||||||
|
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/packages/api/package.json packages/api/
|
||||||
|
COPY --from=builder /app/packages/api/dist packages/api/dist/
|
||||||
|
COPY --from=builder /app/packages/api/prisma packages/api/prisma/
|
||||||
|
COPY --from=builder /app/packages/api/node_modules packages/api/node_modules/
|
||||||
|
COPY --from=builder /app/packages/web/dist packages/web/dist/
|
||||||
|
COPY --from=builder /app/node_modules node_modules/
|
||||||
|
COPY --from=builder /app/echoboard.plugins.ts ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npx prisma migrate deploy --schema=packages/api/prisma/schema.prisma && node packages/api/dist/index.js"]
|
||||||
57
README.md
Normal file
57
README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Echoboard
|
||||||
|
|
||||||
|
A self-hosted feedback board where users submit feature requests and bug reports without creating an account. Anonymous by default, with optional passkey registration for persistence across devices.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd echoboard
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Generate secrets
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
# Paste output into APP_MASTER_KEY, APP_BLIND_INDEX_KEY, TOKEN_SECRET, JWT_SECRET, ALTCHA_HMAC_KEY
|
||||||
|
|
||||||
|
# Generate VAPID keys for push notifications
|
||||||
|
npx web-push generate-vapid-keys
|
||||||
|
# Paste into VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY
|
||||||
|
|
||||||
|
# Set WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN to your domain
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
docker compose exec app npx echoboard create-admin --email you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# Fill in .env with dev values (WEBAUTHN_RP_ID=localhost, WEBAUTHN_ORIGIN=http://localhost:3000)
|
||||||
|
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the API on port 3000 and the Vite dev server on port 5173 (with API proxy).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **packages/api** - Fastify + Prisma backend
|
||||||
|
- **packages/web** - React + Vite frontend
|
||||||
|
- **plugins/** - Optional integrations (Gitea, GitHub, etc.)
|
||||||
|
|
||||||
|
## Identity model
|
||||||
|
|
||||||
|
Two tiers of user identity:
|
||||||
|
|
||||||
|
1. **Anonymous cookie** (default) - zero friction, browser-generated token, single device only
|
||||||
|
2. **Passkey** (optional upgrade) - username + WebAuthn biometric, works across devices, no email needed
|
||||||
|
|
||||||
|
## Plugin system
|
||||||
|
|
||||||
|
Plugins live in `plugins/` and are registered in `echoboard.plugins.ts`. Each plugin is self-contained with its own routes, database tables, and UI components. Removing a plugin leaves zero trace in the core app.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
CC0-1.0
|
||||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://echoboard:${DB_PASSWORD}@db:5432/echoboard
|
||||||
|
APP_MASTER_KEY: ${APP_MASTER_KEY}
|
||||||
|
APP_BLIND_INDEX_KEY: ${APP_BLIND_INDEX_KEY}
|
||||||
|
TOKEN_SECRET: ${TOKEN_SECRET}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY}
|
||||||
|
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Echoboard}
|
||||||
|
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID}
|
||||||
|
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
VAPID_CONTACT: ${VAPID_CONTACT}
|
||||||
|
NODE_ENV: production
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: echoboard
|
||||||
|
POSTGRES_USER: echoboard
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U echoboard"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
8
echoboard.plugins.ts
Normal file
8
echoboard.plugins.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Active plugins - import and add to the array to enable
|
||||||
|
// Remove from array and delete the plugin directory to disable
|
||||||
|
|
||||||
|
// import { giteaPlugin } from './plugins/gitea/src/index.js';
|
||||||
|
|
||||||
|
export const plugins: unknown[] = [
|
||||||
|
// giteaPlugin,
|
||||||
|
];
|
||||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "echoboard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/api",
|
||||||
|
"packages/web",
|
||||||
|
"plugins/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:api\" \"npm run dev:web\"",
|
||||||
|
"dev:api": "npm run dev -w packages/api",
|
||||||
|
"dev:web": "npm run dev -w packages/web",
|
||||||
|
"build": "npm run build -w packages/api && npm run build -w packages/web",
|
||||||
|
"build:api": "npm run build -w packages/api",
|
||||||
|
"build:web": "npm run build -w packages/web",
|
||||||
|
"start": "npm run start -w packages/api",
|
||||||
|
"db:migrate": "npm run db:migrate -w packages/api",
|
||||||
|
"db:generate": "npm run db:generate -w packages/api",
|
||||||
|
"create-admin": "npm run create-admin -w packages/api"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
packages/api/package.json
Normal file
42
packages/api/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@echoboard/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:deploy": "prisma migrate deploy",
|
||||||
|
"create-admin": "tsx src/cli/create-admin.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.0",
|
||||||
|
"@fastify/cors": "^10.0.0",
|
||||||
|
"@fastify/rate-limit": "^10.0.0",
|
||||||
|
"@fastify/static": "^8.0.0",
|
||||||
|
"@prisma/client": "^6.0.0",
|
||||||
|
"@simplewebauthn/server": "^11.0.0",
|
||||||
|
"altcha-lib": "^0.5.0",
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
|
"fastify": "^5.0.0",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"node-cron": "^3.0.0",
|
||||||
|
"rss": "^1.2.0",
|
||||||
|
"web-push": "^3.6.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/web-push": "^3.6.0",
|
||||||
|
"prisma": "^6.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
213
packages/api/prisma/schema.prisma
Normal file
213
packages/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthMethod {
|
||||||
|
COOKIE
|
||||||
|
PASSKEY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PostType {
|
||||||
|
FEATURE_REQUEST
|
||||||
|
BUG_REPORT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PostStatus {
|
||||||
|
OPEN
|
||||||
|
UNDER_REVIEW
|
||||||
|
PLANNED
|
||||||
|
IN_PROGRESS
|
||||||
|
DONE
|
||||||
|
DECLINED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Board {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
externalUrl String?
|
||||||
|
isArchived Boolean @default(false)
|
||||||
|
voteBudget Int @default(10)
|
||||||
|
voteBudgetReset String @default("monthly")
|
||||||
|
lastBudgetReset DateTime?
|
||||||
|
allowMultiVote Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
posts Post[]
|
||||||
|
activityEvents ActivityEvent[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
authMethod AuthMethod @default(COOKIE)
|
||||||
|
tokenHash String? @unique
|
||||||
|
username String?
|
||||||
|
usernameIdx String? @unique
|
||||||
|
displayName String?
|
||||||
|
darkMode String @default("system")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
passkeys Passkey[]
|
||||||
|
posts Post[]
|
||||||
|
comments Comment[]
|
||||||
|
reactions Reaction[]
|
||||||
|
votes Vote[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Passkey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
credentialId String
|
||||||
|
credentialIdIdx String @unique
|
||||||
|
credentialPublicKey Bytes
|
||||||
|
counter BigInt
|
||||||
|
credentialDeviceType String
|
||||||
|
credentialBackedUp Boolean
|
||||||
|
transports String?
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Post {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
type PostType
|
||||||
|
title String
|
||||||
|
description Json
|
||||||
|
status PostStatus @default(OPEN)
|
||||||
|
category String?
|
||||||
|
voteCount Int @default(0)
|
||||||
|
isPinned Boolean @default(false)
|
||||||
|
boardId String
|
||||||
|
authorId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
statusChanges StatusChange[]
|
||||||
|
comments Comment[]
|
||||||
|
votes Vote[]
|
||||||
|
adminResponses AdminResponse[]
|
||||||
|
activityEvents ActivityEvent[]
|
||||||
|
pushSubscriptions PushSubscription[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model StatusChange {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
postId String
|
||||||
|
fromStatus PostStatus
|
||||||
|
toStatus PostStatus
|
||||||
|
changedBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Comment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
body String
|
||||||
|
postId String
|
||||||
|
authorId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
reactions Reaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Reaction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
emoji String
|
||||||
|
commentId String
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([commentId, userId, emoji])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Vote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
weight Int @default(1)
|
||||||
|
postId String
|
||||||
|
voterId String
|
||||||
|
budgetPeriod String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
voter User @relation(fields: [voterId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([postId, voterId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
responses AdminResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminResponse {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
body String
|
||||||
|
postId String
|
||||||
|
adminId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
admin AdminUser @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model ActivityEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
type String
|
||||||
|
boardId String
|
||||||
|
postId String?
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([boardId, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
endpoint String
|
||||||
|
endpointIdx String @unique
|
||||||
|
keysP256dh String
|
||||||
|
keysAuth String
|
||||||
|
userId String
|
||||||
|
boardId String?
|
||||||
|
postId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||||
|
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
86
packages/api/src/cli/create-admin.ts
Normal file
86
packages/api/src/cli/create-admin.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
function getArg(name: string): string | undefined {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx === -1 || idx + 1 >= process.argv.length) return undefined;
|
||||||
|
return process.argv[idx + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLine(prompt: string, hidden = false): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
if (hidden) {
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
let input = "";
|
||||||
|
process.stdin.setRawMode?.(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
const handler = (ch: string) => {
|
||||||
|
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
|
||||||
|
process.stdin.setRawMode?.(false);
|
||||||
|
process.stdin.removeListener("data", handler);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
rl.close();
|
||||||
|
resolve(input);
|
||||||
|
} else if (ch === "\u007F" || ch === "\b") {
|
||||||
|
if (input.length > 0) {
|
||||||
|
input = input.slice(0, -1);
|
||||||
|
process.stdout.write("\b \b");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input += ch;
|
||||||
|
process.stdout.write("*");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.stdin.on("data", handler);
|
||||||
|
} else {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = getArg("email") ?? await readLine("Email: ");
|
||||||
|
if (!email) {
|
||||||
|
console.error("Email is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.adminUser.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
console.error("Admin with this email already exists");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await readLine("Password: ", true);
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
console.error("Password must be at least 8 characters");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirm = await readLine("Confirm password: ", true);
|
||||||
|
if (password !== confirm) {
|
||||||
|
console.error("Passwords do not match");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
const admin = await prisma.adminUser.create({
|
||||||
|
data: { email, passwordHash: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Admin created: ${admin.email} (${admin.id})`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
41
packages/api/src/config.ts
Normal file
41
packages/api/src/config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
DATABASE_URL: z.string(),
|
||||||
|
APP_MASTER_KEY: z.string().regex(/^[0-9a-fA-F]{64}$/, "Must be hex-encoded 256-bit key"),
|
||||||
|
APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]+$/, "Must be hex-encoded"),
|
||||||
|
TOKEN_SECRET: z.string(),
|
||||||
|
JWT_SECRET: z.string(),
|
||||||
|
ALTCHA_HMAC_KEY: z.string(),
|
||||||
|
|
||||||
|
WEBAUTHN_RP_NAME: z.string().default("Echoboard"),
|
||||||
|
WEBAUTHN_RP_ID: z.string(),
|
||||||
|
WEBAUTHN_ORIGIN: z.string().url(),
|
||||||
|
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
ALTCHA_MAX_NUMBER: z.coerce.number().default(500000),
|
||||||
|
ALTCHA_MAX_NUMBER_VOTE: z.coerce.number().default(50000),
|
||||||
|
ALTCHA_EXPIRE_SECONDS: z.coerce.number().default(300),
|
||||||
|
|
||||||
|
VAPID_PUBLIC_KEY: z.string().optional(),
|
||||||
|
VAPID_PRIVATE_KEY: z.string().optional(),
|
||||||
|
VAPID_CONTACT: z.string().optional(),
|
||||||
|
|
||||||
|
DATA_RETENTION_ACTIVITY_DAYS: z.coerce.number().default(90),
|
||||||
|
DATA_RETENTION_ORPHAN_USER_DAYS: z.coerce.number().default(180),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("Invalid environment variables:");
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
console.error(` ${issue.path.join(".")}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = parsed.data;
|
||||||
|
|
||||||
|
export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex");
|
||||||
|
export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex");
|
||||||
60
packages/api/src/cron/index.ts
Normal file
60
packages/api/src/cron/index.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { cleanExpiredChallenges } from "../routes/passkey.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export function startCronJobs() {
|
||||||
|
// prune old activity events - daily at 3am
|
||||||
|
cron.schedule("0 3 * * *", async () => {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);
|
||||||
|
|
||||||
|
const result = await prisma.activityEvent.deleteMany({
|
||||||
|
where: { createdAt: { lt: cutoff } },
|
||||||
|
});
|
||||||
|
if (result.count > 0) {
|
||||||
|
console.log(`Pruned ${result.count} old activity events`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// prune orphaned anonymous users - daily at 4am
|
||||||
|
cron.schedule("0 4 * * *", async () => {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS);
|
||||||
|
|
||||||
|
const result = await prisma.user.deleteMany({
|
||||||
|
where: {
|
||||||
|
authMethod: "COOKIE",
|
||||||
|
createdAt: { lt: cutoff },
|
||||||
|
posts: { none: {} },
|
||||||
|
comments: { none: {} },
|
||||||
|
votes: { none: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count > 0) {
|
||||||
|
console.log(`Pruned ${result.count} orphaned users`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// clean webauthn challenges - every 10 minutes
|
||||||
|
cron.schedule("*/10 * * * *", () => {
|
||||||
|
cleanExpiredChallenges();
|
||||||
|
});
|
||||||
|
|
||||||
|
// remove failed push subscriptions - daily at 5am
|
||||||
|
cron.schedule("0 5 * * *", async () => {
|
||||||
|
// subscriptions with no associated user get cleaned by cascade
|
||||||
|
// this handles any other stale ones
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setDate(cutoff.getDate() - 30);
|
||||||
|
|
||||||
|
const result = await prisma.pushSubscription.deleteMany({
|
||||||
|
where: { createdAt: { lt: cutoff } },
|
||||||
|
});
|
||||||
|
if (result.count > 0) {
|
||||||
|
console.log(`Cleaned ${result.count} old push subscriptions`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
19
packages/api/src/index.ts
Normal file
19
packages/api/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createServer } from "./server.js";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { startCronJobs } from "./cron/index.js";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await createServer();
|
||||||
|
|
||||||
|
startCronJobs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: config.PORT, host: "0.0.0.0" });
|
||||||
|
console.log(`Echoboard API running on port ${config.PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
74
packages/api/src/lib/budget.ts
Normal file
74
packages/api/src/lib/budget.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export function getCurrentPeriod(resetSchedule: string): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
||||||
|
switch (resetSchedule) {
|
||||||
|
case "weekly": {
|
||||||
|
const startOfYear = new Date(year, 0, 1);
|
||||||
|
const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000);
|
||||||
|
const week = Math.ceil((days + startOfYear.getDay() + 1) / 7);
|
||||||
|
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
case "quarterly": {
|
||||||
|
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
|
return `${year}-Q${q}`;
|
||||||
|
}
|
||||||
|
case "yearly":
|
||||||
|
return `${year}`;
|
||||||
|
case "never":
|
||||||
|
return "lifetime";
|
||||||
|
case "monthly":
|
||||||
|
default:
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemainingBudget(userId: string, boardId: string): Promise<number> {
|
||||||
|
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
||||||
|
if (!board) return 0;
|
||||||
|
|
||||||
|
if (board.voteBudgetReset === "never" && board.voteBudget === 0) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||||
|
|
||||||
|
const used = await prisma.vote.aggregate({
|
||||||
|
where: { voterId: userId, post: { boardId }, budgetPeriod: period },
|
||||||
|
_sum: { weight: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const spent = used._sum.weight ?? 0;
|
||||||
|
return Math.max(0, board.voteBudget - spent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextResetDate(resetSchedule: string): Date {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (resetSchedule) {
|
||||||
|
case "weekly": {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setDate(d.getDate() + (7 - d.getDay()));
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
case "quarterly": {
|
||||||
|
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
|
return new Date(now.getFullYear(), q * 3, 1);
|
||||||
|
}
|
||||||
|
case "yearly":
|
||||||
|
return new Date(now.getFullYear() + 1, 0, 1);
|
||||||
|
case "never":
|
||||||
|
return new Date(8640000000000000); // max date
|
||||||
|
case "monthly":
|
||||||
|
default: {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/api/src/middleware/auth.ts
Normal file
102
packages/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { PrismaClient, User } from "@prisma/client";
|
||||||
|
import { hashToken } from "../services/encryption.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
user?: User;
|
||||||
|
adminId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function authPlugin(app: FastifyInstance) {
|
||||||
|
app.decorateRequest("user", undefined);
|
||||||
|
app.decorateRequest("adminId", undefined);
|
||||||
|
|
||||||
|
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// try cookie auth first
|
||||||
|
const token = req.cookies?.echoboard_token;
|
||||||
|
if (token) {
|
||||||
|
const hash = hashToken(token);
|
||||||
|
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||||
|
if (user) {
|
||||||
|
req.user = user;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// try bearer token (passkey sessions)
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||||
|
if (decoded.type === "passkey") {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||||
|
if (user) {
|
||||||
|
req.user = user;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(401).send({ error: "Not authenticated" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("optionalUser", async (req: FastifyRequest) => {
|
||||||
|
const token = req.cookies?.echoboard_token;
|
||||||
|
if (token) {
|
||||||
|
const hash = hashToken(token);
|
||||||
|
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||||
|
if (user) req.user = user;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||||
|
if (decoded.type === "passkey") {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||||
|
if (user) req.user = user;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) {
|
||||||
|
reply.status(401).send({ error: "Admin token required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||||
|
if (decoded.type !== "admin") {
|
||||||
|
reply.status(403).send({ error: "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.adminId = decoded.sub;
|
||||||
|
} catch {
|
||||||
|
reply.status(401).send({ error: "Invalid admin token" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
optionalUser: (req: FastifyRequest) => Promise<void>;
|
||||||
|
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(authPlugin, { name: "auth" });
|
||||||
28
packages/api/src/middleware/security.ts
Normal file
28
packages/api/src/middleware/security.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import fp from "fastify-plugin";
|
||||||
|
|
||||||
|
async function securityPlugin(app: FastifyInstance) {
|
||||||
|
app.addHook("onSend", async (_req, reply) => {
|
||||||
|
reply.header("Content-Security-Policy", [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
].join("; "));
|
||||||
|
reply.header("Referrer-Policy", "no-referrer");
|
||||||
|
reply.header("X-Content-Type-Options", "nosniff");
|
||||||
|
reply.header("X-Frame-Options", "DENY");
|
||||||
|
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
|
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||||
|
reply.header("X-DNS-Prefetch-Control", "off");
|
||||||
|
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||||
|
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(securityPlugin, { name: "security" });
|
||||||
32
packages/api/src/plugins/loader.ts
Normal file
32
packages/api/src/plugins/loader.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { PluginManifest, EchoboardPlugin } from "./types.js";
|
||||||
|
|
||||||
|
export async function loadPlugins(app: FastifyInstance) {
|
||||||
|
const manifestPath = resolve(process.cwd(), "echoboard.plugins.json");
|
||||||
|
let manifest: PluginManifest;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await readFile(manifestPath, "utf-8");
|
||||||
|
manifest = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
app.log.info("No plugin manifest found, skipping plugin loading");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifest.plugins || !Array.isArray(manifest.plugins)) return;
|
||||||
|
|
||||||
|
for (const entry of manifest.plugins) {
|
||||||
|
if (!entry.enabled) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await import(entry.name) as { default: EchoboardPlugin };
|
||||||
|
const plugin = mod.default;
|
||||||
|
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
|
||||||
|
await plugin.register(app, entry.config ?? {});
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(`Failed to load plugin ${entry.name}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/api/src/plugins/types.ts
Normal file
17
packages/api/src/plugins/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
export interface EchoboardPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
register: (app: FastifyInstance, config: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginConfig {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginManifest {
|
||||||
|
plugins: PluginConfig[];
|
||||||
|
}
|
||||||
49
packages/api/src/routes/activity.ts
Normal file
49
packages/api/src/routes/activity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
board: z.string().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function activityRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Querystring: Record<string, string> }>(
|
||||||
|
"/activity",
|
||||||
|
async (req, reply) => {
|
||||||
|
const q = querySchema.parse(req.query);
|
||||||
|
|
||||||
|
const where: Prisma.ActivityEventWhereInput = {};
|
||||||
|
if (q.board) {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: q.board } });
|
||||||
|
if (board) where.boardId = board.id;
|
||||||
|
}
|
||||||
|
if (q.type) where.type = q.type;
|
||||||
|
|
||||||
|
const [events, total] = await Promise.all([
|
||||||
|
prisma.activityEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (q.page - 1) * q.limit,
|
||||||
|
take: q.limit,
|
||||||
|
include: {
|
||||||
|
board: { select: { slug: true, name: true } },
|
||||||
|
post: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.activityEvent.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
events,
|
||||||
|
total,
|
||||||
|
page: q.page,
|
||||||
|
pages: Math.ceil(total / q.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
42
packages/api/src/routes/admin/auth.ts
Normal file
42
packages/api/src/routes/admin/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { config } from "../../config.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const loginBody = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function adminAuthRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Body: z.infer<typeof loginBody> }>(
|
||||||
|
"/admin/login",
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = loginBody.parse(req.body);
|
||||||
|
|
||||||
|
const admin = await prisma.adminUser.findUnique({ where: { email: body.email } });
|
||||||
|
if (!admin) {
|
||||||
|
reply.status(401).send({ error: "Invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(body.password, admin.passwordHash);
|
||||||
|
if (!valid) {
|
||||||
|
reply.status(401).send({ error: "Invalid credentials" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ sub: admin.id, type: "admin" },
|
||||||
|
config.JWT_SECRET,
|
||||||
|
{ expiresIn: "24h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
reply.send({ token });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
112
packages/api/src/routes/admin/boards.ts
Normal file
112
packages/api/src/routes/admin/boards.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const createBoardBody = z.object({
|
||||||
|
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().max(500).optional(),
|
||||||
|
externalUrl: z.string().url().optional(),
|
||||||
|
voteBudget: z.number().int().min(0).default(10),
|
||||||
|
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"),
|
||||||
|
allowMultiVote: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBoardBody = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().max(500).optional().nullable(),
|
||||||
|
externalUrl: z.string().url().optional().nullable(),
|
||||||
|
isArchived: z.boolean().optional(),
|
||||||
|
voteBudget: z.number().int().min(0).optional(),
|
||||||
|
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(),
|
||||||
|
allowMultiVote: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||||
|
app.get(
|
||||||
|
"/admin/boards",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const boards = await prisma.board.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { posts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
reply.send(boards);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: z.infer<typeof createBoardBody> }>(
|
||||||
|
"/admin/boards",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = createBoardBody.parse(req.body);
|
||||||
|
|
||||||
|
const existing = await prisma.board.findUnique({ where: { slug: body.slug } });
|
||||||
|
if (existing) {
|
||||||
|
reply.status(409).send({ error: "Slug already taken" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const board = await prisma.board.create({ data: body });
|
||||||
|
reply.status(201).send(board);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
|
||||||
|
"/admin/boards/:id",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = updateBoardBody.parse(req.body);
|
||||||
|
const updated = await prisma.board.update({
|
||||||
|
where: { id: board.id },
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
"/admin/boards/:id/reset-budget",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.board.update({
|
||||||
|
where: { id: board.id },
|
||||||
|
data: { lastBudgetReset: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/admin/boards/:id",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.board.delete({ where: { id: board.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
46
packages/api/src/routes/admin/categories.ts
Normal file
46
packages/api/src/routes/admin/categories.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const createCategoryBody = z.object({
|
||||||
|
name: z.string().min(1).max(50),
|
||||||
|
slug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function adminCategoryRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Body: z.infer<typeof createCategoryBody> }>(
|
||||||
|
"/admin/categories",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = createCategoryBody.parse(req.body);
|
||||||
|
|
||||||
|
const existing = await prisma.category.findFirst({
|
||||||
|
where: { OR: [{ name: body.name }, { slug: body.slug }] },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
reply.status(409).send({ error: "Category already exists" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cat = await prisma.category.create({ data: body });
|
||||||
|
reply.status(201).send(cat);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/admin/categories/:id",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const cat = await prisma.category.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!cat) {
|
||||||
|
reply.status(404).send({ error: "Category not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.category.delete({ where: { id: cat.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
173
packages/api/src/routes/admin/posts.ts
Normal file
173
packages/api/src/routes/admin/posts.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient, PostStatus, Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { notifyPostSubscribers } from "../../services/push.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const statusBody = z.object({
|
||||||
|
status: z.nativeEnum(PostStatus),
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondBody = z.object({
|
||||||
|
body: z.string().min(1).max(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function adminPostRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Querystring: Record<string, string> }>(
|
||||||
|
"/admin/posts",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10)));
|
||||||
|
const status = req.query.status as PostStatus | undefined;
|
||||||
|
const boardId = req.query.boardId;
|
||||||
|
|
||||||
|
const where: Prisma.PostWhereInput = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (boardId) where.boardId = boardId;
|
||||||
|
|
||||||
|
const [posts, total] = await Promise.all([
|
||||||
|
prisma.post.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
board: { select: { slug: true, name: true } },
|
||||||
|
author: { select: { id: true, displayName: true } },
|
||||||
|
_count: { select: { comments: true, votes: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.post.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({ posts, total, page, pages: Math.ceil(total / limit) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
|
||||||
|
"/admin/posts/:id/status",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status } = statusBody.parse(req.body);
|
||||||
|
const oldStatus = post.status;
|
||||||
|
|
||||||
|
const [updated] = await Promise.all([
|
||||||
|
prisma.post.update({ where: { id: post.id }, data: { status } }),
|
||||||
|
prisma.statusChange.create({
|
||||||
|
data: {
|
||||||
|
postId: post.id,
|
||||||
|
fromStatus: oldStatus,
|
||||||
|
toStatus: status,
|
||||||
|
changedBy: req.adminId!,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.activityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: "status_changed",
|
||||||
|
boardId: post.boardId,
|
||||||
|
postId: post.id,
|
||||||
|
metadata: { from: oldStatus, to: status },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await notifyPostSubscribers(post.id, {
|
||||||
|
title: "Status updated",
|
||||||
|
body: `"${post.title}" moved to ${status}`,
|
||||||
|
url: `/post/${post.id}`,
|
||||||
|
tag: `status-${post.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>(
|
||||||
|
"/admin/posts/:id/pin",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.post.update({
|
||||||
|
where: { id: post.id },
|
||||||
|
data: { isPinned: !post.isPinned },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
|
||||||
|
"/admin/posts/:id/respond",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { body } = respondBody.parse(req.body);
|
||||||
|
|
||||||
|
const response = await prisma.adminResponse.create({
|
||||||
|
data: {
|
||||||
|
body,
|
||||||
|
postId: post.id,
|
||||||
|
adminId: req.adminId!,
|
||||||
|
},
|
||||||
|
include: { admin: { select: { id: true, email: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyPostSubscribers(post.id, {
|
||||||
|
title: "Official response",
|
||||||
|
body: body.slice(0, 100),
|
||||||
|
url: `/post/${post.id}`,
|
||||||
|
tag: `response-${post.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(201).send(response);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/admin/posts/:id",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.post.delete({ where: { id: post.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/admin/comments/:id",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!comment) {
|
||||||
|
reply.status(404).send({ error: "Comment not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.comment.delete({ where: { id: comment.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
86
packages/api/src/routes/admin/stats.ts
Normal file
86
packages/api/src/routes/admin/stats.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { config } from "../../config.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function adminStatsRoutes(app: FastifyInstance) {
|
||||||
|
app.get(
|
||||||
|
"/admin/stats",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const [
|
||||||
|
totalPosts,
|
||||||
|
totalUsers,
|
||||||
|
totalComments,
|
||||||
|
totalVotes,
|
||||||
|
postsByStatus,
|
||||||
|
postsByType,
|
||||||
|
boardStats,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.post.count(),
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.comment.count(),
|
||||||
|
prisma.vote.count(),
|
||||||
|
prisma.post.groupBy({ by: ["status"], _count: true }),
|
||||||
|
prisma.post.groupBy({ by: ["type"], _count: true }),
|
||||||
|
prisma.board.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
_count: { select: { posts: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
totals: {
|
||||||
|
posts: totalPosts,
|
||||||
|
users: totalUsers,
|
||||||
|
comments: totalComments,
|
||||||
|
votes: totalVotes,
|
||||||
|
},
|
||||||
|
postsByStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
|
||||||
|
postsByType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
|
||||||
|
boards: boardStats.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
slug: b.slug,
|
||||||
|
name: b.name,
|
||||||
|
postCount: b._count.posts,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/admin/data-retention",
|
||||||
|
{ preHandler: [app.requireAdmin] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
const activityCutoff = new Date();
|
||||||
|
activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);
|
||||||
|
|
||||||
|
const orphanCutoff = new Date();
|
||||||
|
orphanCutoff.setDate(orphanCutoff.getDate() - config.DATA_RETENTION_ORPHAN_USER_DAYS);
|
||||||
|
|
||||||
|
const [staleEvents, orphanUsers] = await Promise.all([
|
||||||
|
prisma.activityEvent.count({ where: { createdAt: { lt: activityCutoff } } }),
|
||||||
|
prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: orphanCutoff },
|
||||||
|
posts: { none: {} },
|
||||||
|
comments: { none: {} },
|
||||||
|
votes: { none: {} },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
activityRetentionDays: config.DATA_RETENTION_ACTIVITY_DAYS,
|
||||||
|
orphanRetentionDays: config.DATA_RETENTION_ORPHAN_USER_DAYS,
|
||||||
|
staleActivityEvents: staleEvents,
|
||||||
|
orphanedUsers: orphanUsers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
64
packages/api/src/routes/boards.ts
Normal file
64
packages/api/src/routes/boards.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function boardRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/boards", async (_req, reply) => {
|
||||||
|
const boards = await prisma.board.findMany({
|
||||||
|
where: { isArchived: false },
|
||||||
|
include: {
|
||||||
|
_count: { select: { posts: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = boards.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
slug: b.slug,
|
||||||
|
name: b.name,
|
||||||
|
description: b.description,
|
||||||
|
externalUrl: b.externalUrl,
|
||||||
|
voteBudget: b.voteBudget,
|
||||||
|
voteBudgetReset: b.voteBudgetReset,
|
||||||
|
allowMultiVote: b.allowMultiVote,
|
||||||
|
postCount: b._count.posts,
|
||||||
|
createdAt: b.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({
|
||||||
|
where: { slug: req.params.boardSlug },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
posts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
id: board.id,
|
||||||
|
slug: board.slug,
|
||||||
|
name: board.name,
|
||||||
|
description: board.description,
|
||||||
|
externalUrl: board.externalUrl,
|
||||||
|
isArchived: board.isArchived,
|
||||||
|
voteBudget: board.voteBudget,
|
||||||
|
voteBudgetReset: board.voteBudgetReset,
|
||||||
|
allowMultiVote: board.allowMultiVote,
|
||||||
|
postCount: board._count.posts,
|
||||||
|
createdAt: board.createdAt,
|
||||||
|
updatedAt: board.updatedAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
150
packages/api/src/routes/comments.ts
Normal file
150
packages/api/src/routes/comments.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { verifyChallenge } from "../services/altcha.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const createCommentSchema = z.object({
|
||||||
|
body: z.string().min(1).max(5000),
|
||||||
|
altcha: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCommentSchema = z.object({
|
||||||
|
body: z.string().min(1).max(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function commentRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>(
|
||||||
|
"/boards/:boardSlug/posts/:id/comments",
|
||||||
|
async (req, reply) => {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
|
||||||
|
const limit = 50;
|
||||||
|
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [comments, total] = await Promise.all([
|
||||||
|
prisma.comment.findMany({
|
||||||
|
where: { postId: post.id },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, displayName: true } },
|
||||||
|
reactions: {
|
||||||
|
select: { emoji: true, userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.comment.count({ where: { postId: post.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const grouped = comments.map((c) => {
|
||||||
|
const reactionMap: Record<string, { count: number; userIds: string[] }> = {};
|
||||||
|
for (const r of c.reactions) {
|
||||||
|
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] };
|
||||||
|
reactionMap[r.emoji].count++;
|
||||||
|
reactionMap[r.emoji].userIds.push(r.userId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
body: c.body,
|
||||||
|
author: c.author,
|
||||||
|
reactions: reactionMap,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send({ comments: grouped, total, page, pages: Math.ceil(total / limit) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof createCommentSchema> }>(
|
||||||
|
"/boards/:boardSlug/posts/:id/comments",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createCommentSchema.parse(req.body);
|
||||||
|
const valid = await verifyChallenge(body.altcha);
|
||||||
|
if (!valid) {
|
||||||
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
body: body.body,
|
||||||
|
postId: post.id,
|
||||||
|
authorId: req.user!.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, displayName: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.activityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: "comment_created",
|
||||||
|
boardId: post.boardId,
|
||||||
|
postId: post.id,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(201).send(comment);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string }; Body: z.infer<typeof updateCommentSchema> }>(
|
||||||
|
"/comments/:id",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!comment) {
|
||||||
|
reply.status(404).send({ error: "Comment not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (comment.authorId !== req.user!.id) {
|
||||||
|
reply.status(403).send({ error: "Not your comment" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = updateCommentSchema.parse(req.body);
|
||||||
|
const updated = await prisma.comment.update({
|
||||||
|
where: { id: comment.id },
|
||||||
|
data: { body: body.body },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
"/comments/:id",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!comment) {
|
||||||
|
reply.status(404).send({ error: "Comment not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (comment.authorId !== req.user!.id) {
|
||||||
|
reply.status(403).send({ error: "Not your comment" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.comment.delete({ where: { id: comment.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
69
packages/api/src/routes/feed.ts
Normal file
69
packages/api/src/routes/feed.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import RSS from "rss";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function feedRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Params: { boardSlug: string } }>(
|
||||||
|
"/boards/:boardSlug/feed.rss",
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await prisma.post.findMany({
|
||||||
|
where: { boardId: board.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feed = new RSS({
|
||||||
|
title: `${board.name} - Echoboard`,
|
||||||
|
description: board.description ?? "",
|
||||||
|
feed_url: `${req.protocol}://${req.hostname}/api/v1/boards/${board.slug}/feed.rss`,
|
||||||
|
site_url: `${req.protocol}://${req.hostname}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
feed.item({
|
||||||
|
title: post.title,
|
||||||
|
description: `[${post.type}] ${post.status} - ${post.voteCount} votes`,
|
||||||
|
url: `${req.protocol}://${req.hostname}/board/${board.slug}/post/${post.id}`,
|
||||||
|
date: post.createdAt,
|
||||||
|
categories: post.category ? [post.category] : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/feed.rss", async (req, reply) => {
|
||||||
|
const posts = await prisma.post.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
include: { board: { select: { slug: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const feed = new RSS({
|
||||||
|
title: "Echoboard - All Feedback",
|
||||||
|
feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`,
|
||||||
|
site_url: `${req.protocol}://${req.hostname}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
feed.item({
|
||||||
|
title: `[${post.board.name}] ${post.title}`,
|
||||||
|
description: `[${post.type}] ${post.status} - ${post.voteCount} votes`,
|
||||||
|
url: `${req.protocol}://${req.hostname}/board/${post.board.slug}/post/${post.id}`,
|
||||||
|
date: post.createdAt,
|
||||||
|
categories: post.category ? [post.category] : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
139
packages/api/src/routes/identity.ts
Normal file
139
packages/api/src/routes/identity.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { hashToken, encrypt, decrypt } from "../services/encryption.js";
|
||||||
|
import { masterKey } from "../config.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const updateMeSchema = z.object({
|
||||||
|
displayName: z.string().max(50).optional().nullable(),
|
||||||
|
darkMode: z.enum(["system", "light", "dark"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function identityRoutes(app: FastifyInstance) {
|
||||||
|
app.post("/identity", async (_req, reply) => {
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const hash = hashToken(token);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { tokenHash: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply
|
||||||
|
.setCookie("echoboard_token", token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "strict",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
})
|
||||||
|
.status(201)
|
||||||
|
.send({
|
||||||
|
id: user.id,
|
||||||
|
authMethod: user.authMethod,
|
||||||
|
darkMode: user.darkMode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
|
||||||
|
"/me",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = updateMeSchema.parse(req.body);
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (body.displayName !== undefined) {
|
||||||
|
data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null;
|
||||||
|
}
|
||||||
|
if (body.darkMode !== undefined) {
|
||||||
|
data.darkMode = body.darkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
id: updated.id,
|
||||||
|
displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null,
|
||||||
|
darkMode: updated.darkMode,
|
||||||
|
authMethod: updated.authMethod,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/me/posts",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const posts = await prisma.post.findMany({
|
||||||
|
where: { authorId: req.user!.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
board: { select: { slug: true, name: true } },
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(posts.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
type: p.type,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
voteCount: p.voteCount,
|
||||||
|
commentCount: p._count.comments,
|
||||||
|
board: p.board,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete(
|
||||||
|
"/me",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
await prisma.user.delete({ where: { id: req.user!.id } });
|
||||||
|
|
||||||
|
reply
|
||||||
|
.clearCookie("echoboard_token", { path: "/" })
|
||||||
|
.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/me/export",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
|
||||||
|
const [user, posts, comments, votes, reactions] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId } }),
|
||||||
|
prisma.post.findMany({ where: { authorId: userId } }),
|
||||||
|
prisma.comment.findMany({ where: { authorId: userId } }),
|
||||||
|
prisma.vote.findMany({ where: { voterId: userId } }),
|
||||||
|
prisma.reaction.findMany({ where: { userId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const decryptedUser = user ? {
|
||||||
|
id: user.id,
|
||||||
|
authMethod: user.authMethod,
|
||||||
|
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
|
||||||
|
username: user.username ? decrypt(user.username, masterKey) : null,
|
||||||
|
darkMode: user.darkMode,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
user: decryptedUser,
|
||||||
|
posts,
|
||||||
|
comments,
|
||||||
|
votes: votes.map((v) => ({ postId: v.postId, weight: v.weight, createdAt: v.createdAt })),
|
||||||
|
reactions: reactions.map((r) => ({ commentId: r.commentId, emoji: r.emoji, createdAt: r.createdAt })),
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
247
packages/api/src/routes/passkey.ts
Normal file
247
packages/api/src/routes/passkey.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import type {
|
||||||
|
RegistrationResponseJSON,
|
||||||
|
AuthenticationResponseJSON,
|
||||||
|
} from "@simplewebauthn/server";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { config, masterKey, blindIndexKey } from "../config.js";
|
||||||
|
import { encrypt, decrypt, blindIndex } from "../services/encryption.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const challenges = new Map<string, { challenge: string; expires: number }>();
|
||||||
|
|
||||||
|
function storeChallenge(userId: string, challenge: string) {
|
||||||
|
challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChallenge(userId: string): string | null {
|
||||||
|
const entry = challenges.get(userId);
|
||||||
|
if (!entry || entry.expires < Date.now()) {
|
||||||
|
challenges.delete(userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
challenges.delete(userId);
|
||||||
|
return entry.challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanExpiredChallenges() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, val] of challenges) {
|
||||||
|
if (val.expires < now) challenges.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerBody = z.object({
|
||||||
|
username: z.string().min(3).max(30),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function passkeyRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Body: z.infer<typeof registerBody> }>(
|
||||||
|
"/auth/passkey/register/options",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const { username } = registerBody.parse(req.body);
|
||||||
|
const user = req.user!;
|
||||||
|
|
||||||
|
const usernameHash = blindIndex(username, blindIndexKey);
|
||||||
|
const existing = await prisma.user.findUnique({ where: { usernameIdx: usernameHash } });
|
||||||
|
if (existing && existing.id !== user.id) {
|
||||||
|
reply.status(409).send({ error: "Username taken" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPasskeys = await prisma.passkey.findMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: config.WEBAUTHN_RP_NAME,
|
||||||
|
rpID: config.WEBAUTHN_RP_ID,
|
||||||
|
userID: new TextEncoder().encode(user.id),
|
||||||
|
userName: username,
|
||||||
|
attestationType: "none",
|
||||||
|
excludeCredentials: existingPasskeys.map((pk) => ({
|
||||||
|
id: decrypt(pk.credentialId, masterKey),
|
||||||
|
})),
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: "preferred",
|
||||||
|
userVerification: "preferred",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
storeChallenge(user.id, options.challenge);
|
||||||
|
reply.send(options);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>(
|
||||||
|
"/auth/passkey/register/verify",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const user = req.user!;
|
||||||
|
const { response, username } = req.body;
|
||||||
|
|
||||||
|
const expectedChallenge = getChallenge(user.id);
|
||||||
|
if (!expectedChallenge) {
|
||||||
|
reply.status(400).send({ error: "Challenge expired" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: config.WEBAUTHN_ORIGIN,
|
||||||
|
expectedRPID: config.WEBAUTHN_RP_ID,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
reply.status(400).send({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
reply.status(400).send({ error: "Verification failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||||
|
|
||||||
|
const credIdStr = Buffer.from(credential.id).toString("base64url");
|
||||||
|
|
||||||
|
await prisma.passkey.create({
|
||||||
|
data: {
|
||||||
|
credentialId: encrypt(credIdStr, masterKey),
|
||||||
|
credentialIdIdx: blindIndex(credIdStr, blindIndexKey),
|
||||||
|
credentialPublicKey: Buffer.from(credential.publicKey),
|
||||||
|
counter: BigInt(credential.counter),
|
||||||
|
credentialDeviceType,
|
||||||
|
credentialBackedUp,
|
||||||
|
transports: credential.transports ? encrypt(JSON.stringify(credential.transports), masterKey) : null,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const usernameHash = blindIndex(username, blindIndexKey);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
authMethod: "PASSKEY",
|
||||||
|
username: encrypt(username, masterKey),
|
||||||
|
usernameIdx: usernameHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send({ verified: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/auth/passkey/login/options",
|
||||||
|
async (_req, reply) => {
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: config.WEBAUTHN_RP_ID,
|
||||||
|
userVerification: "preferred",
|
||||||
|
});
|
||||||
|
|
||||||
|
storeChallenge("login:" + options.challenge, options.challenge);
|
||||||
|
reply.send(options);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Body: { response: AuthenticationResponseJSON } }>(
|
||||||
|
"/auth/passkey/login/verify",
|
||||||
|
async (req, reply) => {
|
||||||
|
const { response } = req.body;
|
||||||
|
|
||||||
|
const credIdStr = response.id;
|
||||||
|
const credIdx = blindIndex(credIdStr, blindIndexKey);
|
||||||
|
|
||||||
|
const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } });
|
||||||
|
if (!passkey) {
|
||||||
|
reply.status(400).send({ error: "Passkey not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedChallenge = getChallenge("login:" + response.response.clientDataJSON);
|
||||||
|
// we stored with the challenge value, try to find it
|
||||||
|
let challenge: string | null = null;
|
||||||
|
for (const [key, val] of challenges) {
|
||||||
|
if (key.startsWith("login:") && val.expires > Date.now()) {
|
||||||
|
challenge = val.challenge;
|
||||||
|
challenges.delete(key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!challenge) {
|
||||||
|
reply.status(400).send({ error: "Challenge expired" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
|
response,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: config.WEBAUTHN_ORIGIN,
|
||||||
|
expectedRPID: config.WEBAUTHN_RP_ID,
|
||||||
|
credential: {
|
||||||
|
id: decrypt(passkey.credentialId, masterKey),
|
||||||
|
publicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||||
|
counter: Number(passkey.counter),
|
||||||
|
transports: passkey.transports
|
||||||
|
? JSON.parse(decrypt(passkey.transports, masterKey))
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
reply.status(400).send({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified) {
|
||||||
|
reply.status(400).send({ error: "Verification failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.passkey.update({
|
||||||
|
where: { id: passkey.id },
|
||||||
|
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ sub: passkey.userId, type: "passkey" },
|
||||||
|
config.JWT_SECRET,
|
||||||
|
{ expiresIn: "30d" }
|
||||||
|
);
|
||||||
|
|
||||||
|
reply.send({ verified: true, token });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/auth/passkey/logout",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (_req, reply) => {
|
||||||
|
reply
|
||||||
|
.clearCookie("echoboard_token", { path: "/" })
|
||||||
|
.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { name: string } }>(
|
||||||
|
"/auth/passkey/check-username/:name",
|
||||||
|
async (req, reply) => {
|
||||||
|
const hash = blindIndex(req.params.name, blindIndexKey);
|
||||||
|
const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } });
|
||||||
|
reply.send({ available: !existing });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
216
packages/api/src/routes/posts.ts
Normal file
216
packages/api/src/routes/posts.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { verifyChallenge } from "../services/altcha.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const createPostSchema = z.object({
|
||||||
|
type: z.nativeEnum(PostType),
|
||||||
|
title: z.string().min(3).max(200),
|
||||||
|
description: z.any(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
altcha: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePostSchema = z.object({
|
||||||
|
title: z.string().min(3).max(200).optional(),
|
||||||
|
description: z.any().optional(),
|
||||||
|
category: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
type: z.nativeEnum(PostType).optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
status: z.nativeEnum(PostStatus).optional(),
|
||||||
|
sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().min(1).default(1),
|
||||||
|
limit: z.coerce.number().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function postRoutes(app: FastifyInstance) {
|
||||||
|
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
|
||||||
|
"/boards/:boardSlug/posts",
|
||||||
|
{ preHandler: [app.optionalUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = querySchema.parse(req.query);
|
||||||
|
|
||||||
|
const where: Prisma.PostWhereInput = { boardId: board.id };
|
||||||
|
if (q.type) where.type = q.type;
|
||||||
|
if (q.category) where.category = q.category;
|
||||||
|
if (q.status) where.status = q.status;
|
||||||
|
if (q.search) where.title = { contains: q.search, mode: "insensitive" };
|
||||||
|
|
||||||
|
let orderBy: Prisma.PostOrderByWithRelationInput;
|
||||||
|
switch (q.sort) {
|
||||||
|
case "oldest": orderBy = { createdAt: "asc" }; break;
|
||||||
|
case "top": orderBy = { voteCount: "desc" }; break;
|
||||||
|
case "trending": orderBy = { voteCount: "desc" }; break;
|
||||||
|
default: orderBy = { createdAt: "desc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [posts, total] = await Promise.all([
|
||||||
|
prisma.post.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ isPinned: "desc" }, orderBy],
|
||||||
|
skip: (q.page - 1) * q.limit,
|
||||||
|
take: q.limit,
|
||||||
|
include: {
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
author: { select: { id: true, displayName: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.post.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
posts: posts.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
type: p.type,
|
||||||
|
title: p.title,
|
||||||
|
status: p.status,
|
||||||
|
category: p.category,
|
||||||
|
voteCount: p.voteCount,
|
||||||
|
isPinned: p.isPinned,
|
||||||
|
commentCount: p._count.comments,
|
||||||
|
author: p.author,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page: q.page,
|
||||||
|
pages: Math.ceil(total / q.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||||
|
"/boards/:boardSlug/posts/:id",
|
||||||
|
{ preHandler: [app.optionalUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, displayName: true } },
|
||||||
|
_count: { select: { comments: true, votes: true } },
|
||||||
|
adminResponses: {
|
||||||
|
include: { admin: { select: { id: true, email: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
statusChanges: { orderBy: { createdAt: "asc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let voted = false;
|
||||||
|
if (req.user) {
|
||||||
|
const existing = await prisma.vote.findUnique({
|
||||||
|
where: { postId_voterId: { postId: post.id, voterId: req.user.id } },
|
||||||
|
});
|
||||||
|
voted = !!existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({ ...post, voted });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
|
||||||
|
"/boards/:boardSlug/posts",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
|
if (!board || board.isArchived) {
|
||||||
|
reply.status(404).send({ error: "Board not found or archived" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createPostSchema.parse(req.body);
|
||||||
|
|
||||||
|
const valid = await verifyChallenge(body.altcha);
|
||||||
|
if (!valid) {
|
||||||
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await prisma.post.create({
|
||||||
|
data: {
|
||||||
|
type: body.type,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
category: body.category,
|
||||||
|
boardId: board.id,
|
||||||
|
authorId: req.user!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.activityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: "post_created",
|
||||||
|
boardId: board.id,
|
||||||
|
postId: post.id,
|
||||||
|
metadata: { title: post.title, type: post.type },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(201).send(post);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>(
|
||||||
|
"/boards/:boardSlug/posts/:id",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (post.authorId !== req.user!.id) {
|
||||||
|
reply.status(403).send({ error: "Not your post" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = updatePostSchema.parse(req.body);
|
||||||
|
const updated = await prisma.post.update({
|
||||||
|
where: { id: post.id },
|
||||||
|
data: {
|
||||||
|
...(body.title !== undefined && { title: body.title }),
|
||||||
|
...(body.description !== undefined && { description: body.description }),
|
||||||
|
...(body.category !== undefined && { category: body.category }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { boardSlug: string; id: string } }>(
|
||||||
|
"/boards/:boardSlug/posts/:id",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (post.authorId !== req.user!.id) {
|
||||||
|
reply.status(403).send({ error: "Not your post" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.post.delete({ where: { id: post.id } });
|
||||||
|
reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
50
packages/api/src/routes/privacy.ts
Normal file
50
packages/api/src/routes/privacy.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { generateChallenge } from "../services/altcha.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function privacyRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/altcha/challenge", async (req, reply) => {
|
||||||
|
const difficulty = req.query && (req.query as any).difficulty === "light" ? "light" : "normal";
|
||||||
|
const challenge = await generateChallenge(difficulty as "normal" | "light");
|
||||||
|
reply.send(challenge);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/privacy/data-manifest", async (_req, reply) => {
|
||||||
|
reply.send({
|
||||||
|
dataCollected: {
|
||||||
|
anonymous: {
|
||||||
|
cookieToken: "SHA-256 hashed, used for session identity",
|
||||||
|
displayName: "AES-256-GCM encrypted, optional",
|
||||||
|
posts: "Stored with author reference, deletable",
|
||||||
|
comments: "Stored with author reference, deletable",
|
||||||
|
votes: "Stored with voter reference, deletable",
|
||||||
|
reactions: "Stored with user reference, deletable",
|
||||||
|
},
|
||||||
|
passkey: {
|
||||||
|
username: "AES-256-GCM encrypted with blind index",
|
||||||
|
credentialId: "AES-256-GCM encrypted with blind index",
|
||||||
|
publicKey: "Encrypted at rest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retention: {
|
||||||
|
activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`,
|
||||||
|
orphanedUsers: `${config.DATA_RETENTION_ORPHAN_USER_DAYS} days`,
|
||||||
|
},
|
||||||
|
encryption: "AES-256-GCM with 96-bit random IV per value",
|
||||||
|
indexing: "HMAC-SHA256 blind indexes for lookups",
|
||||||
|
thirdParty: "None - fully self-hosted",
|
||||||
|
export: "GET /api/v1/me/export",
|
||||||
|
deletion: "DELETE /api/v1/me",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/categories", async (_req, reply) => {
|
||||||
|
const cats = await prisma.category.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
reply.send(cats);
|
||||||
|
});
|
||||||
|
}
|
||||||
91
packages/api/src/routes/push.ts
Normal file
91
packages/api/src/routes/push.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { encrypt, blindIndex } from "../services/encryption.js";
|
||||||
|
import { masterKey, blindIndexKey } from "../config.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const subscribeBody = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
keys: z.object({
|
||||||
|
p256dh: z.string(),
|
||||||
|
auth: z.string(),
|
||||||
|
}),
|
||||||
|
boardId: z.string().optional(),
|
||||||
|
postId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeBody = z.object({
|
||||||
|
endpoint: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function pushRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Body: z.infer<typeof subscribeBody> }>(
|
||||||
|
"/push/subscribe",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = subscribeBody.parse(req.body);
|
||||||
|
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
|
||||||
|
|
||||||
|
const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
|
||||||
|
if (existing) {
|
||||||
|
await prisma.pushSubscription.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
boardId: body.boardId ?? null,
|
||||||
|
postId: body.postId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
reply.send({ ok: true, updated: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.pushSubscription.create({
|
||||||
|
data: {
|
||||||
|
endpoint: encrypt(body.endpoint, masterKey),
|
||||||
|
endpointIdx,
|
||||||
|
keysP256dh: encrypt(body.keys.p256dh, masterKey),
|
||||||
|
keysAuth: encrypt(body.keys.auth, masterKey),
|
||||||
|
userId: req.user!.id,
|
||||||
|
boardId: body.boardId,
|
||||||
|
postId: body.postId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(201).send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Body: z.infer<typeof unsubscribeBody> }>(
|
||||||
|
"/push/subscribe",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const body = unsubscribeBody.parse(req.body);
|
||||||
|
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
|
||||||
|
|
||||||
|
const deleted = await prisma.pushSubscription.deleteMany({
|
||||||
|
where: { endpointIdx, userId: req.user!.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted.count === 0) {
|
||||||
|
reply.status(404).send({ error: "Subscription not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/push/subscriptions",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const subs = await prisma.pushSubscription.findMany({
|
||||||
|
where: { userId: req.user!.id },
|
||||||
|
select: { id: true, boardId: true, postId: true, createdAt: true },
|
||||||
|
});
|
||||||
|
reply.send(subs);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
70
packages/api/src/routes/reactions.ts
Normal file
70
packages/api/src/routes/reactions.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const reactionBody = z.object({
|
||||||
|
emoji: z.string().min(1).max(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function reactionRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
|
||||||
|
"/comments/:id/reactions",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!comment) {
|
||||||
|
reply.status(404).send({ error: "Comment not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { emoji } = reactionBody.parse(req.body);
|
||||||
|
|
||||||
|
const existing = await prisma.reaction.findUnique({
|
||||||
|
where: {
|
||||||
|
commentId_userId_emoji: {
|
||||||
|
commentId: comment.id,
|
||||||
|
userId: req.user!.id,
|
||||||
|
emoji,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.reaction.delete({ where: { id: existing.id } });
|
||||||
|
reply.send({ toggled: false });
|
||||||
|
} else {
|
||||||
|
await prisma.reaction.create({
|
||||||
|
data: {
|
||||||
|
emoji,
|
||||||
|
commentId: comment.id,
|
||||||
|
userId: req.user!.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
reply.send({ toggled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; emoji: string } }>(
|
||||||
|
"/comments/:id/reactions/:emoji",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const deleted = await prisma.reaction.deleteMany({
|
||||||
|
where: {
|
||||||
|
commentId: req.params.id,
|
||||||
|
userId: req.user!.id,
|
||||||
|
emoji: req.params.emoji,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleted.count === 0) {
|
||||||
|
reply.status(404).send({ error: "Reaction not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
138
packages/api/src/routes/votes.ts
Normal file
138
packages/api/src/routes/votes.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { verifyChallenge } from "../services/altcha.js";
|
||||||
|
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const voteBody = z.object({
|
||||||
|
altcha: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default async function voteRoutes(app: FastifyInstance) {
|
||||||
|
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof voteBody> }>(
|
||||||
|
"/boards/:boardSlug/posts/:id/vote",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!post || post.boardId !== board.id) {
|
||||||
|
reply.status(404).send({ error: "Post not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = voteBody.parse(req.body);
|
||||||
|
const valid = await verifyChallenge(body.altcha);
|
||||||
|
if (!valid) {
|
||||||
|
reply.status(400).send({ error: "Invalid challenge response" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.vote.findUnique({
|
||||||
|
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing && !board.allowMultiVote) {
|
||||||
|
reply.status(409).send({ error: "Already voted" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await getRemainingBudget(req.user!.id, board.id);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
reply.status(429).send({ error: "Vote budget exhausted" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||||
|
|
||||||
|
if (existing && board.allowMultiVote) {
|
||||||
|
await prisma.vote.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { weight: existing.weight + 1 },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.vote.create({
|
||||||
|
data: {
|
||||||
|
postId: post.id,
|
||||||
|
voterId: req.user!.id,
|
||||||
|
budgetPeriod: period,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.post.update({
|
||||||
|
where: { id: post.id },
|
||||||
|
data: { voteCount: { increment: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.activityEvent.create({
|
||||||
|
data: {
|
||||||
|
type: "vote_cast",
|
||||||
|
boardId: board.id,
|
||||||
|
postId: post.id,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send({ ok: true, voteCount: post.voteCount + 1 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { boardSlug: string; id: string } }>(
|
||||||
|
"/boards/:boardSlug/posts/:id/vote",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vote = await prisma.vote.findUnique({
|
||||||
|
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!vote) {
|
||||||
|
reply.status(404).send({ error: "No vote found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weight = vote.weight;
|
||||||
|
await prisma.vote.delete({ where: { id: vote.id } });
|
||||||
|
await prisma.post.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { voteCount: { decrement: weight } },
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.send({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { boardSlug: string } }>(
|
||||||
|
"/boards/:boardSlug/budget",
|
||||||
|
{ preHandler: [app.requireUser] },
|
||||||
|
async (req, reply) => {
|
||||||
|
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||||
|
if (!board) {
|
||||||
|
reply.status(404).send({ error: "Board not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await getRemainingBudget(req.user!.id, board.id);
|
||||||
|
const nextReset = getNextResetDate(board.voteBudgetReset);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
total: board.voteBudget,
|
||||||
|
remaining,
|
||||||
|
resetSchedule: board.voteBudgetReset,
|
||||||
|
nextReset: nextReset.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
93
packages/api/src/server.ts
Normal file
93
packages/api/src/server.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import cors from "@fastify/cors";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
import securityPlugin from "./middleware/security.js";
|
||||||
|
import authPlugin from "./middleware/auth.js";
|
||||||
|
import { loadPlugins } from "./plugins/loader.js";
|
||||||
|
|
||||||
|
import boardRoutes from "./routes/boards.js";
|
||||||
|
import postRoutes from "./routes/posts.js";
|
||||||
|
import voteRoutes from "./routes/votes.js";
|
||||||
|
import commentRoutes from "./routes/comments.js";
|
||||||
|
import reactionRoutes from "./routes/reactions.js";
|
||||||
|
import identityRoutes from "./routes/identity.js";
|
||||||
|
import passkeyRoutes from "./routes/passkey.js";
|
||||||
|
import feedRoutes from "./routes/feed.js";
|
||||||
|
import activityRoutes from "./routes/activity.js";
|
||||||
|
import pushRoutes from "./routes/push.js";
|
||||||
|
import privacyRoutes from "./routes/privacy.js";
|
||||||
|
import adminAuthRoutes from "./routes/admin/auth.js";
|
||||||
|
import adminPostRoutes from "./routes/admin/posts.js";
|
||||||
|
import adminBoardRoutes from "./routes/admin/boards.js";
|
||||||
|
import adminCategoryRoutes from "./routes/admin/categories.js";
|
||||||
|
import adminStatsRoutes from "./routes/admin/stats.js";
|
||||||
|
|
||||||
|
export async function createServer() {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
serializers: {
|
||||||
|
req(req) {
|
||||||
|
return {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cookie, { secret: process.env.TOKEN_SECRET });
|
||||||
|
await app.register(cors, {
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
max: 100,
|
||||||
|
timeWindow: "1 minute",
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(securityPlugin);
|
||||||
|
await app.register(authPlugin);
|
||||||
|
|
||||||
|
// api routes under /api/v1
|
||||||
|
await app.register(async (api) => {
|
||||||
|
await api.register(boardRoutes);
|
||||||
|
await api.register(postRoutes);
|
||||||
|
await api.register(voteRoutes);
|
||||||
|
await api.register(commentRoutes);
|
||||||
|
await api.register(reactionRoutes);
|
||||||
|
await api.register(identityRoutes);
|
||||||
|
await api.register(passkeyRoutes);
|
||||||
|
await api.register(feedRoutes);
|
||||||
|
await api.register(activityRoutes);
|
||||||
|
await api.register(pushRoutes);
|
||||||
|
await api.register(privacyRoutes);
|
||||||
|
await api.register(adminAuthRoutes);
|
||||||
|
await api.register(adminPostRoutes);
|
||||||
|
await api.register(adminBoardRoutes);
|
||||||
|
await api.register(adminCategoryRoutes);
|
||||||
|
await api.register(adminStatsRoutes);
|
||||||
|
}, { prefix: "/api/v1" });
|
||||||
|
|
||||||
|
// serve static frontend build in production
|
||||||
|
const webDist = resolve(process.cwd(), "../web/dist");
|
||||||
|
if (process.env.NODE_ENV === "production" && existsSync(webDist)) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: webDist,
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setNotFoundHandler((_req, reply) => {
|
||||||
|
reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPlugins(app);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
21
packages/api/src/services/altcha.ts
Normal file
21
packages/api/src/services/altcha.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createChallenge, verifySolution } from "altcha-lib";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
|
||||||
|
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
|
||||||
|
const challenge = await createChallenge({
|
||||||
|
hmacKey: config.ALTCHA_HMAC_KEY,
|
||||||
|
maxNumber,
|
||||||
|
expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000),
|
||||||
|
});
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyChallenge(payload: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/api/src/services/encryption.ts
Normal file
30
packages/api/src/services/encryption.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHmac, createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const IV_LEN = 12;
|
||||||
|
const TAG_LEN = 16;
|
||||||
|
|
||||||
|
export function encrypt(plaintext: string, key: Buffer): string {
|
||||||
|
const iv = randomBytes(IV_LEN);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, encrypted, tag]).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(encoded: string, key: Buffer): string {
|
||||||
|
const buf = Buffer.from(encoded, "base64");
|
||||||
|
const iv = buf.subarray(0, IV_LEN);
|
||||||
|
const tag = buf.subarray(buf.length - TAG_LEN);
|
||||||
|
const ciphertext = buf.subarray(IV_LEN, buf.length - TAG_LEN);
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return decipher.update(ciphertext) + decipher.final("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blindIndex(value: string, key: Buffer): string {
|
||||||
|
return createHmac("sha256", key).update(value.toLowerCase()).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
71
packages/api/src/services/push.ts
Normal file
71
packages/api/src/services/push.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { decrypt } from "./encryption.js";
|
||||||
|
import { masterKey } from "../config.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
config.VAPID_CONTACT,
|
||||||
|
config.VAPID_PUBLIC_KEY,
|
||||||
|
config.VAPID_PRIVATE_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
url?: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: decrypt(sub.endpoint, masterKey),
|
||||||
|
keys: {
|
||||||
|
p256dh: decrypt(sub.keysP256dh, masterKey),
|
||||||
|
auth: decrypt(sub.keysAuth, masterKey),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
JSON.stringify(payload)
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.statusCode === 404 || err.statusCode === 410) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyPostSubscribers(postId: string, event: PushPayload) {
|
||||||
|
const subs = await prisma.pushSubscription.findMany({ where: { postId } });
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
for (const sub of subs) {
|
||||||
|
const ok = await sendNotification(sub, event);
|
||||||
|
if (!ok) failed.push(sub.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyBoardSubscribers(boardId: string, event: PushPayload) {
|
||||||
|
const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } });
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
for (const sub of subs) {
|
||||||
|
const ok = await sendNotification(sub, event);
|
||||||
|
if (!ok) failed.push(sub.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
12
packages/web/index.html
Normal file
12
packages/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Echoboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
packages/web/package.json
Normal file
28
packages/web/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@echoboard/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/space-grotesk": "^5.0.0",
|
||||||
|
"@fontsource/sora": "^5.0.0",
|
||||||
|
"@simplewebauthn/browser": "^11.0.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/web/src/App.tsx
Normal file
77
packages/web/src/App.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuthState } from './hooks/useAuth'
|
||||||
|
import { ThemeProvider, useThemeState } from './hooks/useTheme'
|
||||||
|
import Sidebar from './components/Sidebar'
|
||||||
|
import MobileNav from './components/MobileNav'
|
||||||
|
import ThemeToggle from './components/ThemeToggle'
|
||||||
|
import IdentityBanner from './components/IdentityBanner'
|
||||||
|
import CommandPalette from './components/CommandPalette'
|
||||||
|
import PasskeyModal from './components/PasskeyModal'
|
||||||
|
import BoardIndex from './pages/BoardIndex'
|
||||||
|
import BoardFeed from './pages/BoardFeed'
|
||||||
|
import PostDetail from './pages/PostDetail'
|
||||||
|
import ActivityFeed from './pages/ActivityFeed'
|
||||||
|
import IdentitySettings from './pages/IdentitySettings'
|
||||||
|
import MySubmissions from './pages/MySubmissions'
|
||||||
|
import PrivacyPage from './pages/PrivacyPage'
|
||||||
|
import AdminLogin from './pages/admin/AdminLogin'
|
||||||
|
import AdminDashboard from './pages/admin/AdminDashboard'
|
||||||
|
import AdminPosts from './pages/admin/AdminPosts'
|
||||||
|
import AdminBoards from './pages/admin/AdminBoards'
|
||||||
|
|
||||||
|
function Layout() {
|
||||||
|
const location = useLocation()
|
||||||
|
const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null)
|
||||||
|
const isAdmin = location.pathname.startsWith('/admin')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CommandPalette />
|
||||||
|
<div className="flex min-h-screen" style={{ background: 'var(--bg)' }}>
|
||||||
|
{!isAdmin && <Sidebar />}
|
||||||
|
<main className="flex-1 pb-20 md:pb-0">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<BoardIndex />} />
|
||||||
|
<Route path="/b/:boardSlug" element={<BoardFeed />} />
|
||||||
|
<Route path="/b/:boardSlug/post/:postId" element={<PostDetail />} />
|
||||||
|
<Route path="/b/:boardSlug/new" element={<BoardFeed />} />
|
||||||
|
<Route path="/activity" element={<ActivityFeed />} />
|
||||||
|
<Route path="/settings" element={<IdentitySettings />} />
|
||||||
|
<Route path="/my-posts" element={<MySubmissions />} />
|
||||||
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
|
<Route path="/admin" element={<AdminDashboard />} />
|
||||||
|
<Route path="/admin/posts" element={<AdminPosts />} />
|
||||||
|
<Route path="/admin/boards" element={<AdminBoards />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{!isAdmin && <MobileNav />}
|
||||||
|
<ThemeToggle />
|
||||||
|
{!isAdmin && (
|
||||||
|
<IdentityBanner onRegister={() => setPasskeyMode('register')} />
|
||||||
|
)}
|
||||||
|
<PasskeyModal
|
||||||
|
mode={passkeyMode || 'register'}
|
||||||
|
open={passkeyMode !== null}
|
||||||
|
onClose={() => setPasskeyMode(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const auth = useAuthState()
|
||||||
|
const theme = useThemeState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider value={theme}>
|
||||||
|
<AuthProvider value={auth}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Layout />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
packages/web/src/app.css
Normal file
161
packages/web/src/app.css
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--bg: #141420;
|
||||||
|
--surface: #1c1c2e;
|
||||||
|
--surface-hover: #24243a;
|
||||||
|
--border: rgba(245, 240, 235, 0.08);
|
||||||
|
--border-hover: rgba(245, 240, 235, 0.15);
|
||||||
|
--text: #f5f0eb;
|
||||||
|
--text-secondary: rgba(245, 240, 235, 0.6);
|
||||||
|
--text-tertiary: rgba(245, 240, 235, 0.35);
|
||||||
|
--accent: #F59E0B;
|
||||||
|
--accent-hover: #D97706;
|
||||||
|
--accent-subtle: rgba(245, 158, 11, 0.15);
|
||||||
|
--admin-accent: #06B6D4;
|
||||||
|
--admin-subtle: rgba(6, 182, 212, 0.15);
|
||||||
|
--success: #22C55E;
|
||||||
|
--warning: #EAB308;
|
||||||
|
--error: #EF4444;
|
||||||
|
--info: #3B82F6;
|
||||||
|
--font-heading: 'Space Grotesk', system-ui, sans-serif;
|
||||||
|
--font-body: 'Sora', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
--bg: #faf9f6;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: #f0eeea;
|
||||||
|
--border: rgba(20, 20, 32, 0.08);
|
||||||
|
--border-hover: rgba(20, 20, 32, 0.15);
|
||||||
|
--text: #1a1a2e;
|
||||||
|
--text-secondary: rgba(26, 26, 46, 0.6);
|
||||||
|
--text-tertiary: rgba(26, 26, 46, 0.35);
|
||||||
|
--accent: #D97706;
|
||||||
|
--accent-hover: #B45309;
|
||||||
|
--accent-subtle: rgba(217, 119, 6, 0.15);
|
||||||
|
--admin-accent: #0891B2;
|
||||||
|
--admin-subtle: rgba(8, 145, 178, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background 200ms ease-out, color 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 200ms ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #141420;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-admin {
|
||||||
|
background: var(--admin-accent);
|
||||||
|
color: #141420;
|
||||||
|
}
|
||||||
|
.btn-admin:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: border-color 200ms ease-out, box-shadow 200ms ease-out;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: border-color 200ms ease-out;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(16px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
}
|
||||||
227
packages/web/src/components/CommandPalette.tsx
Normal file
227
packages/web/src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'post' | 'board'
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
slug?: string
|
||||||
|
boardSlug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandPalette() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const nav = useNavigate()
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpen((v) => {
|
||||||
|
if (!v) {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
setSelected(0)
|
||||||
|
}
|
||||||
|
return !v
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault()
|
||||||
|
toggle()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [open, toggle])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
|
||||||
|
setResults(res)
|
||||||
|
setSelected(0)
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const navigate = (r: SearchResult) => {
|
||||||
|
if (r.type === 'board') {
|
||||||
|
nav(`/b/${r.slug}`)
|
||||||
|
} else {
|
||||||
|
nav(`/b/${r.boardSlug}/post/${r.id}`)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelected((s) => Math.min(s + 1, results.length - 1))
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelected((s) => Math.max(s - 1, 0))
|
||||||
|
} else if (e.key === 'Enter' && results[selected]) {
|
||||||
|
navigate(results[selected])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const boards = results.filter((r) => r.type === 'board')
|
||||||
|
const posts = results.filter((r) => r.type === 'post')
|
||||||
|
let idx = -1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 fade-in"
|
||||||
|
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-lg mx-4 rounded-xl overflow-hidden shadow-2xl slide-up"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Search posts and boards..."
|
||||||
|
className="flex-1 bg-transparent outline-none text-sm"
|
||||||
|
style={{ color: 'var(--text)', fontFamily: 'var(--font-body)' }}
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||||
|
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{loading && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && query && results.length === 0 && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
No results for "{query}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !query && (
|
||||||
|
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Start typing to search...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{boards.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Boards
|
||||||
|
</div>
|
||||||
|
{boards.map((r) => {
|
||||||
|
idx++
|
||||||
|
const i = idx
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => navigate(r)}
|
||||||
|
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm"
|
||||||
|
style={{
|
||||||
|
background: selected === i ? 'var(--surface-hover)' : 'transparent',
|
||||||
|
color: 'var(--text)',
|
||||||
|
transition: 'background 100ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)', flexShrink: 0 }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
{r.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{posts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Posts
|
||||||
|
</div>
|
||||||
|
{posts.map((r) => {
|
||||||
|
idx++
|
||||||
|
const i = idx
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => navigate(r)}
|
||||||
|
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm"
|
||||||
|
style={{
|
||||||
|
background: selected === i ? 'var(--surface-hover)' : 'transparent',
|
||||||
|
color: 'var(--text)',
|
||||||
|
transition: 'background 100ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
{r.title}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 flex items-center gap-4 text-[10px] border-t"
|
||||||
|
style={{ borderColor: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>↑↓</kbd> navigate</span>
|
||||||
|
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Enter</kbd> open</span>
|
||||||
|
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Esc</kbd> close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
packages/web/src/components/EmptyState.tsx
Normal file
75
packages/web/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
actionLabel?: string
|
||||||
|
onAction?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({
|
||||||
|
title = 'Nothing here yet',
|
||||||
|
message = 'Be the first to share feedback',
|
||||||
|
actionLabel = 'Create a post',
|
||||||
|
onAction,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 fade-in">
|
||||||
|
{/* Megaphone SVG */}
|
||||||
|
<svg
|
||||||
|
width="120"
|
||||||
|
height="120"
|
||||||
|
viewBox="0 0 120 120"
|
||||||
|
fill="none"
|
||||||
|
className="mb-6"
|
||||||
|
>
|
||||||
|
<circle cx="60" cy="60" r="55" stroke="var(--text-tertiary)" strokeWidth="1" strokeDasharray="4 4" />
|
||||||
|
<path
|
||||||
|
d="M75 35L45 50H35a5 5 0 00-5 5v10a5 5 0 005 5h10l30 15V35z"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M85 48a10 10 0 010 24"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.6"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M92 40a20 20 0 010 40"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="none"
|
||||||
|
opacity="0.3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M42 70v10a5 5 0 005 5h5a5 5 0 005-5v-7"
|
||||||
|
stroke="var(--text-tertiary)"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className="text-lg font-semibold mb-2"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-6" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{onAction && (
|
||||||
|
<button onClick={onAction} className="btn btn-primary">
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
packages/web/src/components/IdentityBanner.tsx
Normal file
74
packages/web/src/components/IdentityBanner.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'echoboard-identity-ack'
|
||||||
|
|
||||||
|
export default function IdentityBanner({ onRegister }: { onRegister: () => void }) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localStorage.getItem(DISMISSED_KEY)) {
|
||||||
|
const t = setTimeout(() => setVisible(true), 800)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
localStorage.setItem(DISMISSED_KEY, '1')
|
||||||
|
setVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-50 md:left-[280px] slide-up"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-4 mb-4 p-5 rounded-xl shadow-2xl md:max-w-lg md:mx-auto"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
|
||||||
|
style={{ background: 'var(--accent-subtle)' }}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)' }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3
|
||||||
|
className="text-base font-semibold mb-1"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Your identity is cookie-based
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
You can post and vote right now - no signup needed. A cookie links your activity. Register a passkey to keep access across devices and browsers.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button onClick={dismiss} className="btn btn-primary text-sm">
|
||||||
|
Continue anonymously
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { dismiss(); onRegister() }}
|
||||||
|
className="btn btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
Register with passkey
|
||||||
|
</button>
|
||||||
|
<Link to="/privacy" onClick={dismiss} className="btn btn-ghost text-sm">
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
packages/web/src/components/MobileNav.tsx
Normal file
97
packages/web/src/components/MobileNav.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
label: 'Home',
|
||||||
|
icon: (
|
||||||
|
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
label: 'Search',
|
||||||
|
icon: (
|
||||||
|
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/new',
|
||||||
|
label: 'New',
|
||||||
|
icon: (
|
||||||
|
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/activity',
|
||||||
|
label: 'Activity',
|
||||||
|
icon: (
|
||||||
|
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
label: 'Profile',
|
||||||
|
icon: (
|
||||||
|
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MobileNav() {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around py-2 border-t z-50"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
paddingBottom: 'max(0.5rem, env(safe-area-inset-bottom))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = location.pathname === tab.path ||
|
||||||
|
(tab.path === '/' && location.pathname === '/')
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.path}
|
||||||
|
to={tab.path}
|
||||||
|
className="flex flex-col items-center gap-0.5 px-3 py-1"
|
||||||
|
style={{ transition: 'color 200ms ease-out' }}
|
||||||
|
>
|
||||||
|
{tab.accent ? (
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center -mt-4"
|
||||||
|
style={{ background: 'var(--accent)', color: '#141420' }}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
||||||
|
{tab.icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
packages/web/src/components/PasskeyModal.tsx
Normal file
186
packages/web/src/components/PasskeyModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: 'register' | 'login'
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||||
|
const auth = useAuth()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [available, setAvailable] = useState<boolean | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const checkTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setUsername('')
|
||||||
|
setAvailable(null)
|
||||||
|
setError('')
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'register' || !username.trim() || username.length < 3) {
|
||||||
|
setAvailable(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearTimeout(checkTimer.current)
|
||||||
|
checkTimer.current = setTimeout(async () => {
|
||||||
|
setChecking(true)
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ available: boolean }>(`/identity/check-username?name=${encodeURIComponent(username)}`)
|
||||||
|
setAvailable(res.available)
|
||||||
|
} catch {
|
||||||
|
setAvailable(null)
|
||||||
|
} finally {
|
||||||
|
setChecking(false)
|
||||||
|
}
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(checkTimer.current)
|
||||||
|
}, [username, mode])
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!username.trim()) {
|
||||||
|
setError('Username is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const opts = await api.post<any>('/auth/passkey/register/options', { username })
|
||||||
|
const attestation = await startRegistration({ optionsJSON: opts })
|
||||||
|
await api.post('/auth/passkey/register/verify', { username, attestation })
|
||||||
|
await auth.refresh()
|
||||||
|
onClose()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Registration failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const opts = await api.post<any>('/auth/passkey/login/options')
|
||||||
|
const assertion = await startAuthentication({ optionsJSON: opts })
|
||||||
|
await api.post('/auth/passkey/login/verify', { assertion })
|
||||||
|
await auth.refresh()
|
||||||
|
onClose()
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Authentication failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 fade-in"
|
||||||
|
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-sm mx-4 rounded-xl p-6 shadow-2xl slide-up"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-lg font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{mode === 'register' ? 'Register Passkey' : 'Login with Passkey'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="btn btn-ghost p-1">
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'register' ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Choose a display name and register a passkey to keep your identity across devices.
|
||||||
|
</p>
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="input pr-8"
|
||||||
|
placeholder="Display name"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
{checking && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!checking && available !== null && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{available ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!checking && available === false && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--error)' }}>This name is taken</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Use your registered passkey to sign in and restore your identity.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={mode === 'register' ? handleRegister : handleLogin}
|
||||||
|
disabled={loading || (mode === 'register' && (!username.trim() || available === false))}
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
style={{ opacity: loading ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'rgba(20,20,32,0.3)', borderTopColor: '#141420', animation: 'spin 0.6s linear infinite' }} />
|
||||||
|
) : mode === 'register' ? (
|
||||||
|
'Register Passkey'
|
||||||
|
) : (
|
||||||
|
'Sign in with Passkey'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mode === 'register' && (
|
||||||
|
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Your passkey is stored on your device. No passwords involved.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
packages/web/src/components/PostCard.tsx
Normal file
110
packages/web/src/components/PostCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import StatusBadge from './StatusBadge'
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
excerpt?: string
|
||||||
|
type: 'feature' | 'bug' | 'general'
|
||||||
|
status: string
|
||||||
|
voteCount: number
|
||||||
|
commentCount: number
|
||||||
|
authorName: string
|
||||||
|
createdAt: string
|
||||||
|
boardSlug: string
|
||||||
|
hasVoted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PostCard({
|
||||||
|
post,
|
||||||
|
onVote,
|
||||||
|
}: {
|
||||||
|
post: Post
|
||||||
|
onVote?: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const timeAgo = formatTimeAgo(post.createdAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card flex gap-0 overflow-hidden"
|
||||||
|
style={{ transition: 'border-color 200ms ease-out' }}
|
||||||
|
>
|
||||||
|
{/* Vote column */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); onVote?.(post.id) }}
|
||||||
|
className="flex flex-col items-center justify-center px-3 py-4 shrink-0 gap-1"
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
background: post.hasVoted ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-semibold">{post.voteCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content zone */}
|
||||||
|
<Link
|
||||||
|
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||||
|
className="flex-1 py-3 px-4 min-w-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||||
|
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{post.authorName} - {timeAgo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-medium mb-1 truncate"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
{post.excerpt && (
|
||||||
|
<p
|
||||||
|
className="text-xs line-clamp-2"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Status + comments */}
|
||||||
|
<div className="flex flex-col items-end justify-center px-4 py-3 shrink-0 gap-2">
|
||||||
|
<StatusBadge status={post.status} />
|
||||||
|
<div className="flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs">{post.commentCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(date: string): string {
|
||||||
|
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||||
|
if (seconds < 60) return 'just now'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 30) return `${days}d ago`
|
||||||
|
const months = Math.floor(days / 30)
|
||||||
|
return `${months}mo ago`
|
||||||
|
}
|
||||||
183
packages/web/src/components/PostForm.tsx
Normal file
183
packages/web/src/components/PostForm.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
boardSlug: string
|
||||||
|
onSubmit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostType = 'feature' | 'bug' | 'general'
|
||||||
|
|
||||||
|
export default function PostForm({ boardSlug, onSubmit }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [type, setType] = useState<PostType>('feature')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [expected, setExpected] = useState('')
|
||||||
|
const [actual, setActual] = useState('')
|
||||||
|
const [steps, setSteps] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const formRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setTitle('')
|
||||||
|
setBody('')
|
||||||
|
setExpected('')
|
||||||
|
setActual('')
|
||||||
|
setSteps('')
|
||||||
|
setError('')
|
||||||
|
setExpanded(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Title is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const payload: Record<string, string> = { title, type, body }
|
||||||
|
if (type === 'bug') {
|
||||||
|
payload.stepsToReproduce = steps
|
||||||
|
payload.expected = expected
|
||||||
|
payload.actual = actual
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/boards/${boardSlug}/posts`, payload)
|
||||||
|
reset()
|
||||||
|
onSubmit?.()
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to submit. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="card w-full px-4 py-3 text-left flex items-center gap-3"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Share feedback...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={formRef} className="card p-4 slide-up">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||||
|
New Post
|
||||||
|
</h3>
|
||||||
|
<button onClick={reset} className="btn btn-ghost text-xs">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type selector */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{(['feature', 'bug', 'general'] as PostType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setType(t)}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs font-medium capitalize"
|
||||||
|
style={{
|
||||||
|
background: type === t ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: type === t ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||||
|
border: `1px solid ${type === t ? 'var(--accent)' : 'var(--border)'}`,
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="input mb-3"
|
||||||
|
placeholder="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="input mb-3"
|
||||||
|
placeholder={type === 'bug' ? 'Describe the bug...' : type === 'feature' ? 'Describe the feature...' : 'What is on your mind?'}
|
||||||
|
rows={3}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{type === 'bug' && (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
className="input mb-3"
|
||||||
|
placeholder="Steps to reproduce"
|
||||||
|
rows={2}
|
||||||
|
value={steps}
|
||||||
|
onChange={(e) => setSteps(e.target.value)}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder="Expected behavior"
|
||||||
|
rows={2}
|
||||||
|
value={expected}
|
||||||
|
onChange={(e) => setExpected(e.target.value)}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
placeholder="Actual behavior"
|
||||||
|
rows={2}
|
||||||
|
value={actual}
|
||||||
|
onChange={(e) => setActual(e.target.value)}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ALTCHA widget placeholder */}
|
||||||
|
<div
|
||||||
|
className="mb-4 p-3 rounded-lg text-xs flex items-center gap-2"
|
||||||
|
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
ALTCHA verification
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ opacity: submitting ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
246
packages/web/src/components/Sidebar.tsx
Normal file
246
packages/web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link, useLocation, useParams } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface Board {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
postCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const { boardSlug } = useParams()
|
||||||
|
const location = useLocation()
|
||||||
|
const auth = useAuth()
|
||||||
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Board[]>('/boards').then(setBoards).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path
|
||||||
|
const isBoardActive = (slug: string) => boardSlug === slug
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="hidden md:flex lg:hidden flex-col items-center py-4 gap-2 border-r"
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{boards.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
to={`/b/${b.slug}`}
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-semibold"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.name.charAt(0).toUpperCase()}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Link
|
||||||
|
to="/activity"
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="hidden lg:flex flex-col border-r h-screen sticky top-0"
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-xl font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Echoboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Board list */}
|
||||||
|
<nav className="flex-1 overflow-y-auto py-3 px-3">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-1"
|
||||||
|
style={{
|
||||||
|
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
||||||
|
</svg>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-4 mb-2 px-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Boards
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{boards.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
to={`/b/${b.slug}`}
|
||||||
|
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||||
|
style={{
|
||||||
|
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{b.name}</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{b.postCount}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-6 mb-2 px-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/activity"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||||
|
style={{
|
||||||
|
background: isActive('/activity') ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Activity
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/my-posts"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||||
|
style={{
|
||||||
|
background: isActive('/my-posts') ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isActive('/my-posts') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
My Posts
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||||
|
style={{
|
||||||
|
background: isActive('/settings') ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: isActive('/settings') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Identity footer */}
|
||||||
|
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{auth.displayName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate" style={{ color: 'var(--text)' }}>
|
||||||
|
{auth.displayName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{auth.isPasskeyUser ? 'Passkey user' : 'Cookie identity'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!auth.isPasskeyUser && (
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="block mt-2 text-xs text-center py-1.5 rounded-md"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
Register passkey for persistence
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
packages/web/src/components/StatusBadge.tsx
Normal file
21
packages/web/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const statusConfig: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
|
OPEN: { label: 'Open', bg: 'var(--accent-subtle)', color: 'var(--accent)' },
|
||||||
|
UNDER_REVIEW: { label: 'Under Review', bg: 'var(--admin-subtle)', color: 'var(--admin-accent)' },
|
||||||
|
PLANNED: { label: 'Planned', bg: 'rgba(59, 130, 246, 0.15)', color: 'var(--info)' },
|
||||||
|
IN_PROGRESS: { label: 'In Progress', bg: 'rgba(234, 179, 8, 0.15)', color: 'var(--warning)' },
|
||||||
|
DONE: { label: 'Done', bg: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' },
|
||||||
|
DECLINED: { label: 'Declined', bg: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: { status: string }) {
|
||||||
|
const cfg = statusConfig[status] || { label: status, bg: 'var(--border)', color: 'var(--text-secondary)' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
|
style={{ background: cfg.bg, color: cfg.color }}
|
||||||
|
>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
packages/web/src/components/ThemeToggle.tsx
Normal file
37
packages/web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const { resolved, toggle } = useTheme()
|
||||||
|
const isDark = resolved === 'dark'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="fixed bottom-6 right-6 w-11 h-11 rounded-full flex items-center justify-center z-40 md:bottom-6 md:right-6 bottom-20 shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transition: 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
transform: isDark ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
packages/web/src/components/Timeline.tsx
Normal file
165
packages/web/src/components/Timeline.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface TimelineEntry {
|
||||||
|
id: string
|
||||||
|
type: 'status_change' | 'admin_response' | 'comment'
|
||||||
|
authorName: string
|
||||||
|
content: string
|
||||||
|
oldStatus?: string
|
||||||
|
newStatus?: string
|
||||||
|
createdAt: string
|
||||||
|
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timeline({
|
||||||
|
entries,
|
||||||
|
onReact,
|
||||||
|
}: {
|
||||||
|
entries: TimelineEntry[]
|
||||||
|
onReact?: (entryId: string, emoji: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div
|
||||||
|
className="absolute left-4 top-0 bottom-0 w-px"
|
||||||
|
style={{ background: 'var(--border)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<TimelineItem key={entry.id} entry={entry} onReact={onReact} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineItem({
|
||||||
|
entry,
|
||||||
|
onReact,
|
||||||
|
}: {
|
||||||
|
entry: TimelineEntry
|
||||||
|
onReact?: (entryId: string, emoji: string) => void
|
||||||
|
}) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const quickEmojis = ['👍', '❤️', '🎉', '😄', '🤔', '👀']
|
||||||
|
|
||||||
|
const iconBg = entry.type === 'admin_response'
|
||||||
|
? 'var(--admin-subtle)'
|
||||||
|
: entry.type === 'status_change'
|
||||||
|
? 'var(--accent-subtle)'
|
||||||
|
: 'var(--border)'
|
||||||
|
|
||||||
|
const iconColor = entry.type === 'admin_response'
|
||||||
|
? 'var(--admin-accent)'
|
||||||
|
: entry.type === 'status_change'
|
||||||
|
? 'var(--accent)'
|
||||||
|
: 'var(--text-tertiary)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative pl-10 pb-6">
|
||||||
|
{/* Dot */}
|
||||||
|
<div
|
||||||
|
className="absolute left-2 top-1 w-5 h-5 rounded-full flex items-center justify-center z-10"
|
||||||
|
style={{ background: iconBg }}
|
||||||
|
>
|
||||||
|
{entry.type === 'status_change' ? (
|
||||||
|
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
) : entry.type === 'admin_response' ? (
|
||||||
|
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: entry.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--surface)',
|
||||||
|
border: entry.type === 'admin_response' ? `1px solid rgba(6, 182, 212, 0.2)` : `1px solid var(--border)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium" style={{ color: entry.isAdmin ? 'var(--admin-accent)' : 'var(--text)' }}>
|
||||||
|
{entry.authorName}
|
||||||
|
{entry.isAdmin && (
|
||||||
|
<span className="ml-1 px-1 py-0.5 rounded text-[10px]" style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)' }}>
|
||||||
|
admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{new Date(entry.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entry.type === 'status_change' ? (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Changed status from <strong>{entry.oldStatus}</strong> to <strong>{entry.newStatus}</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{entry.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions */}
|
||||||
|
{(entry.reactions?.length || entry.type === 'comment') && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
|
||||||
|
{entry.reactions?.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.emoji}
|
||||||
|
onClick={() => onReact?.(entry.id, r.emoji)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
||||||
|
style={{
|
||||||
|
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--border)',
|
||||||
|
border: r.hasReacted ? '1px solid var(--accent)' : '1px solid transparent',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.emoji} {r.count}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPicker(!showPicker)}
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||||
|
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
{showPicker && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 mb-1 flex gap-1 p-1.5 rounded-lg z-20 fade-in"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }}
|
||||||
|
>
|
||||||
|
{quickEmojis.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
|
||||||
|
className="w-7 h-7 rounded flex items-center justify-center hover:scale-110"
|
||||||
|
style={{ transition: 'transform 200ms ease-out' }}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
packages/web/src/components/VoteBudget.tsx
Normal file
59
packages/web/src/components/VoteBudget.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
used: number
|
||||||
|
total: number
|
||||||
|
resetsAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoteBudget({ used, total, resetsAt }: Props) {
|
||||||
|
const [showTip, setShowTip] = useState(false)
|
||||||
|
const remaining = total - used
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-help"
|
||||||
|
onMouseEnter={() => setShowTip(true)}
|
||||||
|
onMouseLeave={() => setShowTip(false)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: total }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: i < used ? 'var(--accent)' : 'var(--border-hover)',
|
||||||
|
transition: 'background 200ms ease-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs ml-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{remaining} left
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showTip && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 mb-2 px-3 py-2 rounded-lg text-xs whitespace-nowrap z-50 fade-in"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-medium mb-1" style={{ color: 'var(--text)' }}>
|
||||||
|
Vote Budget
|
||||||
|
</div>
|
||||||
|
<div>{used} of {total} votes used</div>
|
||||||
|
{resetsAt && (
|
||||||
|
<div style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Resets {new Date(resetsAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
packages/web/src/hooks/useAuth.ts
Normal file
82
packages/web/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
displayName: string
|
||||||
|
isPasskeyUser: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isPasskeyUser: boolean
|
||||||
|
displayName: string
|
||||||
|
initIdentity: () => Promise<void>
|
||||||
|
updateProfile: (data: { displayName: string }) => Promise<void>
|
||||||
|
deleteIdentity: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null)
|
||||||
|
|
||||||
|
export const AuthProvider = AuthContext.Provider
|
||||||
|
|
||||||
|
export function useAuthState(): AuthState {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchMe = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const u = await api.get<User>('/me')
|
||||||
|
setUser(u)
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initIdentity = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const u = await api.post<User>('/identity')
|
||||||
|
setUser(u)
|
||||||
|
} catch {
|
||||||
|
await fetchMe()
|
||||||
|
}
|
||||||
|
}, [fetchMe])
|
||||||
|
|
||||||
|
const updateProfile = useCallback(async (data: { displayName: string }) => {
|
||||||
|
const u = await api.put<User>('/me', data)
|
||||||
|
setUser(u)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteIdentity = useCallback(async () => {
|
||||||
|
await api.delete('/me')
|
||||||
|
setUser(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMe()
|
||||||
|
}, [fetchMe])
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isPasskeyUser: user?.isPasskeyUser ?? false,
|
||||||
|
displayName: user?.displayName ?? 'Anonymous',
|
||||||
|
initIdentity,
|
||||||
|
updateProfile,
|
||||||
|
deleteIdentity,
|
||||||
|
refresh: fetchMe,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
47
packages/web/src/hooks/useTheme.ts
Normal file
47
packages/web/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
type Theme,
|
||||||
|
getStoredTheme,
|
||||||
|
resolveTheme,
|
||||||
|
setTheme as applyTheme,
|
||||||
|
initTheme,
|
||||||
|
} from '../lib/theme'
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
theme: Theme
|
||||||
|
resolved: 'dark' | 'light'
|
||||||
|
toggle: () => void
|
||||||
|
set: (t: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeState | null>(null)
|
||||||
|
|
||||||
|
export const ThemeProvider = ThemeContext.Provider
|
||||||
|
|
||||||
|
export function useThemeState(): ThemeState {
|
||||||
|
const [theme, setThemeVal] = useState<Theme>(getStoredTheme)
|
||||||
|
const [resolved, setResolved] = useState<'dark' | 'light'>(() => resolveTheme(getStoredTheme()))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initTheme()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const set = useCallback((t: Theme) => {
|
||||||
|
applyTheme(t)
|
||||||
|
setThemeVal(t)
|
||||||
|
setResolved(resolveTheme(t))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const next: Theme = resolved === 'dark' ? 'light' : 'dark'
|
||||||
|
set(next)
|
||||||
|
}, [resolved, set])
|
||||||
|
|
||||||
|
return { theme, resolved, toggle, set }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeState {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
62
packages/web/src/lib/api.ts
Normal file
62
packages/web/src/lib/api.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const BASE = '/api/v1'
|
||||||
|
|
||||||
|
class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...opts.headers,
|
||||||
|
},
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let body: unknown = null
|
||||||
|
try {
|
||||||
|
body = await res.json()
|
||||||
|
} catch {
|
||||||
|
body = await res.text()
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return null as T
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
|
||||||
|
post: <T>(path: string, data?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
put: <T>(path: string, data?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
patch: <T>(path: string, data?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiError }
|
||||||
43
packages/web/src/lib/theme.ts
Normal file
43
packages/web/src/lib/theme.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'echoboard-theme'
|
||||||
|
|
||||||
|
function getSystemPref(): 'dark' | 'light' {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(resolved: 'dark' | 'light') {
|
||||||
|
document.documentElement.classList.toggle('light', resolved === 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredTheme(): Theme {
|
||||||
|
return (localStorage.getItem(STORAGE_KEY) as Theme) || 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTheme(pref: Theme): 'dark' | 'light' {
|
||||||
|
if (pref === 'system') return getSystemPref()
|
||||||
|
return pref
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(pref: Theme) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, pref)
|
||||||
|
applyTheme(resolveTheme(pref))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTheme() {
|
||||||
|
const pref = getStoredTheme()
|
||||||
|
applyTheme(resolveTheme(pref))
|
||||||
|
|
||||||
|
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => {
|
||||||
|
if (getStoredTheme() === 'system') {
|
||||||
|
applyTheme(getSystemPref())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTheme(): Theme {
|
||||||
|
const current = resolveTheme(getStoredTheme())
|
||||||
|
const next: Theme = current === 'dark' ? 'light' : 'dark'
|
||||||
|
setTheme(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
16
packages/web/src/main.tsx
Normal file
16
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import '@fontsource/space-grotesk/400.css'
|
||||||
|
import '@fontsource/space-grotesk/500.css'
|
||||||
|
import '@fontsource/space-grotesk/700.css'
|
||||||
|
import '@fontsource/sora/400.css'
|
||||||
|
import '@fontsource/sora/500.css'
|
||||||
|
import '@fontsource/sora/600.css'
|
||||||
|
import './app.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
154
packages/web/src/pages/ActivityFeed.tsx
Normal file
154
packages/web/src/pages/ActivityFeed.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: string
|
||||||
|
type: 'post_created' | 'status_changed' | 'comment_added' | 'admin_response' | 'vote'
|
||||||
|
postId: string
|
||||||
|
postTitle: string
|
||||||
|
boardSlug: string
|
||||||
|
boardName: string
|
||||||
|
actorName: string
|
||||||
|
detail?: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
post_created: 'created a post',
|
||||||
|
status_changed: 'changed status',
|
||||||
|
comment_added: 'commented',
|
||||||
|
admin_response: 'responded',
|
||||||
|
vote: 'voted on',
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeIcons: Record<string, JSX.Element> = {
|
||||||
|
post_created: (
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
status_changed: (
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
comment_added: (
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
admin_response: (
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
vote: (
|
||||||
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityFeed() {
|
||||||
|
const [activities, setActivities] = useState<Activity[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [boardFilter, setBoardFilter] = useState('')
|
||||||
|
const [typeFilter, setTypeFilter] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (boardFilter) params.set('board', boardFilter)
|
||||||
|
if (typeFilter) params.set('type', typeFilter)
|
||||||
|
|
||||||
|
api.get<Activity[]>(`/activity?${params}`)
|
||||||
|
.then(setActivities)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [boardFilter, typeFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold mb-6"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 200 }}
|
||||||
|
value={boardFilter}
|
||||||
|
onChange={(e) => setBoardFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All boards</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 200 }}
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="post_created">Posts</option>
|
||||||
|
<option value="comment_added">Comments</option>
|
||||||
|
<option value="status_changed">Status changes</option>
|
||||||
|
<option value="admin_response">Admin responses</option>
|
||||||
|
<option value="vote">Votes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>No activity yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{activities.map((a) => (
|
||||||
|
<Link
|
||||||
|
key={a.id}
|
||||||
|
to={`/b/${a.boardSlug}/post/${a.postId}`}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg"
|
||||||
|
style={{ transition: 'background 200ms ease-out' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5"
|
||||||
|
style={{
|
||||||
|
background: a.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--accent-subtle)',
|
||||||
|
color: a.type === 'admin_response' ? 'var(--admin-accent)' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeIcons[a.type]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{a.actorName}</span>
|
||||||
|
{' '}{typeLabels[a.type] || a.type}{' '}
|
||||||
|
<span style={{ color: 'var(--text)' }}>{a.postTitle}</span>
|
||||||
|
</p>
|
||||||
|
{a.detail && (
|
||||||
|
<p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-tertiary)' }}>{a.detail}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{a.boardName} - {new Date(a.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
packages/web/src/pages/BoardFeed.tsx
Normal file
194
packages/web/src/pages/BoardFeed.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import PostCard from '../components/PostCard'
|
||||||
|
import PostForm from '../components/PostForm'
|
||||||
|
import VoteBudget from '../components/VoteBudget'
|
||||||
|
import EmptyState from '../components/EmptyState'
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
excerpt?: string
|
||||||
|
type: 'feature' | 'bug' | 'general'
|
||||||
|
status: string
|
||||||
|
voteCount: number
|
||||||
|
commentCount: number
|
||||||
|
authorName: string
|
||||||
|
createdAt: string
|
||||||
|
boardSlug: string
|
||||||
|
hasVoted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Board {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Budget {
|
||||||
|
used: number
|
||||||
|
total: number
|
||||||
|
resetsAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortOption = 'newest' | 'top' | 'trending'
|
||||||
|
type StatusFilter = 'all' | 'OPEN' | 'PLANNED' | 'IN_PROGRESS' | 'DONE' | 'DECLINED'
|
||||||
|
|
||||||
|
export default function BoardFeed() {
|
||||||
|
const { boardSlug } = useParams<{ boardSlug: string }>()
|
||||||
|
const [board, setBoard] = useState<Board | null>(null)
|
||||||
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
const [budget, setBudget] = useState<Budget>({ used: 0, total: 10 })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sort, setSort] = useState<SortOption>('newest')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
|
||||||
|
const fetchPosts = useCallback(async () => {
|
||||||
|
if (!boardSlug) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ sort })
|
||||||
|
if (statusFilter !== 'all') params.set('status', statusFilter)
|
||||||
|
if (search) params.set('q', search)
|
||||||
|
|
||||||
|
const [b, p, bud] = await Promise.all([
|
||||||
|
api.get<Board>(`/boards/${boardSlug}`),
|
||||||
|
api.get<Post[]>(`/boards/${boardSlug}/posts?${params}`),
|
||||||
|
api.get<Budget>(`/boards/${boardSlug}/budget`).catch(() => ({ used: 0, total: 10 })),
|
||||||
|
])
|
||||||
|
setBoard(b)
|
||||||
|
setPosts(p)
|
||||||
|
setBudget(bud as Budget)
|
||||||
|
} catch {
|
||||||
|
setPosts([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [boardSlug, sort, statusFilter, search])
|
||||||
|
|
||||||
|
useEffect(() => { fetchPosts() }, [fetchPosts])
|
||||||
|
|
||||||
|
const handleVote = async (postId: string) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/posts/${postId}/vote`)
|
||||||
|
fetchPosts()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOptions: { value: SortOption; label: string }[] = [
|
||||||
|
{ value: 'newest', label: 'Newest' },
|
||||||
|
{ value: 'top', label: 'Top Voted' },
|
||||||
|
{ value: 'trending', label: 'Trending' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statuses: { value: StatusFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'PLANNED', label: 'Planned' },
|
||||||
|
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||||
|
{ value: 'DONE', label: 'Done' },
|
||||||
|
{ value: 'DECLINED', label: 'Declined' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
{/* Header */}
|
||||||
|
{board && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold mb-1"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{board.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Budget */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<VoteBudget used={budget.used} total={budget.total} resetsAt={budget.resetsAt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{sortOptions.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => setSort(o.value)}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
background: sort === o.value ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: sort === o.value ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex gap-1 mb-6 overflow-x-auto pb-1">
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => setStatusFilter(s.value)}
|
||||||
|
className="px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
background: statusFilter === s.value ? 'var(--surface-hover)' : 'transparent',
|
||||||
|
color: statusFilter === s.value ? 'var(--text)' : 'var(--text-tertiary)',
|
||||||
|
border: `1px solid ${statusFilter === s.value ? 'var(--border-hover)' : 'var(--border)'}`,
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Post form */}
|
||||||
|
{boardSlug && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<PostForm boardSlug={boardSlug} onSubmit={fetchPosts} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Posts */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
onAction={() => setShowForm(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostCard key={post.id} post={post} onVote={handleVote} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
packages/web/src/pages/BoardIndex.tsx
Normal file
142
packages/web/src/pages/BoardIndex.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface Board {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
postCount: number
|
||||||
|
openCount: number
|
||||||
|
archived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BoardIndex() {
|
||||||
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Board[]>('/boards')
|
||||||
|
.then(setBoards)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const active = boards.filter((b) => !b.archived)
|
||||||
|
const archived = boards.filter((b) => b.archived)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-bold mb-2"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Feedback Boards
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Choose a board to browse or submit feedback
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{active.map((board, i) => (
|
||||||
|
<Link
|
||||||
|
key={board.id}
|
||||||
|
to={`/b/${board.slug}`}
|
||||||
|
className="card p-5 block group"
|
||||||
|
style={{
|
||||||
|
animation: `fadeIn 200ms ease-out ${i * 80}ms both`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center text-lg font-bold"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
background: 'var(--accent-subtle)',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{board.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
|
||||||
|
className="group-hover:translate-x-0.5"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2
|
||||||
|
className="text-base font-semibold mb-1"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{board.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<span>{board.postCount} posts</span>
|
||||||
|
<span>{board.openCount} open</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{archived.length > 0 && (
|
||||||
|
<div className="mt-10">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
|
className="flex items-center gap-2 text-sm mb-4"
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
style={{
|
||||||
|
transition: 'transform 200ms ease-out',
|
||||||
|
transform: showArchived ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
Archived boards ({archived.length})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showArchived && (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 fade-in">
|
||||||
|
{archived.map((board) => (
|
||||||
|
<Link
|
||||||
|
key={board.id}
|
||||||
|
to={`/b/${board.slug}`}
|
||||||
|
className="card p-4 block opacity-60"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-medium mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||||
|
{board.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{board.postCount} posts - archived
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
205
packages/web/src/pages/IdentitySettings.tsx
Normal file
205
packages/web/src/pages/IdentitySettings.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '../hooks/useAuth'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
export default function IdentitySettings() {
|
||||||
|
const auth = useAuth()
|
||||||
|
const [name, setName] = useState(auth.displayName)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [showDelete, setShowDelete] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [showPasskey, setShowPasskey] = useState(false)
|
||||||
|
|
||||||
|
const saveName = async () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await auth.updateProfile({ displayName: name })
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
} catch {} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<unknown>('/me/export')
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'echoboard-data.json'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await auth.deleteIdentity()
|
||||||
|
window.location.href = '/'
|
||||||
|
} catch {} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-8">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold mb-6"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Display name */}
|
||||||
|
<div className="card p-5 mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Display Name
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={saveName}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : saved ? 'Saved' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity status */}
|
||||||
|
<div className="card p-5 mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Identity
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg mb-3"
|
||||||
|
style={{ background: 'var(--bg)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: auth.isPasskeyUser ? 'rgba(34, 197, 94, 0.15)' : 'var(--accent-subtle)',
|
||||||
|
color: auth.isPasskeyUser ? 'var(--success)' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{auth.isPasskeyUser ? (
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||||
|
{auth.isPasskeyUser ? 'Passkey registered' : 'Cookie-based identity'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{auth.isPasskeyUser
|
||||||
|
? 'Your identity is secured with a passkey'
|
||||||
|
: 'Your identity is tied to this browser cookie'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!auth.isPasskeyUser && (
|
||||||
|
<button onClick={() => setShowPasskey(true)} className="btn btn-primary w-full">
|
||||||
|
Upgrade to passkey
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<div className="card p-5 mb-4">
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Your Data
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Export all your data in JSON format.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleExport} className="btn btn-secondary">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Export Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<div className="card p-5" style={{ borderColor: 'rgba(239, 68, 68, 0.2)' }}>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
Danger Zone
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-3" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This will permanently delete your identity and all associated data. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDelete(true)}
|
||||||
|
className="btn text-sm"
|
||||||
|
style={{ background: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
Delete my identity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{showDelete && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||||
|
onClick={() => setShowDelete(false)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-sm mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--error)' }}>
|
||||||
|
Delete Identity
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Are you sure? All your posts, votes, and data will be permanently removed. This action cannot be reversed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setShowDelete(false)} className="btn btn-secondary flex-1">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="btn flex-1"
|
||||||
|
style={{ background: 'var(--error)', color: 'white', opacity: deleting ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
packages/web/src/pages/MySubmissions.tsx
Normal file
94
packages/web/src/pages/MySubmissions.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
voteCount: number
|
||||||
|
commentCount: number
|
||||||
|
boardSlug: string
|
||||||
|
boardName: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MySubmissions() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Post[]>('/me/posts')
|
||||||
|
.then(setPosts)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold mb-6"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
My Posts
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
You have not submitted any posts yet
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn-primary">Browse boards</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||||
|
className="card p-4 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{post.boardName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded capitalize"
|
||||||
|
style={{
|
||||||
|
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||||
|
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-medium truncate"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<span>{post.voteCount} votes</span>
|
||||||
|
<span>{post.commentCount} comments</span>
|
||||||
|
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={post.status} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
packages/web/src/pages/PostDetail.tsx
Normal file
241
packages/web/src/pages/PostDetail.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import StatusBadge from '../components/StatusBadge'
|
||||||
|
import Timeline from '../components/Timeline'
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
voteCount: number
|
||||||
|
hasVoted: boolean
|
||||||
|
authorName: string
|
||||||
|
createdAt: string
|
||||||
|
boardSlug: string
|
||||||
|
boardName: string
|
||||||
|
stepsToReproduce?: string
|
||||||
|
expected?: string
|
||||||
|
actual?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineEntry {
|
||||||
|
id: string
|
||||||
|
type: 'status_change' | 'admin_response' | 'comment'
|
||||||
|
authorName: string
|
||||||
|
content: string
|
||||||
|
oldStatus?: string
|
||||||
|
newStatus?: string
|
||||||
|
createdAt: string
|
||||||
|
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PostDetail() {
|
||||||
|
const { boardSlug, postId } = useParams()
|
||||||
|
const [post, setPost] = useState<Post | null>(null)
|
||||||
|
const [timeline, setTimeline] = useState<TimelineEntry[]>([])
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchPost = async () => {
|
||||||
|
if (!postId) return
|
||||||
|
try {
|
||||||
|
const [p, t] = await Promise.all([
|
||||||
|
api.get<Post>(`/posts/${postId}`),
|
||||||
|
api.get<TimelineEntry[]>(`/posts/${postId}/timeline`),
|
||||||
|
])
|
||||||
|
setPost(p)
|
||||||
|
setTimeline(t)
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchPost() }, [postId])
|
||||||
|
|
||||||
|
const handleVote = async () => {
|
||||||
|
if (!postId) return
|
||||||
|
try {
|
||||||
|
await api.post(`/posts/${postId}/vote`)
|
||||||
|
fetchPost()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComment = async () => {
|
||||||
|
if (!postId || !comment.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await api.post(`/posts/${postId}/comments`, { content: comment })
|
||||||
|
setComment('')
|
||||||
|
fetchPost()
|
||||||
|
} catch {} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReact = async (entryId: string, emoji: string) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/timeline/${entryId}/react`, { emoji })
|
||||||
|
fetchPost()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-16 text-center">
|
||||||
|
<h2 className="text-lg font-semibold mb-2" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||||
|
Post not found
|
||||||
|
</h2>
|
||||||
|
<Link to={`/b/${boardSlug}`} className="btn btn-secondary mt-4">
|
||||||
|
Back to board
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 mb-6 text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
<Link to="/" className="hover:underline">Home</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to={`/b/${post.boardSlug}`} className="hover:underline">{post.boardName}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{post.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Post header */}
|
||||||
|
<div className="card p-6 mb-6 fade-in">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Vote button */}
|
||||||
|
<button
|
||||||
|
onClick={handleVote}
|
||||||
|
className="flex flex-col items-center gap-1 px-3 py-2 rounded-lg shrink-0"
|
||||||
|
style={{
|
||||||
|
background: post.hasVoted ? 'var(--accent-subtle)' : 'var(--surface-hover)',
|
||||||
|
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||||
|
transition: 'all 200ms ease-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-semibold">{post.voteCount}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded capitalize"
|
||||||
|
style={{
|
||||||
|
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||||
|
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.type}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={post.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="text-xl font-bold mb-2"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
by {post.authorName} - {new Date(post.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm whitespace-pre-wrap mb-4" style={{ color: 'var(--text-secondary)', lineHeight: 1.7 }}>
|
||||||
|
{post.body}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bug report fields */}
|
||||||
|
{post.type === 'bug' && (
|
||||||
|
<div className="grid gap-3 md:grid-cols-1">
|
||||||
|
{post.stepsToReproduce && (
|
||||||
|
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Steps to Reproduce</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.stepsToReproduce}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{post.expected && (
|
||||||
|
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Expected</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.expected}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{post.actual && (
|
||||||
|
<div className="p-3 rounded-lg" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div className="text-xs font-medium mb-1" style={{ color: 'var(--text-tertiary)' }}>Actual</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>{post.actual}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{timeline.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-4"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</h2>
|
||||||
|
<Timeline entries={timeline} onReact={handleReact} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment form */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<h3
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Add a comment
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
className="input mb-3"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Write your comment..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleComment}
|
||||||
|
disabled={submitting || !comment.trim()}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ opacity: submitting || !comment.trim() ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{submitting ? 'Posting...' : 'Post Comment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
packages/web/src/pages/PrivacyPage.tsx
Normal file
163
packages/web/src/pages/PrivacyPage.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
|
||||||
|
interface DataField {
|
||||||
|
field: string
|
||||||
|
purpose: string
|
||||||
|
retention: string
|
||||||
|
deletable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Manifest {
|
||||||
|
fields: DataField[]
|
||||||
|
cookieInfo: string
|
||||||
|
dataLocation: string
|
||||||
|
thirdParties: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PrivacyPage() {
|
||||||
|
const [manifest, setManifest] = useState<Manifest | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Manifest>('/privacy/data-manifest')
|
||||||
|
.then(setManifest)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold mb-2"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Here is exactly what data this Echoboard instance collects and why.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : manifest ? (
|
||||||
|
<>
|
||||||
|
{/* Quick summary */}
|
||||||
|
<div className="card p-5 mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-base font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
The short version
|
||||||
|
</h2>
|
||||||
|
<ul className="flex flex-col gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
No tracking scripts, no analytics, no third-party cookies
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
All data stays on this server - {manifest.dataLocation}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
No external fonts or resources loaded
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
You can delete everything at any time
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cookie info */}
|
||||||
|
<div className="card p-5 mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-base font-semibold mb-2"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Cookies
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{manifest.cookieInfo}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data fields */}
|
||||||
|
<div className="card p-5 mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-base font-semibold mb-4"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
What we store
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{manifest.fields.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.field}
|
||||||
|
className="p-3 rounded-lg"
|
||||||
|
style={{ background: 'var(--bg)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||||
|
{f.field}
|
||||||
|
</span>
|
||||||
|
{f.deletable && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }}>
|
||||||
|
deletable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{f.purpose}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Retained: {f.retention}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Third parties */}
|
||||||
|
{manifest.thirdParties.length > 0 && (
|
||||||
|
<div className="card p-5 mb-6">
|
||||||
|
<h2
|
||||||
|
className="text-base font-semibold mb-2"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Third parties
|
||||||
|
</h2>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{manifest.thirdParties.map((tp) => (
|
||||||
|
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
- {tp}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="card p-5">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This Echoboard instance is self-hosted and privacy-focused. No tracking, no external services, no data sharing. Your identity is cookie-based unless you register a passkey. You can delete all your data at any time from the settings page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
packages/web/src/pages/admin/AdminBoards.tsx
Normal file
260
packages/web/src/pages/admin/AdminBoards.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
|
||||||
|
interface Board {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
postCount: number
|
||||||
|
archived: boolean
|
||||||
|
voteBudget: number
|
||||||
|
voteResetSchedule: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminBoards() {
|
||||||
|
const [boards, setBoards] = useState<Board[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editBoard, setEditBoard] = useState<Board | null>(null)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
voteBudget: 10,
|
||||||
|
voteResetSchedule: 'monthly',
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchBoards = async () => {
|
||||||
|
try {
|
||||||
|
const b = await api.get<Board[]>('/admin/boards')
|
||||||
|
setBoards(b)
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchBoards() }, [])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm({ name: '', slug: '', description: '', voteBudget: 10, voteResetSchedule: 'monthly' })
|
||||||
|
setEditBoard(null)
|
||||||
|
setShowCreate(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (b: Board) => {
|
||||||
|
setEditBoard(b)
|
||||||
|
setForm({
|
||||||
|
name: b.name,
|
||||||
|
slug: b.slug,
|
||||||
|
description: b.description,
|
||||||
|
voteBudget: b.voteBudget,
|
||||||
|
voteResetSchedule: b.voteResetSchedule,
|
||||||
|
})
|
||||||
|
setShowCreate(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (editBoard) {
|
||||||
|
await api.put(`/admin/boards/${editBoard.id}`, form)
|
||||||
|
} else {
|
||||||
|
await api.post('/admin/boards', form)
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
fetchBoards()
|
||||||
|
} catch {} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleArchive = async (id: string, archived: boolean) => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/admin/boards/${id}`, { archived: !archived })
|
||||||
|
fetchBoards()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||||
|
>
|
||||||
|
Boards
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link to="/admin" className="btn btn-ghost text-sm">Back</Link>
|
||||||
|
<button onClick={() => { resetForm(); setShowCreate(true) }} className="btn btn-admin text-sm">
|
||||||
|
New Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{boards.map((board) => (
|
||||||
|
<div
|
||||||
|
key={board.id}
|
||||||
|
className="card p-4 flex items-center gap-4"
|
||||||
|
style={{ opacity: board.archived ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg flex items-center justify-center text-sm font-bold shrink-0"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
background: 'var(--admin-subtle)',
|
||||||
|
color: 'var(--admin-accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{board.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||||
|
{board.name}
|
||||||
|
</h3>
|
||||||
|
{board.archived && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}>
|
||||||
|
archived
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
/{board.slug} - {board.postCount} posts - Budget: {board.voteBudget}/{board.voteResetSchedule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<button onClick={() => openEdit(board)} className="btn btn-ghost text-xs px-2 py-1" style={{ color: 'var(--admin-accent)' }}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleArchive(board.id, board.archived)}
|
||||||
|
className="btn btn-ghost text-xs px-2 py-1"
|
||||||
|
style={{ color: board.archived ? 'var(--success)' : 'var(--warning)' }}
|
||||||
|
>
|
||||||
|
{board.archived ? 'Restore' : 'Archive'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{boards.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-sm mb-4" style={{ color: 'var(--text-tertiary)' }}>No boards yet</p>
|
||||||
|
<button onClick={() => setShowCreate(true)} className="btn btn-admin">Create first board</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit modal */}
|
||||||
|
{showCreate && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-bold mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||||
|
{editBoard ? 'Edit Board' : 'New Board'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Name</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
name: e.target.value,
|
||||||
|
slug: editBoard ? f.slug : slugify(e.target.value),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
placeholder="Feature Requests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Slug</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
|
||||||
|
placeholder="feature-requests"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Description</label>
|
||||||
|
<textarea
|
||||||
|
className="input"
|
||||||
|
rows={2}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="What is this board for?"
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Vote Budget</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.voteBudget}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, voteBudget: parseInt(e.target.value) || 10 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Reset Schedule</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={form.voteResetSchedule}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, voteResetSchedule: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button onClick={resetForm} className="btn btn-secondary flex-1">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !form.name.trim() || !form.slug.trim()}
|
||||||
|
className="btn btn-admin flex-1"
|
||||||
|
style={{ opacity: saving ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : editBoard ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
packages/web/src/pages/admin/AdminDashboard.tsx
Normal file
135
packages/web/src/pages/admin/AdminDashboard.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalPosts: number
|
||||||
|
byStatus: Record<string, number>
|
||||||
|
thisWeek: number
|
||||||
|
topUnresolved: { id: string; title: string; voteCount: number; boardSlug: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get<Stats>('/admin/stats')
|
||||||
|
.then(setStats)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const statCards = stats ? [
|
||||||
|
{ label: 'Total Posts', value: stats.totalPosts, color: 'var(--admin-accent)' },
|
||||||
|
{ label: 'This Week', value: stats.thisWeek, color: 'var(--accent)' },
|
||||||
|
{ label: 'Open', value: stats.byStatus['OPEN'] || 0, color: 'var(--warning)' },
|
||||||
|
{ label: 'In Progress', value: stats.byStatus['IN_PROGRESS'] || 0, color: 'var(--info)' },
|
||||||
|
{ label: 'Done', value: stats.byStatus['DONE'] || 0, color: 'var(--success)' },
|
||||||
|
{ label: 'Declined', value: stats.byStatus['DECLINED'] || 0, color: 'var(--error)' },
|
||||||
|
] : []
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: '/admin/posts', label: 'Manage Posts', desc: 'View, filter, and respond to all posts' },
|
||||||
|
{ to: '/admin/boards', label: 'Manage Boards', desc: 'Create, edit, and archive feedback boards' },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
<Link to="/" className="btn btn-ghost text-sm">
|
||||||
|
View public site
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-8">
|
||||||
|
{statCards.map((s) => (
|
||||||
|
<div key={s.label} className="card p-4 fade-in">
|
||||||
|
<div className="text-2xl font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: s.color }}>
|
||||||
|
{s.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav links */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-3 mb-8">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link key={link.to} to={link.to} className="card p-5 block group">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</h3>
|
||||||
|
<svg
|
||||||
|
width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||||
|
style={{ color: 'var(--text-tertiary)', transition: 'transform 200ms ease-out' }}
|
||||||
|
className="group-hover:translate-x-0.5"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{link.desc}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top unresolved */}
|
||||||
|
{stats && stats.topUnresolved.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className="text-sm font-semibold mb-3"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
Most Voted Unresolved
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{stats.topUnresolved.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to={`/b/${p.boardSlug}/post/${p.id}`}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg"
|
||||||
|
style={{ transition: 'background 200ms ease-out' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-hover)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-sm font-semibold w-8 text-center"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{p.voteCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm flex-1 truncate" style={{ color: 'var(--text)' }}>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
packages/web/src/pages/admin/AdminLogin.tsx
Normal file
86
packages/web/src/pages/admin/AdminLogin.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
|
||||||
|
export default function AdminLogin() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const nav = useNavigate()
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!email || !password) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/admin/login', { email, password })
|
||||||
|
nav('/admin')
|
||||||
|
} catch {
|
||||||
|
setError('Invalid credentials')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4" style={{ background: 'var(--bg)' }}>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm p-6 rounded-xl shadow-xl fade-in"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xl font-bold mb-1"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||||
|
>
|
||||||
|
Admin Login
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Echoboard administration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ borderColor: error ? 'var(--error)' : undefined }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--error)' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn w-full mt-1"
|
||||||
|
style={{
|
||||||
|
background: 'var(--admin-accent)',
|
||||||
|
color: '#141420',
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
259
packages/web/src/pages/admin/AdminPosts.tsx
Normal file
259
packages/web/src/pages/admin/AdminPosts.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../../lib/api'
|
||||||
|
import StatusBadge from '../../components/StatusBadge'
|
||||||
|
|
||||||
|
interface Post {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
status: string
|
||||||
|
voteCount: number
|
||||||
|
commentCount: number
|
||||||
|
authorName: string
|
||||||
|
boardSlug: string
|
||||||
|
boardName: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortField = 'createdAt' | 'voteCount' | 'status'
|
||||||
|
|
||||||
|
const allStatuses = ['OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED']
|
||||||
|
|
||||||
|
export default function AdminPosts() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [sortBy, setSortBy] = useState<SortField>('createdAt')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [actionPost, setActionPost] = useState<Post | null>(null)
|
||||||
|
const [newStatus, setNewStatus] = useState('')
|
||||||
|
const [response, setResponse] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchPosts = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const params = new URLSearchParams({ sort: sortBy })
|
||||||
|
if (statusFilter) params.set('status', statusFilter)
|
||||||
|
if (search) params.set('q', search)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const p = await api.get<Post[]>(`/admin/posts?${params}`)
|
||||||
|
setPosts(p)
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchPosts() }, [sortBy, statusFilter, search])
|
||||||
|
|
||||||
|
const handleStatusChange = async () => {
|
||||||
|
if (!actionPost || !newStatus) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.patch(`/admin/posts/${actionPost.id}`, {
|
||||||
|
status: newStatus,
|
||||||
|
response: response || undefined,
|
||||||
|
})
|
||||||
|
setActionPost(null)
|
||||||
|
setNewStatus('')
|
||||||
|
setResponse('')
|
||||||
|
fetchPosts()
|
||||||
|
} catch {} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Delete this post?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/posts/${id}`)
|
||||||
|
fetchPosts()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}
|
||||||
|
>
|
||||||
|
Posts
|
||||||
|
</h1>
|
||||||
|
<Link to="/admin" className="btn btn-ghost text-sm">Back to dashboard</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 mb-6">
|
||||||
|
<input
|
||||||
|
className="input flex-1 min-w-[200px]"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 160 }}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{allStatuses.map((s) => (
|
||||||
|
<option key={s} value={s}>{s.replace('_', ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ maxWidth: 160 }}
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as SortField)}
|
||||||
|
>
|
||||||
|
<option value="createdAt">Newest</option>
|
||||||
|
<option value="voteCount">Most Voted</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 border-2 rounded-full"
|
||||||
|
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--admin-accent)', animation: 'spin 0.6s linear infinite' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Title</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Board</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Status</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Votes</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-xs" style={{ color: 'var(--text-tertiary)' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<tr
|
||||||
|
key={post.id}
|
||||||
|
style={{ borderBottom: '1px solid var(--border)' }}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link
|
||||||
|
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{post.authorName} - {new Date(post.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{post.boardName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={post.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right" style={{ color: 'var(--accent)' }}>
|
||||||
|
{post.voteCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setActionPost(post); setNewStatus(post.status) }}
|
||||||
|
className="btn btn-ghost text-xs px-2 py-1"
|
||||||
|
style={{ color: 'var(--admin-accent)' }}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(post.id)}
|
||||||
|
className="btn btn-ghost text-xs px-2 py-1"
|
||||||
|
style={{ color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{posts.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
No posts found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action modal */}
|
||||||
|
{actionPost && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||||
|
onClick={() => setActionPost(null)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 fade-in" style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }} />
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md mx-4 p-6 rounded-xl shadow-2xl slide-up"
|
||||||
|
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-bold mb-1" style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)' }}>
|
||||||
|
Manage Post
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm mb-4 truncate" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{actionPost.title}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="input mb-4"
|
||||||
|
value={newStatus}
|
||||||
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
{allStatuses.map((s) => (
|
||||||
|
<option key={s} value={s}>{s.replace('_', ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
Admin Response (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="input mb-4"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Add a public response..."
|
||||||
|
value={response}
|
||||||
|
onChange={(e) => setResponse(e.target.value)}
|
||||||
|
style={{ resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setActionPost(null)} className="btn btn-secondary flex-1">Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStatusChange}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-admin flex-1"
|
||||||
|
style={{ opacity: saving ? 0.6 : 1 }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Update'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
packages/web/src/vite-env.d.ts
vendored
Normal file
1
packages/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
16
packages/web/tsconfig.json
Normal file
16
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
packages/web/vite.config.ts
Normal file
15
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
17
plugins/gitea/package.json
Normal file
17
plugins/gitea/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@echoboard/plugin-gitea",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@echoboard/api": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
plugins/gitea/src/index.ts
Normal file
143
plugins/gitea/src/index.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
interface GiteaConfig {
|
||||||
|
url: string;
|
||||||
|
apiToken: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
syncOnStartup: boolean;
|
||||||
|
syncCron: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(): GiteaConfig {
|
||||||
|
const url = process.env.PLUGIN_GITEA_URL;
|
||||||
|
const apiToken = process.env.PLUGIN_GITEA_API_TOKEN;
|
||||||
|
const webhookSecret = process.env.PLUGIN_GITEA_WEBHOOK_SECRET;
|
||||||
|
|
||||||
|
if (!url || !apiToken || !webhookSecret) {
|
||||||
|
throw new Error('Missing required PLUGIN_GITEA_* env vars');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: url.replace(/\/$/, ''),
|
||||||
|
apiToken,
|
||||||
|
webhookSecret,
|
||||||
|
syncOnStartup: process.env.PLUGIN_GITEA_SYNC_ON_STARTUP !== 'false',
|
||||||
|
syncCron: process.env.PLUGIN_GITEA_SYNC_CRON || '0 */6 * * *',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRepos(config: GiteaConfig) {
|
||||||
|
const repos: Array<{ id: number; name: string; full_name: string; html_url: string; description: string }> = [];
|
||||||
|
let page = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = await fetch(
|
||||||
|
`${config.url}/api/v1/repos/search?token=${config.apiToken}&limit=50&page=${page}`
|
||||||
|
);
|
||||||
|
if (!res.ok) break;
|
||||||
|
|
||||||
|
const body = await res.json() as { data: typeof repos };
|
||||||
|
if (!body.data?.length) break;
|
||||||
|
|
||||||
|
repos.push(...body.data);
|
||||||
|
if (body.data.length < 50) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return repos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
|
||||||
|
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const giteaPlugin = {
|
||||||
|
name: 'gitea',
|
||||||
|
version: '0.1.0',
|
||||||
|
|
||||||
|
onRegister(app: FastifyInstance) {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
app.post('/api/v1/plugins/gitea/webhook', async (req, reply) => {
|
||||||
|
const sig = req.headers['x-gitea-signature'] as string;
|
||||||
|
const rawBody = JSON.stringify(req.body);
|
||||||
|
|
||||||
|
if (!sig || !verifyWebhookSignature(rawBody, sig, config.webhookSecret)) {
|
||||||
|
return reply.status(401).send({ error: 'invalid signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = req.headers['x-gitea-event'] as string;
|
||||||
|
const body = req.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (event === 'repository' && body.action === 'created') {
|
||||||
|
const repo = body.repository as { id: number; name: string; html_url: string; description: string };
|
||||||
|
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
|
||||||
|
const { prisma } = app as unknown as { prisma: { board: { upsert: Function } } };
|
||||||
|
await prisma.board.upsert({
|
||||||
|
where: { slug },
|
||||||
|
create: {
|
||||||
|
slug,
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description || null,
|
||||||
|
externalUrl: repo.html_url,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'repository' && body.action === 'deleted') {
|
||||||
|
const repo = body.repository as { name: string };
|
||||||
|
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
|
||||||
|
const { prisma } = app as unknown as { prisma: { board: { updateMany: Function } } };
|
||||||
|
await prisma.board.updateMany({
|
||||||
|
where: { slug },
|
||||||
|
data: { isArchived: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(200).send({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/plugins/gitea/sync-status', async (_req, reply) => {
|
||||||
|
return reply.send({ status: 'ok', lastSync: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/plugins/gitea/sync', async (_req, reply) => {
|
||||||
|
const repos = await fetchRepos(config);
|
||||||
|
const { prisma } = app as unknown as { prisma: { board: { upsert: Function } } };
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
await prisma.board.upsert({
|
||||||
|
where: { slug },
|
||||||
|
create: {
|
||||||
|
slug,
|
||||||
|
name: repo.name,
|
||||||
|
description: repo.description || null,
|
||||||
|
externalUrl: repo.html_url,
|
||||||
|
},
|
||||||
|
update: { externalUrl: repo.html_url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ synced: repos.length });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async onStartup() {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config.syncOnStartup) return;
|
||||||
|
// initial sync handled by the route handler logic
|
||||||
|
// in production this would call fetchRepos and sync
|
||||||
|
},
|
||||||
|
|
||||||
|
getAdminRoutes() {
|
||||||
|
return [
|
||||||
|
{ path: '/admin/gitea', label: 'Gitea Sync', icon: 'git-branch' },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
8
plugins/gitea/tsconfig.json
Normal file
8
plugins/gitea/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user