guh hackatime work

This commit is contained in:
End Nightshade 2026-03-02 21:41:00 -07:00
parent bab78ab458
commit 827f285438
No known key found for this signature in database
17 changed files with 570 additions and 286 deletions

View file

@ -25,7 +25,13 @@
Languages,
ChevronDown
} from '@lucide/svelte';
import { logout, getUser, userScrapsStore, userScrapsPendingStore, nextPayoutDateStore } from '$lib/auth-client';
import {
logout,
getUser,
userScrapsStore,
userScrapsPendingStore,
nextPayoutDateStore
} from '$lib/auth-client';
import { t, locale, setLocale, type Locale } from '$lib/i18n';
interface User {
@ -53,7 +59,9 @@
let isReviewer = $derived(user?.role === 'admin' || user?.role === 'reviewer');
let isAdminOnly = $derived(user?.role === 'admin');
let isInAdminSection = $derived(currentPath.startsWith('/admin'));
let dashboardMoreActive = $derived(currentPath === '/leaderboard' || currentPath === '/shop' || currentPath === '/refinery');
let dashboardMoreActive = $derived(
currentPath === '/leaderboard' || currentPath === '/shop' || currentPath === '/refinery'
);
let adminMoreActive = $derived(
currentPath.startsWith('/admin/second-pass') ||
currentPath.startsWith('/admin/shop') ||
@ -299,7 +307,7 @@
<!-- Visible on xl+ only -->
<a
href="/admin/second-pass"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath.startsWith(
'/admin/second-pass'
)
? 'border-yellow-500 bg-yellow-500 text-white'
@ -311,7 +319,7 @@
<a
href="/admin/shop"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath.startsWith(
'/admin/shop'
)
? 'border-black bg-black text-white'
@ -322,7 +330,7 @@
</a>
<a
href="/admin/news"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath.startsWith(
'/admin/news'
)
? 'border-black bg-black text-white'
@ -333,7 +341,7 @@
</a>
<a
href="/admin/orders"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath.startsWith(
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath.startsWith(
'/admin/orders'
)
? 'border-black bg-black text-white'
@ -352,14 +360,23 @@
: 'border-black hover:border-dashed'}"
>
<span class="text-lg font-bold">{$t.nav.more}</span>
<ChevronDown size={16} class="transition-transform duration-200 {showMoreMenu ? 'rotate-180' : ''}" />
<ChevronDown
size={16}
class="transition-transform duration-200 {showMoreMenu ? 'rotate-180' : ''}"
/>
</button>
{#if showMoreMenu}
<div class="absolute top-full left-0 z-50 mt-2 min-w-48 overflow-hidden rounded-2xl border-4 border-black bg-white">
<div
class="absolute top-full left-0 z-50 mt-2 min-w-48 overflow-hidden rounded-2xl border-4 border-black bg-white"
>
<a
href="/admin/second-pass"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith('/admin/second-pass') ? 'bg-yellow-50' : ''}"
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith(
'/admin/second-pass'
)
? 'bg-yellow-50'
: ''}"
>
<ClipboardList size={18} />
<span class="font-bold">2nd pass</span>
@ -367,7 +384,11 @@
<a
href="/admin/shop"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith('/admin/shop') ? 'bg-gray-100' : ''}"
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith(
'/admin/shop'
)
? 'bg-gray-100'
: ''}"
>
<ShoppingBag size={18} />
<span class="font-bold">{$t.nav.shop}</span>
@ -375,7 +396,11 @@
<a
href="/admin/news"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith('/admin/news') ? 'bg-gray-100' : ''}"
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith(
'/admin/news'
)
? 'bg-gray-100'
: ''}"
>
<Newspaper size={18} />
<span class="font-bold">{$t.nav.news}</span>
@ -383,7 +408,11 @@
<a
href="/admin/orders"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith('/admin/orders') ? 'bg-gray-100' : ''}"
class="flex w-full cursor-pointer items-center gap-2 px-4 py-3 transition-colors hover:bg-gray-100 {currentPath.startsWith(
'/admin/orders'
)
? 'bg-gray-100'
: ''}"
>
<PackageCheck size={18} />
<span class="font-bold">{$t.nav.orders}</span>
@ -422,7 +451,7 @@
<!-- Visible on xl+ only -->
<a
href="/leaderboard"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath ===
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath ===
'/leaderboard'
? 'border-black bg-black text-white'
: 'border-black hover:border-dashed'}"
@ -433,7 +462,7 @@
<a
href="/shop"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath ===
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath ===
'/shop'
? 'border-black bg-black text-white'
: 'border-black hover:border-dashed'}"
@ -444,7 +473,7 @@
<a
href="/refinery"
class="hidden xl:flex cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 {currentPath ===
class="hidden cursor-pointer items-center gap-2 rounded-full border-4 px-6 py-2 transition-all duration-300 xl:flex {currentPath ===
'/refinery'
? 'border-black bg-black text-white'
: 'border-black hover:border-dashed'}"
@ -462,14 +491,22 @@
: 'border-black hover:border-dashed'}"
>
<span class="text-lg font-bold">{$t.nav.more}</span>
<ChevronDown size={16} class="transition-transform duration-200 {showMoreMenu ? 'rotate-180' : ''}" />
<ChevronDown
size={16}
class="transition-transform duration-200 {showMoreMenu ? 'rotate-180' : ''}"
/>
</button>
{#if showMoreMenu}
<div class="absolute top-full left-0 z-50 mt-2 min-w-48 overflow-hidden rounded-2xl border-4 border-black bg-white">
<div
class="absolute top-full left-0 z-50 mt-2 min-w-48 overflow-hidden rounded-2xl border-4 border-black bg-white"
>
<a
href="/leaderboard"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath === '/leaderboard' ? 'bg-gray-100 font-bold' : ''}"
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath ===
'/leaderboard'
? 'bg-gray-100 font-bold'
: ''}"
>
<Trophy size={18} />
<span class="font-bold">{$t.nav.leaderboard}</span>
@ -477,7 +514,10 @@
<a
href="/shop"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath === '/shop' ? 'bg-gray-100 font-bold' : ''}"
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 transition-colors hover:bg-gray-100 {currentPath ===
'/shop'
? 'bg-gray-100 font-bold'
: ''}"
>
<Store size={18} />
<span class="font-bold">{$t.nav.shop}</span>
@ -485,7 +525,10 @@
<a
href="/refinery"
onclick={closeMoreMenu}
class="flex w-full cursor-pointer items-center gap-2 px-4 py-3 transition-colors hover:bg-gray-100 {currentPath === '/refinery' ? 'bg-gray-100 font-bold' : ''}"
class="flex w-full cursor-pointer items-center gap-2 px-4 py-3 transition-colors hover:bg-gray-100 {currentPath ===
'/refinery'
? 'bg-gray-100 font-bold'
: ''}"
>
<Flame size={18} />
<span class="font-bold">{$t.nav.refinery}</span>
@ -511,7 +554,11 @@
<div
data-tutorial="scraps-counter"
class="relative"
title={$userScrapsPendingStore > 0 ? `+${$userScrapsPendingStore} pending payout in ${countdownText}` : countdownText ? `next payout in ${countdownText}` : ''}
title={$userScrapsPendingStore > 0
? `+${$userScrapsPendingStore} pending — payout in ${countdownText}`
: countdownText
? `next payout in ${countdownText}`
: ''}
>
<div class="flex items-center gap-2 rounded-full border-4 border-black px-6 py-2">
<Spool size={20} />
@ -521,7 +568,10 @@
{/if}
</div>
{#if countdownText}
<span class="absolute top-full left-1/2 -translate-x-1/2 mt-0.5 text-xs text-gray-500 whitespace-nowrap">{countdownText}</span>
<span
class="absolute top-full left-1/2 mt-0.5 -translate-x-1/2 text-xs whitespace-nowrap text-gray-500"
>⏱ {countdownText}</span
>
{/if}
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { X, ChevronDown, Upload, Check } from '@lucide/svelte';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { formatHours, parseHackatimeProjectName } from '$lib/utils';
interface Project {
id: number;
@ -85,7 +85,9 @@
editedProject = { ...project };
imagePreview = project.image;
error = null;
selectedHackatimeName = project.hackatimeProject || null;
selectedHackatimeName = project.hackatimeProject
? parseHackatimeProjectName(project.hackatimeProject)
: null;
fetchHackatimeProjects();
}
});

