Skip to content

Commit 749b6fd

Browse files
authored
Merge pull request #21 from leepoeaik/develop
feat(collaboration-service): Implement UI and features (question panel and session timer)
2 parents c82367b + 6f083b1 commit 749b6fd

File tree

14 files changed

+900
-65
lines changed

14 files changed

+900
-65
lines changed

.env.example

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,47 @@
1-
# NODE VERSION
21
NODE_ENV=development
32

4-
# SUPABASE CREDENTIALS
3+
# APP_ORIGIN=http://localhost:3000 # Docker
4+
APP_ORIGIN=http://localhost:5173 # Local testing
5+
6+
# MongoDB and MongoExpress
7+
MONGO_INITDB_ROOT_USERNAME=
8+
MONGO_INITDB_ROOT_PASSWORD=
9+
MONGO_HOST=
10+
MONGO_PORT=
11+
MONGO_DATABASE=
12+
13+
ME_CONFIG_MONGODB_URL=
14+
ME_CONFIG_BASICAUTH_ENABLED=
15+
ME_CONFIG_BASICAUTH_USERNAME=
16+
ME_CONFIG_BASICAUTH_PASSWORD=
17+
18+
# User Service
19+
MONGO_URI=
20+
USER_SERVICE_PORT=
21+
JWT_SECRET=
22+
JWT_REFRESH_SECRET=
23+
EMAIL_SENDER=
24+
RESEND_API_KEY=
25+
26+
ADMIN_USERNAME=
27+
ADMIN_EMAIL=
28+
ADMIN_PASSWORD=
29+
30+
# Google OAuth
31+
GOOGLE_CLIENT_ID=
32+
GOOGLE_CLIENT_SECRET=
33+
GOOGLE_AUTH_ORIGIN=http://localhost:3000 # Docker
34+
# GOOGLE_AUTH_ORIGIN=http://localhost:5173
35+
GOOGLE_AUTH_REDIR_URI=
36+
37+
# GITHUB_CLIENT_ID= # Docker
38+
GITHUB_CLIENT_ID=
39+
GITHUB_CLIENT_SECRET=
40+
GITHUB_AUTH_ORIGIN=http://localhost:5173
41+
GITHUB_AUTH_REDIR_URI=
42+
543
SUPABASE_URL=
644
SUPABASE_KEY=
745

8-
PARTYKIT_HOST_URL='localhost:8082'
9-
VITE_NGROK_COLLAB_HOST=
46+
PARTYKIT_HOST_URL=localhost:8082
47+
VITE_NGROK_COLLAB_HOST=

