Initial commit: Pokerface sprint planning poker for Jira
Full-stack app with Express/Socket.io backend, React frontend, NATS JetStream for state, and Atlassian Jira OAuth integration. Includes security hardening: NATS auth support, KV bucket TTL enforcement, CAS retry for race conditions, error message sanitization, and OAuth state stored in NATS KV. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fdd9ba8d56
36 changed files with 7596 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.env
|
||||
.DS_Store
|
||||
backend/node_modules
|
||||
frontend/node_modules
|
||||
backend/prisma/migrations
|
||||
frontend/dist
|
||||
backend/.env
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
FROM node:22-alpine AS frontend
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY backend/ ./
|
||||
|
||||
COPY --from=frontend /app/frontend/dist /app/frontend/dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4010
|
||||
|
||||
EXPOSE 4010
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
PORT=4010
|
||||
FRONTEND_URL=http://localhost:5174
|
||||
NATS_URL=nats://localhost:4222
|
||||
|
||||
# Jira OAuth (Atlassian 3LO)
|
||||
JIRA_CLIENT_ID=
|
||||
JIRA_CLIENT_SECRET=
|
||||
JIRA_OAUTH_REDIRECT_URI=http://localhost:4010/api/jira/oauth/callback
|
||||
JIRA_SCOPES="offline_access read:jira-work write:jira-work read:me"
|
||||
JWT_SECRET=change-me-to-a-random-secret
|
||||
JIRA_STORY_POINTS_FIELD=customfield_10016
|
||||
JIRA_MOCK_FALLBACK=true
|
||||
1648
backend/package-lock.json
generated
Normal file
1648
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
24
backend/package.json
Normal file
24
backend/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "pokerface-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mickl/socket.io-nats-adapter": "^2.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"nats": "^2.28.2",
|
||||
"socket.io": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.7"
|
||||
}
|
||||
}
|
||||
294
backend/src/index.js
Normal file
294
backend/src/index.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import dotenv from 'dotenv';
|
||||
import { Server } from 'socket.io';
|
||||
import natsAdapter from '@mickl/socket.io-nats-adapter';
|
||||
const { createAdapter } = natsAdapter;
|
||||
import { connectNats, getNatsConnection } from './lib/nats.js';
|
||||
import pokerRoutes from './routes/poker.js';
|
||||
import jiraRoutes from './routes/jira.js';
|
||||
import roomRoutes from './routes/rooms.js';
|
||||
import {
|
||||
canAccessSession,
|
||||
getSessionSnapshot,
|
||||
isSessionParticipant,
|
||||
joinSession,
|
||||
leaveSession,
|
||||
revealIfComplete,
|
||||
saveScopedEstimate,
|
||||
submitVote
|
||||
} from './services/pokerService.js';
|
||||
import { updateIssueEstimate } from './services/jiraService.js';
|
||||
import { getSocketUser } from './middleware/auth.js';
|
||||
import { safeError } from './lib/errors.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
|
||||
const port = Number(process.env.PORT || 4010);
|
||||
if (isProd && !process.env.FRONTEND_URL) {
|
||||
throw new Error('FRONTEND_URL must be set in production.');
|
||||
}
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5174';
|
||||
const corsOptions = { origin: frontendUrl, credentials: true };
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
if (!origin) return !isProd;
|
||||
return origin === frontendUrl;
|
||||
}
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
if (isProd) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/api/poker', pokerRoutes);
|
||||
app.use('/api/jira', jiraRoutes);
|
||||
app.use('/api/poker/rooms', roomRoutes);
|
||||
|
||||
if (isProd) {
|
||||
const distPath = path.resolve(__dirname, '../../frontend/dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
const io = new Server(httpServer, {
|
||||
cors: corsOptions,
|
||||
allowRequest: (req, callback) => {
|
||||
if (isAllowedOrigin(req.headers.origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback('Origin not allowed', false);
|
||||
}
|
||||
});
|
||||
|
||||
async function emitSessionState(sessionId) {
|
||||
const snapshot = await getSessionSnapshot(sessionId);
|
||||
if (!snapshot) return;
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:participants', {
|
||||
sessionId,
|
||||
participants: snapshot.participants,
|
||||
votedUserKeys: snapshot.votedUserKeys,
|
||||
voteCount: snapshot.voteCount,
|
||||
participantCount: snapshot.participantCount
|
||||
});
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:vote-update', {
|
||||
sessionId,
|
||||
voteCount: snapshot.voteCount,
|
||||
participantCount: snapshot.participantCount,
|
||||
votedUserKeys: snapshot.votedUserKeys,
|
||||
allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount
|
||||
});
|
||||
|
||||
if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') {
|
||||
io.to(`poker:${sessionId}`).emit('poker:revealed', {
|
||||
sessionId,
|
||||
votes: snapshot.votesByUser,
|
||||
average: snapshot.session.averageEstimate,
|
||||
suggestedEstimate: snapshot.session.suggestedEstimate,
|
||||
savedEstimate: snapshot.session.savedEstimate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = getSocketUser(socket);
|
||||
if (!user || !user.jiraCloudId) {
|
||||
socket.emit('poker:error', { error: 'Not authenticated' });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
socket.user = user;
|
||||
|
||||
socket.on('poker:join', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(`poker:${sessionId}`);
|
||||
const snapshot = await joinSession({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId,
|
||||
userName: socket.user.displayName,
|
||||
avatarUrl: socket.user.avatarUrl || null
|
||||
});
|
||||
if (!snapshot) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
await emitSessionState(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:join failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:vote', async ({ sessionId, vote }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session before voting.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const voteResult = await submitVote({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId,
|
||||
vote
|
||||
});
|
||||
if (!voteResult) {
|
||||
socket.emit('poker:error', { error: 'Unable to submit vote for this session.' });
|
||||
return;
|
||||
}
|
||||
const reveal = await revealIfComplete(sessionId);
|
||||
|
||||
await emitSessionState(sessionId);
|
||||
|
||||
if (reveal?.allVoted) {
|
||||
io.to(`poker:${sessionId}`).emit('poker:revealed', {
|
||||
sessionId,
|
||||
votes: reveal.votesByUser,
|
||||
average: reveal.average,
|
||||
suggestedEstimate: reveal.suggestedEstimate
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:vote failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:save', async ({ sessionId, estimate }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session before saving.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const numericEstimate = Number(estimate);
|
||||
if (!Number.isFinite(numericEstimate)) {
|
||||
socket.emit('poker:error', { error: 'estimate must be a number.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await saveScopedEstimate({
|
||||
sessionId,
|
||||
estimate: numericEstimate,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId
|
||||
});
|
||||
if (!saved) {
|
||||
socket.emit('poker:error', { error: 'Unable to save estimate for this session.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const issueRef = saved.session.issueId || saved.session.issueKey;
|
||||
try {
|
||||
await updateIssueEstimate(socket.user.jiraAccountId, issueRef, numericEstimate, saved.session.boardId);
|
||||
} catch (_jiraError) {
|
||||
// Jira update is best-effort so poker flow continues even when Jira is unavailable.
|
||||
}
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:saved', {
|
||||
sessionId,
|
||||
estimate: numericEstimate,
|
||||
issueKey: saved.session.issueKey
|
||||
});
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId });
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:save failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:leave', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) return;
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return;
|
||||
await leaveSession({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId
|
||||
});
|
||||
socket.leave(`poker:${sessionId}`);
|
||||
await emitSessionState(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:leave failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function start() {
|
||||
const nc = await connectNats();
|
||||
io.adapter(createAdapter(nc));
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Pokerface backend listening on :${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
5
backend/src/lib/errors.js
Normal file
5
backend/src/lib/errors.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
export function safeError(error) {
|
||||
return isProd ? 'Internal server error.' : error.message;
|
||||
}
|
||||
78
backend/src/lib/nats.js
Normal file
78
backend/src/lib/nats.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { connect } from 'nats';
|
||||
|
||||
const natsUrl = process.env.NATS_URL || 'nats://localhost:4222';
|
||||
const BUCKET_TTL_MS = 24 * 60 * 60 * 1000; // 24h in milliseconds
|
||||
|
||||
let nc = null;
|
||||
let js = null;
|
||||
let kvOAuth = null;
|
||||
let kvRooms = null;
|
||||
let kvSprintIndex = null;
|
||||
let kvActiveRooms = null;
|
||||
let kvSessions = null;
|
||||
let kvSessionIndex = null;
|
||||
let kvOAuthState = null;
|
||||
|
||||
export async function connectNats() {
|
||||
const opts = { servers: natsUrl };
|
||||
if (process.env.NATS_TOKEN) {
|
||||
opts.token = process.env.NATS_TOKEN;
|
||||
} else if (process.env.NATS_USER) {
|
||||
opts.user = process.env.NATS_USER;
|
||||
opts.pass = process.env.NATS_PASS || '';
|
||||
}
|
||||
nc = await connect(opts);
|
||||
js = nc.jetstream();
|
||||
|
||||
async function getOrCreateKv(name, ttl = BUCKET_TTL_MS) {
|
||||
const kv = await js.views.kv(name, { ttl });
|
||||
// Enforce TTL on existing buckets whose config may be stale
|
||||
const jsm = await nc.jetstreamManager();
|
||||
const info = await jsm.streams.info(`KV_${name}`);
|
||||
if (info.config.max_age !== ttl * 1_000_000) { // NATS uses nanoseconds
|
||||
info.config.max_age = ttl * 1_000_000;
|
||||
await jsm.streams.update(info.config.name, info.config);
|
||||
}
|
||||
return kv;
|
||||
}
|
||||
|
||||
kvOAuth = await getOrCreateKv('oauth');
|
||||
kvRooms = await getOrCreateKv('rooms');
|
||||
kvSprintIndex = await getOrCreateKv('rooms-sprint-index');
|
||||
kvActiveRooms = await getOrCreateKv('active-rooms');
|
||||
kvSessions = await getOrCreateKv('sessions');
|
||||
kvSessionIndex = await getOrCreateKv('session-issue-index');
|
||||
kvOAuthState = await getOrCreateKv('oauth-state', 10 * 60 * 1000); // 10min TTL
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Connected to NATS at', natsUrl);
|
||||
|
||||
return nc;
|
||||
}
|
||||
|
||||
export function getNatsConnection() {
|
||||
return nc;
|
||||
}
|
||||
|
||||
export async function withCasRetry(kv, key, transformFn, maxRetries = 5) {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const entry = await kv.get(key);
|
||||
const current = entry ? entry.json() : null;
|
||||
const revision = entry ? entry.revision : 0;
|
||||
const result = await transformFn(current, revision);
|
||||
if (result === undefined) return current; // no-op
|
||||
try {
|
||||
if (revision === 0) {
|
||||
await kv.create(key, JSON.stringify(result));
|
||||
} else {
|
||||
await kv.update(key, JSON.stringify(result), revision);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (attempt === maxRetries) throw err;
|
||||
// CAS conflict — retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { nc, js, kvOAuth, kvRooms, kvSprintIndex, kvActiveRooms, kvSessions, kvSessionIndex, kvOAuthState };
|
||||
74
backend/src/middleware/auth.js
Normal file
74
backend/src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const COOKIE_NAME = 'pokerface_session';
|
||||
const JWT_EXPIRY = '24h';
|
||||
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function getSecret() {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new Error('Missing JWT_SECRET env var');
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function createSessionToken(user) {
|
||||
return jwt.sign(
|
||||
{
|
||||
jiraAccountId: user.jiraAccountId,
|
||||
jiraCloudId: user.jiraCloudId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
getSecret(),
|
||||
{ expiresIn: JWT_EXPIRY }
|
||||
);
|
||||
}
|
||||
|
||||
export function setSessionCookie(res, token) {
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: SESSION_MAX_AGE_MS
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res) {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, getSecret());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req, res, next) {
|
||||
const token = req.cookies?.[COOKIE_NAME];
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const user = verifyToken(token);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid or expired session' });
|
||||
}
|
||||
|
||||
if (!user.jiraAccountId || !user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Invalid session tenant context' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
export function getSocketUser(socket) {
|
||||
const cookieHeader = socket.handshake.headers.cookie;
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
|
||||
if (!match) return null;
|
||||
|
||||
return verifyToken(match[1]);
|
||||
}
|
||||
175
backend/src/routes/jira.js
Normal file
175
backend/src/routes/jira.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import crypto from 'crypto';
|
||||
import express from 'express';
|
||||
import {
|
||||
buildOAuthUrl,
|
||||
exchangeCodeForToken,
|
||||
getProjects,
|
||||
getBoards,
|
||||
getBoardSprints,
|
||||
getSprintIssues,
|
||||
saveOAuthConnection,
|
||||
updateIssueEstimate
|
||||
} from '../services/jiraService.js';
|
||||
import {
|
||||
createSessionToken,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
requireAuth
|
||||
} from '../middleware/auth.js';
|
||||
import { kvOAuthState } from '../lib/nats.js';
|
||||
import { safeError } from '../lib/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/oauth/start', async (_req, res) => {
|
||||
try {
|
||||
const state = crypto.randomBytes(24).toString('hex');
|
||||
await kvOAuthState.put(state, 'valid');
|
||||
|
||||
const url = buildOAuthUrl(state);
|
||||
res.json({ url });
|
||||
} catch (error) {
|
||||
console.error('[oauth] Start failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/oauth/callback', async (req, res) => {
|
||||
const { state, code } = req.query;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const frontendUrl = process.env.FRONTEND_URL || (isProd ? '' : 'http://localhost:5174');
|
||||
|
||||
try {
|
||||
const entry = await kvOAuthState.get(String(state));
|
||||
if (!state || !entry) {
|
||||
return res.status(400).send('Invalid OAuth state.');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).send('Missing OAuth code.');
|
||||
}
|
||||
|
||||
await kvOAuthState.delete(String(state));
|
||||
|
||||
const tokenPayload = await exchangeCodeForToken(String(code));
|
||||
const { connection, profile } = await saveOAuthConnection(tokenPayload);
|
||||
if (!connection.cloudId) {
|
||||
throw new Error('No Jira cloud site available for this account.');
|
||||
}
|
||||
|
||||
const jwt = createSessionToken({
|
||||
jiraAccountId: profile.accountId,
|
||||
jiraCloudId: connection.cloudId,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl
|
||||
});
|
||||
setSessionCookie(res, jwt);
|
||||
|
||||
res.redirect(`${frontendUrl}?auth=success`);
|
||||
} catch (error) {
|
||||
console.error('[oauth] Callback failed:', error.message);
|
||||
const safeMessage = isProd ? 'Jira authentication failed.' : error.message;
|
||||
res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({
|
||||
jiraAccountId: req.user.jiraAccountId,
|
||||
jiraCloudId: req.user.jiraCloudId,
|
||||
displayName: req.user.displayName,
|
||||
avatarUrl: req.user.avatarUrl
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (_req, res) => {
|
||||
clearSessionCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/projects', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const projects = await getProjects(req.user.jiraAccountId);
|
||||
res.json({ projects });
|
||||
} catch (error) {
|
||||
console.error('[jira] Projects failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/boards', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { projectKeyOrId } = req.query;
|
||||
if (!projectKeyOrId) {
|
||||
return res.status(400).json({ error: 'projectKeyOrId query parameter is required.' });
|
||||
}
|
||||
const boards = await getBoards(req.user.jiraAccountId, projectKeyOrId);
|
||||
res.json({ boards });
|
||||
} catch (error) {
|
||||
console.error('[jira] Boards failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/boards/:boardId/sprints', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sprints = await getBoardSprints(req.user.jiraAccountId, req.params.boardId);
|
||||
res.json({ sprints });
|
||||
} catch (error) {
|
||||
console.error('[jira] Sprints failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
res.json({ connected: true, jiraAccountId: req.user.jiraAccountId });
|
||||
} catch (error) {
|
||||
console.error('[jira] Status failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sprints/:sprintId/issues', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { sprintId } = req.params;
|
||||
const { boardId } = req.query;
|
||||
const issues = await getSprintIssues(req.user.jiraAccountId, String(sprintId), boardId ? String(boardId) : null);
|
||||
res.json({ issues, source: 'jira' });
|
||||
} catch (error) {
|
||||
if (process.env.JIRA_MOCK_FALLBACK === 'true') {
|
||||
const sprintId = req.params.sprintId;
|
||||
const issues = [
|
||||
{ id: `mock-${sprintId}-1`, key: `MOCK-${sprintId}1`, title: 'Authentication hardening', description: 'Harden authentication flow against common attacks.', estimate: 0, status: 'To Do', assignee: 'Alice' },
|
||||
{ id: `mock-${sprintId}-2`, key: `MOCK-${sprintId}2`, title: 'Webhook retries', description: 'Add retry logic with exponential backoff for failed webhooks.', estimate: 0, status: 'To Do', assignee: 'Bob' },
|
||||
{ id: `mock-${sprintId}-3`, key: `MOCK-${sprintId}3`, title: 'Billing summary endpoint', description: 'Create GET /billing/summary endpoint.', estimate: 5, status: 'In Progress', assignee: 'Eve' }
|
||||
];
|
||||
return res.json({ issues, source: 'mock' });
|
||||
}
|
||||
|
||||
console.error('[jira] Sprint issues failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/issues/:issueIdOrKey/estimate', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { issueIdOrKey } = req.params;
|
||||
const { estimate, boardId } = req.body;
|
||||
|
||||
if (estimate === undefined || estimate === null) {
|
||||
return res.status(400).json({ error: 'estimate is required.' });
|
||||
}
|
||||
if (!boardId) {
|
||||
return res.status(400).json({ error: 'boardId is required.' });
|
||||
}
|
||||
|
||||
await updateIssueEstimate(req.user.jiraAccountId, issueIdOrKey, Number(estimate), String(boardId));
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[jira] Estimate update failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
65
backend/src/routes/poker.js
Normal file
65
backend/src/routes/poker.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { canAccessSession, createScopedSession, getSessionSnapshot } from '../services/pokerService.js';
|
||||
import { getRoomById } from '../services/roomService.js';
|
||||
import { safeError } from '../lib/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/sessions', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { issueKey, issueId, issueTitle, roomId } = req.body;
|
||||
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
if (!issueKey || !issueTitle) {
|
||||
return res.status(400).json({ error: 'issueKey and issueTitle are required.' });
|
||||
}
|
||||
if (!roomId) {
|
||||
return res.status(400).json({ error: 'roomId is required.' });
|
||||
}
|
||||
|
||||
const room = await getRoomById(String(roomId), req.user.jiraCloudId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found.' });
|
||||
}
|
||||
|
||||
const snapshot = await createScopedSession({
|
||||
issueKey,
|
||||
issueId,
|
||||
issueTitle,
|
||||
roomId: String(roomId),
|
||||
boardId: room.boardId,
|
||||
tenantCloudId: req.user.jiraCloudId
|
||||
});
|
||||
return res.json({ session: snapshot.session });
|
||||
} catch (error) {
|
||||
console.error('[poker] Create session failed:', error);
|
||||
return res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
if (!await canAccessSession(req.params.sessionId, req.user.jiraCloudId)) {
|
||||
return res.status(404).json({ error: 'Session not found.' });
|
||||
}
|
||||
|
||||
const snapshot = await getSessionSnapshot(req.params.sessionId);
|
||||
if (!snapshot) {
|
||||
return res.status(404).json({ error: 'Session not found.' });
|
||||
}
|
||||
res.json(snapshot);
|
||||
} catch (error) {
|
||||
console.error('[poker] Get session failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
98
backend/src/routes/rooms.js
Normal file
98
backend/src/routes/rooms.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { createRoom, getActiveRooms, getRoomById, deleteRoom } from '../services/roomService.js';
|
||||
import { safeError } from '../lib/errors.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const { boardId, projectKey, projectName, sprintId, sprintName } = req.body;
|
||||
|
||||
if (!boardId || !sprintId || !sprintName) {
|
||||
return res.status(400).json({ error: 'boardId, sprintId, and sprintName are required.' });
|
||||
}
|
||||
|
||||
const numericBoardId = Number(boardId);
|
||||
const numericSprintId = Number(sprintId);
|
||||
if (!Number.isInteger(numericBoardId) || !Number.isInteger(numericSprintId) || numericBoardId <= 0 || numericSprintId <= 0) {
|
||||
return res.status(400).json({ error: 'boardId and sprintId must be valid integers.' });
|
||||
}
|
||||
|
||||
const room = await createRoom({
|
||||
cloudId: req.user.jiraCloudId,
|
||||
boardId: numericBoardId,
|
||||
projectKey: projectKey || '',
|
||||
projectName: projectName || '',
|
||||
sprintId: numericSprintId,
|
||||
sprintName,
|
||||
createdByAccountId: req.user.jiraAccountId,
|
||||
createdByName: req.user.displayName
|
||||
});
|
||||
|
||||
res.json({ room });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Create room failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const rooms = await getActiveRooms(req.user.jiraCloudId);
|
||||
res.json({ rooms });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Get active rooms failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:roomId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found.' });
|
||||
}
|
||||
res.json({ room });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Get room failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:roomId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.jiraCloudId) {
|
||||
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
|
||||
}
|
||||
|
||||
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Room not found.' });
|
||||
}
|
||||
|
||||
if (room.createdByAccountId !== req.user.jiraAccountId) {
|
||||
return res.status(403).json({ error: 'Only the room creator can delete this room.' });
|
||||
}
|
||||
|
||||
await deleteRoom(req.params.roomId, req.user.jiraCloudId);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[rooms] Delete room failed:', error);
|
||||
res.status(500).json({ error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
285
backend/src/services/jiraService.js
Normal file
285
backend/src/services/jiraService.js
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { kvOAuth } from '../lib/nats.js';
|
||||
|
||||
const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
|
||||
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
|
||||
const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources';
|
||||
const ATLASSIAN_ME_URL = 'https://api.atlassian.com/me';
|
||||
|
||||
function getRequiredEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing required env var: ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function getExpiresAt(expiresInSeconds) {
|
||||
if (!expiresInSeconds) return null;
|
||||
return Date.now() + (Number(expiresInSeconds) - 60) * 1000;
|
||||
}
|
||||
|
||||
export function buildOAuthUrl(state) {
|
||||
const clientId = getRequiredEnv('JIRA_CLIENT_ID');
|
||||
const redirectUri = getRequiredEnv('JIRA_OAUTH_REDIRECT_URI');
|
||||
const scope = process.env.JIRA_SCOPES || 'offline_access read:jira-work write:jira-work read:me';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
audience: 'api.atlassian.com',
|
||||
client_id: clientId,
|
||||
scope,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
response_type: 'code',
|
||||
prompt: 'consent'
|
||||
});
|
||||
|
||||
return `${ATLASSIAN_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeCodeForToken(code) {
|
||||
const body = {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
|
||||
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
|
||||
code,
|
||||
redirect_uri: getRequiredEnv('JIRA_OAUTH_REDIRECT_URI')
|
||||
};
|
||||
|
||||
const response = await fetch(ATLASSIAN_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function getAccessibleResources(accessToken) {
|
||||
const response = await fetch(ATLASSIAN_RESOURCES_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to load Jira resources: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchJiraProfile(accessToken) {
|
||||
const response = await fetch(ATLASSIAN_ME_URL, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to fetch Jira profile: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
accountId: data.account_id,
|
||||
displayName: data.name || data.displayName || 'Unknown',
|
||||
avatarUrl: data.picture || null,
|
||||
email: data.email || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveOAuthConnection(tokenPayload) {
|
||||
const profile = await fetchJiraProfile(tokenPayload.access_token);
|
||||
const resources = await getAccessibleResources(tokenPayload.access_token);
|
||||
const primary = resources[0] || {};
|
||||
|
||||
const connection = {
|
||||
jiraAccountId: profile.accountId,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
email: profile.email,
|
||||
cloudId: primary.id || null,
|
||||
siteUrl: primary.url || null,
|
||||
accessToken: tokenPayload.access_token,
|
||||
refreshToken: tokenPayload.refresh_token || null,
|
||||
scope: tokenPayload.scope || null,
|
||||
expiresAt: getExpiresAt(tokenPayload.expires_in)
|
||||
};
|
||||
|
||||
await kvOAuth.put(`oauth.${profile.accountId}`, JSON.stringify(connection));
|
||||
|
||||
return { connection, profile };
|
||||
}
|
||||
|
||||
async function refreshAccessToken(connection) {
|
||||
if (!connection.refreshToken) {
|
||||
throw new Error('Jira refresh token is missing. Reconnect Jira OAuth.');
|
||||
}
|
||||
|
||||
const body = {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
|
||||
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
|
||||
refresh_token: connection.refreshToken
|
||||
};
|
||||
|
||||
const response = await fetch(ATLASSIAN_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const refreshed = await response.json();
|
||||
|
||||
const updated = {
|
||||
...connection,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || connection.refreshToken,
|
||||
scope: refreshed.scope || connection.scope,
|
||||
expiresAt: getExpiresAt(refreshed.expires_in)
|
||||
};
|
||||
|
||||
await kvOAuth.put(`oauth.${connection.jiraAccountId}`, JSON.stringify(updated));
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function getValidConnection(jiraAccountId) {
|
||||
const entry = await kvOAuth.get(`oauth.${jiraAccountId}`);
|
||||
if (!entry) throw new Error('Jira is not connected for this account.');
|
||||
|
||||
const connection = entry.json();
|
||||
if (!connection.accessToken || !connection.cloudId) {
|
||||
throw new Error('Jira is not connected for this account.');
|
||||
}
|
||||
|
||||
if (!connection.expiresAt || connection.expiresAt > Date.now()) {
|
||||
return connection;
|
||||
}
|
||||
|
||||
return refreshAccessToken(connection);
|
||||
}
|
||||
|
||||
async function jiraFetch(jiraAccountId, path, options = {}) {
|
||||
const connection = await getValidConnection(jiraAccountId);
|
||||
const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Jira API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function normalizeIssue(issue) {
|
||||
return {
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
title: issue.fields?.summary || issue.key,
|
||||
description: issue.fields?.description || null,
|
||||
estimate: issue.fields?.[process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016'] || 0,
|
||||
status: issue.fields?.status?.name || 'Unknown',
|
||||
reporter: issue.fields?.reporter?.displayName || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSprintIssues(jiraAccountId, sprintId, boardId) {
|
||||
let spField = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016';
|
||||
if (boardId) {
|
||||
try {
|
||||
const config = await jiraFetch(jiraAccountId, `/rest/agile/1.0/board/${boardId}/configuration`);
|
||||
const estField = config?.estimation?.field?.fieldId;
|
||||
if (estField) spField = estField;
|
||||
} catch (_e) {
|
||||
// fall back to default
|
||||
}
|
||||
}
|
||||
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
`/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=200&jql=${encodeURIComponent('resolution = Unresolved AND issuetype not in subtaskIssueTypes()')}&fields=summary,status,reporter,description,${spField}`
|
||||
);
|
||||
|
||||
return (response?.issues || []).map((issue) => ({
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
title: issue.fields?.summary || issue.key,
|
||||
description: issue.fields?.description || null,
|
||||
estimate: issue.fields?.[spField] || 0,
|
||||
status: issue.fields?.status?.name || 'Unknown',
|
||||
reporter: issue.fields?.reporter?.displayName || null
|
||||
})).filter((issue) => !issue.estimate);
|
||||
}
|
||||
|
||||
export async function updateIssueEstimate(jiraAccountId, issueIdOrKey, estimate, boardId) {
|
||||
await jiraFetch(jiraAccountId, `/rest/agile/1.0/issue/${issueIdOrKey}/estimation?boardId=${boardId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(estimate) })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProjects(jiraAccountId) {
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
'/rest/api/3/project/search?maxResults=100&orderBy=name'
|
||||
);
|
||||
return (response?.values || []).map((project) => ({
|
||||
id: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
avatarUrl: project.avatarUrls?.['24x24'] || null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBoards(jiraAccountId, projectKeyOrId) {
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
`/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}&maxResults=100`
|
||||
);
|
||||
return (response?.values || [])
|
||||
.filter((board) => board.type === 'scrum' || board.type === 'simple')
|
||||
.map((board) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
type: board.type,
|
||||
projectKey: board.location?.projectKey || null,
|
||||
projectName: board.location?.projectName || null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBoardSprints(jiraAccountId, boardId) {
|
||||
const response = await jiraFetch(
|
||||
jiraAccountId,
|
||||
`/rest/agile/1.0/board/${boardId}/sprint?state=active,future`
|
||||
);
|
||||
return (response?.values || []).map((sprint) => ({
|
||||
id: sprint.id,
|
||||
name: sprint.name,
|
||||
state: sprint.state,
|
||||
startDate: sprint.startDate || null,
|
||||
endDate: sprint.endDate || null
|
||||
}));
|
||||
}
|
||||
250
backend/src/services/pokerService.js
Normal file
250
backend/src/services/pokerService.js
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import crypto from 'crypto';
|
||||
import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js';
|
||||
|
||||
const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100];
|
||||
|
||||
function parseVote(vote) {
|
||||
if (vote === null || vote === undefined) return { rawValue: '', numericValue: null };
|
||||
if (typeof vote === 'number') return { rawValue: String(vote), numericValue: vote };
|
||||
if (typeof vote === 'string') {
|
||||
const normalized = vote.trim();
|
||||
const numeric = Number(normalized);
|
||||
return {
|
||||
rawValue: normalized,
|
||||
numericValue: Number.isFinite(numeric) ? numeric : null
|
||||
};
|
||||
}
|
||||
return { rawValue: String(vote), numericValue: null };
|
||||
}
|
||||
|
||||
function nearestFibonacci(value) {
|
||||
return FIBONACCI.reduce((best, candidate) =>
|
||||
Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best
|
||||
);
|
||||
}
|
||||
|
||||
function serializeSession(session) {
|
||||
return {
|
||||
...session,
|
||||
participants: Object.fromEntries(session.participants),
|
||||
votes: Object.fromEntries(session.votes)
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeSession(data) {
|
||||
if (!data) return null;
|
||||
data.participants = new Map(Object.entries(data.participants));
|
||||
data.votes = new Map(Object.entries(data.votes));
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getSession(sessionId) {
|
||||
const entry = await kvSessions.get(sessionId);
|
||||
if (!entry) return null;
|
||||
return deserializeSession(entry.json());
|
||||
}
|
||||
|
||||
async function putSession(session) {
|
||||
await kvSessions.put(session.id, JSON.stringify(serializeSession(session)));
|
||||
}
|
||||
|
||||
async function withSessionCas(sessionId, transformFn) {
|
||||
return withCasRetry(kvSessions, sessionId, (raw) => {
|
||||
const session = deserializeSession(raw);
|
||||
if (!session) return undefined;
|
||||
const result = transformFn(session);
|
||||
if (result === undefined) return undefined;
|
||||
return serializeSession(result);
|
||||
});
|
||||
}
|
||||
|
||||
function getSnapshot(session) {
|
||||
// Accept both Map-based and plain-object sessions
|
||||
const participantsMap = session.participants instanceof Map
|
||||
? session.participants
|
||||
: new Map(Object.entries(session.participants));
|
||||
const votesMap = session.votes instanceof Map
|
||||
? session.votes
|
||||
: new Map(Object.entries(session.votes));
|
||||
|
||||
const participants = [...participantsMap.values()];
|
||||
const votes = Object.fromEntries(votesMap);
|
||||
const votedUserKeys = [...votesMap.keys()];
|
||||
|
||||
return {
|
||||
session: {
|
||||
id: session.id,
|
||||
issueKey: session.issueKey,
|
||||
issueId: session.issueId,
|
||||
issueTitle: session.issueTitle,
|
||||
boardId: session.boardId,
|
||||
state: session.state,
|
||||
averageEstimate: session.averageEstimate,
|
||||
suggestedEstimate: session.suggestedEstimate,
|
||||
savedEstimate: session.savedEstimate
|
||||
},
|
||||
participants,
|
||||
votesByUser: votes,
|
||||
voteCount: votesMap.size,
|
||||
participantCount: participantsMap.size,
|
||||
votedUserKeys
|
||||
};
|
||||
}
|
||||
|
||||
function issueIndexKey(cloudId, issueKey) {
|
||||
return `${cloudId}.${issueKey}`;
|
||||
}
|
||||
|
||||
export async function createScopedSession({ issueKey, issueId, issueTitle, roomId, boardId, tenantCloudId }) {
|
||||
// Check for existing active session via issue index
|
||||
const indexEntry = await kvSessionIndex.get(issueIndexKey(tenantCloudId, issueKey));
|
||||
if (indexEntry) {
|
||||
const existingId = indexEntry.string();
|
||||
const existing = await getSession(existingId);
|
||||
if (existing && existing.tenantCloudId === tenantCloudId) {
|
||||
if (existing.state === 'VOTING') {
|
||||
return getSnapshot(existing);
|
||||
}
|
||||
// Clean up stale revealed/saved sessions
|
||||
await kvSessions.delete(existingId);
|
||||
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, issueKey));
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const session = {
|
||||
id,
|
||||
issueKey,
|
||||
issueId,
|
||||
issueTitle,
|
||||
roomId,
|
||||
boardId,
|
||||
tenantCloudId,
|
||||
createdAt: Date.now(),
|
||||
state: 'VOTING',
|
||||
participants: new Map(),
|
||||
votes: new Map(),
|
||||
averageEstimate: null,
|
||||
suggestedEstimate: null,
|
||||
savedEstimate: null
|
||||
};
|
||||
await putSession(session);
|
||||
await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id);
|
||||
return getSnapshot(session);
|
||||
}
|
||||
|
||||
export async function getSessionSnapshot(sessionId) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return null;
|
||||
return getSnapshot(session);
|
||||
}
|
||||
|
||||
export async function canAccessSession(sessionId, tenantCloudId) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return false;
|
||||
return session.tenantCloudId === tenantCloudId;
|
||||
}
|
||||
|
||||
export async function isSessionParticipant(sessionId, userKey) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return false;
|
||||
return session.participants.has(userKey);
|
||||
}
|
||||
|
||||
export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) {
|
||||
const result = await withSessionCas(sessionId, (session) => {
|
||||
if (session.tenantCloudId !== tenantCloudId) return undefined;
|
||||
session.participants.set(userKey, { userKey, userName, avatarUrl });
|
||||
session.votes.delete(userKey);
|
||||
return session;
|
||||
});
|
||||
if (!result) return null;
|
||||
return getSnapshot(result);
|
||||
}
|
||||
|
||||
export async function leaveSession({ sessionId, tenantCloudId, userKey }) {
|
||||
const result = await withSessionCas(sessionId, (session) => {
|
||||
if (session.tenantCloudId !== tenantCloudId) return undefined;
|
||||
session.participants.delete(userKey);
|
||||
session.votes.delete(userKey);
|
||||
return session;
|
||||
});
|
||||
if (!result) return null;
|
||||
return getSnapshot(result);
|
||||
}
|
||||
|
||||
export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) {
|
||||
const result = await withSessionCas(sessionId, (session) => {
|
||||
if (session.state === 'REVEALED' || session.state === 'SAVED') return undefined;
|
||||
if (session.state !== 'VOTING') return undefined;
|
||||
if (session.tenantCloudId !== tenantCloudId) return undefined;
|
||||
if (!session.participants.has(userKey)) return undefined;
|
||||
const parsed = parseVote(vote);
|
||||
session.votes.set(userKey, parsed.rawValue);
|
||||
return session;
|
||||
});
|
||||
if (!result) {
|
||||
// Return current snapshot for REVEALED/SAVED states
|
||||
const current = await getSession(sessionId);
|
||||
if (current && (current.state === 'REVEALED' || current.state === 'SAVED')) {
|
||||
return getSnapshot(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return getSnapshot(result);
|
||||
}
|
||||
|
||||
export async function revealIfComplete(sessionId) {
|
||||
const result = await withSessionCas(sessionId, (session) => {
|
||||
const allVoted = session.participants.size > 0 &&
|
||||
session.votes.size === session.participants.size;
|
||||
|
||||
if (!allVoted) return undefined; // no mutation needed
|
||||
|
||||
const numericVotes = [...session.votes.values()]
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
|
||||
const average = numericVotes.length
|
||||
? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length
|
||||
: 0;
|
||||
|
||||
const suggestedEstimate = nearestFibonacci(average);
|
||||
|
||||
session.state = 'REVEALED';
|
||||
session.averageEstimate = average;
|
||||
session.suggestedEstimate = suggestedEstimate;
|
||||
return session;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
// Not all voted — return current snapshot
|
||||
const current = await getSession(sessionId);
|
||||
if (!current) return null;
|
||||
return { ...getSnapshot(current), allVoted: false };
|
||||
}
|
||||
|
||||
const snapshot = getSnapshot(result);
|
||||
return {
|
||||
...snapshot,
|
||||
allVoted: true,
|
||||
average: snapshot.session.averageEstimate,
|
||||
suggestedEstimate: snapshot.session.suggestedEstimate
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.tenantCloudId !== tenantCloudId) return null;
|
||||
if (!session.participants.has(userKey)) return null;
|
||||
|
||||
session.savedEstimate = estimate;
|
||||
session.state = 'SAVED';
|
||||
|
||||
const snapshot = getSnapshot(session);
|
||||
// Clean up — session is done
|
||||
await kvSessions.delete(sessionId);
|
||||
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, session.issueKey));
|
||||
return snapshot;
|
||||
}
|
||||
124
backend/src/services/roomService.js
Normal file
124
backend/src/services/roomService.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import crypto from 'crypto';
|
||||
import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js';
|
||||
|
||||
function assertCloudId(cloudId) {
|
||||
if (!cloudId) {
|
||||
throw new Error('Missing Jira tenant (cloudId). Please reconnect Jira.');
|
||||
}
|
||||
}
|
||||
|
||||
function roomKey(cloudId, id) { return `${cloudId}.${id}`; }
|
||||
function sprintKey(cloudId, sprintId) { return `${cloudId}.${sprintId}`; }
|
||||
|
||||
export async function createRoom(payload) {
|
||||
const {
|
||||
cloudId,
|
||||
boardId,
|
||||
projectKey,
|
||||
projectName,
|
||||
sprintId,
|
||||
sprintName,
|
||||
createdByAccountId,
|
||||
createdByName
|
||||
} = payload;
|
||||
assertCloudId(cloudId);
|
||||
|
||||
// Return existing room for this sprint if one exists
|
||||
try {
|
||||
const existingEntry = await kvSprintIndex.get(sprintKey(cloudId, sprintId));
|
||||
if (existingEntry) {
|
||||
const existingId = existingEntry.string();
|
||||
if (existingId) {
|
||||
const roomEntry = await kvRooms.get(roomKey(cloudId, existingId));
|
||||
if (roomEntry) return roomEntry.json();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stale index data — continue to create a new room
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const room = {
|
||||
id,
|
||||
cloudId,
|
||||
boardId,
|
||||
projectKey,
|
||||
projectName,
|
||||
sprintId,
|
||||
sprintName,
|
||||
createdByAccountId,
|
||||
createdByName
|
||||
};
|
||||
|
||||
await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room));
|
||||
await kvSprintIndex.put(sprintKey(cloudId, sprintId), id);
|
||||
|
||||
// Update active rooms set with CAS retry
|
||||
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
|
||||
const ids = activeIds || [];
|
||||
if (ids.includes(id)) return undefined; // already present, no-op
|
||||
return [...ids, id];
|
||||
});
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
export async function getActiveRooms(cloudId) {
|
||||
assertCloudId(cloudId);
|
||||
|
||||
const activeEntry = await kvActiveRooms.get(cloudId);
|
||||
if (!activeEntry) return [];
|
||||
|
||||
const ids = activeEntry.json();
|
||||
if (!ids.length) return [];
|
||||
|
||||
const staleIds = [];
|
||||
const parsedRooms = [];
|
||||
|
||||
for (const id of ids) {
|
||||
if (!id) { staleIds.push(id); continue; }
|
||||
try {
|
||||
const entry = await kvRooms.get(roomKey(cloudId, id));
|
||||
if (!entry) {
|
||||
staleIds.push(id);
|
||||
continue;
|
||||
}
|
||||
parsedRooms.push(entry.json());
|
||||
} catch {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length) {
|
||||
await withCasRetry(kvActiveRooms, cloudId, (current) => {
|
||||
const currentIds = current || [];
|
||||
return currentIds.filter((id) => !staleIds.includes(id));
|
||||
});
|
||||
}
|
||||
|
||||
return parsedRooms;
|
||||
}
|
||||
|
||||
export async function getRoomById(roomId, cloudId) {
|
||||
assertCloudId(cloudId);
|
||||
const entry = await kvRooms.get(roomKey(cloudId, roomId));
|
||||
return entry ? entry.json() : null;
|
||||
}
|
||||
|
||||
export async function deleteRoom(roomId, cloudId) {
|
||||
assertCloudId(cloudId);
|
||||
const entry = await kvRooms.get(roomKey(cloudId, roomId));
|
||||
if (!entry) return;
|
||||
|
||||
const room = entry.json();
|
||||
await kvRooms.delete(roomKey(cloudId, roomId));
|
||||
await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId));
|
||||
|
||||
// Remove from active rooms set with CAS retry
|
||||
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
|
||||
if (!activeIds) return undefined;
|
||||
const filtered = activeIds.filter((id) => id !== roomId);
|
||||
if (filtered.length === activeIds.length) return undefined; // not found, no-op
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=http://localhost:4010/api
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Pokerface</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2375
frontend/package-lock.json
generated
Normal file
2375
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "pokerface-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0f172a"/>
|
||||
<rect x="4" y="3" width="24" height="26" rx="3" fill="none" stroke="#10b981" stroke-width="2"/>
|
||||
<text x="16" y="20.5" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="800" font-size="12" fill="#10b981">PF</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
137
frontend/src/App.jsx
Normal file
137
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import LoginScreen from './components/LoginScreen';
|
||||
import Lobby from './components/Lobby';
|
||||
import Room from './components/Room';
|
||||
import LegalPage from './components/LegalPage';
|
||||
import { api } from './services/api';
|
||||
|
||||
function useDarkMode() {
|
||||
const [dark, setDark] = useState(() => {
|
||||
const saved = localStorage.getItem('pokerface-dark');
|
||||
if (saved !== null) return saved === 'true';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
localStorage.setItem('pokerface-dark', dark);
|
||||
}, [dark]);
|
||||
|
||||
return [dark, () => setDark((d) => !d)];
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState('loading');
|
||||
const [user, setUser] = useState(null);
|
||||
const [activeRoom, setActiveRoom] = useState(null);
|
||||
const [prevView, setPrevView] = useState('login');
|
||||
const [dark, toggleDark] = useDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
async function checkAuth() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('auth') === 'error') {
|
||||
const message = params.get('message') || 'Authentication failed.';
|
||||
console.error('[auth] OAuth callback error:', message);
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
setView('login');
|
||||
return;
|
||||
}
|
||||
if (params.has('auth')) {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await api.getMe();
|
||||
setUser(me);
|
||||
|
||||
// Restore room from URL if present
|
||||
const roomId = new URLSearchParams(window.location.search).get('room');
|
||||
if (roomId) {
|
||||
try {
|
||||
const { room } = await api.getRoom(roomId);
|
||||
if (room) {
|
||||
setActiveRoom(room);
|
||||
setView('room');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// room gone, fall through to lobby
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
setView('lobby');
|
||||
} catch {
|
||||
setView('login');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUser(null);
|
||||
setActiveRoom(null);
|
||||
setView('login');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
|
||||
if (view === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center dark:bg-slate-900">
|
||||
<p className="text-slate-400 text-sm tracking-wide">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function showLegal(page) {
|
||||
setPrevView(view);
|
||||
setView(`legal-${page}`);
|
||||
}
|
||||
|
||||
if (view.startsWith('legal-')) {
|
||||
const page = view.replace('legal-', '');
|
||||
return <LegalPage page={page} dark={dark} onBack={() => setView(prevView)} />;
|
||||
}
|
||||
|
||||
if (view === 'login') {
|
||||
return <LoginScreen dark={dark} toggleDark={toggleDark} onShowLegal={showLegal} />;
|
||||
}
|
||||
|
||||
if (view === 'room' && activeRoom) {
|
||||
return (
|
||||
<Room
|
||||
room={activeRoom}
|
||||
user={user}
|
||||
dark={dark}
|
||||
toggleDark={toggleDark}
|
||||
onBack={() => {
|
||||
setActiveRoom(null);
|
||||
setView('lobby');
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Lobby
|
||||
user={user}
|
||||
dark={dark}
|
||||
toggleDark={toggleDark}
|
||||
onEnterRoom={(room) => {
|
||||
setActiveRoom(room);
|
||||
setView('room');
|
||||
window.history.replaceState({}, '', `?room=${room.id}`);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onShowLegal={showLegal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
194
frontend/src/components/AdfRenderer.jsx
Normal file
194
frontend/src/components/AdfRenderer.jsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import java from 'highlight.js/lib/languages/java';
|
||||
import sql from 'highlight.js/lib/languages/sql';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import css from 'highlight.js/lib/languages/css';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import csharp from 'highlight.js/lib/languages/csharp';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import 'highlight.js/styles/github-dark.min.css';
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('js', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('ts', typescript);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('py', python);
|
||||
hljs.registerLanguage('java', java);
|
||||
hljs.registerLanguage('sql', sql);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('html', xml);
|
||||
hljs.registerLanguage('css', css);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('sh', bash);
|
||||
hljs.registerLanguage('shell', bash);
|
||||
hljs.registerLanguage('csharp', csharp);
|
||||
hljs.registerLanguage('c#', csharp);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
|
||||
function CodeBlock({ code, language }) {
|
||||
const elRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!elRef.current) return;
|
||||
const codeEl = elRef.current.querySelector('code');
|
||||
if (!codeEl) return;
|
||||
|
||||
// Reset for re-highlight
|
||||
codeEl.removeAttribute('data-highlighted');
|
||||
codeEl.className = language && hljs.getLanguage(language) ? `language-${language}` : '';
|
||||
codeEl.textContent = code;
|
||||
|
||||
try {
|
||||
hljs.highlightElement(codeEl);
|
||||
} catch {
|
||||
// fallback: no highlighting
|
||||
}
|
||||
}, [code, language]);
|
||||
|
||||
return (
|
||||
<pre ref={elRef} className="bg-slate-900 dark:bg-slate-950 text-slate-100 rounded p-3 my-2 overflow-x-auto text-xs font-mono">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function parseWikiInline(text) {
|
||||
const parts = [];
|
||||
const regex = /\*([^*\n]+)\*|_([^_\n]+)_|-([^-\n]+)-|\+([^+\n]+)\+|\{\{([^}]*?)\}\}|\[([^|\]\n]+)\|([^\]\n]+?)(?:\|[^\]]*?)?\]|\[(https?:\/\/[^\]\s]+)\]/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||
|
||||
if (match[1] != null) {
|
||||
parts.push(<strong key={match.index}>{match[1]}</strong>);
|
||||
} else if (match[2] != null) {
|
||||
parts.push(<em key={match.index}>{match[2]}</em>);
|
||||
} else if (match[3] != null) {
|
||||
parts.push(<del key={match.index}>{match[3]}</del>);
|
||||
} else if (match[4] != null) {
|
||||
parts.push(<u key={match.index}>{match[4]}</u>);
|
||||
} else if (match[5] != null) {
|
||||
parts.push(<code key={match.index} className="bg-slate-100 dark:bg-slate-700 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded text-sm font-mono">{match[5]}</code>);
|
||||
} else if (match[6] != null && match[7] != null) {
|
||||
parts.push(<a key={match.index} href={match[7]} target="_blank" rel="noopener noreferrer" className="text-emerald-600 dark:text-emerald-400 hover:underline">{match[6]}</a>);
|
||||
} else if (match[8] != null) {
|
||||
parts.push(<a key={match.index} href={match[8]} target="_blank" rel="noopener noreferrer" className="text-emerald-600 dark:text-emerald-400 hover:underline">{match[8]}</a>);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts.length === 0 ? [text] : parts;
|
||||
}
|
||||
|
||||
function renderWikiMarkup(text) {
|
||||
const elements = [];
|
||||
|
||||
// Extract code blocks first
|
||||
const segments = [];
|
||||
const codeRegex = /\{(code|noformat)(?::([^}]*))?\}([\s\S]*?)\{\1\}/g;
|
||||
let lastIdx = 0;
|
||||
let m;
|
||||
while ((m = codeRegex.exec(text)) !== null) {
|
||||
if (m.index > lastIdx) segments.push({ type: 'text', value: text.slice(lastIdx, m.index) });
|
||||
segments.push({ type: 'code', value: m[3], language: m[2] || '' });
|
||||
lastIdx = m.index + m[0].length;
|
||||
}
|
||||
if (lastIdx < text.length) segments.push({ type: 'text', value: text.slice(lastIdx) });
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'code') {
|
||||
elements.push(<CodeBlock key={elements.length} code={seg.value.trim()} language={seg.language} />);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = seg.value.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
const headingMatch = line.match(/^h([1-6])\.\s+(.*)/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1];
|
||||
const Tag = `h${level}`;
|
||||
const sizes = { 1: 'text-xl font-bold mb-3 mt-4', 2: 'text-lg font-bold mb-2 mt-3', 3: 'text-base font-semibold mb-2 mt-3', 4: 'text-sm font-semibold mb-1 mt-2', 5: 'text-xs font-semibold mb-1 mt-2', 6: 'text-xs font-semibold mb-1 mt-1' };
|
||||
elements.push(<Tag key={elements.length} className={sizes[level]}>{parseWikiInline(headingMatch[2])}</Tag>);
|
||||
i++; continue;
|
||||
}
|
||||
|
||||
if (line.match(/^----?\s*$/)) {
|
||||
elements.push(<hr key={elements.length} className="my-4 border-slate-200 dark:border-slate-700" />);
|
||||
i++; continue;
|
||||
}
|
||||
|
||||
const bqMatch = line.match(/^bq\.\s+(.*)/);
|
||||
if (bqMatch) {
|
||||
elements.push(
|
||||
<blockquote key={elements.length} className="border-l-2 border-slate-300 dark:border-slate-600 pl-3 italic text-slate-500 dark:text-slate-400 my-2">
|
||||
<p>{parseWikiInline(bqMatch[1])}</p>
|
||||
</blockquote>
|
||||
);
|
||||
i++; continue;
|
||||
}
|
||||
|
||||
if (line.match(/^\*+\s+/)) {
|
||||
const items = [];
|
||||
while (i < lines.length && lines[i].match(/^\*+\s+/)) {
|
||||
const lm = lines[i].match(/^(\*+)\s+(.*)/);
|
||||
items.push({ depth: lm[1].length, text: lm[2] });
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<ul key={elements.length} className="list-disc pl-5 mb-2 space-y-0.5">
|
||||
{items.map((item, j) => <li key={j} className="leading-relaxed" style={{ marginLeft: `${(item.depth - 1) * 16}px` }}>{parseWikiInline(item.text)}</li>)}
|
||||
</ul>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.match(/^#+\s+/)) {
|
||||
const items = [];
|
||||
while (i < lines.length && lines[i].match(/^#+\s+/)) {
|
||||
const lm = lines[i].match(/^(#+)\s+(.*)/);
|
||||
items.push({ depth: lm[1].length, text: lm[2] });
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<ol key={elements.length} className="list-decimal pl-5 mb-2 space-y-0.5">
|
||||
{items.map((item, j) => <li key={j} className="leading-relaxed" style={{ marginLeft: `${(item.depth - 1) * 16}px` }}>{parseWikiInline(item.text)}</li>)}
|
||||
</ol>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') { i++; continue; }
|
||||
|
||||
elements.push(<p key={elements.length} className="mb-2 leading-relaxed">{parseWikiInline(line)}</p>);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export default function AdfRenderer({ document, className = '', fallback = '' }) {
|
||||
if (!document) {
|
||||
return fallback ? <p className="text-slate-400 italic text-sm">{fallback}</p> : null;
|
||||
}
|
||||
|
||||
if (typeof document === 'string') {
|
||||
return <div className={`text-sm ${className}`.trim()}>{renderWikiMarkup(document)}</div>;
|
||||
}
|
||||
|
||||
return fallback ? <p className="text-slate-400 italic text-sm">{fallback}</p> : null;
|
||||
}
|
||||
19
frontend/src/components/DarkModeToggle.jsx
Normal file
19
frontend/src/components/DarkModeToggle.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export default function DarkModeToggle({ dark, toggleDark, className = '' }) {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className={`w-8 h-8 flex items-center justify-center transition-colors cursor-pointer border-none bg-transparent ${className}`}
|
||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{dark ? (
|
||||
<svg className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-slate-400 hover:text-slate-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
239
frontend/src/components/LegalPage.jsx
Normal file
239
frontend/src/components/LegalPage.jsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
export default function LegalPage({ page, dark, onBack }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: dark ? '#09090b' : '#f0f1f5' }}>
|
||||
<header className="flex items-center gap-3 px-5 py-3 shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm font-medium px-3 py-1 border-none cursor-pointer transition-colors"
|
||||
style={{
|
||||
color: dark ? '#94a3b8' : '#64748b',
|
||||
background: dark ? '#1e293b' : '#e2e8f0'
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="font-syne font-bold text-sm tracking-tight" style={{ color: dark ? '#fff' : '#0f172a' }}>
|
||||
POKERFACE
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-5 py-6 max-w-2xl mx-auto w-full">
|
||||
{page === 'terms' && <TermsOfService dark={dark} />}
|
||||
{page === 'privacy' && <PrivacyPolicy dark={dark} />}
|
||||
{page === 'support' && <Support dark={dark} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Heading({ children, dark }) {
|
||||
return <h1 className="text-2xl font-bold mb-4 mt-0" style={{ color: dark ? '#fff' : '#0f172a' }}>{children}</h1>;
|
||||
}
|
||||
|
||||
function SubHeading({ children, dark }) {
|
||||
return <h2 className="text-base font-semibold mt-6 mb-2" style={{ color: dark ? '#e2e8f0' : '#1e293b' }}>{children}</h2>;
|
||||
}
|
||||
|
||||
function P({ children, dark }) {
|
||||
return <p className="text-sm leading-relaxed my-2" style={{ color: dark ? '#94a3b8' : '#475569' }}>{children}</p>;
|
||||
}
|
||||
|
||||
function Li({ children, dark }) {
|
||||
return <li className="text-sm leading-relaxed my-1" style={{ color: dark ? '#94a3b8' : '#475569' }}>{children}</li>;
|
||||
}
|
||||
|
||||
function TermsOfService({ dark }) {
|
||||
return (
|
||||
<>
|
||||
<Heading dark={dark}>Terms of Service</Heading>
|
||||
<P dark={dark}><em>Last updated: February 2026</em></P>
|
||||
|
||||
<SubHeading dark={dark}>1. Acceptance</SubHeading>
|
||||
<P dark={dark}>
|
||||
By accessing or using Pokerface ("the Service"), you agree to these terms.
|
||||
If you do not agree, do not use the Service.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>2. Description</SubHeading>
|
||||
<P dark={dark}>
|
||||
Pokerface is a free sprint planning poker tool that integrates with Atlassian Jira.
|
||||
It is provided as a convenience for agile teams to facilitate estimation sessions.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>3. No Warranty</SubHeading>
|
||||
<P dark={dark}>
|
||||
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND,
|
||||
WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
|
||||
NON-INFRINGEMENT. NO ADVICE OR INFORMATION, WHETHER ORAL OR WRITTEN, OBTAINED FROM
|
||||
THE SERVICE SHALL CREATE ANY WARRANTY NOT EXPRESSLY STATED HEREIN.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>4. Limitation of Liability</SubHeading>
|
||||
<P dark={dark}>
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL THE DEVELOPERS,
|
||||
OPERATORS, OR CONTRIBUTORS OF POKERFACE BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
|
||||
SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, DATA, USE, OR
|
||||
GOODWILL, HOWEVER CAUSED AND UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
||||
TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY, OR OTHERWISE, ARISING OUT OF OR IN
|
||||
CONNECTION WITH YOUR ACCESS TO OR USE OF (OR INABILITY TO USE) THE SERVICE.
|
||||
</P>
|
||||
<P dark={dark}>
|
||||
THE TOTAL AGGREGATE LIABILITY OF THE SERVICE OPERATORS FOR ALL CLAIMS RELATING TO
|
||||
THE SERVICE SHALL NOT EXCEED ZERO EUROS (EUR 0.00).
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>5. No Guarantee of Availability</SubHeading>
|
||||
<P dark={dark}>
|
||||
The Service may be modified, suspended, or discontinued at any time without notice.
|
||||
We are under no obligation to maintain, support, or update the Service.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>6. User Responsibilities</SubHeading>
|
||||
<P dark={dark}>
|
||||
You are responsible for your use of the Service and any data you transmit through it.
|
||||
You must comply with Atlassian's terms of service when using Jira integration features.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>7. Third-Party Services</SubHeading>
|
||||
<P dark={dark}>
|
||||
Pokerface integrates with Atlassian Jira via OAuth. Your use of Jira is governed by
|
||||
Atlassian's own terms and privacy policy. We are not responsible for any third-party
|
||||
service availability or behavior.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>8. Changes to Terms</SubHeading>
|
||||
<P dark={dark}>
|
||||
These terms may be updated at any time. Continued use of the Service after changes
|
||||
constitutes acceptance of the revised terms.
|
||||
</P>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivacyPolicy({ dark }) {
|
||||
return (
|
||||
<>
|
||||
<Heading dark={dark}>Privacy Policy</Heading>
|
||||
<P dark={dark}><em>Last updated: February 2026</em></P>
|
||||
|
||||
<SubHeading dark={dark}>1. What Data We Collect</SubHeading>
|
||||
<P dark={dark}>
|
||||
When you sign in with Jira, we receive the following information from Atlassian via OAuth:
|
||||
</P>
|
||||
<ul className="pl-5 my-2">
|
||||
<Li dark={dark}><strong>Jira account ID</strong> — your unique Atlassian identifier</Li>
|
||||
<Li dark={dark}><strong>Display name</strong> — your Jira profile name</Li>
|
||||
<Li dark={dark}><strong>Avatar URL</strong> — a link to your Jira profile picture</Li>
|
||||
<Li dark={dark}><strong>Email address</strong> — your Jira account email</Li>
|
||||
<Li dark={dark}><strong>Cloud ID and site URL</strong> — identifies your Jira workspace</Li>
|
||||
<Li dark={dark}><strong>OAuth tokens</strong> — access and refresh tokens for Jira API calls</Li>
|
||||
</ul>
|
||||
<P dark={dark}>
|
||||
During poker sessions, we temporarily store:
|
||||
</P>
|
||||
<ul className="pl-5 my-2">
|
||||
<Li dark={dark}>Room and session metadata (project name, sprint name, issue keys)</Li>
|
||||
<Li dark={dark}>Participant names and avatar URLs</Li>
|
||||
<Li dark={dark}>Votes submitted during estimation sessions</Li>
|
||||
</ul>
|
||||
|
||||
<SubHeading dark={dark}>2. How We Use Your Data</SubHeading>
|
||||
<P dark={dark}>
|
||||
Your data is used solely to operate the poker planning functionality:
|
||||
authenticating you with Jira, displaying participants in sessions, recording votes,
|
||||
and writing agreed estimates back to Jira issues. We do not use your data for
|
||||
analytics, advertising, profiling, or any other purpose.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>3. Data Storage and Retention</SubHeading>
|
||||
<P dark={dark}>
|
||||
All data is stored in NATS JetStream key-value buckets with automatic time-to-live (TTL) expiration:
|
||||
</P>
|
||||
<ul className="pl-5 my-2">
|
||||
<Li dark={dark}><strong>OAuth connections</strong> — automatically deleted after 24 hours</Li>
|
||||
<Li dark={dark}><strong>Rooms and sessions</strong> — automatically deleted after 24 hours</Li>
|
||||
<Li dark={dark}><strong>OAuth state tokens</strong> — automatically deleted after 10 minutes</Li>
|
||||
</ul>
|
||||
<P dark={dark}>
|
||||
There is no long-term database. All session data is ephemeral and automatically purged
|
||||
by TTL. When a poker session is saved, the session data is deleted immediately.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>4. Cookies</SubHeading>
|
||||
<P dark={dark}>
|
||||
Pokerface uses a single, strictly functional cookie:
|
||||
</P>
|
||||
<ul className="pl-5 my-2">
|
||||
<Li dark={dark}>
|
||||
<strong>pokerface_session</strong> — an HttpOnly, Secure, SameSite=Lax JWT cookie
|
||||
that contains your Jira account ID, cloud ID, display name, and avatar URL. It
|
||||
expires after 24 hours. This cookie is required for the application to function.
|
||||
</Li>
|
||||
</ul>
|
||||
<P dark={dark}>
|
||||
We do not use tracking cookies, analytics cookies, or any third-party cookies.
|
||||
A dark mode preference is stored in your browser's localStorage (not a cookie)
|
||||
and never sent to our servers.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>5. Data Sharing</SubHeading>
|
||||
<P dark={dark}>
|
||||
We do not sell, share, or transfer your personal data to any third party.
|
||||
The only external communication is between our backend and Atlassian's Jira API,
|
||||
using the OAuth tokens you authorized, to read project/sprint data and write
|
||||
estimates back to issues.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>6. Data Security</SubHeading>
|
||||
<P dark={dark}>
|
||||
All traffic is encrypted via HTTPS in production. Session cookies are marked HttpOnly
|
||||
and Secure. Security headers (HSTS, X-Frame-Options DENY, nosniff) are applied to all
|
||||
responses. OAuth tokens are stored server-side only and never exposed to the browser.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>7. Your Rights</SubHeading>
|
||||
<P dark={dark}>
|
||||
Since all data expires automatically within 24 hours, there is no persistent personal
|
||||
data to request deletion of. You can sign out at any time to clear your session cookie.
|
||||
Revoking the Pokerface OAuth connection in your Atlassian account settings will
|
||||
invalidate all stored tokens.
|
||||
</P>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Support({ dark }) {
|
||||
return (
|
||||
<>
|
||||
<Heading dark={dark}>Support</Heading>
|
||||
<P dark={dark}><em>Last updated: February 2026</em></P>
|
||||
|
||||
<SubHeading dark={dark}>About Pokerface</SubHeading>
|
||||
<P dark={dark}>
|
||||
Pokerface is a free, open tool for sprint planning poker with Jira integration.
|
||||
It is provided as-is, with no guarantees of availability, support, or maintenance.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>No Formal Support</SubHeading>
|
||||
<P dark={dark}>
|
||||
This product does not come with dedicated support, SLAs, or guaranteed response times.
|
||||
There is no helpdesk, ticketing system, or support team.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>Best-Effort Assistance</SubHeading>
|
||||
<P dark={dark}>
|
||||
If you encounter a bug or issue, you may reach out via the project's repository.
|
||||
Any assistance is provided on a best-effort basis at the maintainer's discretion.
|
||||
</P>
|
||||
|
||||
<SubHeading dark={dark}>Alternatives</SubHeading>
|
||||
<P dark={dark}>
|
||||
If Pokerface does not meet your needs, there are many alternative planning poker tools
|
||||
available in the Atlassian Marketplace and elsewhere. You are free to stop using
|
||||
Pokerface at any time — simply sign out and revoke the OAuth connection in your
|
||||
Atlassian account settings.
|
||||
</P>
|
||||
</>
|
||||
);
|
||||
}
|
||||
269
frontend/src/components/Lobby.jsx
Normal file
269
frontend/src/components/Lobby.jsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import SearchSelect from './SearchSelect';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
|
||||
export default function Lobby({ user, dark, toggleDark, onEnterRoom, onLogout, onShowLegal }) {
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [boards, setBoards] = useState([]);
|
||||
const [sprints, setSprints] = useState([]);
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [selectedBoard, setSelectedBoard] = useState(null);
|
||||
const [selectedSprint, setSelectedSprint] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadRooms();
|
||||
}, []);
|
||||
|
||||
async function loadRooms() {
|
||||
try {
|
||||
const { rooms: activeRooms } = await api.getRooms();
|
||||
setRooms(activeRooms || []);
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlanSession() {
|
||||
setShowCreate(true);
|
||||
setError('');
|
||||
try {
|
||||
const { projects: projectList } = await api.getProjects();
|
||||
setProjects(projectList || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProjectChange(projectKey) {
|
||||
const project = projects.find((p) => p.key === projectKey);
|
||||
setSelectedProject(project || null);
|
||||
setSelectedBoard(null);
|
||||
setSelectedSprint(null);
|
||||
setBoards([]);
|
||||
setSprints([]);
|
||||
|
||||
if (!projectKey) return;
|
||||
|
||||
try {
|
||||
const { boards: boardList } = await api.getBoards(projectKey);
|
||||
const list = boardList || [];
|
||||
setBoards(list);
|
||||
if (list.length === 1) {
|
||||
const board = list[0];
|
||||
setSelectedBoard(board);
|
||||
try {
|
||||
const { sprints: sprintList } = await api.getBoardSprints(board.id);
|
||||
setSprints(sprintList || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBoardChange(boardId) {
|
||||
const board = boards.find((b) => b.id === Number(boardId));
|
||||
setSelectedBoard(board || null);
|
||||
setSelectedSprint(null);
|
||||
setSprints([]);
|
||||
|
||||
if (!boardId) return;
|
||||
|
||||
try {
|
||||
const { sprints: sprintList } = await api.getBoardSprints(boardId);
|
||||
setSprints(sprintList || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRoom() {
|
||||
if (!selectedProject || !selectedBoard || !selectedSprint) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { room } = await api.createRoom({
|
||||
boardId: selectedBoard.id,
|
||||
projectKey: selectedProject.key,
|
||||
projectName: selectedProject.name,
|
||||
sprintId: selectedSprint.id,
|
||||
sprintName: selectedSprint.name
|
||||
});
|
||||
onEnterRoom(room);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const projectOptions = projects.map((p) => ({ value: p.key, label: `${p.name} (${p.key})` }));
|
||||
const boardOptions = boards.map((b) => ({ value: b.id, label: b.name }));
|
||||
const sprintOptions = sprints.map((s) => ({ value: s.id, label: `${s.name} (${s.state})` }));
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col dark:bg-slate-900">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-center bg-slate-900 dark:bg-slate-950 text-white px-5 py-2 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-syne font-bold text-sm tracking-tight">POKERFACE</span>
|
||||
<span className="text-slate-600 text-xs hidden sm:inline">/</span>
|
||||
<span className="text-slate-400 text-xs hidden sm:inline">Lobby</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DarkModeToggle dark={dark} toggleDark={toggleDark} />
|
||||
{user.avatarUrl && (
|
||||
<img src={user.avatarUrl} alt="" className="w-6 h-6 rounded-full object-cover" />
|
||||
)}
|
||||
<span className="text-sm text-slate-300">{user.displayName}</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium px-3 py-1 transition-colors cursor-pointer border-none"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<section className="flex-1 px-5 py-5 flex flex-col gap-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 m-0">Active Rooms</h2>
|
||||
<button
|
||||
onClick={handlePlanSession}
|
||||
className="border border-emerald-500/50 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500 hover:text-white font-medium text-sm px-4 py-1.5 transition-all cursor-pointer bg-transparent"
|
||||
>
|
||||
Plan Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 dark:text-red-400 text-sm m-0">{error}</p>}
|
||||
|
||||
{showCreate && (
|
||||
<div className="animate-fade-in bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 p-4 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 items-end relative z-10">
|
||||
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
|
||||
<span>Project</span>
|
||||
<SearchSelect
|
||||
options={projectOptions}
|
||||
value={selectedProject?.key || ''}
|
||||
onChange={(val) => handleProjectChange(val)}
|
||||
placeholder="Select a project..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{boards.length > 1 && (
|
||||
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
|
||||
<span>Board</span>
|
||||
<SearchSelect
|
||||
options={boardOptions}
|
||||
value={selectedBoard?.id || ''}
|
||||
onChange={(val) => handleBoardChange(val)}
|
||||
placeholder="Select a board..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sprints.length > 0 && (
|
||||
<div className="flex flex-col text-xs font-medium text-slate-500 dark:text-slate-400 gap-1.5 uppercase tracking-wider">
|
||||
<span>Sprint</span>
|
||||
<SearchSelect
|
||||
options={sprintOptions}
|
||||
value={selectedSprint?.id || ''}
|
||||
onChange={(val) => {
|
||||
const s = sprints.find((sp) => sp.id === Number(val));
|
||||
setSelectedSprint(s || null);
|
||||
}}
|
||||
placeholder="Select a sprint..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCreateRoom}
|
||||
disabled={!selectedProject || !selectedBoard || !selectedSprint || loading}
|
||||
className="bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold px-4 py-2 transition-colors cursor-pointer border-none text-sm"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
|
||||
{rooms.length === 0 ? (
|
||||
<p className="text-slate-400 text-sm">No active rooms. Create one to start estimating.</p>
|
||||
) : (
|
||||
rooms.map((room, i) => (
|
||||
<div
|
||||
key={room.id}
|
||||
onClick={() => onEnterRoom(room)}
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
className="animate-fade-up border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 cursor-pointer flex flex-col gap-1.5 transition-all hover:border-emerald-400 hover:shadow-lg hover:shadow-emerald-500/5 hover:-translate-y-0.5 group relative"
|
||||
>
|
||||
{room.createdByAccountId === user.jiraAccountId && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(room);
|
||||
}}
|
||||
className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center text-slate-300 dark:text-slate-600 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors bg-transparent border-none cursor-pointer text-base leading-none opacity-0 group-hover:opacity-100"
|
||||
title="Delete room"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<strong className="text-sm font-semibold text-slate-900 dark:text-slate-100 group-hover:text-emerald-600 dark:group-hover:text-emerald-400 transition-colors">
|
||||
{room.projectName || room.projectKey}
|
||||
</strong>
|
||||
<span className="text-slate-500 dark:text-slate-400 text-xs">{room.sprintName}</span>
|
||||
<span className="text-slate-400 dark:text-slate-500 text-xs">Created by {room.createdByName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="flex justify-center gap-3 px-5 py-3 text-xs" style={{ color: dark ? '#475569' : '#94a3b8' }}>
|
||||
<button onClick={() => onShowLegal('terms')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Terms</button>
|
||||
<span>·</span>
|
||||
<button onClick={() => onShowLegal('privacy')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Privacy</button>
|
||||
<span>·</span>
|
||||
<button onClick={() => onShowLegal('support')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Support</button>
|
||||
</footer>
|
||||
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setDeleteTarget(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="bg-white dark:bg-slate-800 w-72 p-5 flex flex-col gap-4 shadow-xl animate-fade-in">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100 m-0">Delete Room</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 m-0">
|
||||
Remove <strong>{deleteTarget.projectName || deleteTarget.projectKey}</strong> — {deleteTarget.sprintName}?
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
className="px-3 py-1.5 text-xs font-medium text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 border-none cursor-pointer transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
api.deleteRoom(deleteTarget.id).then(loadRooms).catch(() => {});
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 border-none cursor-pointer transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/LoginScreen.jsx
Normal file
72
frontend/src/components/LoginScreen.jsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
|
||||
export default function LoginScreen({ dark, toggleDark, onShowLegal }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleLogin() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { url } = await api.startJiraOAuth();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden bg-[#09090b] dark:bg-[#09090b] light:bg-white transition-colors"
|
||||
style={{ background: dark ? '#09090b' : '#f0f1f5' }}
|
||||
>
|
||||
<DarkModeToggle dark={dark} toggleDark={toggleDark} className="absolute top-4 right-4" />
|
||||
|
||||
{/* Ambient glow */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
background: dark
|
||||
? 'radial-gradient(ellipse at 50% 40%, rgba(16,185,129,0.07) 0%, transparent 55%)'
|
||||
: 'radial-gradient(ellipse at 50% 40%, rgba(16,185,129,0.1) 0%, transparent 55%)'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-8 max-w-xs w-full text-center relative z-10">
|
||||
<div className="animate-fade-up flex flex-col items-center gap-4">
|
||||
<h1 className="font-syne text-5xl font-extrabold tracking-tight m-0"
|
||||
style={{ color: dark ? '#fff' : '#0f172a' }}
|
||||
>
|
||||
POKERFACE
|
||||
</h1>
|
||||
<div className="w-10 h-px bg-emerald-500" />
|
||||
<p className="text-sm tracking-wide m-0" style={{ color: dark ? '#94a3b8' : '#64748b' }}>
|
||||
Sprint planning poker for Jira teams
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="animate-fade-up w-full flex flex-col gap-3" style={{ animationDelay: '120ms' }}>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-400 disabled:opacity-40 disabled:cursor-not-allowed font-semibold px-6 py-3 transition-colors cursor-pointer border-none tracking-wide text-sm"
|
||||
style={{ color: dark ? '#09090b' : '#fff' }}
|
||||
>
|
||||
{loading ? 'Redirecting...' : 'Sign in with Jira'}
|
||||
</button>
|
||||
{error && <p className="text-red-400 text-sm m-0 animate-fade-in">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="animate-fade-up flex gap-3 text-xs" style={{ animationDelay: '240ms', color: dark ? '#64748b' : '#94a3b8' }}>
|
||||
<button onClick={() => onShowLegal('terms')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Terms</button>
|
||||
<span>·</span>
|
||||
<button onClick={() => onShowLegal('privacy')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Privacy</button>
|
||||
<span>·</span>
|
||||
<button onClick={() => onShowLegal('support')} className="bg-transparent border-none cursor-pointer underline p-0" style={{ color: 'inherit', fontSize: 'inherit' }}>Support</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
frontend/src/components/PokerRoom.jsx
Normal file
255
frontend/src/components/PokerRoom.jsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getSocket } from '../services/socket';
|
||||
|
||||
const CARDS = [
|
||||
{ value: '?', label: '?', color: '#94a3b8' },
|
||||
{ value: '☕', label: '☕', color: '#f59e0b' },
|
||||
{ value: '0', label: '0', color: '#64748b' },
|
||||
{ value: '0.5', label: '0.5', color: '#64748b' },
|
||||
{ value: '1', label: '1', color: '#3b82f6' },
|
||||
{ value: '2', label: '2', color: '#3b82f6' },
|
||||
{ value: '3', label: '3', color: '#8b5cf6' },
|
||||
{ value: '5', label: '5', color: '#8b5cf6' },
|
||||
{ value: '8', label: '8', color: '#ec4899' },
|
||||
{ value: '13', label: '13', color: '#ec4899' },
|
||||
{ value: '20', label: '20', color: '#ef4444' },
|
||||
{ value: '40', label: '40', color: '#ef4444' },
|
||||
{ value: '100', label: '100', color: '#dc2626' }
|
||||
];
|
||||
|
||||
export default function PokerRoom({ session, issue, user, onSaved }) {
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [votedUserKeys, setVotedUserKeys] = useState([]);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [votes, setVotes] = useState({});
|
||||
const [myVote, setMyVote] = useState(null);
|
||||
const [suggestedEstimate, setSuggestedEstimate] = useState(null);
|
||||
const [average, setAverage] = useState(null);
|
||||
const [manualEstimate, setManualEstimate] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const socket = useMemo(() => getSocket(), []);
|
||||
const joinedRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onParticipants(payload) {
|
||||
if (payload.sessionId !== session.id) return;
|
||||
setParticipants(payload.participants || []);
|
||||
setVotedUserKeys(payload.votedUserKeys || []);
|
||||
}
|
||||
|
||||
function onVoteUpdate(payload) {
|
||||
if (payload.sessionId !== session.id) return;
|
||||
setVotedUserKeys(payload.votedUserKeys || []);
|
||||
}
|
||||
|
||||
function onRevealed(payload) {
|
||||
if (payload.sessionId !== session.id) return;
|
||||
setRevealed(true);
|
||||
setVotes(payload.votes || {});
|
||||
setAverage(payload.average ?? null);
|
||||
setSuggestedEstimate(payload.suggestedEstimate ?? null);
|
||||
if (payload.suggestedEstimate !== undefined && payload.suggestedEstimate !== null) {
|
||||
setManualEstimate(String(payload.suggestedEstimate));
|
||||
}
|
||||
}
|
||||
|
||||
function onSavedPayload(payload) {
|
||||
if (payload.sessionId !== session.id) return;
|
||||
onSaved(payload.estimate);
|
||||
}
|
||||
|
||||
function onError(payload) {
|
||||
setError(payload?.error || 'Unexpected poker error');
|
||||
}
|
||||
|
||||
socket.on('poker:participants', onParticipants);
|
||||
socket.on('poker:vote-update', onVoteUpdate);
|
||||
socket.on('poker:revealed', onRevealed);
|
||||
socket.on('poker:saved', onSavedPayload);
|
||||
socket.on('poker:error', onError);
|
||||
|
||||
// Guard against React strict mode double-invoking effects
|
||||
if (joinedRef.current !== session.id) {
|
||||
joinedRef.current = session.id;
|
||||
socket.emit('poker:join', {
|
||||
sessionId: session.id,
|
||||
userKey: user.key,
|
||||
userName: user.name
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.off('poker:participants', onParticipants);
|
||||
socket.off('poker:vote-update', onVoteUpdate);
|
||||
socket.off('poker:revealed', onRevealed);
|
||||
socket.off('poker:saved', onSavedPayload);
|
||||
socket.off('poker:error', onError);
|
||||
};
|
||||
}, [session.id, socket, user.key, user.name]);
|
||||
|
||||
function handleVote(value) {
|
||||
if (revealed) return;
|
||||
setMyVote(value);
|
||||
socket.emit('poker:vote', {
|
||||
sessionId: session.id,
|
||||
userKey: user.key,
|
||||
vote: value
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const estimate = Number(manualEstimate || suggestedEstimate);
|
||||
if (!Number.isFinite(estimate)) {
|
||||
setError('Pick a numeric estimate before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('poker:save', {
|
||||
sessionId: session.id,
|
||||
estimate
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Cards or Results */}
|
||||
<div className="border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider m-0">
|
||||
{revealed ? 'Results' : 'Choose Your Estimate'}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800/50">
|
||||
{revealed ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-center p-6 bg-purple-50 dark:bg-purple-900/20 rounded-sm">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 mb-2">Average</div>
|
||||
<div className="text-4xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{average?.toFixed(1) ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-6 bg-green-50 dark:bg-green-900/20 rounded-sm">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400 mb-2">Suggested</div>
|
||||
<div className="text-4xl font-bold text-green-600 dark:text-green-400">
|
||||
{suggestedEstimate ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Individual votes */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(votes).map(([userKey, value]) => {
|
||||
const name = participants.find((p) => p.userKey === userKey)?.userName || userKey;
|
||||
return (
|
||||
<span
|
||||
key={userKey}
|
||||
className="text-xs bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm px-3 py-1 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{name}: <strong>{value}</strong>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Override + Save */}
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={manualEstimate}
|
||||
onChange={(e) => setManualEstimate(e.target.value)}
|
||||
placeholder="Override"
|
||||
className="flex-1 border border-slate-300 dark:border-slate-600 rounded-sm px-3 py-2 text-sm bg-white dark:bg-slate-800 dark:text-slate-100"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-sm px-6 py-2 transition-colors cursor-pointer border-none"
|
||||
>
|
||||
Save Estimate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{CARDS.map((card) => {
|
||||
const isSelected = myVote === card.value;
|
||||
return (
|
||||
<button
|
||||
key={card.value}
|
||||
onClick={() => handleVote(card.value)}
|
||||
className="aspect-[2/3] rounded-sm border-2 transition-all flex flex-col items-center justify-center gap-1 hover:scale-105 cursor-pointer bg-transparent"
|
||||
style={{
|
||||
borderColor: isSelected ? card.color : 'var(--card-border, #e2e8f0)',
|
||||
backgroundColor: isSelected ? `${card.color}15` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-base font-bold"
|
||||
style={{ color: isSelected ? card.color : 'var(--card-text, #475569)' }}
|
||||
>
|
||||
{card.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider m-0">
|
||||
Participants
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 p-4 bg-slate-50 dark:bg-slate-800/50">
|
||||
<div className="flex flex-col gap-2">
|
||||
{participants.map((participant) => {
|
||||
const hasVoted = revealed
|
||||
? votes[participant.userKey] !== undefined
|
||||
: votedUserKeys.includes(participant.userKey);
|
||||
const vote = revealed ? votes[participant.userKey] : null;
|
||||
const isCurrentUser = participant.userKey === user.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={participant.userKey}
|
||||
className="flex items-center justify-between p-2 border border-slate-200 dark:border-slate-700 rounded-sm bg-white dark:bg-slate-800"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="w-7 h-7 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold shrink-0">
|
||||
{participant.userName?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<span className="text-sm text-slate-900 dark:text-slate-100 truncate">
|
||||
{participant.userName}
|
||||
{isCurrentUser && <span className="text-slate-400"> (you)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm shrink-0 ml-2">
|
||||
{revealed && vote !== null && vote !== undefined ? (
|
||||
<span className="font-semibold text-purple-600 dark:text-purple-400">{vote}</span>
|
||||
) : hasVoted ? (
|
||||
<span className="text-green-600 dark:text-green-400">✓</span>
|
||||
) : (
|
||||
<span className="text-slate-400">...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-t border-red-200 dark:border-red-800">
|
||||
<p className="text-red-700 dark:text-red-400 text-sm m-0">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
frontend/src/components/Room.jsx
Normal file
210
frontend/src/components/Room.jsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import PokerRoom from './PokerRoom';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import AdfRenderer from './AdfRenderer';
|
||||
|
||||
export default function Room({ room, user, dark, toggleDark, onBack }) {
|
||||
const [issues, setIssues] = useState([]);
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const issuesRef = useRef([]);
|
||||
|
||||
const pokerUser = { key: user.jiraAccountId, name: user.displayName };
|
||||
|
||||
useEffect(() => {
|
||||
loadIssues();
|
||||
}, [room.sprintId]);
|
||||
|
||||
async function loadIssues() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { issues: sprintIssues } = await api.getSprintIssues(room.sprintId, room.boardId);
|
||||
const list = sprintIssues || [];
|
||||
setIssues(list);
|
||||
issuesRef.current = list;
|
||||
startFirstUnestimated(list);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startFirstUnestimated(issueList) {
|
||||
const issue = issueList.find((i) => !i.estimate || i.estimate === 0);
|
||||
if (!issue) {
|
||||
finishSession();
|
||||
return;
|
||||
}
|
||||
startSessionForIssue(issue);
|
||||
}
|
||||
|
||||
async function startSessionForIssue(issue) {
|
||||
setError('');
|
||||
try {
|
||||
const { session } = await api.startSession({
|
||||
issueKey: issue.key,
|
||||
issueId: issue.id,
|
||||
issueTitle: issue.title,
|
||||
roomId: room.id,
|
||||
boardId: room.boardId
|
||||
});
|
||||
setActiveSession({ session, issue });
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishSession() {
|
||||
setDone(true);
|
||||
try {
|
||||
await api.deleteRoom(room.id);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessionRef = useRef(null);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
const advanceToNext = useCallback((estimate) => {
|
||||
if (!activeSessionRef.current) return;
|
||||
|
||||
const currentKey = activeSessionRef.current.issue.key;
|
||||
const updated = issuesRef.current.map((i) =>
|
||||
i.key === currentKey ? { ...i, estimate } : i
|
||||
);
|
||||
issuesRef.current = updated;
|
||||
setIssues(updated);
|
||||
setActiveSession(null);
|
||||
|
||||
const next = updated.find((i) => !i.estimate || i.estimate === 0);
|
||||
if (!next) {
|
||||
finishSession();
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => startSessionForIssue(next), 300);
|
||||
}, []);
|
||||
|
||||
const estimatedCount = issues.filter((i) => i.estimate && i.estimate > 0).length;
|
||||
const totalCount = issues.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center dark:bg-slate-900">
|
||||
<p className="text-slate-400 text-sm">Loading sprint issues...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 dark:bg-slate-900">
|
||||
<div className="max-w-xl w-full text-center flex flex-col gap-4 items-center animate-fade-up">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 m-0">Session Complete</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 m-0">
|
||||
{estimatedCount} of {totalCount} issues estimated
|
||||
</p>
|
||||
<ul className="w-full text-left list-none m-0 p-0 flex flex-col gap-2">
|
||||
{issues.map((issue) => (
|
||||
<li
|
||||
key={issue.key}
|
||||
className="border border-slate-200 dark:border-slate-700 p-3 flex justify-between gap-3 bg-white dark:bg-slate-800"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<strong className="text-sm dark:text-slate-100">{issue.key}</strong>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 m-0 mt-0.5 truncate">{issue.title}</p>
|
||||
</div>
|
||||
<span className="font-bold text-purple-600 dark:text-purple-400 shrink-0">
|
||||
{issue.estimate || 0} pts
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-emerald-500 hover:bg-emerald-400 text-white font-semibold px-6 py-3 transition-colors cursor-pointer border-none text-sm tracking-wide"
|
||||
>
|
||||
Back to Lobby
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col dark:bg-slate-900">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-center bg-slate-900 dark:bg-slate-950 text-white px-5 py-2 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-syne font-bold text-xs text-slate-500 tracking-tight">POKERFACE</span>
|
||||
<span className="text-slate-600">/</span>
|
||||
<strong className="text-sm">{room.projectName || room.projectKey}</strong>
|
||||
<span className="text-slate-600">—</span>
|
||||
<span className="text-slate-400 text-sm">{room.sprintName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<DarkModeToggle dark={dark} toggleDark={toggleDark} />
|
||||
<span className="text-slate-400 text-sm">{estimatedCount}/{totalCount} estimated</span>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="bg-slate-700 hover:bg-slate-600 text-white text-xs font-medium px-3 py-1 transition-colors cursor-pointer border-none"
|
||||
>
|
||||
Leave Room
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
|
||||
<p className="text-red-700 dark:text-red-400 text-sm m-0">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Split layout */}
|
||||
{activeSession && (
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Left: Poker cards + participants */}
|
||||
<div className="flex-1 flex flex-col border-r border-slate-200 dark:border-slate-700 overflow-y-auto bg-white dark:bg-slate-900">
|
||||
<PokerRoom
|
||||
key={activeSession.session.id}
|
||||
session={activeSession.session}
|
||||
issue={activeSession.issue}
|
||||
user={pokerUser}
|
||||
onSaved={advanceToNext}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Issue details */}
|
||||
<div className="flex-1 overflow-y-auto bg-slate-50 dark:bg-slate-800/50">
|
||||
<div className="bg-slate-100 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-4 py-3">
|
||||
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||
{activeSession.issue.key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100 leading-tight m-0">
|
||||
{activeSession.issue.title}
|
||||
</h3>
|
||||
</div>
|
||||
{activeSession.issue.description && (
|
||||
<div className="p-4 text-slate-700 dark:text-slate-300">
|
||||
<AdfRenderer document={activeSession.issue.description} />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700">
|
||||
<div className="flex gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>Reporter: {activeSession.issue.reporter || 'Unknown'}</span>
|
||||
<span>Status: {activeSession.issue.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/SearchSelect.jsx
Normal file
107
frontend/src/components/SearchSelect.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export default function SearchSelect({ options, value, onChange, placeholder }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const filtered = options.filter((o) =>
|
||||
o.label.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
const selectedLabel = options.find((o) => String(o.value) === String(value))?.label || '';
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightIndex(0);
|
||||
}, [query]);
|
||||
|
||||
function handleOpen() {
|
||||
setOpen(true);
|
||||
setQuery('');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function selectOption(opt) {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered[highlightIndex]) selectOption(filtered[highlightIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="w-full border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-left cursor-pointer flex justify-between items-center px-3 py-2 text-sm transition-colors hover:border-slate-400 dark:hover:border-slate-500"
|
||||
>
|
||||
<span className={value ? 'text-slate-900 dark:text-slate-100' : 'text-slate-400'}>
|
||||
{selectedLabel || placeholder || 'Select...'}
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-slate-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="square" strokeLinejoin="miter" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-1 w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-xl shadow-black/8 max-h-60 flex flex-col">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="border-b border-slate-200 dark:border-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 outline-none bg-transparent"
|
||||
/>
|
||||
<div className="overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-slate-400">No matches</div>
|
||||
) : (
|
||||
filtered.map((opt, i) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => selectOption(opt)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
i === highlightIndex
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
} ${String(opt.value) === String(value) ? 'font-semibold' : ''}`}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
55
frontend/src/services/api.js
Normal file
55
frontend/src/services/api.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('UNAUTHORIZED');
|
||||
}
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getMe: () => request('/jira/me'),
|
||||
logout: () => request('/jira/logout', { method: 'POST' }),
|
||||
startJiraOAuth: () => request('/jira/oauth/start'),
|
||||
getProjects: () => request('/jira/projects'),
|
||||
getBoards: (projectKeyOrId) => request(`/jira/boards?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}`),
|
||||
getBoardSprints: (boardId) => request(`/jira/boards/${encodeURIComponent(boardId)}/sprints`),
|
||||
getSprintIssues: (sprintId, boardId) => request(`/jira/sprints/${encodeURIComponent(sprintId)}/issues${boardId ? `?boardId=${encodeURIComponent(boardId)}` : ''}`),
|
||||
createRoom: (payload) =>
|
||||
request('/poker/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
getRooms: () => request('/poker/rooms'),
|
||||
getRoom: (roomId) => request(`/poker/rooms/${encodeURIComponent(roomId)}`),
|
||||
deleteRoom: (roomId) =>
|
||||
request(`/poker/rooms/${encodeURIComponent(roomId)}`, { method: 'DELETE' }),
|
||||
startSession: (payload) =>
|
||||
request('/poker/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
getSession: (sessionId) => request(`/poker/sessions/${encodeURIComponent(sessionId)}`)
|
||||
};
|
||||
|
||||
// When using same-origin (default), socketBaseUrl is undefined so io() connects to the current host
|
||||
const explicit = import.meta.env.VITE_API_URL;
|
||||
export const socketBaseUrl = explicit ? explicit.replace(/\/api$/, '') : undefined;
|
||||
25
frontend/src/services/socket.js
Normal file
25
frontend/src/services/socket.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { io } from 'socket.io-client';
|
||||
import { socketBaseUrl } from './api';
|
||||
|
||||
let socket;
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
const opts = {
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 750
|
||||
};
|
||||
|
||||
if (socketBaseUrl) {
|
||||
opts.withCredentials = true;
|
||||
socket = io(socketBaseUrl, opts);
|
||||
} else {
|
||||
socket = io(opts);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
58
frontend/src/styles.css
Normal file
58
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Syne:wght@700;800&display=swap');
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-syne: 'Syne', sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #0f172a;
|
||||
background: #f0f1f5;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.045) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.03) 0%, transparent 50%);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
:root {
|
||||
--card-border: #e2e8f0;
|
||||
--card-text: #475569;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--card-border: #334155;
|
||||
--card-text: #94a3b8;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(16, 185, 129, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(99, 102, 241, 0.04) 0%, transparent 50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@utility animate-fade-up {
|
||||
animation: fade-up 0.5s ease-out both;
|
||||
}
|
||||
|
||||
@utility animate-fade-in {
|
||||
animation: fade-in 0.35s ease-out both;
|
||||
}
|
||||
22
frontend/vite.config.js
Normal file
22
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5174,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4010',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:4010',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
330
package-lock.json
generated
Normal file
330
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
{
|
||||
"name": "pokerface",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pokerface",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "pokerface",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "npm install --prefix backend && npm install --prefix frontend",
|
||||
"dev": "concurrently -n be,fe -c blue,green \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"build": "npm run build --prefix frontend",
|
||||
"start": "node backend/src/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue