Skip to content

Commit d815bfd

Browse files
committed
feat: add resubmit workflow
1 parent 5376891 commit d815bfd

File tree

11 files changed

+1728
-284
lines changed

11 files changed

+1728
-284
lines changed

lark-ui/src/lib/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export type Project = {
7979
repoUrl: string | null;
8080
playableUrl: string | null;
8181
screenshotUrl: string | null;
82+
isLocked: boolean;
8283
submissions: {
8384
submissionId: number;
8485
approvedHours: number | null;

lark-ui/src/routes/admin/+page.svelte

Lines changed: 210 additions & 91 deletions
Large diffs are not rendered by default.
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
4+
type EditRequest = {
5+
requestId: number;
6+
userId: number;
7+
projectId: number;
8+
requestType: string;
9+
currentData: any;
10+
requestedData: any;
11+
status: 'pending' | 'approved' | 'rejected';
12+
reason: string | null;
13+
reviewedBy: number | null;
14+
reviewedAt: string | null;
15+
createdAt: string;
16+
updatedAt: string;
17+
user: {
18+
userId: number;
19+
firstName: string | null;
20+
lastName: string | null;
21+
email: string;
22+
};
23+
project: {
24+
projectId: number;
25+
projectTitle: string;
26+
projectType: string;
27+
};
28+
};
29+
30+
let { apiUrl, initialEditRequests = [] }: { apiUrl: string; initialEditRequests?: EditRequest[] } = $props();
31+
32+
let editRequests = $state<EditRequest[]>(initialEditRequests);
33+
let loading = $state(false);
34+
let statusFilter = $state<'all' | 'pending' | 'approved' | 'rejected'>('pending');
35+
let editRequestBusy = $state<Record<number, boolean>>({});
36+
let editRequestErrors = $state<Record<number, string>>({});
37+
let editRequestSuccess = $state<Record<number, string>>({});
38+
39+
let filteredRequests = $derived(
40+
editRequests.filter(req => {
41+
if (statusFilter !== 'all' && req.status !== statusFilter) {
42+
return false;
43+
}
44+
return true;
45+
})
46+
);
47+
48+
async function loadEditRequests() {
49+
loading = true;
50+
try {
51+
const response = await fetch(`${apiUrl}/api/admin/edit-requests`, {
52+
credentials: 'include',
53+
});
54+
55+
if (!response.ok) {
56+
throw new Error('Failed to load edit requests');
57+
}
58+
59+
editRequests = await response.json();
60+
} catch (err) {
61+
console.error('Error loading edit requests:', err);
62+
} finally {
63+
loading = false;
64+
}
65+
}
66+
67+
async function approveEditRequest(requestId: number) {
68+
editRequestBusy = { ...editRequestBusy, [requestId]: true };
69+
editRequestErrors = { ...editRequestErrors, [requestId]: '' };
70+
editRequestSuccess = { ...editRequestSuccess, [requestId]: '' };
71+
72+
try {
73+
const response = await fetch(`${apiUrl}/api/admin/edit-requests/${requestId}/approve`, {
74+
method: 'PUT',
75+
credentials: 'include',
76+
});
77+
78+
if (!response.ok) {
79+
const { message } = await response.json().catch(() => ({ message: 'Failed to approve request' }));
80+
editRequestErrors = { ...editRequestErrors, [requestId]: message ?? 'Failed to approve request' };
81+
return;
82+
}
83+
84+
editRequestSuccess = { ...editRequestSuccess, [requestId]: 'Request approved successfully' };
85+
await loadEditRequests();
86+
} catch (err) {
87+
editRequestErrors = {
88+
...editRequestErrors,
89+
[requestId]: err instanceof Error ? err.message : 'Failed to approve request',
90+
};
91+
} finally {
92+
editRequestBusy = { ...editRequestBusy, [requestId]: false };
93+
}
94+
}
95+
96+
async function rejectEditRequest(requestId: number) {
97+
const reason = prompt('Provide a reason for rejecting this request:');
98+
if (!reason) return;
99+
100+
editRequestBusy = { ...editRequestBusy, [requestId]: true };
101+
editRequestErrors = { ...editRequestErrors, [requestId]: '' };
102+
editRequestSuccess = { ...editRequestSuccess, [requestId]: '' };
103+
104+
try {
105+
const response = await fetch(`${apiUrl}/api/admin/edit-requests/${requestId}/reject`, {
106+
method: 'PUT',
107+
headers: { 'Content-Type': 'application/json' },
108+
credentials: 'include',
109+
body: JSON.stringify({ reason }),
110+
});
111+
112+
if (!response.ok) {
113+
const { message } = await response.json().catch(() => ({ message: 'Failed to reject request' }));
114+
editRequestErrors = { ...editRequestErrors, [requestId]: message ?? 'Failed to reject request' };
115+
return;
116+
}
117+
118+
editRequestSuccess = { ...editRequestSuccess, [requestId]: 'Request rejected successfully' };
119+
await loadEditRequests();
120+
} catch (err) {
121+
editRequestErrors = {
122+
...editRequestErrors,
123+
[requestId]: err instanceof Error ? err.message : 'Failed to reject request',
124+
};
125+
} finally {
126+
editRequestBusy = { ...editRequestBusy, [requestId]: false };
127+
}
128+
}
129+
130+
function formatDate(value: string) {
131+
return new Date(value).toLocaleString();
132+
}
133+
134+
function fullName(user: { firstName: string | null; lastName: string | null }) {
135+
const first = user.firstName ?? '';
136+
const last = user.lastName ?? '';
137+
const name = `${first} ${last}`.trim();
138+
return name || 'Unknown';
139+
}
140+
141+
function renderValue(value: any): string {
142+
if (value === null || value === undefined) return '';
143+
if (Array.isArray(value)) return value.join(', ');
144+
if (typeof value === 'object') return JSON.stringify(value, null, 2);
145+
return String(value);
146+
}
147+
148+
function getChangedFields(currentData: any, requestedData: any): string[] {
149+
const fields = new Set([...Object.keys(currentData || {}), ...Object.keys(requestedData || {})]);
150+
return Array.from(fields).filter(key => {
151+
const current = currentData?.[key];
152+
const requested = requestedData?.[key];
153+
return JSON.stringify(current) !== JSON.stringify(requested);
154+
});
155+
}
156+
</script>
157+
158+
<section class="space-y-4">
159+
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
160+
<h2 class="text-2xl font-semibold">Edit Requests</h2>
161+
<button
162+
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg border border-gray-700 transition-colors"
163+
onclick={loadEditRequests}
164+
disabled={loading}
165+
>
166+
{loading ? 'Loading...' : 'Refresh'}
167+
</button>
168+
</div>
169+
170+
<div class="rounded-2xl border border-gray-700 bg-gray-900/70 backdrop-blur p-6 space-y-6">
171+
<div class="flex gap-2">
172+
<button
173+
class={`px-4 py-2 rounded-lg border transition-colors ${statusFilter === 'all' ? 'bg-purple-600 border-purple-400' : 'bg-gray-800 border-gray-700 hover:bg-gray-700'}`}
174+
onclick={() => (statusFilter = 'all')}
175+
>
176+
All
177+
</button>
178+
<button
179+
class={`px-4 py-2 rounded-lg border transition-colors ${statusFilter === 'pending' ? 'bg-purple-600 border-purple-400' : 'bg-gray-800 border-gray-700 hover:bg-gray-700'}`}
180+
onclick={() => (statusFilter = 'pending')}
181+
>
182+
Pending
183+
</button>
184+
<button
185+
class={`px-4 py-2 rounded-lg border transition-colors ${statusFilter === 'approved' ? 'bg-purple-600 border-purple-400' : 'bg-gray-800 border-gray-700 hover:bg-gray-700'}`}
186+
onclick={() => (statusFilter = 'approved')}
187+
>
188+
Approved
189+
</button>
190+
<button
191+
class={`px-4 py-2 rounded-lg border transition-colors ${statusFilter === 'rejected' ? 'bg-purple-600 border-purple-400' : 'bg-gray-800 border-gray-700 hover:bg-gray-700'}`}
192+
onclick={() => (statusFilter = 'rejected')}
193+
>
194+
Rejected
195+
</button>
196+
</div>
197+
198+
{#if filteredRequests.length === 0}
199+
<div class="text-center py-12 text-gray-400">
200+
No {statusFilter !== 'all' ? statusFilter : ''} edit requests found
201+
</div>
202+
{:else}
203+
<div class="space-y-4">
204+
{#each filteredRequests as request (request.requestId)}
205+
{@const changedFields = getChangedFields(request.currentData, request.requestedData)}
206+
<div class="rounded-xl border border-gray-700 bg-gray-800/50 p-6 space-y-4">
207+
<div class="flex justify-between items-start">
208+
<div>
209+
<h3 class="text-xl font-semibold text-white">{request.project.projectTitle}</h3>
210+
<p class="text-sm text-gray-400">
211+
{fullName(request.user)} ({request.user.email})
212+
</p>
213+
<p class="text-xs text-gray-500 mt-1">
214+
Requested: {formatDate(request.createdAt)}
215+
</p>
216+
</div>
217+
<div class="flex flex-col items-end gap-2">
218+
<span
219+
class={`px-3 py-1 rounded-full text-xs font-semibold ${
220+
request.status === 'pending'
221+
? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
222+
: request.status === 'approved'
223+
? 'bg-green-500/20 text-green-400 border border-green-500/30'
224+
: 'bg-red-500/20 text-red-400 border border-red-500/30'
225+
}`}
226+
>
227+
{request.status.toUpperCase()}
228+
</span>
229+
<span class="text-xs text-gray-500">ID: {request.requestId}</span>
230+
</div>
231+
</div>
232+
233+
{#if request.reason}
234+
<div class="bg-gray-900/50 rounded-lg p-4 border border-gray-700">
235+
<p class="text-sm text-gray-400 font-semibold mb-1">Reason for Changes:</p>
236+
<p class="text-white whitespace-pre-wrap">{request.reason}</p>
237+
</div>
238+
{/if}
239+
240+
<div class="space-y-3">
241+
<h4 class="text-sm font-semibold text-gray-400 uppercase tracking-wide">Requested Changes:</h4>
242+
{#each changedFields as field}
243+
<div class="bg-gray-900/50 rounded-lg p-4 border border-gray-700">
244+
<p class="text-sm text-gray-400 font-semibold mb-2">{field}</p>
245+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
246+
<div>
247+
<p class="text-xs text-gray-500 mb-1">Current:</p>
248+
<pre class="text-sm text-red-400 bg-red-900/20 p-2 rounded border border-red-500/30 overflow-x-auto">{renderValue(request.currentData?.[field])}</pre>
249+
</div>
250+
<div>
251+
<p class="text-xs text-gray-500 mb-1">Requested:</p>
252+
<pre class="text-sm text-green-400 bg-green-900/20 p-2 rounded border border-green-500/30 overflow-x-auto">{renderValue(request.requestedData?.[field])}</pre>
253+
</div>
254+
</div>
255+
</div>
256+
{/each}
257+
</div>
258+
259+
{#if editRequestErrors[request.requestId]}
260+
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3 text-sm text-red-400">
261+
{editRequestErrors[request.requestId]}
262+
</div>
263+
{/if}
264+
265+
{#if editRequestSuccess[request.requestId]}
266+
<div class="bg-green-500/10 border border-green-500/30 rounded-lg p-3 text-sm text-green-400">
267+
{editRequestSuccess[request.requestId]}
268+
</div>
269+
{/if}
270+
271+
{#if request.status === 'pending'}
272+
<div class="flex gap-3">
273+
<button
274+
class="flex-1 px-4 py-2 bg-green-600 hover:bg-green-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-semibold"
275+
onclick={() => approveEditRequest(request.requestId)}
276+
disabled={editRequestBusy[request.requestId]}
277+
>
278+
{editRequestBusy[request.requestId] ? 'Approving...' : 'Approve'}
279+
</button>
280+
<button
281+
class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-semibold"
282+
onclick={() => rejectEditRequest(request.requestId)}
283+
disabled={editRequestBusy[request.requestId]}
284+
>
285+
{editRequestBusy[request.requestId] ? 'Rejecting...' : 'Reject'}
286+
</button>
287+
</div>
288+
{:else if request.reviewedAt}
289+
<p class="text-sm text-gray-400">
290+
Reviewed: {formatDate(request.reviewedAt)}
291+
</p>
292+
{/if}
293+
</div>
294+
{/each}
295+
</div>
296+
{/if}
297+
</div>
298+
</section>

lark-ui/src/routes/app/projects/[id]/+layout.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@
8888
8989
.back-button {
9090
margin-bottom: 30px;
91+
position: sticky;
92+
top: 57px;
93+
z-index: 10;
9194
}
9295
9396
.project-overview {

lark-ui/src/routes/app/projects/[id]/+page.svelte

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
<script lang="ts">
2-
import type { Project, User } from '$lib/auth';
32
import Button from '$lib/Button.svelte';
4-
import ProjectCardPreview from '$lib/cards/ProjectCardPreview.svelte';
5-
import { getContext } from 'svelte';
63
import { goto } from '$app/navigation';
74
import { projectPageState } from './state.svelte';
85
@@ -61,7 +58,7 @@
6158
</p>
6259

6360
{#if projectPageState.project?.submissions && projectPageState.project.submissions.length > 0}
64-
{@const submission = projectPageState.project.submissions[0]}
61+
{@const submission = projectPageState.project.submissions[projectPageState.project.submissions.length - 1]}
6562
{@const status = submission.approvalStatus}
6663
<div class="tracking-container">
6764
<div class="tracking-header">
@@ -146,11 +143,21 @@
146143

147144
{#if projectPageState.user && projectPageState.user.hackatimeAccount}
148145
{#if projectPageState.project?.submissions && projectPageState.project.submissions.length > 0}
146+
{@const latestSubmission = projectPageState.project.submissions[projectPageState.project.submissions.length - 1]}
149147
<div class="submit-section">
150-
<Button
151-
label="Submiited"
152-
disabled
153-
/>
148+
{#if latestSubmission.approvalStatus === 'approved'}
149+
<Button label="RE-SUBMIT" onclick={() => goto(`/app/projects/${projectPageState.project?.projectId}/submit`)}/>
150+
{:else if latestSubmission.approvalStatus === 'rejected'}
151+
<Button label="RE-SUBMIT" onclick={() => goto(`/app/projects/${projectPageState.project?.projectId}/submit`)}/>
152+
{:else}
153+
<Button
154+
label="Submitted"
155+
disabled
156+
/>
157+
{/if}
158+
{#if projectPageState.project.isLocked}
159+
<Button label="REQUEST CHANGES" icon="edit" color="blue" onclick={() => goto(`/app/projects/${projectPageState.project?.projectId}/request-changes`)}/>
160+
{/if}
154161
</div>
155162
{:else if projectPageState.project?.nowHackatimeProjects && projectPageState.project.nowHackatimeProjects.length > 0}
156163
<div class="submit-section">

0 commit comments

Comments
 (0)