mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 19:45:14 +00:00
add instuff
This commit is contained in:
parent
73272ffbe3
commit
32fd592d1f
23 changed files with 1496 additions and 586 deletions
1138
backend/dist/index.js
vendored
1138
backend/dist/index.js
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -8,8 +8,16 @@ import { userBonusesTable } from '../schemas/users'
|
|||
export const PHI = (1 + Math.sqrt(5)) / 2
|
||||
export const MULTIPLIER = 10
|
||||
|
||||
export function calculateScrapsFromHours(hours: number): number {
|
||||
return Math.floor(hours * PHI * MULTIPLIER)
|
||||
export const TIER_MULTIPLIERS: Record<number, number> = {
|
||||
1: 0.75,
|
||||
2: 1.0,
|
||||
3: 1.25,
|
||||
4: 1.5
|
||||
}
|
||||
|
||||
export function calculateScrapsFromHours(hours: number, tier: number = 1): number {
|
||||
const tierMultiplier = TIER_MULTIPLIERS[tier] ?? 1.0
|
||||
return Math.floor(hours * PHI * MULTIPLIER * tierMultiplier)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
|
|||
|
|
@ -416,11 +416,12 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
|
|||
const user = await requireReviewer(headers as Record<string, string>)
|
||||
if (!user) return { error: 'Unauthorized' }
|
||||
|
||||
const { action, feedbackForAuthor, internalJustification, hoursOverride, userInternalNotes } = body as {
|
||||
const { action, feedbackForAuthor, internalJustification, hoursOverride, tierOverride, userInternalNotes } = body as {
|
||||
action: 'approved' | 'denied' | 'permanently_rejected'
|
||||
feedbackForAuthor: string
|
||||
internalJustification?: string
|
||||
hoursOverride?: number
|
||||
tierOverride?: number
|
||||
userInternalNotes?: string
|
||||
}
|
||||
|
||||
|
|
@ -486,10 +487,15 @@ admin.post('/reviews/:id', async ({ params, body, headers }) => {
|
|||
updateData.hoursOverride = hoursOverride
|
||||
}
|
||||
|
||||
if (tierOverride !== undefined) {
|
||||
updateData.tierOverride = tierOverride
|
||||
}
|
||||
|
||||
let scrapsAwarded = 0
|
||||
if (action === 'approved') {
|
||||
const hours = hoursOverride ?? project[0].hours ?? 0
|
||||
scrapsAwarded = calculateScrapsFromHours(hours)
|
||||
const tier = tierOverride ?? project[0].tier ?? 1
|
||||
scrapsAwarded = calculateScrapsFromHours(hours, tier)
|
||||
updateData.scrapsAwarded = scrapsAwarded
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,44 @@ leaderboard.get('/', async ({ query }) => {
|
|||
})
|
||||
})
|
||||
|
||||
leaderboard.get('/views', async () => {
|
||||
const results = await db
|
||||
.select({
|
||||
id: projectsTable.id,
|
||||
name: projectsTable.name,
|
||||
image: projectsTable.image,
|
||||
views: projectsTable.views,
|
||||
userId: projectsTable.userId
|
||||
})
|
||||
.from(projectsTable)
|
||||
.where(and(
|
||||
eq(projectsTable.status, 'shipped'),
|
||||
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted))
|
||||
))
|
||||
.orderBy(desc(projectsTable.views))
|
||||
.limit(10)
|
||||
|
||||
const userIds = [...new Set(results.map(p => p.userId))]
|
||||
let users: { id: number; username: string | null; avatar: string | null }[] = []
|
||||
if (userIds.length > 0) {
|
||||
users = await db
|
||||
.select({ id: usersTable.id, username: usersTable.username, avatar: usersTable.avatar })
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.id} IN ${userIds}`)
|
||||
}
|
||||
|
||||
const userMap = new Map(users.map(u => [u.id, u]))
|
||||
|
||||
return results.map((project, index) => ({
|
||||
rank: index + 1,
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
image: project.image,
|
||||
views: project.views,
|
||||
owner: userMap.get(project.userId) ?? null
|
||||
}))
|
||||
})
|
||||
|
||||
leaderboard.get('/probability-leaders', async () => {
|
||||
const items = await db
|
||||
.select({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Elysia } from 'elysia'
|
||||
import { eq, and, sql, desc, inArray, or, isNull } from 'drizzle-orm'
|
||||
import { eq, and, sql, desc, inArray, or, isNull, ilike } from 'drizzle-orm'
|
||||
import { db } from '../db'
|
||||
import { projectsTable } from '../schemas/projects'
|
||||
import { reviewsTable } from '../schemas/reviews'
|
||||
|
|
@ -48,6 +48,95 @@ function parseHackatimeProject(hackatimeProject: string | null): { slackId: stri
|
|||
|
||||
const projects = new Elysia({ prefix: '/projects' })
|
||||
|
||||
// Public explore endpoint - returns minimal data for browsing
|
||||
projects.get('/explore', async ({ query }) => {
|
||||
const page = parseInt(query.page as string) || 1
|
||||
const limit = Math.min(parseInt(query.limit as string) || 20, 50)
|
||||
const offset = (page - 1) * limit
|
||||
const search = (query.search as string)?.trim() || ''
|
||||
const tier = query.tier ? parseInt(query.tier as string) : null
|
||||
const status = query.status as string || null
|
||||
|
||||
const conditions = [
|
||||
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
|
||||
or(eq(projectsTable.status, 'shipped'), eq(projectsTable.status, 'in_progress'))
|
||||
]
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(projectsTable.name, `%${search}%`),
|
||||
ilike(projectsTable.description, `%${search}%`)
|
||||
)!
|
||||
)
|
||||
}
|
||||
|
||||
if (tier && tier >= 1 && tier <= 4) {
|
||||
conditions.push(eq(projectsTable.tier, tier))
|
||||
}
|
||||
|
||||
if (status === 'shipped' || status === 'in_progress') {
|
||||
// Replace the default status condition with specific one
|
||||
conditions[1] = eq(projectsTable.status, status)
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions)
|
||||
|
||||
const [projectsList, countResult] = await Promise.all([
|
||||
db.select({
|
||||
id: projectsTable.id,
|
||||
name: projectsTable.name,
|
||||
description: projectsTable.description,
|
||||
image: projectsTable.image,
|
||||
hours: projectsTable.hours,
|
||||
tier: projectsTable.tier,
|
||||
status: projectsTable.status,
|
||||
views: projectsTable.views,
|
||||
userId: projectsTable.userId
|
||||
})
|
||||
.from(projectsTable)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(projectsTable.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(projectsTable)
|
||||
.where(whereClause)
|
||||
])
|
||||
|
||||
// Fetch usernames for all projects
|
||||
const userIds = [...new Set(projectsList.map(p => p.userId))]
|
||||
let users: { id: number; username: string | null }[] = []
|
||||
if (userIds.length > 0) {
|
||||
users = await db
|
||||
.select({ id: usersTable.id, username: usersTable.username })
|
||||
.from(usersTable)
|
||||
.where(inArray(usersTable.id, userIds))
|
||||
}
|
||||
|
||||
const total = Number(countResult[0]?.count || 0)
|
||||
|
||||
return {
|
||||
data: projectsList.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description.substring(0, 150) + (p.description.length > 150 ? '...' : ''),
|
||||
image: p.image,
|
||||
hours: p.hours,
|
||||
tier: p.tier,
|
||||
status: p.status,
|
||||
views: p.views,
|
||||
username: users.find(u => u.id === p.userId)?.username || null
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
projects.get('/', async ({ headers, query }) => {
|
||||
const user = await getUserFromSession(headers as Record<string, string>)
|
||||
if (!user) return { error: 'Unauthorized' }
|
||||
|
|
@ -104,6 +193,14 @@ projects.get('/:id', async ({ params, headers }) => {
|
|||
return { error: 'Not found' }
|
||||
}
|
||||
|
||||
// Increment view count if not owner
|
||||
if (!isOwner) {
|
||||
await db
|
||||
.update(projectsTable)
|
||||
.set({ views: sql`${projectsTable.views} + 1` })
|
||||
.where(eq(projectsTable.id, parseInt(params.id)))
|
||||
}
|
||||
|
||||
const projectOwner = await db
|
||||
.select({ id: usersTable.id, username: usersTable.username, avatar: usersTable.avatar })
|
||||
.from(usersTable)
|
||||
|
|
@ -207,8 +304,11 @@ projects.get('/:id', async ({ params, headers }) => {
|
|||
hackatimeProject: isOwner ? project[0].hackatimeProject : undefined,
|
||||
hours: project[0].hoursOverride ?? project[0].hours,
|
||||
hoursOverride: isOwner ? project[0].hoursOverride : undefined,
|
||||
tier: project[0].tier,
|
||||
tierOverride: isOwner ? project[0].tierOverride : undefined,
|
||||
status: project[0].status,
|
||||
scrapsAwarded: project[0].scrapsAwarded,
|
||||
views: project[0].views,
|
||||
createdAt: project[0].createdAt,
|
||||
updatedAt: project[0].updatedAt
|
||||
},
|
||||
|
|
@ -228,6 +328,7 @@ projects.post('/', async ({ body, headers }) => {
|
|||
image?: string
|
||||
githubUrl?: string
|
||||
hackatimeProject?: string
|
||||
tier?: number
|
||||
}
|
||||
|
||||
let hours = 0
|
||||
|
|
@ -236,6 +337,8 @@ projects.post('/', async ({ body, headers }) => {
|
|||
hours = await fetchHackatimeHours(parsed.slackId, parsed.projectName)
|
||||
}
|
||||
|
||||
const tier = data.tier !== undefined ? Math.max(1, Math.min(4, data.tier)) : 1
|
||||
|
||||
const newProject = await db
|
||||
.insert(projectsTable)
|
||||
.values({
|
||||
|
|
@ -245,7 +348,8 @@ projects.post('/', async ({ body, headers }) => {
|
|||
image: data.image || null,
|
||||
githubUrl: data.githubUrl || null,
|
||||
hackatimeProject: data.hackatimeProject || null,
|
||||
hours
|
||||
hours,
|
||||
tier
|
||||
})
|
||||
.returning()
|
||||
|
||||
|
|
@ -283,6 +387,7 @@ projects.put('/:id', async ({ params, body, headers }) => {
|
|||
githubUrl?: string | null
|
||||
playableUrl?: string | null
|
||||
hackatimeProject?: string | null
|
||||
tier?: number
|
||||
}
|
||||
|
||||
let hours = 0
|
||||
|
|
@ -291,6 +396,8 @@ projects.put('/:id', async ({ params, body, headers }) => {
|
|||
hours = await fetchHackatimeHours(parsed.slackId, parsed.projectName)
|
||||
}
|
||||
|
||||
const tier = data.tier !== undefined ? Math.max(1, Math.min(4, data.tier)) : undefined
|
||||
|
||||
const updated = await db
|
||||
.update(projectsTable)
|
||||
.set({
|
||||
|
|
@ -301,6 +408,7 @@ projects.put('/:id', async ({ params, body, headers }) => {
|
|||
playableUrl: data.playableUrl,
|
||||
hackatimeProject: data.hackatimeProject,
|
||||
hours,
|
||||
tier,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(and(eq(projectsTable.id, parseInt(params.id)), eq(projectsTable.userId, user.id)))
|
||||
|
|
|
|||
|
|
@ -15,9 +15,12 @@ export const projectsTable = pgTable('projects', {
|
|||
hackatimeProject: varchar('hackatime_project'),
|
||||
hours: real().default(0),
|
||||
hoursOverride: real('hours_override'),
|
||||
tier: integer().notNull().default(1),
|
||||
tierOverride: integer('tier_override'),
|
||||
status: varchar().notNull().default('in_progress'),
|
||||
deleted: integer('deleted').default(0),
|
||||
scrapsAwarded: integer('scraps_awarded').notNull().default(0),
|
||||
views: integer().notNull().default(0),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull()
|
||||
|
|
|
|||
|
|
@ -57,6 +57,14 @@
|
|||
let showDropdown = $state(false)
|
||||
let loading = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let selectedTier = $state(1)
|
||||
|
||||
const TIERS = [
|
||||
{ value: 1, description: 'simple projects, tutorials, small scripts' },
|
||||
{ value: 2, description: 'moderate complexity, multi-file projects' },
|
||||
{ value: 3, description: 'complex features, APIs, integrations' },
|
||||
{ value: 4, description: 'full applications, major undertakings' }
|
||||
]
|
||||
|
||||
const NAME_MAX = 50
|
||||
const DESC_MIN = 20
|
||||
|
|
@ -152,6 +160,7 @@
|
|||
imagePreview = null
|
||||
selectedHackatimeProject = null
|
||||
showDropdown = false
|
||||
selectedTier = 1
|
||||
error = null
|
||||
}
|
||||
|
||||
|
|
@ -181,7 +190,8 @@
|
|||
description,
|
||||
image: imageUrl || null,
|
||||
githubUrl: finalGithubUrl,
|
||||
hackatimeProject: hackatimeValue
|
||||
hackatimeProject: hackatimeValue,
|
||||
tier: selectedTier
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -354,6 +364,23 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tier Selector -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold mb-1">project tier</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each TIERS as tier}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTier = tier.value)}
|
||||
class="px-3 py-2 border-2 border-black rounded-lg font-bold transition-all duration-200 cursor-pointer text-left {selectedTier === tier.value ? 'bg-black text-white' : 'hover:border-dashed'}"
|
||||
>
|
||||
<span>tier {tier.value}</span>
|
||||
<p class="text-xs mt-1 {selectedTier === tier.value ? 'text-gray-300' : 'text-gray-500'}">{tier.description}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Checklist -->
|
||||
<div class="border-2 border-black rounded-lg p-4">
|
||||
<p class="font-bold mb-3">requirements</p>
|
||||
|
|
|
|||
46
frontend/src/lib/components/ErrorModal.svelte
Normal file
46
frontend/src/lib/components/ErrorModal.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { X, AlertTriangle } from '@lucide/svelte'
|
||||
import { errorStore, clearError } from '$lib/stores'
|
||||
|
||||
let error = $derived($errorStore)
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-[200] flex items-center justify-center p-4"
|
||||
onclick={(e) => e.target === e.currentTarget && clearError()}
|
||||
onkeydown={(e) => e.key === 'Escape' && clearError()}
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="bg-white rounded-2xl w-full max-w-md p-6 border-4 border-red-600">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle size={24} class="text-red-600" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-red-600">{error.title || 'error'}</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={clearError}
|
||||
class="p-2 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-6">{error.message}</p>
|
||||
{#if error.details}
|
||||
<div class="mb-6 p-3 bg-gray-100 rounded-lg text-sm text-gray-500 font-mono overflow-x-auto">
|
||||
{error.details}
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={clearError}
|
||||
class="w-full px-4 py-2 bg-red-600 text-white rounded-full font-bold border-4 border-red-600 hover:border-dashed transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -16,7 +16,8 @@
|
|||
Users,
|
||||
ShoppingBag,
|
||||
Newspaper,
|
||||
PackageCheck
|
||||
PackageCheck,
|
||||
Compass
|
||||
} from '@lucide/svelte'
|
||||
import { logout, getUser, userScrapsStore } from '$lib/auth-client'
|
||||
|
||||
|
|
@ -222,6 +223,17 @@
|
|||
{:else}
|
||||
<!-- Dashboard nav for logged-in users - centered -->
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href="/explore"
|
||||
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 cursor-pointer {currentPath ===
|
||||
'/explore'
|
||||
? 'bg-black text-white border-black'
|
||||
: 'border-black hover:border-dashed'}"
|
||||
>
|
||||
<Compass size={18} />
|
||||
<span class="text-lg font-bold">explore</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 cursor-pointer {currentPath ===
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
ArrowRight,
|
||||
X,
|
||||
LayoutDashboard,
|
||||
Plus
|
||||
Plus,
|
||||
Layers
|
||||
} from '@lucide/svelte'
|
||||
import { API_URL } from '$lib/config'
|
||||
import { refreshUserScraps } from '$lib/auth-client'
|
||||
|
|
@ -123,6 +124,12 @@
|
|||
'when you\'re ready to ship, click "review & submit" to submit your project. once approved, you\'ll earn scraps based on your coding time!',
|
||||
highlight: 'submit-button'
|
||||
},
|
||||
{
|
||||
title: 'project tiers',
|
||||
description:
|
||||
'when submitting, you can select a tier (1-4) based on your project\'s complexity. higher tiers earn more scraps per hour.',
|
||||
highlight: null
|
||||
},
|
||||
{
|
||||
title: 'earn scraps',
|
||||
description:
|
||||
|
|
@ -400,8 +407,10 @@
|
|||
{:else if currentStep === 6}
|
||||
<ShoppingBag size={32} />
|
||||
{:else if currentStep === 7}
|
||||
<Flame size={32} />
|
||||
<Layers size={32} />
|
||||
{:else if currentStep === 8}
|
||||
<Flame size={32} />
|
||||
{:else if currentStep === 9}
|
||||
<Trophy size={32} />
|
||||
{:else}
|
||||
<Gift size={32} />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface Project {
|
|||
githubUrl: string | null
|
||||
hackatimeProject: string | null
|
||||
hours: number
|
||||
tier: number
|
||||
status: string
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +58,19 @@ export interface ProbabilityLeader {
|
|||
effectiveProbability: number
|
||||
}
|
||||
|
||||
export interface ViewsLeaderEntry {
|
||||
rank: number
|
||||
id: number
|
||||
name: string
|
||||
image: string | null
|
||||
views: number
|
||||
owner: {
|
||||
id: number
|
||||
username: string | null
|
||||
avatar: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: number
|
||||
title: string
|
||||
|
|
@ -64,6 +78,12 @@ export interface NewsItem {
|
|||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ErrorState {
|
||||
title?: string
|
||||
message: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
// Stores
|
||||
export const userStore = writable<User | null>(null)
|
||||
export const tutorialActiveStore = writable(false)
|
||||
|
|
@ -76,6 +96,8 @@ export const leaderboardStore = writable<{ hours: LeaderboardEntry[]; scraps: Le
|
|||
})
|
||||
export const newsStore = writable<NewsItem[]>([])
|
||||
export const probabilityLeadersStore = writable<ProbabilityLeader[]>([])
|
||||
export const viewsLeaderboardStore = writable<ViewsLeaderEntry[]>([])
|
||||
export const errorStore = writable<ErrorState | null>(null)
|
||||
|
||||
// Loading states
|
||||
export const projectsLoading = writable(true)
|
||||
|
|
@ -83,6 +105,7 @@ export const shopLoading = writable(true)
|
|||
export const leaderboardLoading = writable(true)
|
||||
export const newsLoading = writable(true)
|
||||
export const probabilityLeadersLoading = writable(true)
|
||||
export const viewsLeaderboardLoading = writable(true)
|
||||
|
||||
// Track if this is a fresh page load (refresh/external) vs SPA navigation
|
||||
let isInitialLoad = true
|
||||
|
|
@ -115,11 +138,13 @@ export function invalidateAllStores() {
|
|||
leaderboardStore.set({ hours: [], scraps: [] })
|
||||
newsStore.set([])
|
||||
probabilityLeadersStore.set([])
|
||||
viewsLeaderboardStore.set([])
|
||||
projectsLoading.set(true)
|
||||
shopLoading.set(true)
|
||||
leaderboardLoading.set(true)
|
||||
newsLoading.set(true)
|
||||
probabilityLeadersLoading.set(true)
|
||||
viewsLeaderboardLoading.set(true)
|
||||
}
|
||||
|
||||
// Fetch functions
|
||||
|
|
@ -242,6 +267,30 @@ export async function fetchProbabilityLeaders(force = false) {
|
|||
return []
|
||||
}
|
||||
|
||||
export async function fetchViewsLeaderboard(force = false) {
|
||||
if (!browser) return
|
||||
|
||||
const current = get(viewsLeaderboardStore)
|
||||
if (current.length > 0 && !force && !get(viewsLeaderboardLoading)) return current
|
||||
|
||||
viewsLeaderboardLoading.set(true)
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/leaderboard/views`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
viewsLeaderboardStore.set(data)
|
||||
return data
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch views leaderboard:', e)
|
||||
} finally {
|
||||
viewsLeaderboardLoading.set(false)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Background prefetch for common data
|
||||
export async function prefetchUserData() {
|
||||
if (!browser) return
|
||||
|
|
@ -275,3 +324,41 @@ export function updateShopItemHeart(itemId: number, hearted: boolean, heartCount
|
|||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Error helpers
|
||||
export function showError(error: ErrorState | string) {
|
||||
if (typeof error === 'string') {
|
||||
errorStore.set({ message: error })
|
||||
} else {
|
||||
errorStore.set(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearError() {
|
||||
errorStore.set(null)
|
||||
}
|
||||
|
||||
export async function handleApiError(response: Response, fallbackMessage = 'something went wrong') {
|
||||
let message = fallbackMessage
|
||||
let details: string | undefined
|
||||
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data.error) {
|
||||
message = data.error
|
||||
} else if (data.message) {
|
||||
message = data.message
|
||||
}
|
||||
} catch {
|
||||
if (response.status >= 500) {
|
||||
message = 'server error - please try again later'
|
||||
details = `status ${response.status}`
|
||||
}
|
||||
}
|
||||
|
||||
showError({
|
||||
title: response.status >= 500 ? 'server error' : 'error',
|
||||
message,
|
||||
details
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import Navbar from '$lib/components/Navbar.svelte'
|
||||
import Footer from '$lib/components/Footer.svelte'
|
||||
import Tutorial from '$lib/components/Tutorial.svelte'
|
||||
import ErrorModal from '$lib/components/ErrorModal.svelte'
|
||||
import { handleNavigation, prefetchUserData } from '$lib/stores'
|
||||
import { getUser, type User } from '$lib/auth-client'
|
||||
|
||||
|
|
@ -57,3 +58,5 @@
|
|||
{#if showTutorial}
|
||||
<Tutorial onComplete={handleTutorialComplete} />
|
||||
{/if}
|
||||
|
||||
<ErrorModal />
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@
|
|||
<div class="prose prose-lg">
|
||||
<p class="text-xl font-bold mb-4">tl;dr</p>
|
||||
|
||||
<p class="mb-2"><strong>ys:</strong> any project <Superscript number={3} tooltip="silly, nonsensical, or fun" /></p>
|
||||
<p class="mb-2"><strong>ys:</strong> any project <Superscript number={3} tooltip="optionally silly, nonsensical, or fun" /></p>
|
||||
|
||||
<p class="mb-6">
|
||||
<strong>ws:</strong> random items from hq<Superscript number={4} tooltip="(including stickers)" /> (more hours, more stuff)
|
||||
|
|
@ -237,7 +237,7 @@
|
|||
<p class="text-xl font-bold mb-4">but how, you may ask?</p>
|
||||
|
||||
<p class="mb-6">
|
||||
well, it's simple: you just ship any projects that are extra silly, nonsensical, or fun<Superscript number={8} tooltip="or literally any project" />, and
|
||||
well, it's simple: you just ship any projects that are slightly silly, nonsensical, or fun<Superscript number={8} tooltip="or literally any project" />, and
|
||||
you will earn scraps for the time you put in! track your time with <a
|
||||
href="https://hackatime.hackclub.com"
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -39,9 +39,13 @@
|
|||
status: string
|
||||
hours: number
|
||||
hoursOverride: number | null
|
||||
tier: number
|
||||
tierOverride: number | null
|
||||
deleted: number | null
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
|
|
@ -66,6 +70,7 @@
|
|||
let internalJustification = $state('')
|
||||
let userInternalNotes = $state('')
|
||||
let hoursOverride = $state<number | undefined>(undefined)
|
||||
let tierOverride = $state<number | undefined>(undefined)
|
||||
|
||||
let confirmAction = $state<'approved' | 'denied' | 'permanently_rejected' | null>(null)
|
||||
|
||||
|
|
@ -157,6 +162,7 @@
|
|||
feedbackForAuthor,
|
||||
internalJustification: internalJustification || undefined,
|
||||
hoursOverride: hoursOverride !== undefined ? hoursOverride : undefined,
|
||||
tierOverride: tierOverride !== undefined ? tierOverride : undefined,
|
||||
userInternalNotes: userInternalNotes || undefined
|
||||
})
|
||||
})
|
||||
|
|
@ -274,6 +280,7 @@
|
|||
{#if project.hackatimeProject}
|
||||
<span class="px-3 py-1 bg-gray-100 rounded-full font-bold border-2 border-black">hackatime: {project.hackatimeProject}</span>
|
||||
{/if}
|
||||
<span class="px-3 py-1 bg-gray-100 rounded-full font-bold border-2 border-black">tier {project.tier}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
|
|
@ -298,7 +305,7 @@
|
|||
href={project.playableUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 border-4 border-dashed border-black rounded-full font-bold hover:border-solid transition-all duration-200 cursor-pointer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 border-4 border-solid border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<Globe size={18} />
|
||||
<span>try it out</span>
|
||||
|
|
@ -420,6 +427,19 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-bold mb-1">tier override</label>
|
||||
<select
|
||||
bind:value={tierOverride}
|
||||
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed cursor-pointer"
|
||||
>
|
||||
<option value={undefined}>use user's tier (tier {project.tier})</option>
|
||||
{#each [1, 2, 3, 4] as tier}
|
||||
<option value={tier}>tier {tier}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-bold mb-1">internal justification</label>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -87,9 +87,14 @@
|
|||
<span class="font-bold text-lg truncate">{project.name}</span>
|
||||
<span class="text-gray-500 text-sm shrink-0">{formatHours(project.hours)}h</span>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full {project.status === 'shipped' ? 'bg-green-100' : project.status === 'waiting_for_review' ? 'bg-yellow-100' : 'bg-gray-100'}">
|
||||
{project.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100">
|
||||
tier {project.tier}
|
||||
</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full {project.status === 'shipped' ? 'bg-green-100' : project.status === 'waiting_for_review' ? 'bg-yellow-100' : 'bg-gray-100'}">
|
||||
{project.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
|
|||
236
frontend/src/routes/explore/+page.svelte
Normal file
236
frontend/src/routes/explore/+page.svelte
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Search, Eye, X } from '@lucide/svelte'
|
||||
import { API_URL } from '$lib/config'
|
||||
import { formatHours } from '$lib/utils'
|
||||
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte'
|
||||
|
||||
interface ExploreProject {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
image: string | null
|
||||
hours: number
|
||||
tier: number
|
||||
status: string
|
||||
views: number
|
||||
username: string | null
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
let projects = $state<ExploreProject[]>([])
|
||||
let pagination = $state<Pagination | null>(null)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let searchQuery = $state('')
|
||||
let selectedTier = $state<number | null>(null)
|
||||
let selectedStatus = $state<string | null>(null)
|
||||
let currentPage = $state(1)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const TIERS = [1, 2, 3, 4]
|
||||
const STATUSES = [
|
||||
{ value: 'shipped', label: 'shipped' },
|
||||
{ value: 'in_progress', label: 'in progress' }
|
||||
]
|
||||
|
||||
async function fetchProjects() {
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', currentPage.toString())
|
||||
params.set('limit', '20')
|
||||
if (searchQuery.trim()) params.set('search', searchQuery.trim())
|
||||
if (selectedTier) params.set('tier', selectedTier.toString())
|
||||
if (selectedStatus) params.set('status', selectedStatus)
|
||||
|
||||
const response = await fetch(`${API_URL}/projects/explore?${params}`, {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch projects')
|
||||
|
||||
const data = await response.json()
|
||||
projects = data.data
|
||||
pagination = data.pagination
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load projects'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchProjects()
|
||||
})
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1
|
||||
fetchProjects()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function toggleTier(tier: number) {
|
||||
selectedTier = selectedTier === tier ? null : tier
|
||||
currentPage = 1
|
||||
fetchProjects()
|
||||
}
|
||||
|
||||
function toggleStatus(status: string) {
|
||||
selectedStatus = selectedStatus === status ? null : status
|
||||
currentPage = 1
|
||||
fetchProjects()
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage = page
|
||||
fetchProjects()
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>explore - scraps</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
|
||||
<h1 class="text-4xl md:text-5xl font-bold mb-8">explore</h1>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
<div class="mb-8 space-y-4">
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search size={20} class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
oninput={handleSearch}
|
||||
placeholder="search projects..."
|
||||
class="w-full pl-12 pr-4 py-3 border-4 border-black rounded-full focus:outline-none focus:border-dashed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<!-- Tier filters -->
|
||||
<span class="text-sm font-bold self-center mr-2">tier:</span>
|
||||
{#each TIERS as tier}
|
||||
<button
|
||||
onclick={() => toggleTier(tier)}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {selectedTier === tier ? 'bg-black text-white' : 'hover:border-dashed'}"
|
||||
>
|
||||
{tier}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<span class="mx-4 border-l-2 border-gray-300 h-8"></span>
|
||||
|
||||
<!-- Status filters -->
|
||||
<span class="text-sm font-bold self-center mr-2">status:</span>
|
||||
{#each STATUSES as status}
|
||||
<button
|
||||
onclick={() => toggleStatus(status.value)}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {selectedStatus === status.value ? 'bg-black text-white' : 'hover:border-dashed'}"
|
||||
>
|
||||
{status.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if selectedTier || selectedStatus}
|
||||
<button
|
||||
onclick={() => { selectedTier = null; selectedStatus = null; currentPage = 1; fetchProjects(); }}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer hover:border-dashed flex items-center gap-2"
|
||||
>
|
||||
<X size={16} />
|
||||
clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-gray-500">loading projects...</div>
|
||||
{:else if error}
|
||||
<div class="text-center py-12 text-red-600">{error}</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="border-4 border-dashed border-gray-300 rounded-2xl p-12 text-center">
|
||||
<p class="text-gray-500 text-lg">no projects found</p>
|
||||
<p class="text-gray-400 text-sm mt-2">try adjusting your filters or search query</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each projects as project}
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="border-4 border-black rounded-2xl overflow-hidden hover:border-dashed transition-all duration-200 cursor-pointer flex flex-col bg-white"
|
||||
>
|
||||
<div class="h-40 overflow-hidden">
|
||||
{#if project.image}
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<ProjectPlaceholder seed={project.id} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-4 flex-1 flex flex-col">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="font-bold text-lg truncate">{project.name}</h3>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0 {project.status === 'shipped' ? 'bg-green-100' : 'bg-gray-100'}">
|
||||
{project.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 flex-1 line-clamp-2">{project.description}</p>
|
||||
<div class="mt-3 flex items-center justify-between text-sm">
|
||||
<span class="text-gray-500">by {project.username || 'anonymous'}</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-500">{formatHours(project.hours)}h</span>
|
||||
<span class="px-2 py-0.5 bg-gray-100 rounded-full text-xs">tier {project.tier}</span>
|
||||
<span class="flex items-center gap-1 text-gray-400">
|
||||
<Eye size={14} />
|
||||
{project.views}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if pagination && pagination.totalPages > 1}
|
||||
<div class="mt-8 flex justify-center gap-2">
|
||||
<button
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer hover:border-dashed disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
prev
|
||||
</button>
|
||||
<span class="px-4 py-2 font-bold self-center">
|
||||
{currentPage} / {pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === pagination.totalPages}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer hover:border-dashed disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
next
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -20,5 +20,21 @@
|
|||
html,
|
||||
body {
|
||||
font-family: 'PhantomSans', sans-serif;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: black transparent;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
body::-webkit-scrollbar-thumb {
|
||||
background-color: black;
|
||||
border-radius: 9999px;
|
||||
border: 4px solid transparent;
|
||||
border-right-width: 8px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,19 +7,25 @@
|
|||
fetchLeaderboard as fetchLeaderboardData,
|
||||
probabilityLeadersStore,
|
||||
probabilityLeadersLoading,
|
||||
fetchProbabilityLeaders
|
||||
fetchProbabilityLeaders,
|
||||
viewsLeaderboardStore,
|
||||
viewsLeaderboardLoading,
|
||||
fetchViewsLeaderboard
|
||||
} from '$lib/stores'
|
||||
import { formatHours } from '$lib/utils'
|
||||
|
||||
let activeTab = $state<'scraps' | 'hours' | 'probability'>('scraps')
|
||||
let sortBy = $derived(activeTab === 'probability' ? 'scraps' : activeTab)
|
||||
let activeTab = $state<'scraps' | 'hours' | 'probability' | 'views'>('scraps')
|
||||
let sortBy = $derived(activeTab === 'probability' || activeTab === 'views' ? 'scraps' : activeTab)
|
||||
let leaderboard = $derived($leaderboardStore[sortBy as 'scraps' | 'hours'])
|
||||
let probabilityLeaders = $derived($probabilityLeadersStore)
|
||||
let viewsLeaderboard = $derived($viewsLeaderboardStore)
|
||||
|
||||
function setActiveTab(value: 'scraps' | 'hours' | 'probability') {
|
||||
function setActiveTab(value: 'scraps' | 'hours' | 'probability' | 'views') {
|
||||
activeTab = value
|
||||
if (value === 'probability') {
|
||||
fetchProbabilityLeaders()
|
||||
} else if (value === 'views') {
|
||||
fetchViewsLeaderboard()
|
||||
} else {
|
||||
fetchLeaderboardData(value)
|
||||
}
|
||||
|
|
@ -64,9 +70,77 @@
|
|||
>
|
||||
probability leaders
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {activeTab === 'views'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
onclick={() => setActiveTab('views')}
|
||||
>
|
||||
most viewed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'probability'}
|
||||
{#if activeTab === 'views'}
|
||||
<div class="border-4 border-black rounded-2xl p-6">
|
||||
{#if $viewsLeaderboardLoading && viewsLeaderboard.length === 0}
|
||||
<div class="text-center text-gray-500">loading...</div>
|
||||
{:else if viewsLeaderboard.length === 0}
|
||||
<div class="text-center text-gray-500">no projects yet</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each viewsLeaderboard as project (project.id)}
|
||||
<a
|
||||
href="/projects/{project.id}"
|
||||
class="border-4 border-black rounded-2xl p-4 hover:border-dashed transition-all block"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-2xl font-bold">
|
||||
{#if project.rank === 1}
|
||||
🥇
|
||||
{:else if project.rank === 2}
|
||||
🥈
|
||||
{:else if project.rank === 3}
|
||||
🥉
|
||||
{:else}
|
||||
#{project.rank}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-bold text-xl truncate">{project.name}</span>
|
||||
</div>
|
||||
{#if project.image}
|
||||
<img
|
||||
src={project.image}
|
||||
alt={project.name}
|
||||
class="w-full h-32 rounded-lg object-cover border-2 border-black mb-3"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-32 rounded-lg bg-gray-200 border-2 border-black mb-3 flex items-center justify-center text-gray-400">
|
||||
no image
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center justify-between">
|
||||
{#if project.owner}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if project.owner.avatar}
|
||||
<img
|
||||
src={project.owner.avatar}
|
||||
alt={project.owner.username}
|
||||
class="w-6 h-6 rounded-full border-2 border-black"
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-sm text-gray-600">{project.owner.username}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-sm text-gray-400">unknown</span>
|
||||
{/if}
|
||||
<span class="font-bold">{project.views.toLocaleString()} views</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'probability'}
|
||||
<div class="border-4 border-black rounded-2xl p-6">
|
||||
{#if $probabilityLeadersLoading && probabilityLeaders.length === 0}
|
||||
<div class="text-center text-gray-500">loading...</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { ArrowLeft, Package, CheckCircle, Clock, Truck, MapPin, Origami } from '@lucide/svelte'
|
||||
import { ArrowLeft, Package, CheckCircle, Clock, Truck, MapPin, Origami, Spool } from '@lucide/svelte'
|
||||
import { API_URL } from '$lib/config'
|
||||
import { getUser } from '$lib/auth-client'
|
||||
|
||||
|
|
@ -218,8 +218,9 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-4 text-sm">
|
||||
<span class="text-gray-600">
|
||||
<span class="font-bold">{order.totalPrice}</span> scraps
|
||||
<span class="text-gray-600 font-bold flex items-center gap-1">
|
||||
<Spool size={16} />
|
||||
{order.totalPrice}
|
||||
</span>
|
||||
{#if order.quantity > 1}
|
||||
<span class="text-gray-600">qty: {order.quantity}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { ArrowLeft, Pencil, Send, Clock, CheckCircle, XCircle, AlertCircle, Github, AlertTriangle, PlaneTakeoff, Plus, Globe, Spool } from '@lucide/svelte'
|
||||
import { ArrowLeft, Pencil, Send, Clock, CheckCircle, XCircle, AlertCircle, Github, AlertTriangle, PlaneTakeoff, Plus, Globe, Spool, Eye } from '@lucide/svelte'
|
||||
import { getUser } from '$lib/auth-client'
|
||||
import { API_URL } from '$lib/config'
|
||||
import { formatHours } from '$lib/utils'
|
||||
|
|
@ -20,12 +20,16 @@
|
|||
hackatimeProject?: string | null
|
||||
hours: number
|
||||
hoursOverride?: number | null
|
||||
tier: number
|
||||
status: string
|
||||
scrapsAwarded: number
|
||||
views: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Owner {
|
||||
id: number
|
||||
username: string | null
|
||||
|
|
@ -185,7 +189,7 @@
|
|||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h1 class="text-3xl md:text-4xl font-bold">{project.name}</h1>
|
||||
{#if project.status === 'shipped'}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-bold border-2 bg-green-100 text-green-700 border-green-600 flex items-center gap-1 shrink-0">
|
||||
|
|
@ -204,6 +208,11 @@
|
|||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="px-3 py-1 rounded-full text-sm font-bold border-2 bg-gray-100 text-gray-700 border-gray-400">
|
||||
tier {project.tier}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if project.description}
|
||||
<p class="text-lg text-gray-700 mb-4">{project.description}</p>
|
||||
|
|
@ -211,10 +220,10 @@
|
|||
<p class="text-lg text-gray-400 italic mb-4">no description yet</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-3">
|
||||
<span class="px-4 py-2 bg-white rounded-full font-bold border-4 border-black flex items-center gap-2">
|
||||
<Clock size={18} />
|
||||
{formatHours(project.hours)}h
|
||||
<Eye size={18} />
|
||||
{project.views.toLocaleString()} views
|
||||
</span>
|
||||
{#if project.scrapsAwarded > 0}
|
||||
<span class="px-4 py-2 bg-green-100 text-green-700 rounded-full font-bold border-4 border-green-600 flex items-center gap-2">
|
||||
|
|
@ -222,6 +231,12 @@
|
|||
+{project.scrapsAwarded} scraps earned
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="px-4 py-2 bg-white rounded-full font-bold border-4 border-black flex items-center gap-2">
|
||||
<Clock size={18} />
|
||||
{formatHours(project.hours)}h
|
||||
</span>
|
||||
{#if project.githubUrl}
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
|
|
@ -243,7 +258,7 @@
|
|||
href={project.playableUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-4 py-2 border-4 border-dashed border-black rounded-full font-bold hover:border-solid transition-all duration-200 cursor-pointer flex items-center gap-2"
|
||||
class="px-4 py-2 border-4 border-solid border-black rounded-full font-bold hover:border-dashed transition-all duration-200 cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Globe size={18} />
|
||||
try it out
|
||||
|
|
|
|||
|
|
@ -19,9 +19,17 @@
|
|||
playableUrl: string | null
|
||||
hackatimeProject: string | null
|
||||
hours: number
|
||||
tier: number
|
||||
status: string
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{ value: 1, description: 'simple projects, tutorials, small scripts' },
|
||||
{ value: 2, description: 'moderate complexity, multi-file projects' },
|
||||
{ value: 3, description: 'complex features, APIs, integrations' },
|
||||
{ value: 4, description: 'full applications, major undertakings' }
|
||||
]
|
||||
|
||||
interface HackatimeProject {
|
||||
name: string
|
||||
hours: number
|
||||
|
|
@ -42,6 +50,7 @@
|
|||
let selectedHackatimeName = $state<string | null>(null)
|
||||
let loadingProjects = $state(false)
|
||||
let showDropdown = $state(false)
|
||||
let selectedTier = $state(1)
|
||||
|
||||
const NAME_MAX = 50
|
||||
const DESC_MIN = 20
|
||||
|
|
@ -71,6 +80,7 @@
|
|||
}
|
||||
project = responseData.project
|
||||
imagePreview = project?.image || null
|
||||
selectedTier = project?.tier || 1
|
||||
if (project?.hackatimeProject) {
|
||||
const parts = project.hackatimeProject.split('/')
|
||||
selectedHackatimeName = parts.length > 1 ? parts.slice(1).join('/') : parts[0]
|
||||
|
|
@ -180,7 +190,8 @@
|
|||
image: project.image,
|
||||
githubUrl: project.githubUrl,
|
||||
playableUrl: project.playableUrl,
|
||||
hackatimeProject: hackatimeValue
|
||||
hackatimeProject: hackatimeValue,
|
||||
tier: selectedTier
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -378,20 +389,37 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier Selector -->
|
||||
<div>
|
||||
<label class="block text-sm font-bold mb-2">project tier</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each TIERS as tier}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTier = tier.value)}
|
||||
class="px-3 py-2 border-2 border-black rounded-lg font-bold transition-all duration-200 cursor-pointer text-left {selectedTier === tier.value ? 'bg-black text-white' : 'hover:border-dashed'}"
|
||||
>
|
||||
<span>tier {tier.value}</span>
|
||||
<p class="text-xs mt-1 {selectedTier === tier.value ? 'text-gray-300' : 'text-gray-500'}">{tier.description}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-4 mt-8">
|
||||
<a
|
||||
href="/projects/{data.id}"
|
||||
class="flex-1 px-4 py-3 border-4 border-black rounded-full font-bold text-center hover:border-dashed transition-all duration-200 cursor-pointer"
|
||||
class="w-1/2 px-4 py-3 border-4 border-black rounded-full font-bold text-center hover:border-dashed transition-all duration-200 cursor-pointer flex items-center justify-center"
|
||||
>
|
||||
cancel
|
||||
</a>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={saving || !canSave}
|
||||
class="flex-1 px-4 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer"
|
||||
class="w-1/2 px-4 py-3 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all duration-200 disabled:opacity-50 flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Save size={18} />
|
||||
{saving ? 'saving...' : 'save changes'}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,16 @@
|
|||
hackatimeProject: string | null
|
||||
hours: number
|
||||
status: string
|
||||
tier: number
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{ value: 1, description: 'simple projects, tutorials, small scripts' },
|
||||
{ value: 2, description: 'moderate complexity, multi-file projects' },
|
||||
{ value: 3, description: 'complex features, APIs, integrations' },
|
||||
{ value: 4, description: 'full applications, major undertakings' }
|
||||
]
|
||||
|
||||
interface HackatimeProject {
|
||||
name: string
|
||||
hours: number
|
||||
|
|
@ -37,6 +45,7 @@
|
|||
let selectedHackatimeName = $state<string | null>(null)
|
||||
let loadingProjects = $state(false)
|
||||
let showDropdown = $state(false)
|
||||
let selectedTier = $state(1)
|
||||
|
||||
const NAME_MAX = 50
|
||||
const DESC_MIN = 20
|
||||
|
|
@ -76,6 +85,7 @@
|
|||
const parts = project.hackatimeProject.split('/')
|
||||
selectedHackatimeName = parts.length > 1 ? parts.slice(1).join('/') : parts[0]
|
||||
}
|
||||
selectedTier = project?.tier ?? 1
|
||||
fetchHackatimeProjects()
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load project'
|
||||
|
|
@ -137,7 +147,8 @@
|
|||
image: project.image,
|
||||
githubUrl: project.githubUrl,
|
||||
playableUrl: project.playableUrl,
|
||||
hackatimeProject: hackatimeValue
|
||||
hackatimeProject: hackatimeValue,
|
||||
tier: selectedTier
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -305,6 +316,26 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier Selection -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold mb-2">project tier <span class="text-red-500">*</span></label>
|
||||
<p class="text-xs text-gray-500 mb-3">select the complexity tier that best matches your project</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each TIERS as tier}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selectedTier = tier.value)}
|
||||
class="px-4 py-3 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer text-left {selectedTier === tier.value ? 'bg-black text-white' : 'hover:border-dashed'}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>tier {tier.value}</span>
|
||||
</div>
|
||||
<p class="text-xs mt-1 {selectedTier === tier.value ? 'text-gray-300' : 'text-gray-500'}">{tier.description}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requirements Checklist -->
|
||||
<div class="border-2 border-black rounded-lg p-4 mb-6">
|
||||
<p class="font-bold mb-3">requirements checklist</p>
|
||||
|
|
|
|||
|
|
@ -171,61 +171,60 @@
|
|||
<p class="text-lg text-gray-600 mb-8">items up for grabs</p>
|
||||
|
||||
<!-- Filters & Sort -->
|
||||
<div class="flex flex-col md:flex-row gap-4 mb-8 md:items-start justify-between">
|
||||
<div class="flex flex-wrap gap-2 items-center mb-8">
|
||||
<!-- Category Filter -->
|
||||
<div class="flex gap-2 flex-wrap items-center flex-1">
|
||||
{#each categories as category}
|
||||
<button
|
||||
onclick={() => toggleCategory(category)}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {selectedCategories.has(category)
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
{#if selectedCategories.size > 0}
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer hover:border-dashed flex items-center gap-2"
|
||||
>
|
||||
<X size={16} />
|
||||
clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm font-bold self-center mr-2">tags:</span>
|
||||
{#each categories as category}
|
||||
<button
|
||||
onclick={() => toggleCategory(category)}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {selectedCategories.has(category)
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
{/each}
|
||||
{#if selectedCategories.size > 0}
|
||||
<button
|
||||
onclick={clearFilters}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer hover:border-dashed flex items-center gap-2"
|
||||
>
|
||||
<X size={16} />
|
||||
clear
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span class="mx-4 border-l-2 border-gray-300 h-8"></span>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<div class="flex gap-2 items-center shrink-0">
|
||||
<span class="font-bold">sort:</span>
|
||||
<button
|
||||
onclick={() => (sortBy = 'default')}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
|
||||
'default'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
default
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sortBy = 'favorites')}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
|
||||
'favorites'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
favorites
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sortBy = 'probability')}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
|
||||
'probability'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
probability
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-sm font-bold self-center mr-2">sort:</span>
|
||||
<button
|
||||
onclick={() => (sortBy = 'default')}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
|
||||
'default'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
default
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sortBy = 'favorites')}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
|
||||
'favorites'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
favorites
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (sortBy = 'probability')}
|
||||
class="px-4 py-2 border-4 border-black rounded-full font-bold transition-all duration-200 cursor-pointer {sortBy ===
|
||||
'probability'
|
||||
? 'bg-black text-white'
|
||||
: 'hover:border-dashed'}"
|
||||
>
|
||||
probability
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue