add support for updates

This commit is contained in:
Nathan 2026-02-11 16:34:21 -05:00
parent f17adfa7e6
commit 02e66b488f
17 changed files with 1791 additions and 211 deletions

120
CLAUDE.md Normal file
View file

@ -0,0 +1,120 @@
# CLAUDE.md
## Architecture
- **frontend/**: SvelteKit 2 + Svelte 5 app with Tailwind CSS v4, static adapter
- **backend/**: Bun + Elysia API server with Drizzle ORM + PostgreSQL
## Commands
| Task | Frontend (`cd frontend`) | Backend (`cd backend`) |
|------|--------------------------|------------------------|
| Dev | `bun run dev` | `bun --watch src/index.ts` |
| Build | `bun run build` | `bun build src/index.ts --target bun --outdir ./dist` |
| Lint | `bun run lint` | — |
| Format | `bun run format` | — |
| Typecheck | `bun run check` | — |
| Test | — | `bun test` (single: `bun test <file>`) |
## Code Style
- Use tabs, single quotes, no trailing commas (Prettier configured in frontend)
- Frontend: Svelte 5 runes, TypeScript strict, Lucide icons
- Backend: Drizzle schemas in `schemas/`, snake_case for DB columns, camelCase in TS
- No comments unless complex; no `// @ts-expect-error` or `as any`
## UI Style Guide
### Buttons
All buttons should follow these patterns:
**Primary Button (filled)**
```html
class="px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 cursor-pointer"
```
**Secondary Button (outlined) - Navigation/Action buttons**
```html
class="px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer"
```
**Toggle/Filter Button (selected state)**
```html
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {isSelected
? 'bg-black text-white'
: 'hover:border-dashed'}"
```
### Cards & Containers
```html
class="border-4 border-black rounded-2xl p-6 hover:border-dashed transition-all"
```
### Inputs
```html
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
```
### Modals
```html
class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black max-h-[90vh] overflow-y-auto"
```
### Confirmation Modals
Use the `ConfirmModal` component from `$lib/components/ConfirmModal.svelte` or follow this pattern:
**Backdrop**
```html
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={(e) => e.target === e.currentTarget && onCancel()}
onkeydown={(e) => e.key === 'Escape' && onCancel()}
role="dialog"
tabindex="-1"
>
```
**Modal Container**
```html
class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-black"
```
**Title**
```html
<h2 class="text-2xl font-bold mb-4">{title}</h2>
```
**Message**
```html
<p class="text-gray-600 mb-6">{message}</p>
```
**Button Row**
```html
<div class="flex gap-3">
<!-- Cancel button (secondary) -->
<button class="flex-1 px-4 py-2 border-4 border-black rounded-full font-bold hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer">
cancel
</button>
<!-- Confirm button (primary/success/warning/danger) -->
<button class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold border-4 border-black hover:border-dashed transition-all duration-200 disabled:opacity-50 cursor-pointer">
confirm
</button>
</div>
```
**Confirm Button Variants**
- Primary: `bg-black text-white`
- Success: `bg-green-600 text-white`
- Warning: `bg-yellow-500 text-white`
- Danger: `bg-red-600 text-white`
**Do NOT use browser `alert()` or `confirm()`. Always use styled modals.**
### Key Patterns
- **Border style**: `border-4` for buttons, `border-2` for inputs, `border-4` for cards/containers
- **Rounding**: `rounded-full` for buttons, `rounded-2xl` for cards, `rounded-lg` for inputs
- **Hover state**: `hover:border-dashed` for outlined elements
- **Focus state**: `focus:border-dashed` for inputs
- **Selected state**: `bg-black text-white` (filled)
- **Animation**: Always include `transition-all duration-200`
- **Colors**: Black borders, white backgrounds, no colors except for errors (red)
- **Cursor**: Always include `cursor-pointer` on clickable elements (buttons, links, interactive cards)
- **Disabled cursor**: Always include `disabled:cursor-not-allowed` on buttons that can be disabled

View file

@ -0,0 +1,10 @@
-- Add shipped_at timestamp to track when projects were shipped
ALTER TABLE projects ADD COLUMN shipped_at timestamp;
-- Backfill shipped_at for existing shipped projects using updatedAt as approximation
UPDATE projects
SET shipped_at = updated_at
WHERE status = 'shipped' AND shipped_at IS NULL;
-- Create index for efficient ordering queries
CREATE INDEX idx_projects_shipped_at ON projects(shipped_at) WHERE shipped_at IS NOT NULL;

View file

@ -0,0 +1,4 @@
-- Remove shipped_at column from projects table
-- Ship dates are now derived from the project_activity table (project_shipped action)
DROP INDEX IF EXISTS idx_projects_shipped_at;
ALTER TABLE projects DROP COLUMN IF EXISTS shipped_at;

View file

@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN IF NOT EXISTS reviewer_notes TEXT;

View file

@ -8,6 +8,7 @@ import { reviewsTable } from '../schemas/reviews'
import { config } from '../config'
import { eq, and, or, isNull, min, sql, inArray } from 'drizzle-orm'
import { fetchUserInfo } from './auth'
import { getProjectShippedDates } from './effective-hours'
const SYNC_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
@ -209,6 +210,9 @@ async function syncProjectsToAirtable(): Promise<void> {
// Cache userinfo per userId to avoid redundant API calls
const userInfoCache: Map<number, Awaited<ReturnType<typeof fetchUserInfo>>> = new Map()
// Batch-fetch first shipped dates from project_activity for all projects
const shippedDates = await getProjectShippedDates(projects.map(p => p.id))
// Track which Code URLs we've already seen to detect duplicates
const seenCodeUrls = new Set<string>()
@ -235,13 +239,19 @@ async function syncProjectsToAirtable(): Promise<void> {
const userInfo = userInfoCache.get(project.userId)
// Compute effective hours by deducting overlapping shipped project hours
// Only deduct from projects that were shipped BEFORE this one (using activity-derived dates)
const projectShippedDate = shippedDates.get(project.id)
let effectiveHours = project.hoursOverride ?? project.hours ?? 0
if (project.hackatimeProject) {
if (project.hackatimeProject && projectShippedDate) {
const hackatimeNames = project.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0)
if (hackatimeNames.length > 0) {
for (const op of projects) {
if (op.id === project.id || op.userId !== project.userId) continue
if (!op.hackatimeProject) continue
const opShippedDate = shippedDates.get(op.id)
if (!opShippedDate) continue
// Only deduct from projects shipped before this one
if (opShippedDate >= projectShippedDate) continue
const opNames = op.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0)
if (opNames.some(name => hackatimeNames.includes(name))) {
effectiveHours -= (op.hoursOverride ?? op.hours ?? 0)

View file

@ -0,0 +1,139 @@
import { db } from '../db'
import { projectActivityTable } from '../schemas/activity'
import { projectsTable } from '../schemas/projects'
import { eq, and, or, isNull, sql, inArray, min } from 'drizzle-orm'
/**
* Get the first shipped date for each project from the project_activity table.
* Returns a Map of projectId -> first shipped Date.
*/
export async function getProjectShippedDates(projectIds: number[]): Promise<Map<number, Date>> {
if (projectIds.length === 0) return new Map()
const rows = await db
.select({
projectId: projectActivityTable.projectId,
firstShipped: min(projectActivityTable.createdAt)
})
.from(projectActivityTable)
.where(and(
inArray(projectActivityTable.projectId, projectIds),
eq(projectActivityTable.action, 'project_shipped')
))
.groupBy(projectActivityTable.projectId)
const map = new Map<number, Date>()
for (const row of rows) {
if (row.projectId != null && row.firstShipped != null) {
map.set(row.projectId, row.firstShipped)
}
}
return map
}
/**
* Get the first shipped date for a single project from the project_activity table.
*/
export async function getProjectShippedDate(projectId: number): Promise<Date | null> {
const map = await getProjectShippedDates([projectId])
return map.get(projectId) ?? null
}
/**
* Check if a project has ever been shipped (has a project_shipped activity entry).
*/
export async function hasProjectBeenShipped(projectId: number): Promise<boolean> {
const date = await getProjectShippedDate(projectId)
return date !== null
}
/**
* Compute effective hours for a project by subtracting overlapping shipped hours.
* Only deducts from projects that were shipped BEFORE the current project.
*
* @param project - The project to compute effective hours for
* @param allShipped - All shipped projects (must include shippedDate from activity table)
*/
export function computeEffectiveHours(
project: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null; shippedDate: Date | null },
allShipped: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null; shippedDate: Date | null }[]
): number {
const hours = project.hoursOverride ?? project.hours ?? 0
if (!project.hackatimeProject || !project.shippedDate) return hours
const hackatimeNames = project.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0)
if (hackatimeNames.length === 0) return hours
let deducted = 0
for (const op of allShipped) {
if (op.id === project.id || op.userId !== project.userId) continue
if (!op.hackatimeProject || !op.shippedDate) continue
if (op.shippedDate >= project.shippedDate) continue
const opNames = op.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0)
if (opNames.some(name => hackatimeNames.includes(name))) {
deducted += op.hoursOverride ?? op.hours ?? 0
}
}
return Math.max(0, hours - deducted)
}
/**
* Compute effective hours with deduction details for a single project.
* Fetches overlapping shipped projects from the DB and the shipped dates from activity.
* Used in review detail pages.
*/
export async function computeEffectiveHoursForProject(
project: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null }
): Promise<{ overlappingProjects: { id: number; name: string; hours: number }[]; deductedHours: number; effectiveHours: number }> {
const projectHours = project.hoursOverride ?? project.hours ?? 0
if (!project.hackatimeProject) return { overlappingProjects: [], deductedHours: 0, effectiveHours: projectHours }
const hackatimeNames = project.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
if (hackatimeNames.length === 0) return { overlappingProjects: [], deductedHours: 0, effectiveHours: projectHours }
// Get this project's first shipped date
const projectShippedDate = await getProjectShippedDate(project.id)
const deductBeforeDate = projectShippedDate || new Date()
// Get all shipped projects by same user (excluding this one)
const shipped = await db
.select({
id: projectsTable.id,
name: projectsTable.name,
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject
})
.from(projectsTable)
.where(and(
eq(projectsTable.userId, project.userId),
eq(projectsTable.status, 'shipped'),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.id} != ${project.id}`
))
if (shipped.length === 0) return { overlappingProjects: [], deductedHours: 0, effectiveHours: projectHours }
// Get shipped dates for all these projects
const shippedDates = await getProjectShippedDates(shipped.map(s => s.id))
const overlapping = shipped.filter(op => {
if (!op.hackatimeProject) return false
const opShippedDate = shippedDates.get(op.id)
if (!opShippedDate || opShippedDate >= deductBeforeDate) return false
const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
return opNames.some((name: string) => hackatimeNames.includes(name))
}).map(op => ({
id: op.id,
name: op.name,
hours: op.hoursOverride ?? op.hours ?? 0
}))
const deductedHours = overlapping.reduce((sum, op) => sum + op.hours, 0)
return {
overlappingProjects: overlapping,
deductedHours,
effectiveHours: Math.max(0, projectHours - deductedHours)
}
}

View file

@ -13,6 +13,7 @@ import { payoutPendingScraps, getNextPayoutDate } from '../lib/scraps-payout'
import { syncSingleProject } from '../lib/hackatime-sync'
import { notifyProjectReview } from '../lib/slack'
import { config } from '../config'
import { computeEffectiveHours, getProjectShippedDates, hasProjectBeenShipped, computeEffectiveHoursForProject } from '../lib/effective-hours'
const admin = new Elysia({ prefix: '/admin' })
@ -30,30 +31,6 @@ async function requireAdmin(headers: Record<string, string>) {
return user
}
// Helper: compute effective hours for a project by subtracting overlapping shipped hours
function computeEffectiveHours(
project: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null },
allShipped: { id: number; userId: number; hours: number | null; hoursOverride: number | null; hackatimeProject: string | null }[]
): number {
const hours = project.hoursOverride ?? project.hours ?? 0
if (!project.hackatimeProject) return hours
const hackatimeNames = project.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0)
if (hackatimeNames.length === 0) return hours
let deducted = 0
for (const op of allShipped) {
if (op.id === project.id || op.userId !== project.userId) continue
if (!op.hackatimeProject) continue
const opNames = op.hackatimeProject.split(',').map(n => n.trim()).filter(n => n.length > 0)
if (opNames.some(name => hackatimeNames.includes(name))) {
deducted += op.hoursOverride ?? op.hours ?? 0
}
}
return Math.max(0, hours - deducted)
}
// Get admin stats (info page)
admin.get('/stats', async ({ headers, status }) => {
const user = await requireReviewer(headers as Record<string, string>)
@ -82,10 +59,19 @@ admin.get('/stats', async ({ headers, status }) => {
const pending = allProjects.filter(p => p.status === 'waiting_for_review')
const inProgress = allProjects.filter(p => p.status === 'in_progress')
// Get shipped dates from activity table for all projects
const allProjectIds = allProjects.map(p => p.id)
const shippedDates = await getProjectShippedDates(allProjectIds)
// Attach shippedDate to each project for computeEffectiveHours
const shippedWithDates = shipped.map(p => ({ ...p, shippedDate: shippedDates.get(p.id) ?? null }))
const pendingWithDates = pending.map(p => ({ ...p, shippedDate: shippedDates.get(p.id) ?? null }))
const inProgressWithDates = inProgress.map(p => ({ ...p, shippedDate: shippedDates.get(p.id) ?? null }))
// Compute effective hours for each category (deducting overlapping shipped project hours)
const totalHours = shipped.reduce((sum, p) => sum + computeEffectiveHours(p, shipped), 0)
const pendingHours = pending.reduce((sum, p) => sum + computeEffectiveHours(p, shipped), 0)
const inProgressHours = inProgress.reduce((sum, p) => sum + computeEffectiveHours(p, shipped), 0)
const totalHours = shippedWithDates.reduce((sum, p) => sum + computeEffectiveHours(p, shippedWithDates), 0)
const pendingHours = pendingWithDates.reduce((sum, p) => sum + computeEffectiveHours(p, shippedWithDates), 0)
const inProgressHours = inProgressWithDates.reduce((sum, p) => sum + computeEffectiveHours(p, shippedWithDates), 0)
const totalUsers = Number(usersCount[0]?.count || 0)
const totalProjects = Number(projectsCount[0]?.count || 0)
@ -421,7 +407,32 @@ admin.get('/reviews', async ({ headers, query }) => {
: asc(projectsTable.updatedAt)
const [projects, countResult] = await Promise.all([
db.select().from(projectsTable)
db.select({
id: projectsTable.id,
userId: projectsTable.userId,
name: projectsTable.name,
description: projectsTable.description,
image: projectsTable.image,
githubUrl: projectsTable.githubUrl,
playableUrl: projectsTable.playableUrl,
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject,
tier: projectsTable.tier,
tierOverride: projectsTable.tierOverride,
status: projectsTable.status,
deleted: projectsTable.deleted,
scrapsAwarded: projectsTable.scrapsAwarded,
scrapsPaidAt: projectsTable.scrapsPaidAt,
views: projectsTable.views,
updateDescription: projectsTable.updateDescription,
aiDescription: projectsTable.aiDescription,
feedbackSource: projectsTable.feedbackSource,
feedbackGood: projectsTable.feedbackGood,
feedbackImprove: projectsTable.feedbackImprove,
createdAt: projectsTable.createdAt,
updatedAt: projectsTable.updatedAt
}).from(projectsTable)
.where(eq(projectsTable.status, 'waiting_for_review'))
.orderBy(orderClause)
.limit(limit)
@ -434,36 +445,8 @@ admin.get('/reviews', async ({ headers, query }) => {
// Compute effective hours for each project (subtract overlapping shipped hours)
const projectsWithEffective = await Promise.all(projects.map(async (p) => {
const hours = p.hoursOverride ?? p.hours ?? 0
if (!p.hackatimeProject) return { ...p, effectiveHours: hours, deductedHours: 0 }
const hackatimeNames = p.hackatimeProject.split(',').map((n: string) => n.trim()).filter((n: string) => n.length > 0)
if (hackatimeNames.length === 0) return { ...p, effectiveHours: hours, deductedHours: 0 }
const shipped = await db
.select({
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject
})
.from(projectsTable)
.where(and(
eq(projectsTable.userId, p.userId),
eq(projectsTable.status, 'shipped'),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.id} != ${p.id}`
))
let deductedHours = 0
for (const op of shipped) {
if (!op.hackatimeProject) continue
const opNames = op.hackatimeProject.split(',').map((n: string) => n.trim()).filter((n: string) => n.length > 0)
if (opNames.some((name: string) => hackatimeNames.includes(name))) {
deductedHours += op.hoursOverride ?? op.hours ?? 0
}
}
return { ...p, effectiveHours: Math.max(0, hours - deductedHours), deductedHours }
const result = await computeEffectiveHoursForProject(p)
return { ...p, effectiveHours: result.effectiveHours, deductedHours: result.deductedHours }
}))
return {
@ -520,15 +503,26 @@ admin.get('/reviews/:id', async ({ params, headers }) => {
.where(inArray(usersTable.id, reviewerIds))
}
const isAdmin = user.role === 'admin'
// Hide pending_admin_approval from non-admin reviewers
const maskedProject = (!isAdmin && project[0].status === 'pending_admin_approval')
? { ...project[0], status: 'waiting_for_review' }
: project[0]
// Hide approval reviews from non-admin reviewers when project is pending admin approval
const visibleReviews = (!isAdmin && project[0].status === 'pending_admin_approval')
? reviews.filter(r => r.action !== 'approved')
: reviews
return {
project: project[0],
project: maskedProject,
user: projectUser[0] ? {
id: projectUser[0].id,
username: projectUser[0].username,
avatar: projectUser[0].avatar,
internalNotes: projectUser[0].internalNotes
} : null,
reviews: reviews.map(r => {
reviews: visibleReviews.map(r => {
const reviewer = reviewers.find(rv => rv.id === r.reviewerId)
return {
...r,
@ -537,46 +531,7 @@ admin.get('/reviews/:id', async ({ params, headers }) => {
reviewerId: r.reviewerId
}
}),
...await (async () => {
if (!project[0].hackatimeProject) return { overlappingProjects: [], deductedHours: 0, effectiveHours: project[0].hoursOverride ?? project[0].hours ?? 0 }
const hackatimeNames = project[0].hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
if (hackatimeNames.length === 0) return { overlappingProjects: [], deductedHours: 0, effectiveHours: project[0].hoursOverride ?? project[0].hours ?? 0 }
const shipped = await db
.select({
id: projectsTable.id,
name: projectsTable.name,
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject,
status: projectsTable.status
})
.from(projectsTable)
.where(and(
eq(projectsTable.userId, project[0].userId),
eq(projectsTable.status, 'shipped'),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.id} != ${project[0].id}`
))
const overlapping = shipped.filter(op => {
if (!op.hackatimeProject) return false
const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
return opNames.some((name: string) => hackatimeNames.includes(name))
}).map(op => ({
id: op.id,
name: op.name,
hours: op.hoursOverride ?? op.hours ?? 0
}))
const deductedHours = overlapping.reduce((sum, op) => sum + op.hours, 0)
const projectHours = project[0].hoursOverride ?? project[0].hours ?? 0
return {
overlappingProjects: overlapping,
deductedHours,
effectiveHours: Math.max(0, projectHours - deductedHours)
}
})()
...await computeEffectiveHoursForProject(project[0])
}
} catch (err) {
console.error(err);
@ -659,10 +614,13 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
// Update project status
let newStatus = 'in_progress'
const isAdmin = user.role === 'admin'
switch (action) {
case "approved":
newStatus = "shipped";
// If reviewer (not admin) approves, send to second-pass review
// If admin approves, ship directly
newStatus = isAdmin ? "shipped" : "pending_admin_approval";
break;
case "denied":
newStatus = "in_progress";
@ -692,40 +650,27 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
const hours = hoursOverride ?? project[0].hours ?? 0
const tier = tierOverride ?? project[0].tier ?? 1
// Subtract hours from previously shipped projects that share the same hackatime project
let deductedHours = 0
if (project[0].hackatimeProject) {
const hackatimeNames = project[0].hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
if (hackatimeNames.length > 0) {
const overlapping = await db
.select({
id: projectsTable.id,
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject
})
.from(projectsTable)
.where(and(
eq(projectsTable.userId, project[0].userId),
eq(projectsTable.status, 'shipped'),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.id} != ${projectId}`
))
// Compute effective hours using activity-derived shipped dates
const { effectiveHours } = await computeEffectiveHoursForProject({
...project[0],
hoursOverride: hoursOverride ?? project[0].hoursOverride
})
const newScrapsAwarded = calculateScrapsFromHours(effectiveHours, tier)
for (const op of overlapping) {
if (!op.hackatimeProject) continue
const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
const hasOverlap = opNames.some((name: string) => hackatimeNames.includes(name))
if (hasOverlap) {
deductedHours += op.hoursOverride ?? op.hours ?? 0
}
}
// Only set scrapsAwarded if admin is approving
// Reviewer approvals just go to pending_admin_approval without awarding scraps yet
if (isAdmin) {
const previouslyShipped = await hasProjectBeenShipped(projectId)
// If this is an update to an already-shipped project, calculate ADDITIONAL scraps
// (difference between new award and previous award)
if (previouslyShipped && project[0].scrapsAwarded > 0) {
scrapsAwarded = Math.max(0, newScrapsAwarded - project[0].scrapsAwarded)
} else {
scrapsAwarded = newScrapsAwarded
}
}
const effectiveHours = Math.max(0, hours - deductedHours)
scrapsAwarded = calculateScrapsFromHours(effectiveHours, tier)
updateData.scrapsAwarded = scrapsAwarded
updateData.scrapsAwarded = newScrapsAwarded
}
}
await db
@ -733,20 +678,25 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
.set(updateData)
.where(eq(projectsTable.id, projectId))
if (action === 'approved' && scrapsAwarded > 0) {
await db.insert(projectActivityTable).values({
userId: project[0].userId,
projectId,
action: `earned ${scrapsAwarded} scraps`
})
}
if (action === 'approved') {
await db.insert(projectActivityTable).values({
userId: project[0].userId,
projectId,
action: 'project_shipped'
})
const previouslyShipped = await hasProjectBeenShipped(projectId)
if (scrapsAwarded > 0) {
await db.insert(projectActivityTable).values({
userId: project[0].userId,
projectId,
action: previouslyShipped ? `earned ${scrapsAwarded} additional scraps (update)` : `earned ${scrapsAwarded} scraps`
})
}
// Only log shipping activity if admin approved (not pending second-pass)
if (isAdmin) {
await db.insert(projectActivityTable).values({
userId: project[0].userId,
projectId,
action: previouslyShipped ? 'project_updated' : 'project_shipped'
})
}
}
// Update user internal notes if provided
@ -760,7 +710,10 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
}
// Send Slack DM notification to the project author
if (config.slackBotToken) {
// Skip notification when a non-admin reviewer approves (goes to pending_admin_approval)
// The second-pass flow sends its own notification when an admin accepts/rejects
const shouldNotify = isAdmin || action !== 'approved'
if (config.slackBotToken && shouldNotify) {
try {
// Get the project author's Slack ID
const projectAuthor = await db
@ -812,6 +765,326 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
}
})
// Second-pass review endpoints (admin only)
// Get projects pending admin approval (reviewer-approved projects)
admin.get('/second-pass', async ({ headers, query }) => {
try {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const page = parseInt(query.page as string) || 1
const limit = Math.min(parseInt(query.limit as string) || 20, 100)
const offset = (page - 1) * limit
const sort = (query.sort as string) || 'oldest'
const orderClause = sort === 'newest'
? desc(projectsTable.updatedAt)
: asc(projectsTable.updatedAt)
const [projects, countResult] = await Promise.all([
db.select({
id: projectsTable.id,
userId: projectsTable.userId,
name: projectsTable.name,
description: projectsTable.description,
image: projectsTable.image,
githubUrl: projectsTable.githubUrl,
playableUrl: projectsTable.playableUrl,
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject,
tier: projectsTable.tier,
tierOverride: projectsTable.tierOverride,
status: projectsTable.status,
deleted: projectsTable.deleted,
scrapsAwarded: projectsTable.scrapsAwarded,
scrapsPaidAt: projectsTable.scrapsPaidAt,
views: projectsTable.views,
updateDescription: projectsTable.updateDescription,
aiDescription: projectsTable.aiDescription,
feedbackSource: projectsTable.feedbackSource,
feedbackGood: projectsTable.feedbackGood,
feedbackImprove: projectsTable.feedbackImprove,
createdAt: projectsTable.createdAt,
updatedAt: projectsTable.updatedAt
}).from(projectsTable)
.where(eq(projectsTable.status, 'pending_admin_approval'))
.orderBy(orderClause)
.limit(limit)
.offset(offset),
db.select({ count: sql<number>`count(*)` }).from(projectsTable)
.where(eq(projectsTable.status, 'pending_admin_approval'))
])
const total = Number(countResult[0]?.count || 0)
// Compute effective hours for each project
const projectsWithEffective = await Promise.all(projects.map(async (p) => {
const result = await computeEffectiveHoursForProject(p)
return { ...p, effectiveHours: result.effectiveHours, deductedHours: result.deductedHours }
}))
return {
data: projectsWithEffective,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
}
} catch (err) {
console.error(err)
return { error: 'Failed to fetch second-pass reviews' }
}
})
// Get single project for second-pass review
admin.get('/second-pass/:id', async ({ params, headers }) => {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
try {
const project = await db
.select()
.from(projectsTable)
.where(eq(projectsTable.id, parseInt(params.id)))
.limit(1)
if (project.length <= 0) return { error: "Project not found!" }
if (project[0].status !== 'pending_admin_approval') {
return { error: "Project is not pending admin approval" }
}
const projectUser = await db
.select({
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
internalNotes: usersTable.internalNotes
})
.from(usersTable)
.where(eq(usersTable.id, project[0].userId))
.limit(1)
const reviews = await db
.select()
.from(reviewsTable)
.where(eq(reviewsTable.projectId, parseInt(params.id)))
const reviewerIds = reviews.map(r => r.reviewerId)
let reviewers: { id: number; username: string | null; avatar: string | null }[] = []
if (reviewerIds.length > 0) {
reviewers = await db
.select({ id: usersTable.id, username: usersTable.username, avatar: usersTable.avatar })
.from(usersTable)
.where(inArray(usersTable.id, reviewerIds))
}
// Calculate effective hours and overlapping projects
const effectiveHoursData = await computeEffectiveHoursForProject(project[0])
return {
project: project[0],
user: projectUser[0] ? {
id: projectUser[0].id,
username: projectUser[0].username,
avatar: projectUser[0].avatar,
internalNotes: projectUser[0].internalNotes
} : null,
reviews: reviews.map(r => {
const reviewer = reviewers.find(rv => rv.id === r.reviewerId)
return {
...r,
reviewerName: reviewer?.username,
reviewerAvatar: reviewer?.avatar,
reviewerId: r.reviewerId
}
}),
...effectiveHoursData
}
} catch (err) {
console.error(err)
return { error: "Something went wrong while trying to get project" }
}
})
// Accept or reject a second-pass review
admin.post('/second-pass/:id', async ({ params, body, headers }) => {
try {
const user = await requireAdmin(headers as Record<string, string>)
if (!user) return { error: 'Unauthorized' }
const { action, feedbackForAuthor, hoursOverride } = body as {
action: 'accept' | 'reject'
feedbackForAuthor?: string
hoursOverride?: number
}
if (!['accept', 'reject'].includes(action)) {
return { error: 'Invalid action. Must be "accept" or "reject"' }
}
const projectId = parseInt(params.id)
// Get project
const project = await db
.select()
.from(projectsTable)
.where(eq(projectsTable.id, projectId))
.limit(1)
if (!project[0]) return { error: 'Project not found' }
if (project[0].status !== 'pending_admin_approval') {
return { error: 'Project is not pending admin approval' }
}
if (action === 'accept') {
// Accept the reviewer's approval and ship the project
const tier = project[0].tierOverride ?? project[0].tier ?? 1
// Apply hours override if provided
if (hoursOverride !== undefined) {
await db
.update(projectsTable)
.set({ hoursOverride })
.where(eq(projectsTable.id, projectId))
project[0].hoursOverride = hoursOverride
}
// Compute effective hours using activity-derived shipped dates
const { effectiveHours } = await computeEffectiveHoursForProject(project[0])
const newScrapsAwarded = calculateScrapsFromHours(effectiveHours, tier)
const previouslyShipped = await hasProjectBeenShipped(projectId)
let scrapsAwarded = 0
const updateData: Record<string, unknown> = {
status: 'shipped',
updatedAt: new Date()
}
// Calculate scraps
if (previouslyShipped && project[0].scrapsAwarded > 0) {
scrapsAwarded = Math.max(0, newScrapsAwarded - project[0].scrapsAwarded)
} else {
scrapsAwarded = newScrapsAwarded
}
updateData.scrapsAwarded = newScrapsAwarded
// Update project
await db
.update(projectsTable)
.set(updateData)
.where(eq(projectsTable.id, projectId))
// Log scraps earned
if (scrapsAwarded > 0) {
await db.insert(projectActivityTable).values({
userId: project[0].userId,
projectId,
action: previouslyShipped ? `earned ${scrapsAwarded} additional scraps (update)` : `earned ${scrapsAwarded} scraps`
})
}
// Log project shipped
await db.insert(projectActivityTable).values({
userId: project[0].userId,
projectId,
action: previouslyShipped ? 'project_updated' : 'project_shipped'
})
// Send notification to project author
if (config.slackBotToken) {
try {
const projectAuthor = await db
.select({ slackId: usersTable.slackId })
.from(usersTable)
.where(eq(usersTable.id, project[0].userId))
.limit(1)
if (projectAuthor[0]?.slackId) {
await notifyProjectReview({
userSlackId: projectAuthor[0].slackId,
projectName: project[0].name,
projectId,
action: 'approved',
feedbackForAuthor: 'Your project has been approved and shipped!',
reviewerSlackId: user.slackId ?? null,
adminSlackIds: [],
scrapsAwarded,
frontendUrl: config.frontendUrl,
token: config.slackBotToken
})
}
} catch (slackErr) {
console.error('Failed to send Slack notification:', slackErr)
}
}
return { success: true, scrapsAwarded }
} else {
// Reject: Delete the approval review and add a denial review
// Find and delete the approval review
await db
.delete(reviewsTable)
.where(and(
eq(reviewsTable.projectId, projectId),
eq(reviewsTable.action, 'approved')
))
// Add a denial review from the admin
await db.insert(reviewsTable).values({
projectId,
reviewerId: user.id,
action: 'denied',
feedbackForAuthor: feedbackForAuthor || 'The admin has rejected the initial approval. Please make improvements and resubmit.',
internalJustification: 'Second-pass rejection'
})
// Set project back to in_progress
await db
.update(projectsTable)
.set({ status: 'in_progress', updatedAt: new Date() })
.where(eq(projectsTable.id, projectId))
// Send notification to project author
if (config.slackBotToken) {
try {
const projectAuthor = await db
.select({ slackId: usersTable.slackId })
.from(usersTable)
.where(eq(usersTable.id, project[0].userId))
.limit(1)
if (projectAuthor[0]?.slackId) {
await notifyProjectReview({
userSlackId: projectAuthor[0].slackId,
projectName: project[0].name,
projectId,
action: 'denied',
feedbackForAuthor: feedbackForAuthor || 'The admin has rejected the initial approval. Please make improvements and resubmit.',
reviewerSlackId: user.slackId ?? null,
adminSlackIds: [],
scrapsAwarded: 0,
frontendUrl: config.frontendUrl,
token: config.slackBotToken
})
}
} catch (slackErr) {
console.error('Failed to send Slack notification:', slackErr)
}
}
return { success: true }
}
} catch (err) {
console.error(err)
return { error: 'Failed to process second-pass review' }
}
})
// Get pending scraps payout info (admin only)
admin.get('/scraps-payout', async ({ headers }) => {
try {

View file

@ -9,6 +9,7 @@ import { getUserFromSession, fetchUserIdentity } from '../lib/auth'
import { syncSingleProject } from '../lib/hackatime-sync'
import { notifyProjectSubmitted } from '../lib/slack'
import { config } from '../config'
import { computeEffectiveHoursForProject } from '../lib/effective-hours'
const ALLOWED_IMAGE_DOMAIN = 'cdn.hackclub.com'
@ -74,7 +75,7 @@ projects.get('/explore', async ({ query }) => {
const conditions = [
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
or(eq(projectsTable.status, 'shipped'), eq(projectsTable.status, 'in_progress'), eq(projectsTable.status, 'waiting_for_review'))
or(eq(projectsTable.status, 'shipped'), eq(projectsTable.status, 'in_progress'), eq(projectsTable.status, 'waiting_for_review'), eq(projectsTable.status, 'pending_admin_approval'))
]
if (search) {
@ -90,9 +91,11 @@ projects.get('/explore', async ({ query }) => {
conditions.push(eq(projectsTable.tier, tier))
}
if (status === 'shipped' || status === 'in_progress' || status === 'waiting_for_review') {
// Replace the default status condition with specific one
if (status === 'shipped' || status === 'in_progress') {
conditions[1] = eq(projectsTable.status, status)
} else if (status === 'waiting_for_review') {
// Include pending_admin_approval since it appears as waiting_for_review externally
conditions[1] = or(eq(projectsTable.status, 'waiting_for_review'), eq(projectsTable.status, 'pending_admin_approval'))!
}
const whereClause = and(...conditions)
@ -150,7 +153,7 @@ projects.get('/explore', async ({ query }) => {
image: p.image,
hours: p.hoursOverride ?? p.hours,
tier: p.tier,
status: p.status,
status: p.status === 'pending_admin_approval' ? 'waiting_for_review' : p.status,
views: p.views,
username: users.find(u => u.id === p.userId)?.username || null
})),
@ -190,7 +193,10 @@ projects.get('/', async ({ headers, query }) => {
const total = Number(countResult[0]?.count || 0)
return {
data: projectsList,
data: projectsList.map(p => ({
...p,
status: p.status === 'pending_admin_approval' ? 'waiting_for_review' : p.status
})),
pagination: {
page,
limit,
@ -214,8 +220,8 @@ projects.get('/:id', async ({ params, headers }) => {
const isOwner = project[0].userId === user.id
// If not owner, only show shipped, in_progress, or waiting_for_review projects
if (!isOwner && project[0].status !== 'shipped' && project[0].status !== 'in_progress' && project[0].status !== 'waiting_for_review') {
// If not owner, only show shipped, in_progress, waiting_for_review, or pending_admin_approval projects
if (!isOwner && project[0].status !== 'shipped' && project[0].status !== 'in_progress' && project[0].status !== 'waiting_for_review' && project[0].status !== 'pending_admin_approval') {
return { error: 'Not found' }
}
@ -264,7 +270,10 @@ projects.get('/:id', async ({ params, headers }) => {
}
// Add review entries
// Hide approval reviews when project is pending admin approval (internal status)
const isPendingAdmin = project[0].status === 'pending_admin_approval'
for (const r of reviews) {
if (isPendingAdmin && r.action === 'approved') continue
activity.push({
type: 'review',
action: r.action,
@ -343,35 +352,11 @@ projects.get('/:id', async ({ params, headers }) => {
}
// Calculate effective hours (subtract overlapping shipped project hours)
// Uses activity-derived shipped dates for ordering (consistent with Airtable sync and admin review)
const projectHours = project[0].hoursOverride ?? project[0].hours ?? 0
let deductedHours = 0
if (project[0].hackatimeProject) {
const hackatimeNames = project[0].hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
if (hackatimeNames.length > 0) {
const shipped = await db
.select({
hours: projectsTable.hours,
hoursOverride: projectsTable.hoursOverride,
hackatimeProject: projectsTable.hackatimeProject
})
.from(projectsTable)
.where(and(
eq(projectsTable.userId, project[0].userId),
eq(projectsTable.status, 'shipped'),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.id} != ${project[0].id}`
))
for (const op of shipped) {
if (!op.hackatimeProject) continue
const opNames = op.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0)
if (opNames.some((name: string) => hackatimeNames.includes(name))) {
deductedHours += op.hoursOverride ?? op.hours ?? 0
}
}
}
}
const effectiveHours = Math.max(0, projectHours - deductedHours)
const effectiveHoursResult = await computeEffectiveHoursForProject(project[0])
const effectiveHours = effectiveHoursResult.effectiveHours
const deductedHours = effectiveHoursResult.deductedHours
return {
project: {
@ -386,11 +371,12 @@ projects.get('/:id', async ({ params, headers }) => {
hoursOverride: isOwner ? project[0].hoursOverride : undefined,
tier: project[0].tier,
tierOverride: isOwner ? project[0].tierOverride : undefined,
status: project[0].status,
status: project[0].status === 'pending_admin_approval' ? 'waiting_for_review' : project[0].status,
scrapsAwarded: project[0].scrapsAwarded,
views: project[0].views,
updateDescription: project[0].updateDescription,
aiDescription: isOwner ? project[0].aiDescription : undefined,
reviewerNotes: isOwner ? project[0].reviewerNotes : undefined,
usedAi: !!project[0].aiDescription,
effectiveHours,
deductedHours,
@ -470,8 +456,8 @@ projects.put('/:id', async ({ params, body, headers }) => {
if (!existing[0]) return { error: 'Not found' }
// Reject edits while waiting for review
if (existing[0].status === 'waiting_for_review') {
// Reject edits while waiting for review or pending admin approval
if (existing[0].status === 'waiting_for_review' || existing[0].status === 'pending_admin_approval') {
return { error: 'Cannot edit project while waiting for review' }
}
@ -485,6 +471,7 @@ projects.put('/:id', async ({ params, body, headers }) => {
tier?: number
updateDescription?: string | null
aiDescription?: string | null
reviewerNotes?: string | null
}
if (!validateImageUrl(data.image)) {
@ -511,6 +498,7 @@ projects.put('/:id', async ({ params, body, headers }) => {
tier,
updateDescription: data.updateDescription !== undefined ? (data.updateDescription || null) : undefined,
aiDescription: data.aiDescription !== undefined ? (data.aiDescription || null) : undefined,
reviewerNotes: data.reviewerNotes !== undefined ? (data.reviewerNotes || null) : undefined,
updatedAt: new Date()
})
.where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id)))
@ -561,10 +549,20 @@ projects.post("/:id/unsubmit", async ({ params, headers }) => {
if (!project[0]) return { error: "Not found" }
if (project[0].status !== 'waiting_for_review') {
if (project[0].status !== 'waiting_for_review' && project[0].status !== 'pending_admin_approval') {
return { error: "Project can only be unsubmitted while waiting for review" }
}
// If project was pending admin approval, clean up the reviewer's approval review
if (project[0].status === 'pending_admin_approval') {
await db
.delete(reviewsTable)
.where(and(
eq(reviewsTable.projectId, parseInt(params.id)),
eq(reviewsTable.action, 'approved')
))
}
const updated = await db
.update(projectsTable)
.set({
@ -618,7 +616,7 @@ projects.post("/:id/submit", async ({ params, headers, body }) => {
if (!project[0]) return { error: "Not found" }
if (project[0].status !== 'in_progress') {
if (project[0].status !== 'in_progress' && project[0].status !== 'shipped') {
return { error: "Project cannot be submitted in current status" }
}
@ -696,7 +694,12 @@ projects.get("/:id/reviews", async ({ params, headers }) => {
.where(inArray(usersTable.id, reviewerIds))
}
return reviews.map(r => ({
// Hide approval reviews when project is pending admin approval (internal status)
const filteredReviews = project[0].status === 'pending_admin_approval'
? reviews.filter(r => r.action !== 'approved')
: reviews
return filteredReviews.map(r => ({
id: r.id,
action: r.action,
feedbackForAuthor: r.feedbackForAuthor,

View file

@ -29,6 +29,9 @@ export const projectsTable = pgTable('projects', {
// AI usage fields
aiDescription: text('ai_description'),
// Notes for reviewer
reviewerNotes: text('reviewer_notes'),
// Feedback fields (filled on submission)
feedbackSource: text('feedback_source'),
feedbackGood: text('feedback_good'),

View file

@ -263,6 +263,20 @@
<span class="text-lg font-bold">{$t.nav.reviews}</span>
</a>
{#if isAdminOnly}
<a
href="/admin/second-pass"
class="flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
'/admin/second-pass'
)
? 'border-yellow-500 bg-yellow-500 text-white'
: 'border-yellow-500 hover:border-dashed'}"
>
<ClipboardList size={18} />
<span class="text-lg font-bold">2nd pass</span>
</a>
{/if}
<a
href="/admin/users"
class="flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
@ -580,6 +594,21 @@
<span class="text-lg font-bold">{$t.nav.reviews}</span>
</a>
{#if isAdminOnly}
<a
href="/admin/second-pass"
onclick={handleMobileNavClick}
class="flex cursor-pointer items-center gap-3 rounded-full border-4 px-4 py-3 transition-all duration-300 {currentPath.startsWith(
'/admin/second-pass'
)
? 'border-yellow-500 bg-yellow-500 text-white'
: 'border-yellow-500 hover:border-dashed'}"
>
<ClipboardList size={20} />
<span class="text-lg font-bold">2nd pass</span>
</a>
{/if}
<a
href="/admin/users"
onclick={handleMobileNavClick}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { ChevronLeft, ChevronRight, ArrowUpDown } from '@lucide/svelte';
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowLeft } from '@lucide/svelte';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
@ -85,7 +85,15 @@
<title>{$t.nav.reviews} - {$t.nav.admin} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<div class="mx-auto max-w-6xl px-6 pt-24 pb-32 md:px-12">
<a
href="/admin"
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to admin
</a>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.admin.reviewQueue}</h1>

View file

@ -14,7 +14,9 @@
Globe,
RefreshCw,
Bot,
Loader
Loader,
ArrowLeft,
MessageSquare
} from '@lucide/svelte';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { getUser } from '$lib/auth-client';
@ -67,6 +69,7 @@
feedbackImprove: string | null;
updateDescription: string | null;
aiDescription: string | null;
reviewerNotes: string | null;
}
interface User {
@ -330,21 +333,41 @@
<title>review {project?.name || 'project'} - admin - scraps</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
<div class="mx-auto max-w-4xl px-6 pt-24 pb-32 md:px-12">
{#if loading}
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if !project}
<div class="py-12 text-center">
<p class="mb-4 text-xl text-gray-500">{$t.project.projectNotFound}</p>
<a href="/admin/reviews" class="font-bold underline">{$t.project.back}</a>
<a
href="/admin/reviews"
class="inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
{$t.project.back}
</a>
</div>
{:else if project.deleted}
<div class="py-12 text-center">
<p class="mb-2 text-xl text-gray-500">this project has been deleted</p>
<p class="mb-4 text-gray-400">status: {project.status}</p>
<a href="/projects/{project.id}" class="font-bold underline">view project</a>
<a
href="/admin/reviews"
class="inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to reviews
</a>
</div>
{:else}
<a
href="/admin/reviews"
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to reviews
</a>
{@const isReviewable = project.status === 'waiting_for_review'}
<!-- Status Banner (shown when project is not waiting for review) -->
@ -401,6 +424,15 @@
<p class="text-purple-700">{project.aiDescription}</p>
</div>
{/if}
{#if project.reviewerNotes}
<div class="mb-4 rounded-lg border-2 border-dashed border-blue-400 bg-blue-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-blue-600">
<MessageSquare size={14} />
notes from author
</p>
<p class="text-blue-700">{project.reviewerNotes}</p>
</div>
{/if}
<div class="flex flex-wrap items-center gap-3 text-sm">
{#if deductedHours > 0}
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold text-gray-400 line-through"
@ -563,7 +595,7 @@
<div class="mb-6 rounded-2xl border-4 border-black p-6">
<h2 class="mb-4 text-xl font-bold">previous reviews</h2>
<div class="space-y-4">
{#each reviews as review}
{#each [...reviews].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) as review}
{@const ReviewIcon = getReviewIcon(review.action)}
<div
class="rounded-lg border-2 border-black p-4 transition-all duration-200 hover:border-dashed"
@ -596,7 +628,7 @@
{review.action === 'permanently_rejected' ? 'rejected' : review.action === 'scraps_unawarded' ? 'scraps unawarded' : review.action}
</span>
<span class="text-xs text-gray-500">
{new Date(review.createdAt).toLocaleDateString()}
{new Date(review.createdAt).toLocaleString()}
</span>
</div>
</div>

View file

@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { ChevronLeft, ChevronRight, ArrowUpDown, ArrowLeft } from '@lucide/svelte';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
interface Project {
id: number;
userId: number;
name: string;
description: string;
image: string | null;
status: string;
hours: number;
effectiveHours: number;
deductedHours: number;
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
interface User {
id: number;
username: string;
email: string;
avatar: string | null;
slackId: string | null;
scraps: number;
role: string;
}
let user = $state<User | null>(null);
let projects = $state<Project[]>([]);
let pagination = $state<Pagination | null>(null);
let loading = $state(true);
let sortOrder = $state<'oldest' | 'newest'>('oldest');
let scraps = $derived(user?.scraps ?? 0);
async function fetchSecondPass(page = 1) {
loading = true;
try {
const response = await fetch(`${API_URL}/admin/second-pass?page=${page}&limit=12&sort=${sortOrder}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
projects = data.data || [];
pagination = data.pagination;
}
} catch (e) {
console.error('Failed to fetch second-pass reviews:', e);
} finally {
loading = false;
}
}
onMount(async () => {
user = await getUser();
if (!user || user.role !== 'admin') {
goto('/dashboard');
return;
}
await fetchSecondPass();
});
function goToPage(page: number) {
fetchSecondPass(page);
}
function toggleSort() {
sortOrder = sortOrder === 'oldest' ? 'newest' : 'oldest';
fetchSecondPass(1);
}
</script>
<svelte:head>
<title>second pass reviews - admin - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-32 md:px-12">
<a
href="/admin"
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to admin
</a>
<div class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">second pass reviews</h1>
<p class="text-lg text-gray-600">projects approved by reviewers, awaiting admin confirmation</p>
</div>
<button
onclick={toggleSort}
class="flex cursor-pointer items-center gap-2 rounded-full border-2 border-black px-4 py-2 text-sm font-bold transition-all hover:border-dashed"
>
<ArrowUpDown size={16} />
sort: {sortOrder === 'oldest' ? 'oldest first' : 'newest first'}
</button>
</div>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
{:else if projects.length === 0}
<div class="py-12 text-center">
<p class="text-xl text-gray-500">no projects pending second-pass review</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each projects as project}
<a
href="/admin/second-pass/{project.id}"
class="flex flex-col overflow-hidden rounded-2xl border-4 border-yellow-500 bg-yellow-50 transition-all hover:border-dashed"
>
<div class="h-40 overflow-hidden">
{#if project.image}
<img src={project.image} alt={project.name} class="h-full w-full object-cover" />
{:else}
<ProjectPlaceholder seed={project.id} />
{/if}
</div>
<div class="p-4">
<h3 class="mb-1 text-xl font-bold">{project.name}</h3>
<p class="mb-2 line-clamp-2 text-sm text-gray-600">{project.description}</p>
<div class="flex flex-wrap items-center gap-2">
{#if project.deductedHours > 0}
<span class="rounded-full bg-gray-100 px-3 py-1 text-sm font-bold text-gray-400 line-through"
>{formatHours(project.hours)}h</span
>
<span class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-800"
>{formatHours(project.effectiveHours)}h</span
>
{:else}
<span class="rounded-full bg-gray-100 px-3 py-1 text-sm font-bold"
>{formatHours(project.hours)}h</span
>
{/if}
<span class="rounded-full bg-yellow-200 px-3 py-1 text-xs font-bold text-yellow-800">
PENDING APPROVAL
</span>
</div>
</div>
</a>
{/each}
</div>
<!-- Pagination -->
{#if pagination && pagination.totalPages > 1}
<div class="mt-8 flex items-center justify-center gap-4">
<button
onclick={() => goToPage(pagination!.page - 1)}
disabled={pagination.page <= 1}
class="cursor-pointer rounded-full border-2 border-black p-2 transition-all hover:border-dashed disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronLeft size={20} />
</button>
<span class="font-bold">
Page
{pagination.page}
of
{pagination.totalPages}
</span>
<button
onclick={() => goToPage(pagination!.page + 1)}
disabled={pagination.page >= pagination.totalPages}
class="cursor-pointer rounded-full border-2 border-black p-2 transition-all hover:border-dashed disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronRight size={20} />
</button>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,634 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
Check,
X,
Github,
AlertTriangle,
CheckCircle,
XCircle,
Info,
Globe,
ArrowLeft,
RefreshCw,
Bot,
MessageSquare
} from '@lucide/svelte';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
interface Review {
id: number;
action: string;
feedbackForAuthor: string;
internalJustification: string | null;
reviewerName: string | null;
reviewerAvatar: string | null;
reviewerId: number;
createdAt: string;
}
interface ProjectUser {
id: number;
username: string | null;
avatar: string | null;
internalNotes: string | null;
}
interface OverlappingProject {
id: number;
name: string;
hours: number;
}
interface Project {
id: number;
userId: number;
name: string;
description: string;
image: string | null;
githubUrl: string | null;
playableUrl: string | null;
hackatimeProject: string | null;
status: string;
hours: number;
hoursOverride: number | null;
tier: number;
tierOverride: number | null;
feedbackSource: string | null;
feedbackGood: string | null;
feedbackImprove: string | null;
updateDescription: string | null;
aiDescription: string | null;
reviewerNotes: string | null;
}
interface User {
id: number;
username: string;
email: string;
avatar: string | null;
slackId: string | null;
scraps: number;
role: string;
}
let user = $state<User | null>(null);
let project = $state<Project | null>(null);
let projectUser = $state<ProjectUser | null>(null);
let reviews = $state<Review[]>([]);
let overlappingProjects = $state<OverlappingProject[]>([]);
let loading = $state(true);
let submitting = $state(false);
let error = $state<string | null>(null);
let feedbackForAuthor = $state('');
let hoursOverride = $state<number | undefined>(undefined);
let confirmAction = $state<'accept' | 'reject' | null>(null);
let deductedHours = $derived(
overlappingProjects.reduce((sum: number, op: OverlappingProject) => sum + op.hours, 0)
);
let effectiveHours = $derived(
project ? Math.max(0, (hoursOverride ?? project.hoursOverride ?? project.hours) - deductedHours) : 0
);
let hoursOverrideError = $derived(
hoursOverride !== undefined && project && hoursOverride > project.hours
? `hours override cannot exceed project hours (${formatHours(project.hours)}h)`
: null
);
let projectId = $derived(page.params.id);
// Find the approval review
let approvalReview = $derived(reviews.find((r) => r.action === 'approved'));
onMount(async () => {
user = await getUser();
if (!user || user.role !== 'admin') {
goto('/dashboard');
return;
}
try {
const response = await fetch(`${API_URL}/admin/second-pass/${projectId}`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.error) {
error = data.error;
loading = false;
return;
}
project = data.project;
projectUser = data.user;
reviews = data.reviews || [];
overlappingProjects = data.overlappingProjects || [];
if (data.project?.hoursOverride != null) {
hoursOverride = data.project.hoursOverride;
}
}
} catch (e) {
console.error('Failed to fetch second-pass review:', e);
} finally {
loading = false;
}
});
function requestConfirmation(action: 'accept' | 'reject') {
if (action === 'reject' && !feedbackForAuthor.trim()) {
error = 'feedback for author is required when rejecting';
return;
}
error = null;
confirmAction = action;
}
function cancelConfirmation() {
confirmAction = null;
}
async function submitReview() {
if (!confirmAction) return;
if (confirmAction === 'reject' && !feedbackForAuthor.trim()) {
error = 'feedback for author is required when rejecting';
return;
}
submitting = true;
error = null;
try {
const response = await fetch(`${API_URL}/admin/second-pass/${projectId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
action: confirmAction,
feedbackForAuthor: confirmAction === 'reject' ? feedbackForAuthor : undefined,
hoursOverride: hoursOverride !== undefined ? hoursOverride : undefined
})
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
goto('/admin/second-pass');
} catch (e) {
error = e instanceof Error ? e.message : 'failed to submit review';
} finally {
submitting = false;
confirmAction = null;
}
}
function getReviewIcon(action: string) {
switch (action) {
case 'approved':
return CheckCircle;
case 'denied':
return AlertTriangle;
case 'permanently_rejected':
case 'scraps_unawarded':
return XCircle;
default:
return Info;
}
}
function getReviewIconColor(action: string) {
switch (action) {
case 'approved':
return 'text-green-600';
case 'denied':
return 'text-yellow-600';
case 'permanently_rejected':
case 'scraps_unawarded':
return 'text-red-600';
default:
return 'text-gray-600';
}
}
</script>
<svelte:head>
<title>second pass review - admin - scraps</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-6 pt-24 pb-32 md:px-12">
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
{:else if !project}
<div class="py-12 text-center">
<p class="mb-4 text-xl text-gray-500">project not found</p>
<a
href="/admin/second-pass"
class="inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to second pass reviews
</a>
</div>
{:else}
<a
href="/admin/second-pass"
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to second pass reviews
</a>
<!-- Status Banner (yellow alert for pending admin approval) -->
<div class="mb-6 rounded-2xl border-4 border-yellow-500 bg-yellow-50 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<AlertTriangle size={20} class="text-yellow-600" />
<div>
<p class="font-bold text-yellow-800">pending admin approval</p>
<p class="text-sm text-yellow-700">
this project was approved by a reviewer and awaits your final confirmation
</p>
</div>
</div>
<a
href="/projects/{project.id}"
class="font-bold text-yellow-600 underline hover:text-black"
>view project</a
>
</div>
</div>
<!-- Project Image -->
<div class="mb-6 h-64 w-full overflow-hidden rounded-2xl border-4 border-black md:h-80">
{#if project.image}
<img src={project.image} alt={project.name} class="h-full w-full object-cover" />
{:else}
<ProjectPlaceholder seed={project.id} />
{/if}
</div>
<!-- Project Info -->
<div class="mb-6 rounded-2xl border-4 border-black p-6">
<div class="mb-2 flex items-start justify-between">
<h1 class="text-3xl font-bold">{project.name}</h1>
<span
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-800"
>
pending approval
</span>
</div>
<p class="mb-4 text-gray-600">{project.description}</p>
{#if project.updateDescription}
<div class="mb-4 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-gray-600">
<RefreshCw size={14} />
what was updated
</p>
<p class="text-gray-700">{project.updateDescription}</p>
</div>
{/if}
{#if project.aiDescription}
<div class="mb-4 rounded-lg border-2 border-dashed border-purple-400 bg-purple-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-purple-600">
<Bot size={14} />
ai was used
</p>
<p class="text-purple-700">{project.aiDescription}</p>
</div>
{/if}
{#if project.reviewerNotes}
<div class="mb-4 rounded-lg border-2 border-dashed border-blue-400 bg-blue-50 p-4">
<p class="mb-1 flex items-center gap-1.5 text-sm font-bold text-blue-600">
<MessageSquare size={14} />
notes from author
</p>
<p class="text-blue-700">{project.reviewerNotes}</p>
</div>
{/if}
<div class="flex flex-wrap items-center gap-3 text-sm">
{#if deductedHours > 0}
<span
class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold text-gray-400 line-through"
>{formatHours(project.hours)}h logged</span
>
<span
class="rounded-full border-2 border-yellow-500 bg-yellow-100 px-3 py-1 font-bold text-yellow-800"
>{formatHours(effectiveHours)}h effective</span
>
{:else}
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
>{formatHours(project.hours)}h logged</span
>
{/if}
{#if project.hackatimeProject}
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
>hackatime: {project.hackatimeProject}</span
>
{/if}
<span class="rounded-full border-2 border-black bg-gray-100 px-3 py-1 font-bold"
>tier {project.tier}</span
>
</div>
{#if overlappingProjects.length > 0}
<div class="mt-4 rounded-lg border-2 border-dashed border-yellow-500 bg-yellow-50 p-4">
<p class="mb-2 flex items-center gap-1.5 text-sm font-bold text-yellow-700">
<AlertTriangle size={14} />
shared hackatime project — hours will be deducted
</p>
<p class="mb-2 text-sm text-yellow-700">
this project shares a hackatime project with other shipped projects. hours from those
projects will be subtracted when calculating scraps.
</p>
<ul class="mb-3 space-y-1 text-sm text-yellow-800">
{#each overlappingProjects as op}
<li class="flex items-center gap-2">
<span></span>
<a href="/projects/{op.id}" class="font-bold underline">{op.name}</a>
<span>{formatHours(op.hours)}h</span>
</li>
{/each}
</ul>
<div class="flex flex-wrap gap-3 text-sm font-bold">
<span
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-yellow-800"
>
total: {formatHours(project.hoursOverride ?? project.hours)}h
</span>
<span
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-yellow-800"
>
deducted: -{formatHours(deductedHours)}h
</span>
<span class="rounded-full border-2 border-black bg-yellow-200 px-3 py-1 text-black">
effective: {formatHours(effectiveHours)}h
</span>
</div>
</div>
{/if}
<div class="mt-4 flex flex-wrap gap-3">
{#if project.githubUrl}
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Github size={18} />
<span>view on github</span>
</a>
{:else}
<span
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Github size={18} />
<span>view on github</span>
</span>
{/if}
{#if project.playableUrl}
<a
href={project.playableUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-solid border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Globe size={18} />
<span>try it out</span>
</a>
{:else}
<span
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Globe size={18} />
<span>try it out</span>
</span>
{/if}
</div>
<!-- User Info (clickable) -->
{#if projectUser}
<a
href="/admin/users/{projectUser.id}"
class="-mx-6 mt-6 -mb-6 flex cursor-pointer items-center gap-4 border-t-2 border-gray-200 px-6 pt-6 pb-6 transition-all hover:bg-gray-50"
>
{#if projectUser.avatar}
<img
src={projectUser.avatar}
alt=""
class="h-12 w-12 rounded-full border-2 border-black"
/>
{:else}
<div class="h-12 w-12 rounded-full border-2 border-black bg-gray-200"></div>
{/if}
<div class="flex-1">
<p class="font-bold">{projectUser.username || 'unknown'}</p>
</div>
<span class="text-sm text-gray-500">view profile →</span>
</a>
{/if}
</div>
<!-- Author Feedback -->
{#if project.feedbackSource || project.feedbackGood || project.feedbackImprove}
<div class="mb-6 rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-xl font-bold">author feedback</h2>
<div class="space-y-4">
{#if project.feedbackSource}
<div>
<p class="mb-1 text-sm font-bold text-gray-500">how did you hear about this?</p>
<p class="text-gray-700">{project.feedbackSource}</p>
</div>
{/if}
{#if project.feedbackGood}
<div>
<p class="mb-1 text-sm font-bold text-gray-500">what are we doing well?</p>
<p class="text-gray-700">{project.feedbackGood}</p>
</div>
{/if}
{#if project.feedbackImprove}
<div>
<p class="mb-1 text-sm font-bold text-gray-500">how can we improve?</p>
<p class="text-gray-700">{project.feedbackImprove}</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- Reviewer's Approval -->
{#if approvalReview}
<div class="mb-6 rounded-2xl border-4 border-green-500 bg-green-50 p-6">
<h2 class="mb-4 text-xl font-bold text-green-800">reviewer approval</h2>
<div
class="rounded-lg border-2 border-green-600 bg-white p-4 transition-all duration-200"
>
<div class="mb-2 flex items-center justify-between">
<a
href="/admin/users/{approvalReview.reviewerId}"
class="flex cursor-pointer items-center gap-2 transition-all duration-200 hover:opacity-80"
>
{#if approvalReview.reviewerAvatar}
<img
src={approvalReview.reviewerAvatar}
alt=""
class="h-6 w-6 rounded-full border-2 border-black"
/>
{:else}
<div class="h-6 w-6 rounded-full border-2 border-black bg-gray-200"></div>
{/if}
<CheckCircle size={18} class="text-green-600" />
<span class="font-bold">{approvalReview.reviewerName || 'reviewer'}</span>
</a>
<div class="flex items-center gap-2">
<span class="rounded border border-green-600 bg-green-100 px-2 py-1 text-xs font-bold text-green-700">
approved
</span>
<span class="text-xs text-gray-500">
{new Date(approvalReview.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<p class="mb-2 text-sm text-gray-700">
<strong>feedback:</strong>
{approvalReview.feedbackForAuthor}
</p>
{#if approvalReview.internalJustification}
<p class="text-sm text-gray-500">
<strong>internal:</strong>
{approvalReview.internalJustification}
</p>
{/if}
</div>
</div>
{/if}
<!-- User Internal Notes (read-only) -->
{#if projectUser?.internalNotes}
<div class="mb-6 rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-xl font-bold">user internal notes</h2>
<div class="rounded-lg border-2 border-gray-300 bg-gray-50 p-4">
<p class="whitespace-pre-wrap text-sm text-gray-700">{projectUser.internalNotes}</p>
</div>
</div>
{/if}
{#if error}
<div class="mb-6 rounded-lg border-2 border-red-500 bg-red-100 p-4 text-red-700">
{error}
</div>
{/if}
<!-- Review Form -->
<div class="rounded-2xl border-4 border-black p-6">
<h2 class="mb-4 text-xl font-bold">admin decision</h2>
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm font-bold">hours override {#if deductedHours > 0}<span class="font-normal text-yellow-600">(effective: {formatHours(effectiveHours)}h after -{formatHours(deductedHours)}h deduction)</span>{/if}</label>
<input
type="number"
step="0.1"
min="0"
max={project.hours}
bind:value={hoursOverride}
placeholder="{formatHours(project.hoursOverride ?? project.hours)}h ({formatHours(effectiveHours)}h effective)"
class="w-full rounded-lg border-2 px-4 py-2 focus:border-dashed focus:outline-none {hoursOverrideError
? 'border-red-500'
: 'border-black'}"
/>
{#if hoursOverrideError}
<p class="mt-1 text-sm text-red-500">{hoursOverrideError}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-bold">
rejection feedback <span class="text-gray-500">(required only if rejecting)</span>
</label>
<textarea
bind:value={feedbackForAuthor}
rows="4"
placeholder="explain why you're rejecting this approval (user will see this)"
class="w-full resize-none rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
></textarea>
</div>
<div class="flex gap-3 pt-4">
<button
onclick={() => requestConfirmation('accept')}
disabled={submitting || !!hoursOverrideError}
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-green-600 px-4 py-3 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
<Check size={20} />
<span>accept & ship</span>
</button>
<button
onclick={() => requestConfirmation('reject')}
disabled={submitting || !!hoursOverrideError}
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-red-600 px-4 py-3 font-bold text-white transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
<X size={20} />
<span>reject approval</span>
</button>
</div>
</div>
</div>
{/if}
</div>
<!-- Confirmation Modal -->
{#if confirmAction}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => e.target === e.currentTarget && cancelConfirmation()}
onkeydown={(e) => e.key === 'Escape' && cancelConfirmation()}
role="dialog"
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">
confirm {confirmAction === 'accept' ? 'acceptance' : 'rejection'}
</h2>
<p class="mb-6 text-gray-600">
{#if confirmAction === 'accept'}
are you sure you want to <strong>accept</strong> this approval and ship the project? the
user will be notified and scraps will be awarded.
{:else}
are you sure you want to <strong>reject</strong> this approval? the original approval review
will be deleted and the user will need to resubmit.
{/if}
</p>
<div class="flex gap-3">
<button
onclick={cancelConfirmation}
disabled={submitting}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
</button>
<button
onclick={submitReview}
disabled={submitting}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50 {confirmAction ===
'accept'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'}"
>
{submitting
? 'processing...'
: confirmAction === 'accept'
? 'accept & ship'
: 'reject approval'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -452,12 +452,13 @@
{$t.project.awaitingReview}
</span>
{:else if project.status === 'shipped'}
<span
class="flex flex-1 items-center justify-center gap-2 rounded-full border-4 border-black bg-gray-200 px-4 py-3 text-center text-sm font-bold text-gray-600 sm:px-6 sm:text-base"
<a
href="/projects/{project.id}/submit"
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-black px-4 py-3 text-sm font-bold text-white transition-all duration-200 hover:bg-gray-800 sm:px-6 sm:text-base"
>
<Send size={18} />
{$t.project.shipped}
</span>
<RefreshCw size={18} />
ship update
</a>
{:else if project.status === 'permanently_rejected'}
<span
class="flex flex-1 cursor-not-allowed items-center justify-center gap-2 rounded-full border-4 border-black bg-red-100 px-4 py-3 text-center text-sm font-bold text-red-600 sm:px-6 sm:text-base"

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { ArrowLeft, ChevronDown, Upload, X, Save, Check, Trash2 } from '@lucide/svelte';
import { ArrowLeft, ChevronDown, Upload, X, Save, Check, Trash2, MessageSquare } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours, validateGithubUrl, validatePlayableUrl } from '$lib/utils';
@ -24,6 +24,7 @@
status: string;
updateDescription: string | null;
aiDescription: string | null;
reviewerNotes: string | null;
}
const TIERS = [
@ -58,6 +59,7 @@
let updateDescription = $state('');
let usedAi = $state(false);
let aiDescription = $state('');
let reviewerNotes = $state('');
const NAME_MAX = 50;
const DESC_MIN = 20;
@ -101,6 +103,7 @@
updateDescription = project?.updateDescription || '';
usedAi = !!(project?.aiDescription);
aiDescription = project?.aiDescription || '';
reviewerNotes = project?.reviewerNotes || '';
if (project?.hackatimeProject) {
selectedHackatimeNames = project.hackatimeProject.split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0);
}
@ -230,7 +233,8 @@
hackatimeProject: hackatimeValue,
tier: selectedTier,
updateDescription: isUpdate ? updateDescription : null,
aiDescription: usedAi ? aiDescription : null
aiDescription: usedAi ? aiDescription : null,
reviewerNotes: reviewerNotes.trim() || null
})
});
@ -581,6 +585,22 @@
{/if}
</div>
<!-- Notes for Reviewer -->
<div class="mt-6">
<label for="reviewerNotes" class="mb-2 flex items-center gap-1.5 text-sm font-bold">
<MessageSquare size={14} />
notes for reviewer
<span class="text-gray-400">(optional)</span>
</label>
<textarea
id="reviewerNotes"
bind:value={reviewerNotes}
rows="3"
placeholder="anything you'd like the reviewer to know about this project..."
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
></textarea>
</div>
<!-- Actions -->
<div class="mt-8 flex gap-4">
<a

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { ArrowLeft, Send, Check, ChevronDown, Upload, X } from '@lucide/svelte';
import { ArrowLeft, Send, Check, ChevronDown, Upload, X, RefreshCw, Bot, MessageSquare } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours, validateGithubUrl, validatePlayableUrl } from '$lib/utils';
@ -21,6 +21,7 @@
hours: number;
status: string;
tier: number;
reviewerNotes: string | null;
}
const TIERS = [
@ -53,6 +54,11 @@
let feedbackGood = $state('');
let feedbackImprove = $state('');
let hasSubmittedFeedbackBefore = $state(false);
let isShippedUpdate = $state(false);
let updateDescription = $state('');
let usedAi = $state(false);
let aiDescription = $state('');
let reviewerNotes = $state('');
const NAME_MAX = 50;
const DESC_MIN = 20;
@ -77,8 +83,10 @@
feedbackGood.trim().length > 0 &&
feedbackImprove.trim().length > 0)
);
let hasUpdateDescription = $derived(!isShippedUpdate || updateDescription.trim().length > 0);
let hasAiDescription = $derived(!usedAi || aiDescription.trim().length > 0);
let allRequirementsMet = $derived(
hasImage && hasHackatime && hasGithub && hasPlayableUrl && hasDescription && hasName && hasFeedback
hasImage && hasHackatime && hasGithub && hasPlayableUrl && hasDescription && hasName && hasFeedback && hasUpdateDescription && hasAiDescription
);
onMount(async () => {
@ -105,6 +113,8 @@
project = responseData.project;
imagePreview = project?.image || null;
hasSubmittedFeedbackBefore = responseData.hasSubmittedFeedback ?? false;
isShippedUpdate = project?.status === 'shipped';
reviewerNotes = project?.reviewerNotes || '';
if (project?.hackatimeProject) {
selectedHackatimeNames = project.hackatimeProject.split(',').map((p: string) => {
const trimmed = p.trim();
@ -240,7 +250,10 @@
githubUrl: project.githubUrl,
playableUrl: project.playableUrl,
hackatimeProject: hackatimeValue,
tier: selectedTier
tier: selectedTier,
updateDescription: isShippedUpdate ? updateDescription : null,
aiDescription: usedAi ? aiDescription : null,
reviewerNotes: reviewerNotes.trim() || null
})
});
@ -284,7 +297,7 @@
</script>
<svelte:head>
<title>submit {project?.name || 'project'} - scraps</title>
<title>{isShippedUpdate ? 'ship update' : 'submit'} {project?.name || 'project'} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
@ -307,9 +320,9 @@
</div>
{:else if project}
<div class="rounded-2xl border-4 border-black bg-white p-6">
<h1 class="mb-2 text-3xl font-bold">{$t.project.submitForReview}</h1>
<h1 class="mb-2 text-3xl font-bold">{isShippedUpdate ? 'ship update' : $t.project.submitForReview}</h1>
<p class="mb-6 text-gray-600">
{$t.project.submitRequirementsHint}
{isShippedUpdate ? 'submit your updated project for review. you\'ll earn the difference in scraps based on your new hours.' : $t.project.submitRequirementsHint}
</p>
{#if error}
@ -536,6 +549,76 @@
</div>
</div>
<!-- Update Description (required for shipped project updates) -->
{#if isShippedUpdate}
<div class="mb-6 rounded-lg border-2 border-dashed border-gray-400 bg-gray-50 p-4">
<p class="mb-3 flex items-center gap-1.5 text-sm font-bold text-gray-600">
<RefreshCw size={14} />
this is an update to a shipped project
</p>
<div>
<label for="updateDescription" class="mb-2 block text-sm font-bold"
>what did you update? <span class="text-red-500">*</span></label
>
<textarea
id="updateDescription"
bind:value={updateDescription}
rows="3"
placeholder="describe what you changed or added since the last ship..."
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
></textarea>
{#if updateDescription.trim().length === 0}
<p class="mt-1 text-xs text-red-500">please describe what you updated</p>
{/if}
</div>
</div>
{/if}
<!-- AI Usage -->
<div class="mb-6 space-y-4">
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={usedAi}
class="h-5 w-5 cursor-pointer accent-black"
/>
<span class="text-sm font-bold">i used ai in this project</span>
</label>
{#if usedAi}
<div class="rounded-lg border-2 border-dashed border-purple-400 bg-purple-50 p-4">
<p class="mb-2 flex items-center gap-1.5 text-sm font-bold text-purple-600">
<Bot size={14} />
how was ai used? <span class="text-red-500">*</span>
</p>
<textarea
bind:value={aiDescription}
rows="3"
placeholder="describe how you used ai in this project..."
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
></textarea>
{#if aiDescription.trim().length === 0}
<p class="mt-1 text-xs text-red-500">please describe how ai was used</p>
{/if}
</div>
{/if}
</div>
<!-- Notes for Reviewer -->
<div class="mb-6">
<label for="reviewerNotes" class="mb-2 flex items-center gap-1.5 text-sm font-bold">
<MessageSquare size={14} />
notes for reviewer
<span class="text-gray-400">(optional)</span>
</label>
<textarea
id="reviewerNotes"
bind:value={reviewerNotes}
rows="3"
placeholder="anything you'd like the reviewer to know about this project..."
class="w-full resize-none rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
></textarea>
</div>
<!-- Feedback -->
<div class="mb-6 space-y-4">
<div>
@ -654,6 +737,30 @@
>{$t.project.hackatimeProjectSelected}</span
>
</li>
{#if isShippedUpdate}
<li class="flex items-center gap-2 text-sm">
<span
class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-black {hasUpdateDescription
? 'bg-black text-white'
: ''}"
>
{#if hasUpdateDescription}<Check size={12} />{/if}
</span>
<span class={hasUpdateDescription ? '' : 'text-gray-500'}>update description provided</span>
</li>
{/if}
{#if usedAi}
<li class="flex items-center gap-2 text-sm">
<span
class="flex h-5 w-5 items-center justify-center rounded-full border-2 border-black {hasAiDescription
? 'bg-black text-white'
: ''}"
>
{#if hasAiDescription}<Check size={12} />{/if}
</span>
<span class={hasAiDescription ? '' : 'text-gray-500'}>ai usage described</span>
</li>
{/if}
{#if !hasSubmittedFeedbackBefore}
<li class="flex items-center gap-2 text-sm">
<span
@ -684,8 +791,12 @@
disabled={submitting || !allRequirementsMet}
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-4 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
<Send size={18} />
{submitting ? $t.project.submitting : $t.project.submitForReview}
{#if isShippedUpdate}
<RefreshCw size={18} />
{:else}
<Send size={18} />
{/if}
{submitting ? $t.project.submitting : isShippedUpdate ? 'ship update' : $t.project.submitForReview}
</button>
</div>
</div>