View file

@ -382,7 +382,8 @@ export default {
imageMustBeLessThan: 'Image must be less than 5MB',
unsubmitProject: 'unsubmit project',
unsubmitConfirmTitle: 'unsubmit project?',
unsubmitConfirmMessage: 'this will remove your project from the review queue and return it to in-progress status.',
unsubmitConfirmMessage:
'this will remove your project from the review queue and return it to in-progress status.',
unsubmitting: 'unsubmitting...',
unsubmitSuccess: 'project unsubmitted successfully',
isUpdateLabel: 'this project is an update to a previous project',
@ -560,7 +561,8 @@ export default {
restore: 'restore',
permanentDelete: 'permanently delete',
confirmSoftDelete: 'are you sure you want to remove this order? it can be restored later.',
confirmPermanentDelete: 'are you sure you want to permanently delete this order? this will refund the scraps and restore inventory. this cannot be undone.',
confirmPermanentDelete:
'are you sure you want to permanently delete this order? this will refund the scraps and restore inventory. this cannot be undone.',
adminUserPage: 'admin user page'
},
auth: {

View file

@ -422,7 +422,8 @@ export default {
imageMustBeLessThan: 'La imagen debe ser menor a 5MB',
unsubmitProject: 'retirar proyecto',
unsubmitConfirmTitle: '¿retirar proyecto?',
unsubmitConfirmMessage: 'esto eliminará tu proyecto de la cola de revisión y lo devolverá al estado en progreso.',
unsubmitConfirmMessage:
'esto eliminará tu proyecto de la cola de revisión y lo devolverá al estado en progreso.',
unsubmitting: 'retirando...',
unsubmitSuccess: 'proyecto retirado exitosamente',
isUpdateLabel: 'este proyecto es una actualización de un proyecto anterior',
@ -562,8 +563,10 @@ export default {
softDelete: 'eliminar',
restore: 'restaurar',
permanentDelete: 'eliminar permanentemente',
confirmSoftDelete: '¿estás seguro de que quieres eliminar este pedido? se puede restaurar después.',
confirmPermanentDelete: '¿estás seguro de que quieres eliminar permanentemente este pedido? esto reembolsará los scraps y restaurará el inventario. esto no se puede deshacer.',
confirmSoftDelete:
'¿estás seguro de que quieres eliminar este pedido? se puede restaurar después.',
confirmPermanentDelete:
'¿estás seguro de que quieres eliminar permanentemente este pedido? esto reembolsará los scraps y restaurará el inventario. esto no se puede deshacer.',
adminUserPage: 'página de admin del usuario'
},
auth: {

View file

@ -2,7 +2,48 @@ export function formatHours(hours: number): string {
return hours.toFixed(1);
}
export function validateGithubUrl(url: string | null | undefined): { valid: boolean; error?: string } {
/**
* Parse a stored hackatime project entry and extract just the project name.
* Handles all formats:
* - "123:project-name" (migrated format with hackatime user ID)
* - "U12345/project-name" (old Slack ID format)
* - "project-name" (plain format)
*/
export function parseHackatimeProjectName(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) return trimmed;
// New format: "123:projectName" (numeric hackatime user ID with colon)
const colonIndex = trimmed.indexOf(':');
if (colonIndex !== -1 && !trimmed.startsWith('U')) {
return trimmed.substring(colonIndex + 1);
}
// Old format: "U12345/projectName" (Slack ID with slash)
const slashIndex = trimmed.indexOf('/');
if (slashIndex !== -1 && trimmed.startsWith('U')) {
return trimmed.substring(slashIndex + 1);
}
// Plain project name
return trimmed;
}
/**
* Parse a comma-separated hackatime project string into an array of plain project names.
*/
export function parseHackatimeProjectNames(hackatimeProject: string | null): string[] {
if (!hackatimeProject) return [];
return hackatimeProject
.split(',')
.map((p) => parseHackatimeProjectName(p))
.filter((p) => p.length > 0);
}
export function validateGithubUrl(url: string | null | undefined): {
valid: boolean;
error?: string;
} {
if (!url || !url.trim()) return { valid: true };
const trimmed = url.trim();
@ -19,7 +60,10 @@ export function validateGithubUrl(url: string | null | undefined): { valid: bool
return { valid: true };
}
export function validatePlayableUrl(url: string | null | undefined): { valid: boolean; error?: string } {
export function validatePlayableUrl(url: string | null | undefined): {
valid: boolean;
error?: string;
} {
if (!url || !url.trim()) return { valid: true };
const trimmed = url.trim();
@ -39,4 +83,4 @@ export function validatePlayableUrl(url: string | null | undefined): { valid: bo
}
return { valid: true };
}
}

View file

@ -118,7 +118,11 @@
result = result.filter((o) => {
const addr = parseShippingAddress(o.shippingAddress);
const country = addr?.country?.toLowerCase().trim() ?? '';
const isUS = country === 'us' || country === 'usa' || country === 'united states' || country === 'united states of america';
const isUS =
country === 'us' ||
country === 'usa' ||
country === 'united states' ||
country === 'united states of america';
return filterRegion === 'us' ? isUS : !isUS;
});
}
@ -223,9 +227,13 @@
body: JSON.stringify(patchBody)
});
if (response.ok) {
const trackingValue = !order.isFulfilled ? (trackingInputs[order.id]?.trim() || null) : order.trackingNumber;
const trackingValue = !order.isFulfilled
? trackingInputs[order.id]?.trim() || null
: order.trackingNumber;
orders = orders.map((o) =>
o.id === order.id ? { ...o, isFulfilled: !o.isFulfilled, trackingNumber: trackingValue } : o
o.id === order.id
? { ...o, isFulfilled: !o.isFulfilled, trackingNumber: trackingValue }
: o
);
}
} catch (e) {
@ -424,7 +432,9 @@
<select
id="filter-item"
bind:value={filterItem}
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterItem ? 'bg-black text-white' : ''}"
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterItem
? 'bg-black text-white'
: ''}"
>
<option value="">all items</option>
{#each uniqueItems as item}
@ -437,7 +447,9 @@
<select
id="filter-user"
bind:value={filterUser}
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterUser ? 'bg-black text-white' : ''}"
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterUser
? 'bg-black text-white'
: ''}"
>
<option value="">all users</option>
{#each uniqueUsers as username}
@ -446,11 +458,15 @@
</select>
</div>
<div class="flex flex-col">
<label for="filter-region" class="mb-1 text-xs font-bold text-gray-500 uppercase">region</label>
<label for="filter-region" class="mb-1 text-xs font-bold text-gray-500 uppercase"
>region</label
>
<select
id="filter-region"
bind:value={filterRegion}
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterRegion ? 'bg-black text-white' : ''}"
class="cursor-pointer rounded-xl border-4 border-black px-3 py-2 font-bold transition-all duration-200 focus:border-dashed focus:outline-none {filterRegion
? 'bg-black text-white'
: ''}"
>
<option value="">all regions</option>
<option value="us">US only</option>
@ -687,7 +703,9 @@
class="w-full rounded-lg border-2 border-black px-3 py-2 text-sm font-bold transition-all duration-200 placeholder:text-gray-400 focus:border-dashed focus:outline-none"
/>
{:else if order.trackingNumber}
<div class="rounded-lg border-2 border-gray-300 bg-gray-50 px-3 py-2 text-sm">
<div
class="rounded-lg border-2 border-gray-300 bg-gray-50 px-3 py-2 text-sm"
>
<p class="text-xs font-bold text-gray-500 uppercase">tracking</p>
<p class="font-bold break-all">{order.trackingNumber}</p>
</div>

View file

@ -47,9 +47,12 @@
async function fetchReviews(page = 1) {
loading = true;
try {
const response = await fetch(`${API_URL}/admin/reviews?page=${page}&limit=12&sort=${sortOrder}`, {
credentials: 'include'
});
const response = await fetch(
`${API_URL}/admin/reviews?page=${page}&limit=12&sort=${sortOrder}`,
{
credentials: 'include'
}
);
if (response.ok) {
const data = await response.json();
projects = data.data || [];
@ -104,7 +107,9 @@
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} />
{$t.admin.sort}: {sortOrder === 'oldest' ? $t.admin.sortOldestFirst : $t.admin.sortNewestFirst}
{$t.admin.sort}: {sortOrder === 'oldest'
? $t.admin.sortOldestFirst
: $t.admin.sortNewestFirst}
</button>
</div>
@ -133,10 +138,12 @@
<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"
<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-500 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-800"
<span
class="rounded-full border-2 border-yellow-500 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-800"
>{formatHours(project.effectiveHours)}h</span
>
{:else}

View file

@ -401,7 +401,9 @@
<ShieldAlert size={20} class="text-red-600" />
<div>
<p class="font-bold text-red-800">hackatime banned</p>
<p class="text-sm text-red-700">this user is banned on hackatime. they will be redirected to fraud.land on login.</p>
<p class="text-sm text-red-700">
this user is banned on hackatime. they will be redirected to fraud.land on login.
</p>
</div>
</div>
</div>
@ -412,7 +414,10 @@
<ShieldAlert size={20} class="text-orange-600" />
<div>
<p class="font-bold text-orange-800">hackatime suspected</p>
<p class="text-sm text-orange-700">this user is flagged as suspected on hackatime. please review their activity carefully.</p>
<p class="text-sm text-orange-700">
this user is flagged as suspected on hackatime. please review their activity
carefully.
</p>
</div>
</div>
</div>
@ -429,7 +434,10 @@
<p class="text-sm text-gray-500">status: {project.status}</p>
</div>
</div>
<a href="/projects/{project.id}" class="font-bold underline text-gray-600 hover:text-black">view project</a>
<a
href="/projects/{project.id}"
class="font-bold text-gray-600 underline hover:text-black">view project</a
>
</div>
</div>
{/if}
@ -483,10 +491,12 @@
{/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"
<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"
<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}
@ -511,7 +521,8 @@
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.
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}
@ -523,10 +534,14 @@
{/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">
<span
class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-yellow-800"
>
total: {formatHours(hoursOverride ?? project.hours)}h
</span>
<span class="rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-yellow-800">
<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">
@ -543,7 +558,8 @@
update — scraps preview
</p>
<p class="mb-2 text-sm text-blue-700">
this is an updated project. previously awarded scraps will be subtracted from the new total.
this is an updated project. previously awarded scraps will be subtracted from the new
total.
</p>
<div class="flex flex-wrap gap-3 text-sm font-bold">
<span class="rounded-full border-2 border-blue-600 bg-blue-100 px-3 py-1 text-blue-800">
@ -721,7 +737,11 @@
? 'border-yellow-600 bg-yellow-100 text-yellow-700'
: 'border-red-600 bg-red-100 text-red-700'}"
>
{review.action === 'permanently_rejected' ? 'rejected' : review.action === 'scraps_unawarded' ? 'scraps unawarded' : review.action}
{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).toLocaleString()}
@ -774,43 +794,68 @@
{/if}
<!-- Review-only sections (sold-out effect when not reviewable) -->
<div class="relative {!isReviewable ? 'pointer-events-none select-none opacity-50 grayscale' : ''}">
<div
class="relative {!isReviewable ? 'pointer-events-none opacity-50 grayscale select-none' : ''}"
>
<!-- Tier Reference -->
<div class="mb-6 rounded-2xl border-4 border-black p-6">
<h2 class="mb-4 text-xl font-bold">tier reference</h2>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border-2 {project.tier === 1 ? 'border-black bg-black text-white' : 'border-gray-300'} px-4 py-3">
<div
class="rounded-lg border-2 {project.tier === 1
? 'border-black bg-black text-white'
: 'border-gray-300'} px-4 py-3"
>
<div class="flex items-center justify-between">
<span class="font-bold">tier 1</span>
<span class="text-sm {project.tier === 1 ? 'text-gray-300' : 'text-gray-500'}">0.8×</span>
<span class="text-sm {project.tier === 1 ? 'text-gray-300' : 'text-gray-500'}"
>0.8×</span
>
</div>
<p class="mt-1 text-xs {project.tier === 1 ? 'text-gray-300' : 'text-gray-500'}">
{$t.project.tierDescriptions.tier1}
</p>
</div>
<div class="rounded-lg border-2 {project.tier === 2 ? 'border-black bg-black text-white' : 'border-gray-300'} px-4 py-3">
<div
class="rounded-lg border-2 {project.tier === 2
? 'border-black bg-black text-white'
: 'border-gray-300'} px-4 py-3"
>
<div class="flex items-center justify-between">
<span class="font-bold">tier 2</span>
<span class="text-sm {project.tier === 2 ? 'text-gray-300' : 'text-gray-500'}">1.0×</span>
<span class="text-sm {project.tier === 2 ? 'text-gray-300' : 'text-gray-500'}"
>1.0×</span
>
</div>
<p class="mt-1 text-xs {project.tier === 2 ? 'text-gray-300' : 'text-gray-500'}">
{$t.project.tierDescriptions.tier2}
</p>
</div>
<div class="rounded-lg border-2 {project.tier === 3 ? 'border-black bg-black text-white' : 'border-gray-300'} px-4 py-3">
<div
class="rounded-lg border-2 {project.tier === 3
? 'border-black bg-black text-white'
: 'border-gray-300'} px-4 py-3"
>
<div class="flex items-center justify-between">
<span class="font-bold">tier 3</span>
<span class="text-sm {project.tier === 3 ? 'text-gray-300' : 'text-gray-500'}">1.25×</span>
<span class="text-sm {project.tier === 3 ? 'text-gray-300' : 'text-gray-500'}"
>1.25×</span
>
</div>
<p class="mt-1 text-xs {project.tier === 3 ? 'text-gray-300' : 'text-gray-500'}">
{$t.project.tierDescriptions.tier3}
</p>
</div>
<div class="rounded-lg border-2 {project.tier === 4 ? 'border-black bg-black text-white' : 'border-gray-300'} px-4 py-3">
<div
class="rounded-lg border-2 {project.tier === 4
? 'border-black bg-black text-white'
: 'border-gray-300'} px-4 py-3"
>
<div class="flex items-center justify-between">
<span class="font-bold">tier 4</span>
<span class="text-sm {project.tier === 4 ? 'text-gray-300' : 'text-gray-500'}">1.5×</span>
<span class="text-sm {project.tier === 4 ? 'text-gray-300' : 'text-gray-500'}"
>1.5×</span
>
</div>
<p class="mt-1 text-xs {project.tier === 4 ? 'text-gray-300' : 'text-gray-500'}">
{$t.project.tierDescriptions.tier4}
@ -824,7 +869,12 @@
<h2 class="mb-4 text-xl font-bold">submit review</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>
<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"

View file

@ -47,9 +47,12 @@
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'
});
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 || [];
@ -97,7 +100,9 @@
<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>
<p class="text-lg text-gray-600">
projects approved by reviewers, awaiting admin confirmation
</p>
</div>
<button
onclick={toggleSort}
@ -133,10 +138,12 @@
<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"
<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"
<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}

View file

@ -101,7 +101,9 @@
overlappingProjects.reduce((sum: number, op: OverlappingProject) => sum + op.hours, 0)
);
let effectiveHours = $derived(
project ? Math.max(0, (hoursOverride ?? project.hoursOverride ?? project.hours) - deductedHours) : 0
project
? Math.max(0, (hoursOverride ?? project.hoursOverride ?? project.hours) - deductedHours)
: 0
);
const PHI = (1 + Math.sqrt(5)) / 2;
const MULTIPLIER = 10;
@ -276,7 +278,9 @@
<ShieldAlert size={20} class="text-red-600" />
<div>
<p class="font-bold text-red-800">hackatime banned</p>
<p class="text-sm text-red-700">this user is banned on hackatime. they will be redirected to fraud.land on login.</p>
<p class="text-sm text-red-700">
this user is banned on hackatime. they will be redirected to fraud.land on login.
</p>
</div>
</div>
</div>
@ -287,7 +291,10 @@
<ShieldAlert size={20} class="text-orange-600" />
<div>
<p class="font-bold text-orange-800">hackatime suspected</p>
<p class="text-sm text-orange-700">this user is flagged as suspected on hackatime. please review their activity carefully.</p>
<p class="text-sm text-orange-700">
this user is flagged as suspected on hackatime. please review their activity
carefully.
</p>
</div>
</div>
</div>
@ -307,8 +314,7 @@
</div>
<a
href="/projects/{project.id}"
class="font-bold text-yellow-600 underline hover:text-black"
>view project</a
class="font-bold text-yellow-600 underline hover:text-black">view project</a
>
</div>
</div>
@ -429,7 +435,8 @@
update — scraps preview
</p>
<p class="mb-2 text-sm text-blue-700">
this is an updated project. previously awarded scraps will be subtracted from the new total.
this is an updated project. previously awarded scraps will be subtracted from the new
total.
</p>
<div class="flex flex-wrap gap-3 text-sm font-bold">
<span class="rounded-full border-2 border-blue-600 bg-blue-100 px-3 py-1 text-blue-800">
@ -563,9 +570,7 @@
{#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="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}"
@ -584,7 +589,9 @@
<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">
<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">
@ -611,7 +618,7 @@
<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>
<p class="text-sm whitespace-pre-wrap text-gray-700">{projectUser.internalNotes}</p>
</div>
</div>
{/if}
@ -627,14 +634,21 @@
<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>
<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)"
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'}"
@ -694,8 +708,8 @@
</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.
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.

View file

@ -117,7 +117,9 @@
let showTimeline = $state(false);
let undoingOrder = $state<number | null>(null);
let deletingBonus = $state<number | null>(null);
let showDeleteConfirm = $state<{ type: 'order'; id: number } | { type: 'bonus'; id: number } | null>(null);
let showDeleteConfirm = $state<
{ type: 'order'; id: number } | { type: 'bonus'; id: number } | null
>(null);
let showUnshipConfirm = $state<number | null>(null);
let unshipReason = $state('');
let unshipping = $state(false);
@ -1042,9 +1044,11 @@
</h2>
<p class="mb-6 text-gray-600">
{#if showDeleteConfirm.type === 'bonus'}
this will permanently delete this bonus entry from the database. the user's balance will be recalculated.
this will permanently delete this bonus entry from the database. the user's balance will
be recalculated.
{:else}
this will permanently delete this order and all associated records (refinery upgrades, rolls, penalties). item stock will be restored.
this will permanently delete this order and all associated records (refinery upgrades,
rolls, penalties). item stock will be restored.
{/if}
</p>
<div class="flex gap-3">

View file

@ -85,7 +85,9 @@
<div class="border-t-2 border-black bg-white px-4 py-3">
<div class="mb-1 flex items-center justify-between">
<span class="truncate text-lg font-bold">{project.name}</span>
<span class="shrink-0 text-sm text-gray-500">{formatHours(project.hoursOverride ?? project.hours)}h</span>
<span class="shrink-0 text-sm text-gray-500"
>{formatHours(project.hoursOverride ?? project.hours)}h</span
>
</div>
<div class="flex items-center justify-between">
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs">

View file

@ -247,7 +247,9 @@
? 'bg-blue-100'
: 'bg-gray-100'}"
>
{project.status === 'waiting_for_review' ? 'under review' : project.status.replace(/_/g, ' ')}
{project.status === 'waiting_for_review'
? 'under review'
: project.status.replace(/_/g, ' ')}
</span>
</div>
<p class="line-clamp-2 flex-1 text-sm text-gray-600">{project.description}</p>

View file

