mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 19:45:14 +00:00
guh hackatime work
This commit is contained in:
parent
bab78ab458
commit
827f285438
17 changed files with 570 additions and 286 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue