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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue