add instuff

This commit is contained in:
Nathan 2026-02-04 12:17:02 -05:00
parent 73272ffbe3
commit 32fd592d1f
23 changed files with 1496 additions and 586 deletions

1138
backend/dist/index.js vendored

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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
}

View file

@ -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({

View file

@ -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)))

View file

@ -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()

View file

@ -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>

View 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}

View file

@ -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 ===

View file

@ -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} />

View file

@ -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
})
}

View file

@ -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 />

View file

@ -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"

View file

@ -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

View file

@ -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}

View 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>

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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'}

View file

@ -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>

View file

@ -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 -->