collaboration-service/src/server.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,61 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, {
2424

2525
export default class YjsServer implements Party.Server {
2626
constructor(public room: Party.Room) {}
27+
28+
async onRequest(request: Party.Request) {
29+
// CORS headers for frontend access
30+
const corsHeaders = {
31+
'Access-Control-Allow-Origin': '*',
32+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
33+
'Access-Control-Allow-Headers': 'Content-Type',
34+
'Content-Type': 'application/json',
35+
};
36+
37+
// Handle preflight OPTIONS request
38+
if (request.method === 'OPTIONS') {
39+
return new Response(null, {status: 204, headers: corsHeaders});
40+
}
41+
42+
if (request.method === 'GET') {
43+
try {
44+
const {data, error} = await supabase
45+
.from('documents')
46+
.select('created_at')
47+
.eq('name', this.room.id)
48+
.single();
49+
50+
if (error && error.code !== 'PGRST116') {
51+
// PGRST116 is "not found" error, which is okay
52+
console.error(`[${this.room.id}] Failed to fetch timestamp:`, error);
53+
return new Response(
54+
JSON.stringify({
55+
error: 'Failed to fetch room timestamp',
56+
}),
57+
{status: 500, headers: corsHeaders}
58+
);
59+
}
60+
61+
return new Response(
62+
JSON.stringify({
63+
roomId: this.room.id,
64+
createdAt: data?.created_at || new Date().toISOString(),
65+
}),
66+
{status: 200, headers: corsHeaders}
67+
);
68+
} catch (err) {
69+
console.error(`[${this.room.id}] Request error:`, err);
70+
return new Response(
71+
JSON.stringify({
72+
error: 'Internal server error',
73+
}),
74+
{status: 500, headers: corsHeaders}
75+
);
76+
}
77+
}
78+
79+
return new Response('Method not allowed', {status: 405, headers: corsHeaders});
80+
}
81+
2782
async onConnect(connection: Party.Connection) {
2883
const room = this.room;
2984
await y_onConnect(connection, this.room, {

docker-compose.yml

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ services:
1010
- ./user-service/db:/data/db
1111
networks:
1212
- app-network
13-
13+
1414
# For development purposes
1515
user-db-mongo-express:
1616
image: mongo-express
1717
container_name: user-db-mongo-express
18-
restart: unless-stopped
18+
restart: unless-stopped
1919
ports:
2020
- 8084:8081
2121
env_file:
@@ -24,15 +24,14 @@ services:
2424
- app-network
2525
depends_on:
2626
- user-db-mongodb
27-
2827

2928
question-db-pg:
3029
image: postgres:14-alpine
3130
container_name: question-db-pg
3231
ports:
33-
- '5433:5432'
32+
- '5433:5432'
3433
volumes:
35-
- question-data:/var/lib/postgresql/data
34+
- question-data:/var/lib/postgresql/data
3635
- ./question-service/db:/docker-entrypoint-initdb.d
3736
environment:
3837
POSTGRES_USER: postgres
@@ -41,12 +40,11 @@ services:
4140
networks:
4241
- app-network
4342
healthcheck:
44-
test: ["CMD-SHELL", "pg_isready -U postgres"]
43+
test: ['CMD-SHELL', 'pg_isready -U postgres']
4544
interval: 5s
4645
timeout: 5s
4746
retries: 5
4847

49-
5048
# --- FRONTEND ---
5149
frontend:
5250
build:
@@ -102,7 +100,7 @@ services:
102100
- PORT=8083
103101
env_file:
104102
- ./.env
105-
depends_on:
103+
depends_on:
106104
- question-db-pg
107105
volumes:
108106
- ./question-service:/app
@@ -114,7 +112,7 @@ services:
114112
build:
115113
context: ./question-service
116114
dockerfile: Dockerfile
117-
command: ["npm", "test"]
115+
command: ['npm', 'test']
118116
env_file:
119117
- ./question-service/.env
120118
depends_on:
@@ -123,8 +121,6 @@ services:
123121
networks:
124122
- app-network
125123

126-
127-
128124
# Matching service
129125
matching-service:
130126
# ... (No changes needed)
@@ -171,4 +167,4 @@ networks:
171167
driver: bridge
172168

173169
volumes:
174-
question-data:
170+
question-data:

frontend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"preview": "vite preview"
1313
},
1414
"dependencies": {
15+
"@tailwindcss/vite": "^4.1.14",
1516
"@codemirror/lang-cpp": "^6.0.3",
1617
"@codemirror/lang-java": "^6.0.2",
1718
"@codemirror/lang-javascript": "^6.2.4",
@@ -23,7 +24,8 @@
2324
"react": "^19.1.1",
2425
"react-dom": "^19.1.1",
2526
"react-hot-toast": "^2.6.0",
26-
"react-router-dom": "^7.9.2"
27+
"react-router-dom": "^7.9.2",
28+
"tailwindcss": "^4.1.14"
2729
},
2830
"devDependencies": {
2931
"@eslint/compat": "^1.3.2",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {useQuestion} from '../hooks/useQuestion';
2+
3+
const getDifficultyColor = (difficulty: string) => {
4+
switch (difficulty.toLowerCase()) {
5+
case 'easy':
6+
return 'bg-green-100 text-green-700';
7+
case 'medium':
8+
return 'bg-yellow-100 text-yellow-700';
9+
case 'hard':
10+
return 'bg-red-100 text-red-700';
11+
default:
12+
return 'bg-gray-100 text-gray-700';
13+
}
14+
};
15+
16+
const parseTextWithCode = (text: string) => {
17+
const parts = text.split(/(`[^`]+`)/g);
18+
return parts.map((part, index) => {
19+
if (part.startsWith('`') && part.endsWith('`')) {
20+
const code = part.slice(1, -1);
21+
return (
22+
<code key={index} className="bg-gray-100 px-1 rounded">
23+
{code}
24+
</code>
25+
);
26+
}
27+
return <span key={index}>{part}</span>;
28+
});
29+
};
30+
31+
export default function QuestionPanel({questionId}: {questionId: string}) {
32+
const {question, isLoading, error} = useQuestion(questionId);
33+
if (isLoading) {
34+
return (
35+
<div className="w-96 bg-white border-r border-gray-200 overflow-y-auto p-6 flex items-center justify-center">
36+
<div className="text-gray-500">Loading question...</div>
37+
</div>
38+
);
39+
}
40+
41+
if (error || !question) {
42+
return (
43+
<div className="w-96 bg-white border-r border-gray-200 overflow-y-auto p-6">
44+
<div className="text-red-500">{error || 'Question not found'}</div>
45+
</div>
46+
);
47+
}
48+
49+
return (
50+
<div className="w-96 bg-white border-r border-gray-200 overflow-y-auto p-6">
51+
<h1 className="text-2xl font-bold mb-4">{question.title}</h1>
52+
53+
{/* Tags */}
54+
<div className="flex gap-2 mb-6">
55+
<span
56+
className={`px-3 py-1 rounded-full text-sm font-medium ${getDifficultyColor(question.difficulty)}`}
57+
>
58+
{question.difficulty}
59+
</span>
60+
</div>
61+
62+
{/* Problem Description */}
63+
<div className="mb-6">
64+
<h2 className="font-semibold text-gray-900 mb-2">Problem Description</h2>
65+
<p className="text-gray-700 text-sm leading-relaxed">
66+
{parseTextWithCode(question.description)}
67+
</p>
68+
</div>
69+
70+
{/* Examples */}
71+
{question.examples.map((example, index) => {
72+
const lines = example.split('\n').filter(line => line.trim());
73+
return (
74+
<div key={index} className="mb-6">
75+
<h3 className="font-semibold text-gray-900 mb-2">
76+
{lines[0].includes('Example') ? lines[0] : `Example ${index + 1}:`}
77+
</h3>
78+
<div className="bg-gray-50 p-3 rounded text-sm font-mono space-y-1">
79+
{lines.slice(1).map((line, lineIndex) => {
80+
const trimmedLine = line.trim();
81+
return (
82+
<div
83+
key={lineIndex}
84+
className={trimmedLine.startsWith('Explanation') ? 'text-gray-600' : ''}
85+
>
86+
{trimmedLine}
87+
</div>
88+
);
89+
})}
90+
</div>
91+
</div>
92+
);
93+
})}
94+
95+
{/* Constraints */}
96+
{question.constraints && question.constraints.length > 0 && (
97+
<div>
98+
<h3 className="font-semibold text-gray-900 mb-2">Constraints:</h3>
99+
<ul className="text-sm text-gray-700 space-y-1 list-disc list-inside">
100+
{question.constraints.map((constraint, index) => (
101+
<li key={index}>{parseTextWithCode(constraint)}</li>
102+
))}
103+
</ul>
104+
</div>
105+
)}
106+
</div>
107+
);
108+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {useEffect, useState} from 'react';
2+
import PeerPrepIcon from '../../assets/peerprep-icon.svg';
3+
4+
const SECOND = 1000;
5+
const MINUTE = SECOND * 60;
6+
const HOUR = MINUTE * 60;
7+
const DAY = HOUR * 24;
8+
const INITIAL_PENALTY_TIME = MINUTE * 10;
9+
10+
export default function SessionHeader({
11+
sessionStartTime,
12+
handlePenaltyOver,
13+
}: {
14+
sessionStartTime: number | null;
15+
handlePenaltyOver: () => void;
16+
}) {
17+
const [time, setTime] = useState(0);
18+
const [penaltyTime, setPenaltyTime] = useState(INITIAL_PENALTY_TIME);
19+
20+
useEffect(() => {
21+
if (!sessionStartTime) return;
22+
23+
const interval = setInterval(() => {
24+
setTime(Date.now() - sessionStartTime);
25+
}, 1000);
26+
return () => clearInterval(interval);
27+
}, [sessionStartTime]);
28+
29+
useEffect(() => {
30+
if (!sessionStartTime) return;
31+
32+
const interval = setInterval(() => {
33+
const newPenaltyTime = INITIAL_PENALTY_TIME - (Date.now() - sessionStartTime);
34+
if (newPenaltyTime <= 0) {
35+
setPenaltyTime(0);
36+
handlePenaltyOver();
37+
clearInterval(interval);
38+
} else {
39+
setPenaltyTime(newPenaltyTime);
40+
}
41+
}, 1000);
42+
43+
return () => clearInterval(interval);
44+
}, [sessionStartTime]);
45+
46+
return (
47+
<header className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between">
48+
<div className="flex items-center space-x-2">
49+
<div className="text-blue-500 font-bold text-xl">
50+
<img src={PeerPrepIcon} alt="PeerPrep" className="header-logo" />
51+
</div>
52+
</div>
53+
<div className="text-right">
54+
<div className="text-sm font-semibold text-gray-700">
55+
Session{` `}
56+
{/* {`${Math.floor((time / DAY) % 24)}`.padStart(2, '0')}:
57+
{`${Math.floor((time / HOUR) % 60)}`.padStart(2, '0')}: */}
58+
{`${Math.floor((time / MINUTE) % 60)}`.padStart(2, '0')}:
59+
{`${Math.floor((time / SECOND) % 60)}`.padStart(2, '0')}
60+
</div>
61+
{penaltyTime > 0 && (
62+
<div className="text-sm text-red-400">
63+
Penaly Timer{` `}
64+
{`${Math.floor((penaltyTime / MINUTE) % 60)}`.padStart(2, '0')}:
65+
{`${Math.floor((penaltyTime / SECOND) % 60)}`.padStart(2, '0')}
66+
</div>
67+
)}
68+
</div>
69+
</header>
70+
);
71+
}

0 commit comments

Comments
 (0)