@ -265,7 +265,7 @@
<!-- Content -->
<div class="p-6">
<div class="mb-2 flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
<h1 class="min-w-0 wrap-break-word text-3xl font-bold md:text-4xl">{project.name}</h1>
<h1 class="min-w-0 text-3xl font-bold wrap-break-word md:text-4xl">{project.name}</h1>
{#if project.status === 'shipped'}
<span
class="flex shrink-0 items-center gap-1 rounded-full border-2 border-green-600 bg-green-100 px-3 py-1 text-sm font-bold text-green-700"
@ -351,7 +351,9 @@
{#if (isOwner || isAdmin) && project.deductedHours > 0}
<span
class="flex items-center gap-2 rounded-full border-4 border-yellow-500 bg-yellow-100 px-4 py-2 font-bold text-yellow-700"
title="{formatHours(project.deductedHours)}h deducted from overlapping shipped projects"
title="{formatHours(
project.deductedHours
)}h deducted from overlapping shipped projects"
>
{formatHours(project.effectiveHours)}h effective
</span>
@ -429,62 +431,62 @@
{#if isOwner}
<div class="mb-8 flex flex-col gap-3">
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
{#if project.status === 'waiting_for_review'}
<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"
>
<Pencil size={18} />
{$t.project.editProject}
</span>
{:else}
<a
href="/projects/{project.id}/edit"
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black px-4 py-3 text-center text-sm font-bold transition-all duration-200 hover:border-dashed sm:px-6 sm:text-base"
>
<Pencil size={18} />
{$t.project.editProject}
</a>
{/if}
{#if project.status === 'waiting_for_review'}
<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"
>
<Send size={18} />
{$t.project.awaitingReview}
</span>
{:else if project.status === 'shipped'}
<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"
>
<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"
>
<XCircle size={18} />
{$t.project.permanentlyRejected}
</span>
{:else if $tutorialActiveStore}
<span
data-tutorial="submit-button"
class="flex flex-1 cursor-not-allowed items-center justify-center gap-2 rounded-full border-4 border-black bg-black px-4 py-3 text-sm font-bold text-white sm:px-6 sm:text-base"
>
<Send size={18} />
{$t.project.reviewAndSubmit}
</span>
{:else}
<a
href="/projects/{project.id}/submit"
data-tutorial="submit-button"
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.reviewAndSubmit}
</a>
{/if}
{#if project.status === 'waiting_for_review'}
<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"
>
<Pencil size={18} />
{$t.project.editProject}
</span>
{:else}
<a
href="/projects/{project.id}/edit"
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black px-4 py-3 text-center text-sm font-bold transition-all duration-200 hover:border-dashed sm:px-6 sm:text-base"
>
<Pencil size={18} />
{$t.project.editProject}
</a>
{/if}
{#if project.status === 'waiting_for_review'}
<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"
>
<Send size={18} />
{$t.project.awaitingReview}
</span>
{:else if project.status === 'shipped'}
<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"
>
<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"
>
<XCircle size={18} />
{$t.project.permanentlyRejected}
</span>
{:else if $tutorialActiveStore}
<span
data-tutorial="submit-button"
class="flex flex-1 cursor-not-allowed items-center justify-center gap-2 rounded-full border-4 border-black bg-black px-4 py-3 text-sm font-bold text-white sm:px-6 sm:text-base"
>
<Send size={18} />
{$t.project.reviewAndSubmit}
</span>
{:else}
<a
href="/projects/{project.id}/submit"
data-tutorial="submit-button"
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.reviewAndSubmit}
</a>
{/if}
</div>
{#if project.status === 'waiting_for_review'}
<button
@ -515,107 +517,106 @@
<p class="mt-2 text-sm text-gray-400">{$t.project.submitToGetStarted}</p>
{/if}
</div>
{:else}
<div class="relative">
<!-- Timeline line -->
<div class="absolute top-0 bottom-0 left-3 w-0.5 bg-gray-200"></div>
<div class="space-y-4">
{#each activity as entry, i}
{#if entry.type === 'review' && entry.action}
{@const ReviewIcon = getReviewIcon(entry.action)}
<div class="relative">
{:else}
<div class="relative">
<!-- Timeline line -->
<div class="absolute top-0 bottom-0 left-3 w-0.5 bg-gray-200"></div>
<div class="space-y-4">
{#each activity as entry, i}
{#if entry.type === 'review' && entry.action}
{@const ReviewIcon = getReviewIcon(entry.action)}
<div class="relative">
<div
class="ml-8 rounded-2xl border-4 border-black bg-white p-6 transition-all duration-200 hover:border-dashed"
>
<div
class="ml-8 rounded-2xl border-4 border-black bg-white p-6 transition-all duration-200 hover:border-dashed"
class="absolute top-6 left-0 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<div
class="absolute top-6 left-0 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<ReviewIcon size={20} class={getReviewColor(entry.action)} />
<ReviewIcon size={20} class={getReviewColor(entry.action)} />
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<span class="font-bold">{getReviewLabel(entry.action)}</span>
<span class="text-sm text-gray-500">{formatDate(entry.createdAt)}</span>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<span class="font-bold">{getReviewLabel(entry.action)}</span>
<span class="text-sm text-gray-500">{formatDate(entry.createdAt)}</span>
</div>
{#if entry.feedbackForAuthor}
<p class="mb-3 text-gray-700">{entry.feedbackForAuthor}</p>
{/if}
{#if entry.reviewer}
<a
href="/users/{entry.reviewer.id}"
class="inline-flex cursor-pointer items-center gap-2 text-sm text-gray-500 transition-all duration-200 hover:text-black"
{#if entry.feedbackForAuthor}
<p class="mb-3 text-gray-700">{entry.feedbackForAuthor}</p>
{/if}
{#if entry.reviewer}
<a
href="/users/{entry.reviewer.id}"
class="inline-flex cursor-pointer items-center gap-2 text-sm text-gray-500 transition-all duration-200 hover:text-black"
>
{#if entry.reviewer.avatar}
<img
src={entry.reviewer.avatar}
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}
<span
>{$t.project.reviewedBy}
<strong>{entry.reviewer.username || $t.project.reviewer}</strong></span
>
{#if entry.reviewer.avatar}
<img
src={entry.reviewer.avatar}
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}
<span
>{$t.project.reviewedBy}
<strong>{entry.reviewer.username || $t.project.reviewer}</strong
></span
>
</a>
{/if}
</div>
</a>
{/if}
</div>
</div>
{:else if entry.type === 'scraps_earned'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<Spool size={16} class="text-green-600" />
</div>
<span class="text-sm font-bold text-green-600"
>{entry.action} · {formatDate(entry.createdAt)}</span
>
</div>
{:else if entry.type === 'scraps_earned'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<Spool size={16} class="text-green-600" />
</div>
{:else if entry.type === 'submitted'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<PlaneTakeoff size={16} class="text-gray-500" />
</div>
<span class="text-sm text-gray-500"
>{$t.project.submittedForReview} · {formatDate(entry.createdAt)}</span
>
<span class="text-sm font-bold text-green-600"
>{entry.action} · {formatDate(entry.createdAt)}</span
>
</div>
{:else if entry.type === 'submitted'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<PlaneTakeoff size={16} class="text-gray-500" />
</div>
{:else if entry.type === 'unsubmitted'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<Undo2 size={16} class="text-orange-500" />
</div>
<span class="text-sm text-orange-500"
>{$t.project.unsubmittedFromReview} · {formatDate(entry.createdAt)}</span
>
<span class="text-sm text-gray-500"
>{$t.project.submittedForReview} · {formatDate(entry.createdAt)}</span
>
</div>
{:else if entry.type === 'unsubmitted'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<Undo2 size={16} class="text-orange-500" />
</div>
{:else if entry.type === 'created'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<Plus size={16} class="text-gray-500" />
</div>
<span class="text-sm text-gray-500"
>{$t.project.projectCreated} · {formatDate(entry.createdAt)}</span
>
<span class="text-sm text-orange-500"
>{$t.project.unsubmittedFromReview} · {formatDate(entry.createdAt)}</span
>
</div>
{:else if entry.type === 'created'}
<div class="relative ml-8 flex items-center gap-3 py-2">
<div
class="absolute left-[-26px] z-10 flex h-6 w-6 items-center justify-center rounded-full bg-white"
>
<Plus size={16} class="text-gray-500" />
</div>
{/if}
{/each}
</div>
<span class="text-sm text-gray-500"
>{$t.project.projectCreated} · {formatDate(entry.createdAt)}</span
>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -1,10 +1,24 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { ArrowLeft, ChevronDown, Upload, X, Save, Check, Trash2, MessageSquare } 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';
import {
formatHours,
validateGithubUrl,
validatePlayableUrl,
parseHackatimeProjectNames
} from '$lib/utils';
import { invalidateAllStores } from '$lib/stores';
import { t } from '$lib/i18n';
@ -76,7 +90,14 @@
let playableValidation = $derived(validatePlayableUrl(project?.playableUrl));
let updateValid = $derived(!isUpdate || updateDescription.trim().length > 0);
let aiValid = $derived(!usedAi || aiDescription.trim().length > 0);
let canSave = $derived(hasDescription && hasName && githubValidation.valid && playableValidation.valid && updateValid && aiValid);
let canSave = $derived(
hasDescription &&
hasName &&
githubValidation.valid &&
playableValidation.valid &&
updateValid &&
aiValid
);
onMount(async () => {
const user = await getUser();
@ -99,13 +120,13 @@
project = responseData.project;
imagePreview = project?.image || null;
selectedTier = project?.tier || 1;
isUpdate = !!(project?.updateDescription);
isUpdate = !!project?.updateDescription;
updateDescription = project?.updateDescription || '';
usedAi = !!(project?.aiDescription);
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);
selectedHackatimeNames = parseHackatimeProjectNames(project.hackatimeProject);
}
fetchHackatimeProjects();
} catch (e) {
@ -188,7 +209,7 @@
}
// Recalculate total hours from all selected projects
const totalHours = selectedHackatimeNames.reduce((sum, name) => {
const found = hackatimeProjects.find(p => p.name === name);
const found = hackatimeProjects.find((p) => p.name === name);
return sum + (found?.hours || 0);
}, 0);
project.hours = Math.round(totalHours * 10) / 10;
@ -199,10 +220,10 @@
}
function removeHackatimeProject(name: string) {
selectedHackatimeNames = selectedHackatimeNames.filter(n => n !== name);
selectedHackatimeNames = selectedHackatimeNames.filter((n) => n !== name);
if (project) {
const totalHours = selectedHackatimeNames.reduce((sum, n) => {
const found = hackatimeProjects.find(p => p.name === n);
const found = hackatimeProjects.find((p) => p.name === n);
return sum + (found?.hours || 0);
}, 0);
project.hours = Math.round(totalHours * 10) / 10;
@ -215,9 +236,8 @@
saving = true;
error = null;
const hackatimeValue = selectedHackatimeNames.length > 0
? selectedHackatimeNames.join(',')
: null;
const hackatimeValue =
selectedHackatimeNames.length > 0 ? selectedHackatimeNames.join(',') : null;
try {
const response = await fetch(`${API_URL}/projects/${project.id}`, {
@ -399,7 +419,10 @@
type="url"
bind:value={project.githubUrl}
placeholder="https://github.com/user/repo"
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.githubUrl?.trim() && !githubValidation.valid ? 'border-red-500' : 'border-black'}"
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.githubUrl?.trim() &&
!githubValidation.valid
? 'border-red-500'
: 'border-black'}"
/>
{#if project.githubUrl?.trim() && !githubValidation.valid}
<p class="mt-1 text-xs text-red-500">{githubValidation.error}</p>
@ -417,7 +440,10 @@
type="url"
bind:value={project.playableUrl}
placeholder="https://yourproject.com or https://replit.com/..."
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.playableUrl?.trim() && !playableValidation.valid ? 'border-red-500' : 'border-black'}"
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.playableUrl?.trim() &&
!playableValidation.valid
? 'border-red-500'
: 'border-black'}"
/>
{#if project.playableUrl?.trim() && !playableValidation.valid}
<p class="mt-1 text-xs text-red-500">{playableValidation.error}</p>
@ -435,8 +461,10 @@
{#if selectedHackatimeNames.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each selectedHackatimeNames as name}
{@const hp = hackatimeProjects.find(p => p.name === name)}
<span class="flex items-center gap-1 rounded-full border-2 border-black bg-gray-100 px-3 py-1 text-sm font-medium">
{@const hp = hackatimeProjects.find((p) => p.name === name)}
<span
class="flex items-center gap-1 rounded-full border-2 border-black bg-gray-100 px-3 py-1 text-sm font-medium"
>
{name}
{#if hp}
<span class="text-gray-500">({formatHours(hp.hours)}h)</span>
@ -483,7 +511,9 @@
<button
type="button"
onclick={() => selectHackatimeProject(hp)}
class="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-left hover:bg-gray-100 {isSelected ? 'bg-gray-50' : ''}"
class="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-left hover:bg-gray-100 {isSelected
? 'bg-gray-50'
: ''}"
>
<span class="flex items-center gap-2">
{#if isSelected}

View file

@ -1,7 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { ArrowLeft, Send, Check, ChevronDown, Upload, X, RefreshCw, Bot, MessageSquare } 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';
@ -86,7 +96,15 @@
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 && hasUpdateDescription && hasAiDescription
hasImage &&
hasHackatime &&
hasGithub &&
hasPlayableUrl &&
hasDescription &&
hasName &&
hasFeedback &&
hasUpdateDescription &&
hasAiDescription
);
onMount(async () => {
@ -116,11 +134,14 @@
isShippedUpdate = project?.status === 'shipped';
reviewerNotes = project?.reviewerNotes || '';
if (project?.hackatimeProject) {
selectedHackatimeNames = project.hackatimeProject.split(',').map((p: string) => {
const trimmed = p.trim();
const slashIndex = trimmed.indexOf('/');
return slashIndex !== -1 ? trimmed.substring(slashIndex + 1) : trimmed;
}).filter((p: string) => p.length > 0);
selectedHackatimeNames = project.hackatimeProject
.split(',')
.map((p: string) => {
const trimmed = p.trim();
const slashIndex = trimmed.indexOf('/');
return slashIndex !== -1 ? trimmed.substring(slashIndex + 1) : trimmed;
})
.filter((p: string) => p.length > 0);
}
selectedTier = project?.tier ?? 1;
fetchHackatimeProjects();
@ -204,7 +225,7 @@
}
// Recalculate total hours from all selected projects
const totalHours = selectedHackatimeNames.reduce((sum, name) => {
const found = hackatimeProjects.find(p => p.name === name);
const found = hackatimeProjects.find((p) => p.name === name);
return sum + (found?.hours || 0);
}, 0);
project.hours = Math.round(totalHours * 10) / 10;
@ -215,10 +236,10 @@
}
function removeHackatimeProject(name: string) {
selectedHackatimeNames = selectedHackatimeNames.filter(n => n !== name);
selectedHackatimeNames = selectedHackatimeNames.filter((n) => n !== name);
if (project) {
const totalHours = selectedHackatimeNames.reduce((sum, n) => {
const found = hackatimeProjects.find(p => p.name === n);
const found = hackatimeProjects.find((p) => p.name === n);
return sum + (found?.hours || 0);
}, 0);
project.hours = Math.round(totalHours * 10) / 10;
@ -231,9 +252,12 @@
submitting = true;
error = null;
const hackatimeValue = selectedHackatimeNames.length > 0
? selectedHackatimeNames.map(name => userSlackId ? `${userSlackId}/${name}` : name).join(',')
: null;
const hackatimeValue =
selectedHackatimeNames.length > 0
? selectedHackatimeNames
.map((name) => (userSlackId ? `${userSlackId}/${name}` : name))
.join(',')
: null;
try {
// First update the project with any changes
@ -320,9 +344,13 @@
</div>
{:else if project}
<div class="rounded-2xl border-4 border-black bg-white p-6">
<h1 class="mb-2 text-3xl font-bold">{isShippedUpdate ? 'ship update' : $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">
{isShippedUpdate ? 'submit your updated project for review. you\'ll earn the difference in scraps based on your new hours.' : $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}
@ -416,7 +444,10 @@
type="url"
bind:value={project.githubUrl}
placeholder="https://github.com/user/repo"
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.githubUrl?.trim() && !githubValidation.valid ? 'border-red-500' : 'border-black'}"
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.githubUrl?.trim() &&
!githubValidation.valid
? 'border-red-500'
: 'border-black'}"
/>
{#if project.githubUrl?.trim() && !githubValidation.valid}
<p class="mt-1 text-xs text-red-500">{githubValidation.error}</p>
@ -433,7 +464,10 @@
type="url"
bind:value={project.playableUrl}
placeholder="https://yourproject.com or https://replit.com/..."
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.playableUrl?.trim() && !playableValidation.valid ? 'border-red-500' : 'border-black'}"
class="w-full rounded-lg border-2 px-4 py-3 focus:border-dashed focus:outline-none {project.playableUrl?.trim() &&
!playableValidation.valid
? 'border-red-500'
: 'border-black'}"
/>
{#if project.playableUrl?.trim() && !playableValidation.valid}
<p class="mt-1 text-xs text-red-500">{playableValidation.error}</p>
@ -450,8 +484,10 @@
{#if selectedHackatimeNames.length > 0}
<div class="mb-2 flex flex-wrap gap-2">
{#each selectedHackatimeNames as name}
{@const hp = hackatimeProjects.find(p => p.name === name)}
<span class="flex items-center gap-1 rounded-full border-2 border-black bg-gray-100 px-3 py-1 text-sm font-medium">
{@const hp = hackatimeProjects.find((p) => p.name === name)}
<span
class="flex items-center gap-1 rounded-full border-2 border-black bg-gray-100 px-3 py-1 text-sm font-medium"
>
{name}
{#if hp}
<span class="text-gray-500">({formatHours(hp.hours)}h)</span>
@ -498,7 +534,9 @@
<button
type="button"
onclick={() => selectHackatimeProject(hp)}
class="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-left hover:bg-gray-100 {isSelected ? 'bg-gray-50' : ''}"
class="flex w-full cursor-pointer items-center justify-between px-4 py-2 text-left hover:bg-gray-100 {isSelected
? 'bg-gray-50'
: ''}"
>
<span class="flex items-center gap-2">
{#if isSelected}
@ -746,7 +784,9 @@
>
{#if hasUpdateDescription}<Check size={12} />{/if}
</span>
<span class={hasUpdateDescription ? '' : 'text-gray-500'}>update description provided</span>
<span class={hasUpdateDescription ? '' : 'text-gray-500'}
>update description provided</span
>
</li>
{/if}
{#if usedAi}
@ -770,9 +810,7 @@
>
{#if hasFeedback}<Check size={12} />{/if}
</span>
<span class={hasFeedback ? '' : 'text-gray-500'}
>{$t.project.feedbackCompleted}</span
>
<span class={hasFeedback ? '' : 'text-gray-500'}>{$t.project.feedbackCompleted}</span>
</li>
{/if}
</ul>
@ -796,7 +834,11 @@
{:else}
<Send size={18} />
{/if}
{submitting ? $t.project.submitting : isShippedUpdate ? 'ship update' : $t.project.submitForReview}
{submitting
? $t.project.submitting
: isShippedUpdate
? 'ship update'
: $t.project.submitForReview}
</button>
</div>
</div>

View file

@ -171,7 +171,9 @@
<!-- Feedback Questions -->
<div>
<label for="feedbackSource" class="mb-2 block text-sm font-bold">{$t.submit.feedbackSourceLabel}</label>
<label for="feedbackSource" class="mb-2 block text-sm font-bold"
>{$t.submit.feedbackSourceLabel}</label
>
<textarea
id="feedbackSource"
bind:value={feedbackSource}
@ -182,7 +184,9 @@
</div>
<div>
<label for="feedbackGood" class="mb-2 block text-sm font-bold">{$t.submit.feedbackGoodLabel}</label>
<label for="feedbackGood" class="mb-2 block text-sm font-bold"
>{$t.submit.feedbackGoodLabel}</label
>
<textarea
id="feedbackGood"
bind:value={feedbackGood}
@ -193,7 +197,9 @@
</div>
<div>
<label for="feedbackImprove" class="mb-2 block text-sm font-bold">{$t.submit.feedbackImproveLabel}</label>
<label for="feedbackImprove" class="mb-2 block text-sm font-bold"
>{$t.submit.feedbackImproveLabel}</label
>
<textarea
id="feedbackImprove"
bind:value={feedbackImprove}