Merge pull request #1 from hackclub/i8n

add spanish
This commit is contained in:
Nathan 2026-02-05 18:09:53 -05:00 committed by GitHub
commit 91c49cf2ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2198 additions and 735 deletions

50
backend/dist/index.js vendored
View file

@ -28572,6 +28572,7 @@ projects.get("/explore", async ({ query }) => {
const search = query.search?.trim() || "";
const tier = query.tier ? parseInt(query.tier) : null;
const status2 = query.status || null;
const sortBy = query.sortBy || "default";
const conditions = [
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
or(eq(projectsTable.status, "shipped"), eq(projectsTable.status, "in_progress"))
@ -28586,6 +28587,14 @@ projects.get("/explore", async ({ query }) => {
conditions[1] = eq(projectsTable.status, status2);
}
const whereClause = and(...conditions);
let orderClause;
if (sortBy === "views") {
orderClause = desc(projectsTable.views);
} else if (sortBy === "random") {
orderClause = sql`RANDOM()`;
} else {
orderClause = desc(projectsTable.updatedAt);
}
const [projectsList, countResult] = await Promise.all([
db.select({
id: projectsTable.id,
@ -28597,7 +28606,7 @@ projects.get("/explore", async ({ query }) => {
status: projectsTable.status,
views: projectsTable.views,
userId: projectsTable.userId
}).from(projectsTable).where(whereClause).orderBy(desc(projectsTable.updatedAt)).limit(limit).offset(offset),
}).from(projectsTable).where(whereClause).orderBy(orderClause).limit(limit).offset(offset),
db.select({ count: sql`count(*)` }).from(projectsTable).where(whereClause)
]);
const userIds = [...new Set(projectsList.map((p) => p.userId))];
@ -29113,7 +29122,7 @@ authRoutes.get("/me", async ({ headers }) => {
await db.insert(userBonusesTable).values({
userId: user.id,
reason: "tutorial_completion",
amount: 10
amount: 5
});
console.log("[AUTH] Auto-awarded tutorial bonus for user:", user.id);
}
@ -29189,9 +29198,9 @@ user.post("/complete-tutorial", async ({ headers }) => {
await db.insert(userBonusesTable).values({
userId: userData.id,
reason: "tutorial_completion",
amount: 10
amount: 5
});
return { success: true, bonusAwarded: 10 };
return { success: true, bonusAwarded: 5 };
});
user.get("/profile/:id", async ({ params, headers }) => {
const currentUser = await getUserFromSession(headers);
@ -29201,6 +29210,7 @@ user.get("/profile/:id", async ({ params, headers }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
role: usersTable.role,
createdAt: usersTable.createdAt
}).from(usersTable).where(eq(usersTable.id, parseInt(params.id))).limit(1);
if (!targetUser[0])
@ -29236,9 +29246,11 @@ user.get("/profile/:id", async ({ params, headers }) => {
id: targetUser[0].id,
username: targetUser[0].username,
avatar: targetUser[0].avatar,
role: targetUser[0].role,
scraps: scrapsBalance.balance,
createdAt: targetUser[0].createdAt
},
isAdmin: currentUser.role === "admin",
projects: visibleProjects.map((p) => ({
id: p.id,
name: p.name,
@ -29285,8 +29297,9 @@ shop.get("/items", async ({ headers }) => {
boostAmount: shopItemsTable.boostAmount,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as("heart_count")
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = shop_items.id)`.as("heart_count")
}).from(shopItemsTable);
items.forEach((item) => console.log(item.name + " " + item.heartCount));
if (user2) {
const userHearts = await db.select({ shopItemId: shopHeartsTable.shopItemId }).from(shopHeartsTable).where(eq(shopHeartsTable.userId, user2.id));
const userBoosts = await db.select({
@ -29324,6 +29337,7 @@ shop.get("/items", async ({ headers }) => {
heartCount: Number(item.heartCount) || 0,
userBoostPercent: 0,
upgradeCount: 0,
adjustedBaseProbability: item.baseProbability,
effectiveProbability: Math.min(item.baseProbability, 100),
userHearted: false,
nextUpgradeCost: item.baseUpgradeCost
@ -29346,7 +29360,7 @@ shop.get("/items/:id", async ({ params, headers }) => {
boostAmount: shopItemsTable.boostAmount,
createdAt: shopItemsTable.createdAt,
updatedAt: shopItemsTable.updatedAt,
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = ${shopItemsTable.id})`.as("heart_count")
heartCount: sql`(SELECT COUNT(*) FROM shop_hearts WHERE shop_item_id = shop_items.id)`.as("heart_count")
}).from(shopItemsTable).where(eq(shopItemsTable.id, itemId)).limit(1);
if (items.length === 0) {
return { error: "Item not found" };
@ -29817,11 +29831,11 @@ leaderboard.get("/", async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as("scraps_earned"),
scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as("scraps_earned"),
scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as("scraps_spent"),
hours: sql`COALESCE(SUM(${projectsTable.hours}), 0)`.as("total_hours"),
projectCount: sql`COUNT(${projectsTable.id})`.as("project_count")
}).from(usersTable).leftJoin(projectsTable, and(eq(projectsTable.userId, usersTable.id), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)))).groupBy(usersTable.id).orderBy(desc(sql`total_hours`)).limit(10);
}).from(usersTable).leftJoin(projectsTable, and(eq(projectsTable.userId, usersTable.id), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), sql`${projectsTable.status} != 'permanently_rejected'`)).groupBy(usersTable.id).orderBy(desc(sql`total_hours`)).limit(10);
return results2.map((user2, index) => ({
rank: index + 1,
id: user2.id,
@ -29837,11 +29851,11 @@ leaderboard.get("/", async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as("scraps_earned"),
scrapsEarned: sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as("scraps_earned"),
scrapsSpent: sql`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as("scraps_spent"),
hours: sql`COALESCE(SUM(${projectsTable.hours}), 0)`.as("total_hours"),
projectCount: sql`COUNT(${projectsTable.id})`.as("project_count")
}).from(usersTable).leftJoin(projectsTable, and(eq(projectsTable.userId, usersTable.id), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)))).groupBy(usersTable.id).orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`)).limit(10);
}).from(usersTable).leftJoin(projectsTable, and(eq(projectsTable.userId, usersTable.id), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)), sql`${projectsTable.status} != 'permanently_rejected'`)).groupBy(usersTable.id).orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`)).limit(10);
return results.map((user2, index) => ({
rank: index + 1,
id: user2.id,
@ -30074,20 +30088,30 @@ admin.get("/stats", async ({ headers, status: status2 }) => {
if (!user2) {
return status2(401, { error: "Unauthorized" });
}
const [usersCount, projectsCount, totalHoursResult] = await Promise.all([
const [usersCount, projectsCount, totalHoursResult, pendingHoursResult, inProgressHoursResult] = await Promise.all([
db.select({ count: sql`count(*)` }).from(usersTable),
db.select({ count: sql`count(*)` }).from(projectsTable).where(or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)),
db.select({ total: sql`COALESCE(SUM(COALESCE(${projectsTable.hoursOverride}, ${projectsTable.hours})), 0)` }).from(projectsTable).where(and(eq(projectsTable.status, "shipped"), or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)))
db.select({ total: sql`COALESCE(SUM(COALESCE(${projectsTable.hoursOverride}, ${projectsTable.hours})), 0)` }).from(projectsTable).where(and(eq(projectsTable.status, "shipped"), or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`))),
db.select({ total: sql`COALESCE(SUM(COALESCE(${projectsTable.hoursOverride}, ${projectsTable.hours})), 0)` }).from(projectsTable).where(and(eq(projectsTable.status, "waiting_for_review"), or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`))),
db.select({ total: sql`COALESCE(SUM(COALESCE(${projectsTable.hoursOverride}, ${projectsTable.hours})), 0)` }).from(projectsTable).where(and(eq(projectsTable.status, "in_progress"), or(eq(projectsTable.deleted, 0), sql`${projectsTable.deleted} IS NULL`)))
]);
const totalUsers = Number(usersCount[0]?.count || 0);
const totalProjects = Number(projectsCount[0]?.count || 0);
const totalHours = Number(totalHoursResult[0]?.total || 0);
const pendingHours = Number(pendingHoursResult[0]?.total || 0);
const inProgressHours = Number(inProgressHoursResult[0]?.total || 0);
const weightedGrants = Math.round(totalHours / 10 * 100) / 100;
const pendingWeightedGrants = Math.round(pendingHours / 10 * 100) / 100;
const inProgressWeightedGrants = Math.round(inProgressHours / 10 * 100) / 100;
return {
totalUsers,
totalProjects,
totalHours: Math.round(totalHours * 10) / 10,
weightedGrants
weightedGrants,
pendingHours: Math.round(pendingHours * 10) / 10,
pendingWeightedGrants,
inProgressHours: Math.round(inProgressHours * 10) / 10,
inProgressWeightedGrants
};
});
admin.get("/users", async ({ headers, query, status: status2 }) => {

View file

@ -16,7 +16,7 @@ leaderboard.get('/', async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
@ -24,7 +24,8 @@ leaderboard.get('/', async ({ query }) => {
.from(usersTable)
.leftJoin(projectsTable, and(
eq(projectsTable.userId, usersTable.id),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted))
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.status} != 'permanently_rejected'`
))
.groupBy(usersTable.id)
.orderBy(desc(sql`total_hours`))
@ -47,7 +48,7 @@ leaderboard.get('/', async ({ query }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0)`.as('scraps_earned'),
scrapsEarned: sql<number>`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0)`.as('scraps_earned'),
scrapsSpent: sql<number>`COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`.as('scraps_spent'),
hours: sql<number>`COALESCE(SUM(${projectsTable.hours}), 0)`.as('total_hours'),
projectCount: sql<number>`COUNT(${projectsTable.id})`.as('project_count')
@ -55,10 +56,11 @@ leaderboard.get('/', async ({ query }) => {
.from(usersTable)
.leftJoin(projectsTable, and(
eq(projectsTable.userId, usersTable.id),
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted))
or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)),
sql`${projectsTable.status} != 'permanently_rejected'`
))
.groupBy(usersTable.id)
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id}), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`))
.orderBy(desc(sql`COALESCE((SELECT SUM(scraps_awarded) FROM projects WHERE user_id = ${usersTable.id} AND status != 'permanently_rejected'), 0) - COALESCE((SELECT SUM(total_price) FROM shop_orders WHERE user_id = ${usersTable.id}), 0)`))
.limit(10)
return results.map((user, index) => ({

View file

@ -92,6 +92,7 @@ user.get('/profile/:id', async ({ params, headers }) => {
id: usersTable.id,
username: usersTable.username,
avatar: usersTable.avatar,
role: usersTable.role,
createdAt: usersTable.createdAt
})
.from(usersTable)
@ -158,9 +159,11 @@ user.get('/profile/:id', async ({ params, headers }) => {
id: targetUser[0].id,
username: targetUser[0].username,
avatar: targetUser[0].avatar,
role: targetUser[0].role,
scraps: scrapsBalance.balance,
createdAt: targetUser[0].createdAt
},
isAdmin: currentUser.role === 'admin',
projects: visibleProjects.map(p => ({
id: p.id,
name: p.name,

View file

@ -2,6 +2,7 @@
import { ChevronDown, ExternalLink } from '@lucide/svelte';
import { API_URL } from '$lib/config';
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
interface Address {
id: string;
@ -73,7 +74,7 @@
function getSelectedAddressLabel(): string {
const addr = addresses.find((a) => a.id === selectedAddressId);
if (addr) return `${addr.first_name} ${addr.last_name}, ${addr.city}`;
return 'select an address';
return $t.address.selectAnAddress;
}
async function handleSubmit() {
@ -106,13 +107,13 @@
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to save address');
throw new Error(data.message || $t.address.failedToSaveAddress);
}
onComplete();
onClose();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save address';
error = e instanceof Error ? e.message : $t.address.failedToSaveAddress;
} finally {
loading = false;
}
@ -128,17 +129,17 @@
class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border-4 border-black bg-white p-6"
>
<div class="mb-6">
<h2 class="text-2xl font-bold">shipping address</h2>
<h2 class="text-2xl font-bold">{$t.address.shippingAddress}</h2>
</div>
{#if header}
{@render header()}
{:else}
<div class="mb-6 rounded-lg border-2 border-black bg-gray-50 p-4">
<p class="text-lg font-bold">🎉 congratulations!</p>
<p class="text-lg font-bold">{$t.address.congratulations}</p>
<p class="mt-1 text-gray-600">
you won <span class="font-bold">{itemName}</span>! select your shipping address to receive
it.
{$t.address.youWon} <span class="font-bold">{itemName}</span>! {$t.address
.selectShippingAddress}
</p>
</div>
{/if}
@ -151,10 +152,10 @@
<div class="space-y-4">
{#if loadingAddresses}
<div class="py-4 text-center text-gray-500">loading addresses...</div>
<div class="py-4 text-center text-gray-500">{$t.address.loadingAddresses}</div>
{:else if addresses.length > 0}
<div>
<label class="mb-1 block text-sm font-bold">your addresses</label>
<label class="mb-1 block text-sm font-bold">{$t.address.yourAddresses}</label>
<div class="relative">
<button
type="button"
@ -186,7 +187,8 @@
<span class="font-medium"
>{addr.first_name}
{addr.last_name}
{#if addr.primary}<span class="text-xs text-gray-500">(primary)</span
{#if addr.primary}<span class="text-xs text-gray-500"
>({$t.address.primary})</span
>{/if}</span
>
<span class="block text-sm text-gray-500">{addr.line_1}, {addr.city}</span>
@ -199,7 +201,7 @@
{#if selectedAddress}
<div class="rounded-lg border-2 border-black bg-gray-50 p-4">
<p class="mb-2 text-sm font-bold">selected address:</p>
<p class="mb-2 text-sm font-bold">{$t.address.selectedAddress}</p>
<p class="text-sm">{selectedAddress.first_name} {selectedAddress.last_name}</p>
<p class="text-sm">{selectedAddress.line_1}</p>
{#if selectedAddress.line_2}
@ -223,11 +225,11 @@
class="inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-black"
>
<ExternalLink size={14} />
manage addresses on hack club auth
{$t.address.manageAddresses}
</a>
{:else}
<div class="py-6 text-center">
<p class="mb-4 text-gray-600">you don't have any saved addresses yet.</p>
<p class="mb-4 text-gray-600">{$t.address.noSavedAddresses}</p>
<a
href="https://auth.hackclub.com"
target="_blank"
@ -235,10 +237,10 @@
class="inline-flex cursor-pointer items-center gap-2 rounded-full bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800"
>
<ExternalLink size={16} />
add an address on hack club
{$t.address.addAddress}
</a>
<p class="mt-4 text-sm text-gray-500">
after adding an address, refresh this page to select it.
{$t.address.afterAddingAddress}
</p>
</div>
{/if}
@ -251,7 +253,7 @@
disabled={loading || !canSubmit}
class="w-full cursor-pointer rounded-full bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'saving...' : 'confirm shipping address'}
{loading ? $t.common.saving : $t.address.confirmShippingAddress}
</button>
</div>
{/if}

View file

@ -1,9 +1,11 @@
<script lang="ts">
import { t } from '$lib/i18n';
let {
title,
message,
confirmText = 'confirm',
cancelText = 'cancel',
confirmText = $t.common.confirm,
cancelText = $t.common.cancel,
confirmStyle = 'primary',
loading = false,
onConfirm,
@ -62,7 +64,7 @@
{cancelText}
</button>
<button onclick={onConfirm} disabled={loading} class={getConfirmClass()}>
{loading ? 'loading...' : confirmText}
{loading ? $t.common.loading : confirmText}
</button>
</div>
</div>

View file

@ -4,6 +4,7 @@
import { formatHours } from '$lib/utils';
import { tutorialProjectIdStore } from '$lib/stores';
import { goto } from '$app/navigation';
import { t } from '$lib/i18n';
interface Project {
id: number;
@ -61,10 +62,10 @@
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' }
{ value: 1, description: $t.createProject.tierDescriptions.tier1 },
{ value: 2, description: $t.createProject.tierDescriptions.tier2 },
{ value: 3, description: $t.createProject.tierDescriptions.tier3 },
{ value: 4, description: $t.createProject.tierDescriptions.tier4 }
];
const NAME_MAX = 50;
@ -109,7 +110,7 @@
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
error = 'Image must be less than 5MB';
error = $t.createProject.imageMustBeLessThan;
return;
}
@ -134,7 +135,7 @@
imageUrl = data.url;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to upload image';
error = e instanceof Error ? e.message : $t.createProject.failedToCreateProject;
imagePreview = null;
} finally {
uploadingImage = false;
@ -169,7 +170,7 @@
async function handleSubmit() {
if (!allRequirementsMet) {
error = 'Please complete all requirements';
error = $t.createProject.pleaseCompleteRequirements;
return;
}
@ -201,7 +202,7 @@
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to create project');
throw new Error(data.message || $t.createProject.failedToCreateProject);
}
const newProject = await response.json();
@ -215,7 +216,7 @@
onCreated(newProject);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create project';
error = e instanceof Error ? e.message : $t.createProject.failedToCreateProject;
} finally {
loading = false;
}
@ -251,7 +252,7 @@
data-tutorial="create-project-modal"
>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold">new project</h2>
<h2 class="text-2xl font-bold">{$t.createProject.newProject}</h2>
{#if !tutorialMode}
<button
onclick={handleClose}
@ -272,7 +273,8 @@
<!-- Image Upload -->
<div>
<label class="mb-1 block text-sm font-bold"
>image <span class="text-gray-400">(optional)</span></label
>{$t.createProject.image}
<span class="text-gray-400">({$t.createProject.optional})</span></label
>
{#if imagePreview}
<div class="relative h-40 w-full overflow-hidden rounded-lg border-2 border-black">
@ -283,7 +285,7 @@
/>
{#if uploadingImage}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<span class="font-bold text-white">uploading...</span>
<span class="font-bold text-white">{$t.createProject.uploading}</span>
</div>
{:else}
<button
@ -300,7 +302,7 @@
class="flex h-32 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-black transition-colors hover:bg-gray-50"
>
<Upload size={32} class="mb-2 text-gray-400" />
<span class="text-sm text-gray-500">click to upload image</span>
<span class="text-sm text-gray-500">{$t.createProject.clickToUploadImage}</span>
<input type="file" accept="image/*" onchange={handleImageUpload} class="hidden" />
</label>
{/if}
@ -309,7 +311,7 @@
<!-- Name -->
<div>
<label for="name" class="mb-1 block text-sm font-bold"
>name <span class="text-red-500">*</span></label
>{$t.createProject.name} <span class="text-red-500">*</span></label
>
<input
id="name"
@ -325,7 +327,7 @@
<!-- Description -->
<div>
<label for="description" class="mb-1 block text-sm font-bold"
>description <span class="text-red-500">*</span></label
>{$t.createProject.description} <span class="text-red-500">*</span></label
>
<textarea
id="description"
@ -347,7 +349,8 @@
<!-- Hackatime Project Dropdown -->
<div>
<label class="mb-1 block text-sm font-bold"
>hackatime project <span class="text-gray-400">(optional)</span></label
>{$t.createProject.hackatimeProject}
<span class="text-gray-400">({$t.createProject.optional})</span></label
>
<div class="relative">
<button
@ -356,7 +359,7 @@
class="flex w-full items-center justify-between rounded-lg border-2 border-black px-4 py-2 text-left focus:border-dashed focus:outline-none"
>
{#if loadingProjects}
<span class="text-gray-500">loading projects...</span>
<span class="text-gray-500">{$t.createProject.loadingProjects}</span>
{:else if selectedHackatimeProject}
<span
>{selectedHackatimeProject.name}
@ -364,7 +367,7 @@
></span
>
{:else}
<span class="text-gray-500">select a project</span>
<span class="text-gray-500">{$t.createProject.selectAProject}</span>
{/if}
<ChevronDown
size={20}
@ -377,7 +380,9 @@
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border-2 border-black bg-white"
>
{#if hackatimeProjects.length === 0}
<div class="px-4 py-2 text-sm text-gray-500">no projects found</div>
<div class="px-4 py-2 text-sm text-gray-500">
{$t.createProject.noProjectsFound}
</div>
{:else}
{#each hackatimeProjects as project}
<button
@ -398,7 +403,8 @@
<!-- GitHub URL (optional) -->
<div>
<label for="githubUrl" class="mb-1 block text-sm font-bold"
>github url <span class="text-gray-400">(optional)</span></label
>{$t.createProject.githubUrl}
<span class="text-gray-400">({$t.createProject.optional})</span></label
>
<input
id="githubUrl"
@ -411,7 +417,7 @@
<!-- Tier Selector -->
<div>
<label class="mb-1 block text-sm font-bold">project tier</label>
<label class="mb-1 block text-sm font-bold">{$t.createProject.projectTier}</label>
<div class="grid grid-cols-2 gap-2">
{#each TIERS as tier}
<button
@ -422,7 +428,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
<span>tier {tier.value}</span>
<span>{$t.dashboard.tier} {tier.value}</span>
<p
class="mt-1 text-xs {selectedTier === tier.value
? 'text-gray-300'
@ -437,7 +443,7 @@
<!-- Requirements Checklist -->
<div class="rounded-lg border-2 border-black p-4">
<p class="mb-3 font-bold">requirements</p>
<p class="mb-3 font-bold">{$t.createProject.requirements}</p>
<ul class="space-y-2">
<li class="flex items-center gap-2 text-sm">
<span
@ -447,7 +453,7 @@
>
{#if hasName}<Check size={12} />{/if}
</span>
<span class={hasName ? '' : 'text-gray-500'}>add a project name</span>
<span class={hasName ? '' : 'text-gray-500'}>{$t.createProject.addProjectName}</span>
</li>
<li class="flex items-center gap-2 text-sm">
<span
@ -458,7 +464,7 @@
{#if hasDescription}<Check size={12} />{/if}
</span>
<span class={hasDescription ? '' : 'text-gray-500'}
>write a description (min {DESC_MIN} chars)</span
>{$t.createProject.writeDescription.replace('{min}', String(DESC_MIN))}</span
>
</li>
</ul>
@ -471,14 +477,14 @@
disabled={loading || tutorialMode}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={handleSubmit}
disabled={loading || !allRequirementsMet}
class="flex-1 cursor-pointer rounded-full bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
{loading ? 'creating...' : 'create'}
{loading ? $t.common.creating : $t.common.create}
</button>
</div>
</div>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { X, AlertTriangle } from '@lucide/svelte';
import { errorStore, clearError } from '$lib/stores';
import { t } from '$lib/i18n';
let error = $derived($errorStore);
</script>
@ -19,12 +20,12 @@
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-red-100">
<AlertTriangle size={24} class="text-red-600" />
</div>
<h2 class="text-2xl font-bold text-red-600">{error.title || 'error'}</h2>
<h2 class="text-2xl font-bold text-red-600">{error.title || $t.common.error}</h2>
</div>
<button
onclick={clearError}
class="cursor-pointer rounded-lg p-2 transition-colors hover:bg-gray-100"
aria-label="Close"
aria-label={$t.common.close}
>
<X size={20} />
</button>
@ -41,7 +42,7 @@
onclick={clearError}
class="w-full cursor-pointer rounded-full border-4 border-red-600 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
dismiss
{$t.common.dismiss}
</button>
</div>
</div>

View file

@ -1,14 +1,13 @@
<script lang="ts">
import { t } from '$lib/i18n';
</script>
<footer class="w-full py-4 text-center text-sm text-gray-600">
made with &lt;3 by <a
href="https://hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="hover:underline">hack club</a
{$t.footer.madeWith}
<a href="https://hackclub.com" target="_blank" rel="noopener noreferrer" class="hover:underline"
>{$t.footer.hackClub}</a
>
and
{$t.footer.and}
<a
href="https://github.com/notaroomba"
target="_blank"

View file

@ -20,9 +20,12 @@
Compass,
BarChart3,
Menu,
X
X,
Globe,
Languages
} from '@lucide/svelte';
import { logout, getUser, userScrapsStore } from '$lib/auth-client';
import { t, locale, setLocale, type Locale } from '$lib/i18n';
interface User {
id: number;
@ -123,6 +126,11 @@
function handleMobileNavClick() {
showMobileMenu = false;
}
function toggleLanguage() {
const newLocale: Locale = $locale === 'en' ? 'es' : 'en';
setLocale(newLocale);
}
</script>
<svelte:window
@ -153,7 +161,7 @@
: 'border-black hover:border-dashed'}"
>
<Home size={18} />
<span class="text-lg font-bold">home</span>
<span class="text-lg font-bold">{$t.nav.home}</span>
</button>
<button
onclick={() => scrollToSection('scraps')}
@ -163,7 +171,7 @@
: 'border-black hover:border-dashed'}"
>
<Package size={18} />
<span class="text-lg font-bold">scraps</span>
<span class="text-lg font-bold">{$t.nav.scraps}</span>
</button>
<button
onclick={() => scrollToSection('about')}
@ -173,7 +181,15 @@
: 'border-black hover:border-dashed'}"
>
<Info size={18} />
<span class="text-lg font-bold">about</span>
<span class="text-lg font-bold">{$t.nav.about}</span>
</button>
<button
onclick={toggleLanguage}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-6 py-2 transition-all duration-300 hover:border-dashed"
title={$locale === 'en' ? 'Cambiar a Español' : 'Switch to English'}
>
<Languages size={18} />
<span class="text-lg font-bold">{$locale === 'en' ? 'ES' : 'EN'}</span>
</button>
</div>
{:else if isInAdminSection}
@ -196,7 +212,7 @@
: 'border-black hover:border-dashed'}"
>
<BarChart3 size={18} />
<span class="text-lg font-bold">info</span>
<span class="text-lg font-bold">{$t.nav.info}</span>
</a>
<a
@ -208,7 +224,7 @@
: 'border-black hover:border-dashed'}"
>
<ClipboardList size={18} />
<span class="text-lg font-bold">reviews</span>
<span class="text-lg font-bold">{$t.nav.reviews}</span>
</a>
<a
@ -220,7 +236,7 @@
: 'border-black hover:border-dashed'}"
>
<Users size={18} />
<span class="text-lg font-bold">users</span>
<span class="text-lg font-bold">{$t.nav.users}</span>
</a>
{#if isAdminOnly}
@ -233,7 +249,7 @@
: 'border-black hover:border-dashed'}"
>
<ShoppingBag size={18} />
<span class="text-lg font-bold">shop</span>
<span class="text-lg font-bold">{$t.nav.shop}</span>
</a>
<a
href="/admin/news"
@ -244,7 +260,7 @@
: 'border-black hover:border-dashed'}"
>
<Newspaper size={18} />
<span class="text-lg font-bold">news</span>
<span class="text-lg font-bold">{$t.nav.news}</span>
</a>
<a
href="/admin/orders"
@ -255,7 +271,7 @@
: 'border-black hover:border-dashed'}"
>
<PackageCheck size={18} />
<span class="text-lg font-bold">orders</span>
<span class="text-lg font-bold">{$t.nav.orders}</span>
</a>
{/if}
{/if}
@ -271,7 +287,7 @@
: 'border-black hover:border-dashed'}"
>
<Compass size={18} />
<span class="text-lg font-bold">explore</span>
<span class="text-lg font-bold">{$t.nav.explore}</span>
</a>
<a
@ -282,7 +298,7 @@
: 'border-black hover:border-dashed'}"
>
<LayoutDashboard size={18} />
<span class="text-lg font-bold">dashboard</span>
<span class="text-lg font-bold">{$t.nav.dashboard}</span>
</a>
<a
@ -293,7 +309,7 @@
: 'border-black hover:border-dashed'}"
>
<Trophy size={18} />
<span class="text-lg font-bold">leaderboard</span>
<span class="text-lg font-bold">{$t.nav.leaderboard}</span>
</a>
<a
@ -304,7 +320,7 @@
: 'border-black hover:border-dashed'}"
>
<Store size={18} />
<span class="text-lg font-bold">shop</span>
<span class="text-lg font-bold">{$t.nav.shop}</span>
</a>
<a
@ -315,7 +331,7 @@
: 'border-black hover:border-dashed'}"
>
<Flame size={18} />
<span class="text-lg font-bold">refinery</span>
<span class="text-lg font-bold">{$t.nav.refinery}</span>
</a>
</div>
{/if}
@ -368,12 +384,19 @@
<p class="truncate font-bold">{user.username || 'user'}</p>
<p class="truncate text-sm text-gray-500">{user.email}</p>
</div>
<button
onclick={toggleLanguage}
class="flex w-full cursor-pointer items-center gap-2 border-b-2 border-black px-4 py-3 text-left transition-colors hover:bg-gray-100"
>
<Globe size={18} />
<span class="font-bold">{$locale === 'en' ? 'Español' : 'English'}</span>
</button>
<button
onclick={handleLogout}
class="flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-gray-100"
>
<LogOut size={18} />
<span class="font-bold">logout</span>
<span class="font-bold">{$t.nav.logout}</span>
</button>
</div>
{/if}
@ -449,7 +472,7 @@
: 'border-black hover:border-dashed'}"
>
<Home size={20} />
<span class="text-lg font-bold">home</span>
<span class="text-lg font-bold">{$t.nav.home}</span>
</button>
<button
onclick={() => scrollToSection('scraps')}
@ -459,7 +482,7 @@
: 'border-black hover:border-dashed'}"
>
<Package size={20} />
<span class="text-lg font-bold">scraps</span>
<span class="text-lg font-bold">{$t.nav.scraps}</span>
</button>
<button
onclick={() => scrollToSection('about')}
@ -469,7 +492,14 @@
: 'border-black hover:border-dashed'}"
>
<Info size={20} />
<span class="text-lg font-bold">about</span>
<span class="text-lg font-bold">{$t.nav.about}</span>
</button>
<button
onclick={toggleLanguage}
class="flex cursor-pointer items-center gap-3 rounded-full border-4 border-black px-4 py-3 transition-all duration-300 hover:border-dashed"
>
<Languages size={20} />
<span class="text-lg font-bold">{$locale === 'en' ? 'Español' : 'English'}</span>
</button>
</div>
{:else if isInAdminSection}
@ -489,7 +519,7 @@
: 'border-black hover:border-dashed'}"
>
<BarChart3 size={20} />
<span class="text-lg font-bold">info</span>
<span class="text-lg font-bold">{$t.nav.info}</span>
</a>
<a
@ -502,7 +532,7 @@
: 'border-black hover:border-dashed'}"
>
<ClipboardList size={20} />
<span class="text-lg font-bold">reviews</span>
<span class="text-lg font-bold">{$t.nav.reviews}</span>
</a>
<a
@ -515,7 +545,7 @@
: 'border-black hover:border-dashed'}"
>
<Users size={20} />
<span class="text-lg font-bold">users</span>
<span class="text-lg font-bold">{$t.nav.users}</span>
</a>
{#if isAdminOnly}
@ -529,7 +559,7 @@
: 'border-black hover:border-dashed'}"
>
<ShoppingBag size={20} />
<span class="text-lg font-bold">shop</span>
<span class="text-lg font-bold">{$t.nav.shop}</span>
</a>
<a
href="/admin/news"
@ -541,7 +571,7 @@
: 'border-black hover:border-dashed'}"
>
<Newspaper size={20} />
<span class="text-lg font-bold">news</span>
<span class="text-lg font-bold">{$t.nav.news}</span>
</a>
<a
href="/admin/orders"
@ -553,7 +583,7 @@
: 'border-black hover:border-dashed'}"
>
<PackageCheck size={20} />
<span class="text-lg font-bold">orders</span>
<span class="text-lg font-bold">{$t.nav.orders}</span>
</a>
{/if}
{/if}
@ -570,7 +600,7 @@
: 'border-black hover:border-dashed'}"
>
<Compass size={20} />
<span class="text-lg font-bold">explore</span>
<span class="text-lg font-bold">{$t.nav.explore}</span>
</a>
<a
@ -582,7 +612,7 @@
: 'border-black hover:border-dashed'}"
>
<LayoutDashboard size={20} />
<span class="text-lg font-bold">dashboard</span>
<span class="text-lg font-bold">{$t.nav.dashboard}</span>
</a>
<a
@ -594,7 +624,7 @@
: 'border-black hover:border-dashed'}"
>
<Trophy size={20} />
<span class="text-lg font-bold">leaderboard</span>
<span class="text-lg font-bold">{$t.nav.leaderboard}</span>
</a>
<a
@ -606,7 +636,7 @@
: 'border-black hover:border-dashed'}"
>
<Store size={20} />
<span class="text-lg font-bold">shop</span>
<span class="text-lg font-bold">{$t.nav.shop}</span>
</a>
<a
@ -618,7 +648,7 @@
: 'border-black hover:border-dashed'}"
>
<Flame size={20} />
<span class="text-lg font-bold">refinery</span>
<span class="text-lg font-bold">{$t.nav.refinery}</span>
</a>
</div>
{/if}
@ -668,12 +698,19 @@
<Spool size={18} />
<span class="font-bold">{$userScrapsStore}</span>
</div>
<button
onclick={toggleLanguage}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 transition-all duration-200 hover:border-dashed"
>
<Globe size={18} />
<span class="font-bold">{$locale === 'en' ? 'ES' : 'EN'}</span>
</button>
<button
onclick={handleLogout}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 transition-all duration-200 hover:border-dashed"
>
<LogOut size={18} />
<span class="font-bold">logout</span>
<span class="font-bold">{$t.nav.logout}</span>
</button>
</div>
{/if}
@ -687,6 +724,6 @@
class="fixed bottom-6 left-6 z-50 flex cursor-pointer items-center gap-2 rounded-full border-4 border-red-800 bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-red-700 md:px-6 md:py-3"
>
<Shield size={20} />
<span class="hidden sm:inline">{isInAdminSection ? 'escape' : 'admin'}</span>
<span class="hidden sm:inline">{isInAdminSection ? $t.nav.escape : $t.nav.admin}</span>
</a>
{/if}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { newsStore, newsLoading, fetchNews } from '$lib/stores';
import { t, locale } from '$lib/i18n';
let currentIndex = $state(0);
let isPaused = $state(false);
@ -33,7 +34,7 @@
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString($locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
@ -50,20 +51,20 @@
aria-label="News carousel"
>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-lg font-bold">news</h2>
<h2 class="text-lg font-bold">{$t.news.title}</h2>
{#if $newsStore.length > 1}
<div class="flex items-center gap-2">
<button
onclick={prev}
class="cursor-pointer rounded p-1 transition-colors hover:bg-gray-100"
aria-label="Previous news"
aria-label={$t.news.previousNews}
>
<ChevronLeft size={20} />
</button>
<button
onclick={next}
class="cursor-pointer rounded p-1 transition-colors hover:bg-gray-100"
aria-label="Next news"
aria-label={$t.news.nextNews}
>
<ChevronRight size={20} />
</button>
@ -72,7 +73,7 @@
</div>
{#if $newsStore.length === 0}
<p class="text-gray-500">no news right now</p>
<p class="text-gray-500">{$t.news.noNewsRightNow}</p>
{:else}
<div class="relative h-20 overflow-hidden">
{#each $newsStore as item, index (item.id)}
@ -99,7 +100,7 @@
currentIndex
? 'w-6 bg-black'
: 'bg-gray-300 hover:bg-gray-400'}"
aria-label="Go to news {index + 1}"
aria-label="{$t.news.goToNews} {index + 1}"
></button>
{/each}
</div>

View file

@ -4,6 +4,7 @@
import { refreshUserScraps, userScrapsStore } from '$lib/auth-client';
import HeartButton from './HeartButton.svelte';
import { type ShopItem, updateShopItemHeart } from '$lib/stores';
import { t } from '$lib/i18n';
interface LeaderboardUser {
userId: string;
@ -143,7 +144,7 @@
if (!response.ok) {
alertType = 'error';
alertMessage = data.error || 'Failed to try luck';
alertMessage = data.error || $t.shop.failedToTryLuck;
return;
}
@ -156,7 +157,7 @@
} catch (e) {
console.error('Failed to try luck:', e);
alertType = 'error';
alertMessage = 'Something went wrong';
alertMessage = $t.shop.somethingWentWrong;
} finally {
tryingLuck = false;
showConfirmation = false;
@ -226,7 +227,7 @@
<Spool size={20} />
{rollCost}
</span>
<span class="text-sm text-gray-500">{item.count} left</span>
<span class="text-sm text-gray-500">{item.count} {$t.shop.left}</span>
</div>
<HeartButton
count={localHeartCount}
@ -249,16 +250,16 @@
item.effectiveProbability
)}"
>
<p class="mb-2 text-sm font-bold">your chance</p>
<p class="mb-2 text-sm font-bold">{$t.shop.yourChance}</p>
<p class="text-3xl font-bold {getProbabilityColor(item.effectiveProbability)}">
{item.effectiveProbability.toFixed(1)}%
</p>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
<span>base: {item.baseProbability}%</span>
<span>your boost: +{item.userBoostPercent}%</span>
<span>{$t.shop.base}: {item.baseProbability}%</span>
<span>{$t.shop.yourBoost}: +{item.userBoostPercent}%</span>
{#if item.adjustedBaseProbability < item.baseProbability}
<span class="text-red-500"
>previous buy: -{item.baseProbability - item.adjustedBaseProbability}%</span
>{$t.shop.previousBuy}: -{item.baseProbability - item.adjustedBaseProbability}%</span
>
{/if}
</div>
@ -273,7 +274,7 @@
: 'hover:border-dashed'}"
>
<Trophy size={16} />
leaderboard
{$t.shop.leaderboard}
</button>
<button
onclick={() => (activeTab = 'wishlist')}
@ -283,7 +284,7 @@
: 'hover:border-dashed'}"
>
<Heart size={16} />
wishlist ({localHeartCount})
{$t.shop.wishlist} ({localHeartCount})
</button>
<button
onclick={() => (activeTab = 'buyers')}
@ -293,16 +294,16 @@
: 'hover:border-dashed'}"
>
<ShoppingBag size={16} />
buyers
{$t.shop.buyers}
</button>
</div>
<div class="mb-6 min-h-[120px] rounded-lg border-2 border-black p-4">
{#if activeTab === 'leaderboard'}
{#if loadingLeaderboard}
<p class="text-center text-gray-500">loading...</p>
<p class="text-center text-gray-500">{$t.common.loading}</p>
{:else if leaderboard.length === 0}
<p class="text-center text-gray-500">no one has boosted yet</p>
<p class="text-center text-gray-500">{$t.shop.noOneHasBoostedYet}</p>
{:else}
<div class="space-y-2">
{#each leaderboard as user, i}
@ -320,12 +321,12 @@
{:else if activeTab === 'wishlist'}
<div class="text-center">
<p class="mb-2 text-2xl font-bold">{localHeartCount}</p>
<p class="mb-4 text-gray-600">people want this item</p>
<p class="mb-4 text-gray-600">{$t.shop.peopleWantThisItem}</p>
{#if localHearted}
<p class="mb-4 text-sm font-medium text-green-600">including you!</p>
<p class="mb-4 text-sm font-medium text-green-600">{$t.shop.includingYou}</p>
{/if}
{#if loadingHearts}
<p class="text-sm text-gray-500">loading...</p>
<p class="text-sm text-gray-500">{$t.common.loading}</p>
{:else if heartUsers.length > 0}
<div class="mt-2 flex flex-wrap justify-center gap-2">
{#each heartUsers as heartUser, i}
@ -346,9 +347,9 @@
</div>
{:else if activeTab === 'buyers'}
{#if loadingBuyers}
<p class="text-center text-gray-500">loading...</p>
<p class="text-center text-gray-500">{$t.common.loading}</p>
{:else if buyers.length === 0}
<p class="text-center text-gray-500">no one has won this item yet</p>
<p class="text-center text-gray-500">{$t.shop.noOneHasWonYet}</p>
{:else}
<div class="space-y-2">
{#each buyers as buyer}
@ -369,7 +370,7 @@
<span
class="block w-full cursor-not-allowed rounded-full border-4 border-dashed border-gray-300 px-4 py-3 text-center text-lg font-bold text-gray-400"
>
sold out
{$t.shop.soldOut}
</span>
{:else}
<button
@ -378,9 +379,9 @@
class="w-full cursor-pointer rounded-full bg-black px-4 py-3 text-lg font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if !canAfford}
not enough scraps
{$t.shop.notEnoughScraps}
{:else}
try your luck
{$t.shop.tryYourLuck}
{/if}
</button>
{/if}
@ -395,11 +396,12 @@
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">confirm try your luck</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.shop.confirmTryYourLuck}</h2>
<p class="mb-6 text-gray-600">
are you sure you want to try your luck? this will cost <strong>{rollCost} scraps</strong>.
{$t.shop.confirmTryLuckMessage} <strong>{rollCost} {$t.common.scraps}</strong>.
<span class="mt-2 block">
your chance: <strong class={getProbabilityColor(item.effectiveProbability)}
{$t.shop.yourChanceLabel}
<strong class={getProbabilityColor(item.effectiveProbability)}
>{item.effectiveProbability.toFixed(1)}%</strong
>
</span>
@ -410,14 +412,14 @@
disabled={tryingLuck}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={handleTryLuck}
disabled={tryingLuck}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
{tryingLuck ? 'trying...' : 'try luck'}
{tryingLuck ? $t.common.trying : $t.common.tryLuck}
</button>
</div>
</div>
@ -433,13 +435,15 @@
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">{alertType === 'error' ? 'error' : 'result'}</h2>
<h2 class="mb-4 text-2xl font-bold">
{alertType === 'error' ? $t.common.error : $t.common.result}
</h2>
<p class="mb-6 text-gray-600">{alertMessage}</p>
<button
onclick={() => (alertMessage = null)}
class="w-full cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
ok
{$t.common.ok}
</button>
</div>
</div>

View file

@ -18,6 +18,7 @@
import { goto, preloadData } from '$app/navigation';
import { tutorialActiveStore } from '$lib/stores';
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
let { onComplete }: { onComplete: () => void } = $props();
@ -78,92 +79,57 @@
window.removeEventListener('mouseup', handleDragEnd);
});
const steps = [
const stepConfigs = [
{ titleKey: 'welcomeTitle', descKey: 'welcomeDesc', highlight: null },
{ titleKey: 'navigationTitle', descKey: 'navigationDesc', highlight: 'navbar' },
{ titleKey: 'createProjectsTitle', descKey: 'createProjectsDesc', highlight: 'dashboard' },
{
title: 'welcome to scraps!',
description:
"scraps is a program where you earn rewards for building cool projects. let's walk through how it works!",
highlight: null
},
{
title: 'navigation',
description:
'use the navbar to navigate between dashboard (your projects), shop (spend scraps), refinery (boost odds), and leaderboard (see top builders).',
highlight: 'navbar'
},
{
title: 'create projects',
description:
'start by creating projects on your dashboard. link them to Hackatime (hackatime.hackclub.com) to automatically track your coding time.',
highlight: 'dashboard'
},
{
title: 'create your first project',
description:
'click the "new project" button on the right to open the project creation modal.',
titleKey: 'createFirstProjectTitle',
descKey: 'createFirstProjectDesc',
highlight: 'new-project-button',
waitForClick: true
},
{
title: 'fill in project details',
description:
'we\'ve pre-filled some example text for you. feel free to customize it or just click "create" to continue!',
titleKey: 'fillDetailsTitle',
descKey: 'fillDetailsDesc',
highlight: 'create-project-modal',
waitForEvent: 'tutorial:project-created'
},
{
title: 'your project page',
description:
'great job! this is your project page. you can see details, edit your project, and submit it for review when ready.',
titleKey: 'projectPageTitle',
descKey: 'projectPageDesc',
highlight: null,
position: 'bottom-center'
},
{
title: 'submit for review',
description:
'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:
'you earn scraps for the time you put in. the more you build, the more you earn! your scraps balance is shown here.',
highlight: 'scraps-counter'
},
{
title: 'the shop',
description:
'spend your scraps in the shop to try your luck at winning prizes. each item has a base probability of success.',
highlight: 'shop'
},
{
title: 'the refinery',
description:
'invest scraps in the refinery to boost your probability for specific items. higher probability = better odds!',
highlight: 'refinery'
},
{
title: 'strategy time',
description:
'you have a choice: try your luck at base probability OR invest in the refinery first to boost your odds. choose wisely!',
highlight: null
},
{
title: "you're ready!",
description: "here's 5 bonus scraps to get you started. now go build something awesome!",
highlight: null
}
{ titleKey: 'submitReviewTitle', descKey: 'submitReviewDesc', highlight: 'submit-button' },
{ titleKey: 'projectTiersTitle', descKey: 'projectTiersDesc', highlight: null },
{ titleKey: 'earnScrapsTitle', descKey: 'earnScrapsDesc', highlight: 'scraps-counter' },
{ titleKey: 'shopTitle', descKey: 'shopDesc', highlight: 'shop' },
{ titleKey: 'refineryTitle', descKey: 'refineryDesc', highlight: 'refinery' },
{ titleKey: 'strategyTitle', descKey: 'strategyDesc', highlight: null },
{ titleKey: 'readyTitle', descKey: 'readyDesc', highlight: null }
];
let steps = $derived(
stepConfigs.map((config) => ({
title:
$t.tutorial.steps[config.titleKey as keyof typeof $t.tutorial.steps] || config.titleKey,
description:
$t.tutorial.steps[config.descKey as keyof typeof $t.tutorial.steps] || config.descKey,
highlight: config.highlight,
waitForClick: (config as { waitForClick?: boolean }).waitForClick,
waitForEvent: (config as { waitForEvent?: string }).waitForEvent,
position: (config as { position?: string }).position
}))
);
let currentStepData = $derived(steps[currentStep]);
let isLastStep = $derived(currentStep === steps.length - 1);
let stepProgress = $derived(`step ${currentStep + 1} of ${steps.length}`);
let stepProgress = $derived(
$t.tutorial.stepOf
.replace('{current}', String(currentStep + 1))
.replace('{total}', String(steps.length))
);
function getHighlightPosition(highlight: string | null): {
top: number;
@ -414,7 +380,7 @@
<button
onclick={skip}
class="cursor-pointer rounded-full p-2 transition-all duration-200 hover:bg-gray-100"
aria-label="Skip tutorial"
aria-label={$t.tutorial.skipTutorial}
>
<X size={20} />
</button>
@ -472,7 +438,7 @@
rel="noopener noreferrer"
class="mb-4 inline-block font-bold text-black underline hover:no-underline"
>
set up hackatime →
{$t.tutorial.setUpHackatime}
</a>
{/if}
@ -483,21 +449,21 @@
disabled={loading}
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
skip
{$t.tutorial.skip}
</button>
{#if currentStepData.waitForClick}
<div
class="flex flex-1 items-center justify-center gap-2 rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600"
>
<ArrowRight size={18} />
<span>click the button to continue</span>
<span>{$t.tutorial.clickToContinue}</span>
</div>
{:else if (currentStepData as { waitForEvent?: string }).waitForEvent}
<div
class="flex flex-1 items-center justify-center gap-2 rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600"
>
<ArrowRight size={18} />
<span>complete the form to continue</span>
<span>{$t.tutorial.completeFormToContinue}</span>
</div>
{:else}
<button
@ -506,12 +472,12 @@
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if loading}
<span>completing...</span>
<span>{$t.tutorial.completing}</span>
{:else if isLastStep}
<Gift size={18} />
<span>claim 5 scraps</span>
<span>{$t.tutorial.claimScraps}</span>
{:else}
<span>next</span>
<span>{$t.tutorial.next}</span>
<ArrowRight size={18} />
{/if}
</button>

568
frontend/src/lib/i18n/en.ts Normal file
View file

@ -0,0 +1,568 @@
export default {
nav: {
home: 'home',
scraps: 'scraps',
about: 'about',
dashboard: 'dashboard',
leaderboard: 'leaderboard',
shop: 'shop',
refinery: 'refinery',
explore: 'explore',
logout: 'logout',
info: 'info',
reviews: 'reviews',
users: 'users',
orders: 'orders',
news: 'news',
escape: 'escape',
admin: 'admin'
},
landing: {
youShip: 'you ship:',
anyProject: 'any project',
weShip: 'we ship:',
chanceToWin: 'a chance to win something amazing',
yourEmail: 'your email',
pleaseEnterEmail: 'please enter your email',
pleaseEnterValidEmail: 'please enter a valid email',
startScrapping: 'start scrapping',
goToDashboard: 'go to dashboard',
itemsUpForGrabs: '(items up for grabs)',
tldr: 'tl;dr',
aboutScraps: 'about scraps',
sillyTooltip: 'silly, nonsensical, or fun',
rareStickersTooltip: '(including rare stickers)',
optionallySillyTooltip: 'optionally silly, nonsensical, or fun',
stickersTooltip: '(including stickers)',
vermontTooltip: 'in vermont',
hardwareTooltip: 'sensors, esp32s, arduinos, breadboards, and a singular resistor',
limitedEditionTooltip: 'limited edition stickers',
anyProjectTooltip: 'or literally any project',
fudgeTooltip: 'fudge fudge fudge',
collectionTooltip:
'soon to be made collection.hackclub.com to keep track of your sticker collection',
weShipRandomItems: 'random items from hq',
moreHoursMoreStuff: '(more hours, more stuff)',
coldAndWintery: "it's cold and wintery here",
atHackClubHQ:
'at hack club hq. after prototype, overglade, milkyway, and other hackathons, there are boxes and boxes of items, "scraps," if you will. now, dear hack clubber, this ysws is your chance to win the cool leftovers, including random hardware',
postcardsAndMore: ', postcards, fudge, and maybe a secret surprise',
butHow: 'but how, you may ask?',
simpleExplanation:
"well, it's simple: you just ship any projects that are slightly silly, nonsensical, or fun",
earnScraps: ', and you will earn scraps for the time you put in! track your time with',
hackatime: 'hackatime',
watchScrapsRollIn: 'and watch the scraps roll in.',
whatCanYouWin: 'what can you win?',
currentlyRandomAssortment:
'currently, there is a random assortment of hardware left over from prototype, postcards, the famous vermont fudge',
moreItemsPlanned: ', and more items planned as events wrap up. oh, and the best part,',
stickers: 'stickers!',
wonderedHowToGet: 'if you have ever wondered how to get the cool stickers from',
hereIsYourChance:
'? well, here is your chance to get any sticker (that we have in stock) to complete your collection',
rarestStickers:
'! this includes some of the rarest and most sought-after stickers from hack club.',
faq: 'frequently asked questions'
},
dashboard: {
hello: 'hello, {name}',
greetings: {
readyToScrap: 'ready to scrap?',
timeToBuild: 'time to build something silly',
whatWillYouShip: 'what will you ship today?',
letTheScrapBegin: 'let the scrapping begin',
hackAway: 'hack away!',
makeSomethingFun: 'make something fun',
createShipRepeat: 'create, ship, repeat',
keepOnScrapping: 'keep on scrapping'
},
newProject: 'new project',
tier: 'tier',
faq: 'faq',
faqQuestions: {
howDoesShopWork: 'how does the shop work?',
howDoesShopWorkAnswer:
'spend scraps to roll for prizes. each item has a probability of winning - boost your odds in the refinery!',
whatIsHackatime: 'what is hackatime?',
whatIsHackatimeAnswer:
"is hack club's time tracking tool that automatically logs your coding hours.",
whatIsRefinery: 'what is the refinery?',
whatIsRefineryAnswer:
'spend scraps to increase your probability of winning an item. each upgrade boosts your chances!',
howLongDoesReviewTake: 'how long does review take?',
howLongDoesReviewTakeAnswer:
"project reviews typically take a few days. you'll be notified when your project is approved!",
whatIfILoseRoll: 'what happens if i lose a roll?',
whatIfILoseRollAnswer:
'you receive consolation scrap paper. your refinery upgrades are kept, so try again!',
moreQuestions: 'more questions?'
}
},
shop: {
itemsUpForGrabs: 'items up for grabs',
tags: 'tags:',
sort: 'sort:',
default: 'default',
favorites: 'favorites',
probability: 'probability',
loadingItems: 'Loading items...',
noItemsAvailable: 'No items available',
soldOut: 'sold out',
left: 'left',
chance: 'chance',
myOrders: 'my orders',
clear: 'clear',
consolationScrapPaper: 'consolation scrap paper',
betterLuckNextTime: 'better luck next time!',
youRolledButNeeded: 'you rolled {rolled} but needed {needed} or less.',
consolationMessage:
"as a consolation, we'll send you a random scrap of paper from hack club hq! just tell us where to ship it.",
yourChance: 'your chance',
base: 'base',
yourBoost: 'your boost',
previousBuy: 'previous buy',
leaderboard: 'leaderboard',
wishlist: 'wishlist',
buyers: 'buyers',
noOneHasBoostedYet: 'no one has boosted yet',
peopleWantThisItem: 'people want this item',
includingYou: 'including you!',
noOneHasWonYet: 'no one has won this item yet',
notEnoughScraps: 'not enough scraps',
tryYourLuck: 'try your luck',
confirmTryYourLuck: 'confirm try your luck',
confirmTryLuckMessage: 'are you sure you want to try your luck? this will cost',
yourChanceLabel: 'your chance:',
somethingWentWrong: 'something went wrong',
failedToTryLuck: 'Failed to try luck'
},
common: {
cancel: 'cancel',
confirm: 'confirm',
save: 'save',
delete: 'delete',
edit: 'edit',
submit: 'submit',
close: 'close',
loading: 'loading...',
error: 'error',
success: 'success',
serverError: 'server error',
scraps: 'scraps',
dismiss: 'dismiss',
ok: 'ok',
create: 'create',
creating: 'creating...',
trying: 'trying...',
tryLuck: 'try luck',
saving: 'saving...',
result: 'result'
},
refinery: {
refinery: 'refinery',
upgradeYourLuck: 'upgrade your luck',
maxed: 'maxed',
upgrading: 'upgrading...',
fromPreviousBuy: 'from previous buy',
noItemsAvailable: 'no items available for upgrades',
failedToUpgrade: 'Failed to upgrade probability',
ok: 'ok'
},
leaderboard: {
leaderboard: 'leaderboard',
topScrappers: 'top scrappers',
hours: 'hours',
scrapsEarned: 'scraps earned',
rank: 'rank',
user: 'user',
projects: 'projects',
scraps: 'scraps',
earned: 'earned',
probabilityLeaders: 'probability leaders',
base: 'base',
noBoostsYet: 'no boosts yet',
noProbabilityLeadersYet: 'no probability leaders yet'
},
orders: {
myOrders: 'my orders',
backToShop: 'back to shop',
loadingOrders: 'loading orders...',
noOrdersYet: 'no orders yet',
tryYourLuck: 'try your luck in the shop to get some goodies!',
goToShop: 'go to shop',
paperScraps: 'paper scraps',
won: 'won',
consolation: 'consolation',
purchased: 'purchased',
fulfilled: 'fulfilled',
pending: 'pending',
shipped: 'shipped',
delivered: 'delivered',
qty: 'qty',
noShippingAddress: '⚠️ no shipping address provided'
},
explore: {
explore: 'explore',
searchPlaceholder: 'search projects...',
tier: 'tier:',
status: 'status:',
sort: 'sort:',
shipped: 'shipped',
inProgress: 'in progress',
recent: 'recent',
mostViewed: 'most viewed',
random: 'random',
clear: 'clear',
loadingProjects: 'loading projects...',
noProjectsFound: 'no projects found',
tryAdjustingFilters: 'try adjusting your filters or search query',
by: 'by',
anonymous: 'anonymous',
prev: 'prev',
next: 'next'
},
submit: {
title: 'submit project',
pageTitle: 'submit project - scraps',
subtitle: 'submit your project for review to earn scraps',
selectProject: 'select project',
chooseProject: 'choose a project...',
noEligibleProjects: 'no eligible projects',
hoursLogged: '{hours}h logged',
submitForReview: 'submit for review',
submitting: 'submitting...',
pleaseSelectProject: 'Please select a project',
failedToSubmit: 'Failed to submit project'
},
createProject: {
newProject: 'new project',
image: 'image',
optional: 'optional',
uploading: 'uploading...',
clickToUploadImage: 'click to upload image',
imageMustBeLessThan: 'Image must be less than 5MB',
name: 'name',
description: 'description',
hackatimeProject: 'hackatime project',
loadingProjects: 'loading projects...',
selectAProject: 'select a project',
noProjectsFound: 'no projects found',
githubUrl: 'github url',
projectTier: 'project tier',
requirements: 'requirements',
addProjectName: 'add a project name',
writeDescription: 'write a description (min {min} chars)',
pleaseCompleteRequirements: 'Please complete all requirements',
failedToCreateProject: 'Failed to create project',
tierDescriptions: {
tier1: 'simple projects, tutorials, small scripts',
tier2: 'moderate complexity, multi-file projects',
tier3: 'complex features, APIs, integrations',
tier4: 'full applications, major undertakings'
}
},
address: {
shippingAddress: 'shipping address',
congratulations: '🎉 congratulations!',
youWon: 'you won',
selectShippingAddress: 'select your shipping address to receive it.',
yourAddresses: 'your addresses',
selectAnAddress: 'select an address',
primary: 'primary',
selectedAddress: 'selected address:',
manageAddresses: 'manage addresses on hack club auth',
noSavedAddresses: "you don't have any saved addresses yet.",
addAddress: 'add an address on hack club',
afterAddingAddress: 'after adding an address, refresh this page to select it.',
loadingAddresses: 'loading addresses...',
confirmShippingAddress: 'confirm shipping address',
failedToSaveAddress: 'Failed to save address'
},
project: {
backToDashboard: 'back to dashboard',
backToProfile: "back to {username}'s profile",
back: 'back',
backToProject: 'back to project',
loadingProject: 'loading project...',
goBack: 'go back',
projectNotFound: 'project not found',
shipped: 'shipped',
awaitingReview: 'awaiting review',
inProgress: 'in progress',
waitingForReview: 'waiting for review',
rejected: 'rejected',
tier: 'tier {value}',
noDescriptionYet: 'no description yet',
views: '{count} views',
scrapsEarned: '+{count} scraps earned',
viewOnGithub: 'view on github',
tryItOut: 'try it out',
createdBy: 'created by',
unknown: 'unknown',
editProject: 'edit project',
reviewAndSubmit: 'review & submit',
activity: 'activity',
noActivityYet: 'no activity yet',
submitToGetStarted: 'submit your project to get started',
approved: 'approved',
changesRequested: 'changes requested',
permanentlyRejected: 'permanently rejected',
reviewedBy: 'reviewed by',
reviewer: 'reviewer',
submittedForReview: 'submitted for review',
projectCreated: 'project created',
image: 'image',
uploading: 'uploading...',
clickToUploadImage: 'click to upload image',
name: 'name',
description: 'description',
githubUrl: 'github url',
optional: 'optional',
playableUrl: 'playable url',
requiredForSubmission: 'required for submission',
playableUrlHint: 'a link where reviewers can try your project',
hackatimeProject: 'hackatime project',
loadingProjects: 'loading projects...',
selectAProject: 'select a project',
noProjectsFound: 'no projects found',
projectTier: 'project tier',
tierDescriptions: {
tier1: 'simple projects, tutorials, small scripts',
tier2: 'moderate complexity, multi-file projects',
tier3: 'complex features, APIs, integrations',
tier4: 'full applications, major undertakings'
},
saveChanges: 'save changes',
saving: 'saving...',
dangerZone: 'danger zone',
deleteThisProject: 'delete this project',
deleteWarning: 'once deleted, this project cannot be recovered.',
areYouSure: 'are you sure?',
deleteConfirmation: 'this will permanently delete {name}. this action cannot be undone.',
deleting: 'deleting...',
deleteProject: 'delete project',
submitForReview: 'submit for review',
submitRequirementsHint: 'make sure your project meets all requirements before submitting',
projectImage: 'project image',
noImageAddOne: 'no image - add one in edit',
selectComplexityTier: 'select the complexity tier that best matches your project',
requirementsChecklist: 'requirements checklist',
projectImageUploaded: 'project image uploaded',
projectName: 'project name (max {max} chars)',
descriptionRequirement: 'description ({min}-{max} chars)',
githubRepositoryLinked: 'github repository linked',
playableUrlProvided: 'playable url provided',
hackatimeProjectSelected: 'hackatime project selected',
submitting: 'submitting...',
hoursLogged: '{hours}h logged',
github: 'github',
reviewFeedback: 'review feedback',
imageMustBeLessThan: 'Image must be less than 5MB'
},
faq: {
title: 'faq',
pageTitle: 'faq - scraps',
subtitle: 'frequently asked questions about scraps',
whatIsScraps: 'what is scraps?',
whatIsScrapsAnswer:
'scraps is a hack club program where you earn scraps by building projects and spending time coding. you can then spend your scraps in the shop to test your luck and win prizes from hack club hq!',
howDoIEarnScraps: 'how do i earn scraps?',
howDoIEarnScrapsAnswer:
'you earn scraps by submitting projects you\'ve built. each project is reviewed by our team, and you receive scraps based on the hours you\'ve spent working on it. connect your <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a> account to automatically track your coding hours.',
whatIsHackatime: 'what is hackatime?',
whatIsHackatimeAnswer:
'<a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a> is hack club\'s time tracking tool that automatically logs your coding hours. connect it to your projects so we can see how much time you\'ve spent building. visit <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime.hackclub.com</a> to get started.',
howDoesShopWork: 'how does the shop work?',
howDoesShopWorkAnswer:
"in the shop, you spend scraps to roll for prizes. each item has a base probability of winning. if you don't win, you get consolation scrap paper. the cost to roll depends on the item's rarity.",
whatIsRefinery: 'what is the refinery?',
whatIsRefineryAnswer:
"the refinery lets you spend scraps to increase your probability of winning an item. each upgrade boosts your chances, but costs increase with each upgrade. it's a way to improve your odds before rolling.",
whatAreProjectTiers: 'what are project tiers?',
whatAreProjectTiersAnswer:
"projects are assigned tiers (1-4) based on complexity and quality. higher tiers earn more scraps per hour:<br><br><strong>tier 1 (0.8x)</strong> - simple projects, tutorials, small scripts<br><strong>tier 2 (1x)</strong> - moderate complexity, multi-file projects<br><strong>tier 3 (1.25x)</strong> - complex features, APIs, integrations<br><strong>tier 4 (1.5x)</strong> - full applications, major undertakings<br><br>when submitting your project, select the tier that best matches your project's scope. reviewers may adjust the tier during review.",
howLongDoesReviewTake: 'how long does review take?',
howLongDoesReviewTakeAnswer:
"project reviews typically take a few days. our team reviews each submission to verify the work and assign an appropriate tier. you'll be notified when your project is approved.",
whatIfILoseRoll: 'what happens if i lose a roll?',
whatIfILoseRollAnswer:
'if you lose a roll, you receive consolation scrap paper as a small prize. your refinery upgrades are kept, so you can try again with improved odds. keep building projects to earn more scraps!',
canISubmitAnyProject: 'can i submit any project?',
canISubmitAnyProjectAnswer:
'projects should be original work you\'ve built. they need a github repo, description, and tracked hours via <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a>. both new projects and improvements to existing ones count!',
howDoIGetStarted: 'how do i get started?',
howDoIGetStartedAnswer:
'sign in with your hack club account, connect <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a>, and start building! when you\'re ready, submit your project for review. once approved, you\'ll receive scraps based on your hours.',
stillHaveQuestions: 'still have questions?',
reachOutOnSlack: 'reach out to us on the hack club slack in the #scraps channel',
joinSlack: 'join hack club slack'
},
profile: {
backToLeaderboard: 'back to leaderboard',
loading: 'loading...',
userNotFound: 'User not found',
failedToLoad: 'Failed to load profile',
joined: 'joined',
scraps: 'scraps',
shipped: 'shipped',
inProgress: 'in progress',
totalHours: 'total hours',
projects: 'projects',
all: 'all',
underReview: 'under review',
noProjectsFound: 'no projects found',
github: 'github',
wishlist: 'wishlist',
refinements: 'refinements',
noRefinements: 'no refinements to show',
roles: {
admin: 'admin',
reviewer: 'reviewer',
member: 'member',
banned: 'banned'
},
editRole: 'edit role',
changeRole: 'change role',
selectRole: 'select role',
updateRole: 'update role',
roleUpdated: 'role updated successfully',
failedToUpdateRole: 'failed to update role'
},
footer: {
madeWith: 'made with <3 by',
hackClub: 'hack club',
and: 'and'
},
news: {
title: 'news',
previousNews: 'previous news',
nextNews: 'next news',
noNewsRightNow: 'no news right now',
goToNews: 'go to news'
},
tutorial: {
stepOf: 'step {current} of {total}',
skipTutorial: 'skip tutorial',
skip: 'skip',
clickToContinue: 'click the button to continue',
completeFormToContinue: 'complete the form to continue',
completing: 'completing...',
claimScraps: 'claim 5 scraps',
next: 'next',
setUpHackatime: 'set up hackatime →',
steps: {
welcomeTitle: 'welcome to scraps!',
welcomeDesc:
"scraps is a program where you earn rewards for building cool projects. let's walk through how it works!",
navigationTitle: 'navigation',
navigationDesc:
'use the navbar to navigate between dashboard (your projects), shop (spend scraps), refinery (boost odds), and leaderboard (see top builders).',
createProjectsTitle: 'create projects',
createProjectsDesc:
'start by creating projects on your dashboard. link them to Hackatime (hackatime.hackclub.com) to automatically track your coding time.',
createFirstProjectTitle: 'create your first project',
createFirstProjectDesc:
'click the "new project" button on the right to open the project creation modal.',
fillDetailsTitle: 'fill in project details',
fillDetailsDesc:
'we\'ve pre-filled some example text for you. feel free to customize it or just click "create" to continue!',
projectPageTitle: 'your project page',
projectPageDesc:
'great job! this is your project page. you can see details, edit your project, and submit it for review when ready.',
submitReviewTitle: 'submit for review',
submitReviewDesc:
'when you\'re ready to ship, click "review & submit" to submit your project. once approved, you\'ll earn scraps based on your coding time!',
projectTiersTitle: 'project tiers',
projectTiersDesc:
"when submitting, you can select a tier (1-4) based on your project's complexity. higher tiers earn more scraps per hour.",
earnScrapsTitle: 'earn scraps',
earnScrapsDesc:
'you earn scraps for the time you put in. the more you build, the more you earn! your scraps balance is shown here.',
shopTitle: 'the shop',
shopDesc:
'spend your scraps in the shop to try your luck at winning prizes. each item has a base probability of success.',
refineryTitle: 'the refinery',
refineryDesc:
'invest scraps in the refinery to boost your probability for specific items. higher probability = better odds!',
strategyTitle: 'strategy time',
strategyDesc:
'you have a choice: try your luck at base probability OR invest in the refinery first to boost your odds. choose wisely!',
readyTitle: "you're ready!",
readyDesc: "here's 5 bonus scraps to get you started. now go build something awesome!"
}
},
admin: {
adminInfo: 'admin info',
reviewQueue: 'review queue',
projectsWaitingForReview: 'projects waiting for review',
noProjectsWaitingForReview: 'no projects waiting for review',
manageUsersAndPermissions: 'manage users and permissions',
manageShopItemsAndInventory: 'manage shop items and inventory',
manageOrdersAndFulfillment: 'manage shop orders and fulfillment',
manageAnnouncementsAndUpdates: 'manage announcements and updates',
addItem: 'add item',
addNews: 'add news',
editItem: 'edit item',
editNews: 'edit news',
editUser: 'edit user',
confirmDelete: 'confirm delete',
view: 'view',
fulfill: 'fulfill',
unfulfill: 'unfulfill',
all: 'all',
pending: 'pending',
fulfilled: 'fulfilled',
loadingStats: 'loading stats...',
page: 'page',
of: 'of'
},
auth: {
error: 'error',
authenticating: 'authenticating...',
pleaseWait: 'please wait while we verify your account',
oops: 'oops!',
goBackHome: 'go back home',
getHelpOnSlack: 'get help on slack',
needsVerification: {
title: 'verify your identity',
description:
'you need to verify your identity with hack club auth before you can use scraps. click below to complete verification.',
redirectText: 'verify with hack club auth'
},
notEligible: {
title: 'not eligible for ysws',
description:
'your hack club account is not currently eligible for you ship we ship programs. please ask for help in the hack club slack.'
},
authFailed: {
title: 'authentication failed',
description: "we couldn't verify your identity. please try again."
},
unknown: {
title: 'something went wrong',
description: 'an unexpected error occurred. please try again later.'
},
authenticationFailed: 'Authentication failed',
errorDuringAuth: 'An error occurred during authentication'
},
about: {
whoIsEligible: 'who is eligible?',
whoIsEligibleAnswer:
'scraps is for high schoolers! you need to be 13-18 years old to participate.',
howMuchDoesItCost: 'how much does it cost?',
howMuchDoesItCostAnswer:
'100% free - all the prizes are donated to us or paid for by hack club!',
whatTypesOfProjects: 'what types of projects count?',
whatTypesOfProjectsAnswer:
"all kinds of technical projects as long as it's open-source on github!",
howManyProjects: 'how many projects can i build?',
howManyProjectsAnswer: "there's no limit! build as much as you can!",
isThisLegit: 'is this legit?',
isThisLegitAnswer: 'yup! hack club has run programs like',
highSeas: 'high seas',
and: 'and',
summerOfMaking: 'summer of making',
similarPrizes: '— both gave out similar prizes for building personal projects.'
}
} as const;

571
frontend/src/lib/i18n/es.ts Normal file
View file

@ -0,0 +1,571 @@
export default {
nav: {
home: 'inicio',
scraps: 'scraps',
about: 'acerca de',
dashboard: 'panel',
leaderboard: 'clasificación',
shop: 'tienda',
refinery: 'refinería',
explore: 'explorar',
logout: 'cerrar sesión',
info: 'información',
reviews: 'revisiones',
users: 'usuarios',
orders: 'pedidos',
news: 'noticias',
escape: 'salir',
admin: 'admin'
},
landing: {
youShip: 'tú envías:',
anyProject: 'cualquier proyecto',
weShip: 'nosotros enviamos:',
chanceToWin: 'una oportunidad de ganar algo increíble',
yourEmail: 'tu correo electrónico',
pleaseEnterEmail: 'por favor ingresa tu correo',
pleaseEnterValidEmail: 'por favor ingresa un correo válido',
startScrapping: 'empezar a scrapear',
goToDashboard: 'ir al panel',
itemsUpForGrabs: '(artículos disponibles)',
tldr: 'resumen',
aboutScraps: 'acerca de scraps',
sillyTooltip: 'tonto, sin sentido o divertido',
rareStickersTooltip: '(incluyendo stickers raros)',
optionallySillyTooltip: 'opcionalmente tonto, sin sentido o divertido',
stickersTooltip: '(incluyendo stickers)',
vermontTooltip: 'en vermont',
hardwareTooltip: 'sensores, esp32s, arduinos, protoboards y una resistencia solitaria',
limitedEditionTooltip: 'stickers de edición limitada',
anyProjectTooltip: 'o literalmente cualquier proyecto',
fudgeTooltip: 'dulce de chocolate dulce de chocolate dulce de chocolate',
collectionTooltip:
'próximamente collection.hackclub.com para llevar el registro de tu colección de stickers',
weShipRandomItems: 'artículos aleatorios desde las oficinas',
moreHoursMoreStuff: '(más horas, más cosas)',
coldAndWintery: 'hace frío e invernal aquí',
atHackClubHQ:
'en las oficinas de hack club. después de prototype, overglade, milkyway y otros hackatones, hay cajas y cajas de artículos, "scraps", si quieres. ahora, querido hack clubber, este ysws es tu oportunidad de ganar los restos geniales, incluyendo hardware aleatorio',
postcardsAndMore: ', postales, dulce de chocolate y tal vez una sorpresa secreta',
butHow: '¿pero cómo?, te preguntarás',
simpleExplanation:
'bueno, es simple: solo envía proyectos que sean un poco tontos, sin sentido o divertidos',
earnScraps: ', ¡y ganarás scraps por el tiempo que inviertas! rastrea tu tiempo con',
hackatime: 'hackatime',
watchScrapsRollIn: 'y mira cómo llegan los scraps.',
whatCanYouWin: '¿qué puedes ganar?',
currentlyRandomAssortment:
'actualmente, hay un surtido aleatorio de hardware sobrante de prototype, postales, el famoso dulce de chocolate de vermont',
moreItemsPlanned:
', y más artículos planeados a medida que los eventos terminen. ah, y lo mejor,',
stickers: '¡stickers!',
wonderedHowToGet: '¿alguna vez te preguntaste cómo conseguir los stickers geniales de',
hereIsYourChance:
'? bueno, aquí está tu oportunidad de obtener cualquier sticker (que tengamos en stock) para completar tu colección',
rarestStickers: '! esto incluye algunos de los stickers más raros y buscados de hack club.',
faq: 'preguntas frecuentes'
},
dashboard: {
hello: 'hola, {name}',
greetings: {
readyToScrap: '¿listo para scrapear?',
timeToBuild: 'hora de construir algo tonto',
whatWillYouShip: '¿qué enviarás hoy?',
letTheScrapBegin: 'que comience el scrapeo',
hackAway: '¡a hackear!',
makeSomethingFun: 'haz algo divertido',
createShipRepeat: 'crear, enviar, repetir',
keepOnScrapping: 'sigue scrapeando'
},
newProject: 'nuevo proyecto',
tier: 'nivel',
faq: 'preguntas frecuentes',
faqQuestions: {
howDoesShopWork: '¿cómo funciona la tienda?',
howDoesShopWorkAnswer:
'gasta scraps para tirar por premios. cada artículo tiene una probabilidad de ganar - ¡aumenta tus probabilidades en la refinería!',
whatIsHackatime: '¿qué es hackatime?',
whatIsHackatimeAnswer:
'es la herramienta de seguimiento de tiempo de hack club que registra automáticamente tus horas de programación.',
whatIsRefinery: '¿qué es la refinería?',
whatIsRefineryAnswer:
'gasta scraps para aumentar tu probabilidad de ganar un artículo. ¡cada mejora aumenta tus chances!',
howLongDoesReviewTake: '¿cuánto tarda la revisión?',
howLongDoesReviewTakeAnswer:
'las revisiones de proyectos generalmente toman unos días. ¡te notificaremos cuando tu proyecto sea aprobado!',
whatIfILoseRoll: '¿qué pasa si pierdo una tirada?',
whatIfILoseRollAnswer:
'recibes papel scrap de consolación. tus mejoras de la refinería se mantienen, ¡así que intenta de nuevo!',
moreQuestions: '¿más preguntas?'
}
},
shop: {
itemsUpForGrabs: 'artículos disponibles',
tags: 'etiquetas:',
sort: 'ordenar:',
default: 'predeterminado',
favorites: 'favoritos',
probability: 'probabilidad',
loadingItems: 'Cargando artículos...',
noItemsAvailable: 'No hay artículos disponibles',
soldOut: 'agotado',
left: 'restantes',
chance: 'probabilidad',
myOrders: 'mis pedidos',
clear: 'limpiar',
consolationScrapPaper: 'papel scrap de consolación',
betterLuckNextTime: '¡mejor suerte la próxima!',
youRolledButNeeded: 'sacaste {rolled} pero necesitabas {needed} o menos.',
consolationMessage:
'¡como consolación, te enviaremos un papel scrap aleatorio de las oficinas de hack club! solo dinos dónde enviarlo.',
yourChance: 'tu probabilidad',
base: 'base',
yourBoost: 'tu mejora',
previousBuy: 'compra anterior',
leaderboard: 'clasificación',
wishlist: 'lista de deseos',
buyers: 'compradores',
noOneHasBoostedYet: 'nadie ha mejorado aún',
peopleWantThisItem: 'personas quieren este artículo',
includingYou: '¡incluyéndote!',
noOneHasWonYet: 'nadie ha ganado este artículo aún',
notEnoughScraps: 'no tienes suficientes scraps',
tryYourLuck: 'probar suerte',
confirmTryYourLuck: 'confirmar probar suerte',
confirmTryLuckMessage: '¿estás seguro de que quieres probar suerte? esto costará',
yourChanceLabel: 'tu probabilidad:',
somethingWentWrong: 'algo salió mal',
failedToTryLuck: 'Error al probar suerte'
},
common: {
cancel: 'cancelar',
confirm: 'confirmar',
save: 'guardar',
delete: 'eliminar',
edit: 'editar',
submit: 'enviar',
close: 'cerrar',
loading: 'cargando...',
error: 'error',
success: 'éxito',
serverError: 'error del servidor',
scraps: 'scraps',
dismiss: 'descartar',
ok: 'ok',
create: 'crear',
creating: 'creando...',
trying: 'intentando...',
tryLuck: 'probar suerte',
saving: 'guardando...',
result: 'resultado'
},
refinery: {
refinery: 'refinería',
upgradeYourLuck: 'mejora tu suerte',
maxed: 'al máximo',
upgrading: 'mejorando...',
fromPreviousBuy: 'de compra anterior',
noItemsAvailable: 'no hay artículos disponibles para mejorar',
failedToUpgrade: 'Error al mejorar probabilidad',
ok: 'ok'
},
leaderboard: {
leaderboard: 'clasificación',
topScrappers: 'mejores scrappers',
hours: 'horas',
scrapsEarned: 'scraps ganados',
rank: 'posición',
user: 'usuario',
projects: 'proyectos',
scraps: 'scraps',
earned: 'ganados',
probabilityLeaders: 'líderes de probabilidad',
base: 'base',
noBoostsYet: 'sin mejoras aún',
noProbabilityLeadersYet: 'no hay líderes de probabilidad aún'
},
explore: {
explore: 'explorar',
searchPlaceholder: 'buscar proyectos...',
tier: 'nivel:',
status: 'estado:',
sort: 'ordenar:',
shipped: 'enviado',
inProgress: 'en progreso',
recent: 'recientes',
mostViewed: 'más vistos',
random: 'aleatorio',
clear: 'limpiar',
loadingProjects: 'cargando proyectos...',
noProjectsFound: 'no se encontraron proyectos',
tryAdjustingFilters: 'intenta ajustar tus filtros o búsqueda',
by: 'por',
anonymous: 'anónimo',
prev: 'anterior',
next: 'siguiente'
},
orders: {
myOrders: 'mis pedidos',
backToShop: 'volver a la tienda',
loadingOrders: 'cargando pedidos...',
noOrdersYet: 'aún no hay pedidos',
tryYourLuck: '¡prueba tu suerte en la tienda para conseguir premios!',
goToShop: 'ir a la tienda',
paperScraps: 'papel scrap',
won: 'ganado',
consolation: 'consolación',
purchased: 'comprado',
fulfilled: 'completado',
pending: 'pendiente',
shipped: 'enviado',
delivered: 'entregado',
qty: 'cant',
noShippingAddress: '⚠️ no se proporcionó dirección de envío'
},
submit: {
title: 'enviar proyecto',
pageTitle: 'enviar proyecto - scraps',
subtitle: 'envía tu proyecto para revisión y gana scraps',
selectProject: 'seleccionar proyecto',
chooseProject: 'elegir un proyecto...',
noEligibleProjects: 'no hay proyectos elegibles',
hoursLogged: '{hours}h registradas',
submitForReview: 'enviar para revisión',
submitting: 'enviando...',
pleaseSelectProject: 'Por favor selecciona un proyecto',
failedToSubmit: 'Error al enviar el proyecto'
},
createProject: {
newProject: 'nuevo proyecto',
image: 'imagen',
optional: 'opcional',
uploading: 'subiendo...',
clickToUploadImage: 'clic para subir imagen',
imageMustBeLessThan: 'La imagen debe ser menor a 5MB',
name: 'nombre',
description: 'descripción',
hackatimeProject: 'proyecto hackatime',
loadingProjects: 'cargando proyectos...',
selectAProject: 'seleccionar un proyecto',
noProjectsFound: 'no se encontraron proyectos',
githubUrl: 'url de github',
projectTier: 'nivel del proyecto',
requirements: 'requisitos',
addProjectName: 'agregar nombre del proyecto',
writeDescription: 'escribir una descripción (mín {min} caracteres)',
pleaseCompleteRequirements: 'Por favor completa todos los requisitos',
failedToCreateProject: 'Error al crear el proyecto',
tierDescriptions: {
tier1: 'proyectos simples, tutoriales, scripts pequeños',
tier2: 'complejidad moderada, proyectos multi-archivo',
tier3: 'características complejas, APIs, integraciones',
tier4: 'aplicaciones completas, emprendimientos mayores'
}
},
address: {
shippingAddress: 'dirección de envío',
congratulations: '🎉 ¡felicitaciones!',
youWon: 'ganaste',
selectShippingAddress: 'selecciona tu dirección de envío para recibirlo.',
yourAddresses: 'tus direcciones',
selectAnAddress: 'seleccionar una dirección',
primary: 'principal',
selectedAddress: 'dirección seleccionada:',
manageAddresses: 'administrar direcciones en hack club auth',
noSavedAddresses: 'aún no tienes direcciones guardadas.',
addAddress: 'agregar una dirección en hack club',
afterAddingAddress:
'después de agregar una dirección, actualiza esta página para seleccionarla.',
loadingAddresses: 'cargando direcciones...',
confirmShippingAddress: 'confirmar dirección de envío',
failedToSaveAddress: 'Error al guardar la dirección'
},
faq: {
title: 'preguntas frecuentes',
pageTitle: 'preguntas frecuentes - scraps',
subtitle: 'preguntas frecuentes sobre scraps',
whatIsScraps: '¿qué es scraps?',
whatIsScrapsAnswer:
'scraps es un programa de hack club donde ganas scraps construyendo proyectos y programando. ¡luego puedes gastar tus scraps en la tienda para probar tu suerte y ganar premios de hack club hq!',
howDoIEarnScraps: '¿cómo gano scraps?',
howDoIEarnScrapsAnswer:
'ganas scraps enviando proyectos que hayas construido. cada proyecto es revisado por nuestro equipo, y recibes scraps según las horas que hayas trabajado. conecta tu cuenta de <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a> para rastrear automáticamente tus horas de programación.',
whatIsHackatime: '¿qué es hackatime?',
whatIsHackatimeAnswer:
'<a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a> es la herramienta de seguimiento de tiempo de hack club que registra automáticamente tus horas de programación. conéctalo a tus proyectos para que podamos ver cuánto tiempo has pasado construyendo. visita <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime.hackclub.com</a> para comenzar.',
howDoesShopWork: '¿cómo funciona la tienda?',
howDoesShopWorkAnswer:
'en la tienda, gastas scraps para tirar por premios. cada artículo tiene una probabilidad base de ganar. si no ganas, recibes papel scrap de consolación. el costo de tirar depende de la rareza del artículo.',
whatIsRefinery: '¿qué es la refinería?',
whatIsRefineryAnswer:
'la refinería te permite gastar scraps para aumentar tu probabilidad de ganar un artículo. cada mejora aumenta tus chances, pero los costos aumentan con cada mejora. es una forma de mejorar tus probabilidades antes de tirar.',
whatAreProjectTiers: '¿qué son los niveles de proyecto?',
whatAreProjectTiersAnswer:
'los proyectos se asignan niveles (1-4) según complejidad y calidad. niveles más altos ganan más scraps por hora:<br><br><strong>nivel 1 (0.8x)</strong> - proyectos simples, tutoriales, scripts pequeños<br><strong>nivel 2 (1x)</strong> - complejidad moderada, proyectos multi-archivo<br><strong>nivel 3 (1.25x)</strong> - características complejas, APIs, integraciones<br><strong>nivel 4 (1.5x)</strong> - aplicaciones completas, proyectos grandes<br><br>al enviar tu proyecto, selecciona el nivel que mejor coincida con el alcance de tu proyecto. los revisores pueden ajustar el nivel durante la revisión.',
howLongDoesReviewTake: '¿cuánto tarda la revisión?',
howLongDoesReviewTakeAnswer:
'las revisiones de proyectos generalmente toman unos días. nuestro equipo revisa cada envío para verificar el trabajo y asignar un nivel apropiado. te notificaremos cuando tu proyecto sea aprobado.',
whatIfILoseRoll: '¿qué pasa si pierdo una tirada?',
whatIfILoseRollAnswer:
'si pierdes una tirada, recibes papel scrap de consolación como pequeño premio. tus mejoras de la refinería se mantienen, así que puedes intentar de nuevo con mejores probabilidades. ¡sigue construyendo proyectos para ganar más scraps!',
canISubmitAnyProject: '¿puedo enviar cualquier proyecto?',
canISubmitAnyProjectAnswer:
'los proyectos deben ser trabajo original que hayas construido. necesitan un repositorio de github, descripción y horas rastreadas vía <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a>. ¡tanto proyectos nuevos como mejoras a proyectos existentes cuentan!',
howDoIGetStarted: '¿cómo empiezo?',
howDoIGetStartedAnswer:
'¡inicia sesión con tu cuenta de hack club, conecta <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a>, y comienza a construir! cuando estés listo, envía tu proyecto para revisión. una vez aprobado, recibirás scraps según tus horas.',
stillHaveQuestions: '¿aún tienes preguntas?',
reachOutOnSlack: 'contáctanos en el slack de hack club en el canal #scraps',
joinSlack: 'unirse al slack de hack club'
},
project: {
backToDashboard: 'volver al panel',
backToProfile: 'volver al perfil de {username}',
back: 'volver',
backToProject: 'volver al proyecto',
loadingProject: 'cargando proyecto...',
goBack: 'volver',
projectNotFound: 'proyecto no encontrado',
shipped: 'enviado',
awaitingReview: 'esperando revisión',
inProgress: 'en progreso',
waitingForReview: 'esperando revisión',
rejected: 'rechazado',
tier: 'nivel {value}',
noDescriptionYet: 'sin descripción aún',
views: '{count} vistas',
scrapsEarned: '+{count} scraps ganados',
viewOnGithub: 'ver en github',
tryItOut: 'probarlo',
createdBy: 'creado por',
unknown: 'desconocido',
editProject: 'editar proyecto',
reviewAndSubmit: 'revisar y enviar',
activity: 'actividad',
noActivityYet: 'sin actividad aún',
submitToGetStarted: 'envía tu proyecto para comenzar',
approved: 'aprobado',
changesRequested: 'cambios solicitados',
permanentlyRejected: 'rechazado permanentemente',
reviewedBy: 'revisado por',
reviewer: 'revisor',
submittedForReview: 'enviado para revisión',
projectCreated: 'proyecto creado',
image: 'imagen',
uploading: 'subiendo...',
clickToUploadImage: 'clic para subir imagen',
name: 'nombre',
description: 'descripción',
githubUrl: 'url de github',
optional: 'opcional',
playableUrl: 'url jugable',
requiredForSubmission: 'requerido para enviar',
playableUrlHint: 'un enlace donde los revisores puedan probar tu proyecto',
hackatimeProject: 'proyecto hackatime',
loadingProjects: 'cargando proyectos...',
selectAProject: 'seleccionar un proyecto',
noProjectsFound: 'no se encontraron proyectos',
projectTier: 'nivel del proyecto',
tierDescriptions: {
tier1: 'proyectos simples, tutoriales, scripts pequeños',
tier2: 'complejidad moderada, proyectos multi-archivo',
tier3: 'características complejas, APIs, integraciones',
tier4: 'aplicaciones completas, emprendimientos mayores'
},
saveChanges: 'guardar cambios',
saving: 'guardando...',
dangerZone: 'zona de peligro',
deleteThisProject: 'eliminar este proyecto',
deleteWarning: 'una vez eliminado, este proyecto no se puede recuperar.',
areYouSure: '¿estás seguro?',
deleteConfirmation: 'esto eliminará permanentemente {name}. esta acción no se puede deshacer.',
deleting: 'eliminando...',
deleteProject: 'eliminar proyecto',
submitForReview: 'enviar para revisión',
submitRequirementsHint:
'asegúrate de que tu proyecto cumpla todos los requisitos antes de enviar',
projectImage: 'imagen del proyecto',
noImageAddOne: 'sin imagen - agrégala en editar',
selectComplexityTier: 'selecciona el nivel de complejidad que mejor coincida con tu proyecto',
requirementsChecklist: 'lista de requisitos',
projectImageUploaded: 'imagen del proyecto subida',
projectName: 'nombre del proyecto (máx {max} caracteres)',
descriptionRequirement: 'descripción ({min}-{max} caracteres)',
githubRepositoryLinked: 'repositorio github vinculado',
playableUrlProvided: 'url jugable proporcionada',
hackatimeProjectSelected: 'proyecto hackatime seleccionado',
submitting: 'enviando...',
hoursLogged: '{hours}h registradas',
github: 'github',
reviewFeedback: 'comentarios de revisión',
imageMustBeLessThan: 'La imagen debe ser menor a 5MB'
},
profile: {
backToLeaderboard: 'volver a clasificación',
loading: 'cargando...',
userNotFound: 'Usuario no encontrado',
failedToLoad: 'Error al cargar perfil',
joined: 'se unió',
scraps: 'scraps',
shipped: 'enviado',
inProgress: 'en progreso',
totalHours: 'horas totales',
projects: 'proyectos',
all: 'todos',
underReview: 'en revisión',
noProjectsFound: 'no se encontraron proyectos',
github: 'github',
wishlist: 'lista de deseos',
refinements: 'refinamientos',
noRefinements: 'no hay refinamientos para mostrar',
roles: {
admin: 'admin',
reviewer: 'revisor',
member: 'miembro',
banned: 'baneado'
},
editRole: 'editar rol',
changeRole: 'cambiar rol',
selectRole: 'seleccionar rol',
updateRole: 'actualizar rol',
roleUpdated: 'rol actualizado correctamente',
failedToUpdateRole: 'error al actualizar rol'
},
footer: {
madeWith: 'hecho con <3 por',
hackClub: 'hack club',
and: 'y'
},
news: {
title: 'noticias',
previousNews: 'noticia anterior',
nextNews: 'siguiente noticia',
noNewsRightNow: 'no hay noticias ahora',
goToNews: 'ir a noticia'
},
tutorial: {
stepOf: 'paso {current} de {total}',
skipTutorial: 'saltar tutorial',
skip: 'saltar',
clickToContinue: 'haz clic en el botón para continuar',
completeFormToContinue: 'completa el formulario para continuar',
completing: 'completando...',
claimScraps: 'reclamar 5 scraps',
next: 'siguiente',
setUpHackatime: 'configurar hackatime →',
steps: {
welcomeTitle: '¡bienvenido a scraps!',
welcomeDesc:
'scraps es un programa donde ganas recompensas por construir proyectos geniales. ¡veamos cómo funciona!',
navigationTitle: 'navegación',
navigationDesc:
'usa la barra de navegación para moverte entre el panel (tus proyectos), tienda (gastar scraps), refinería (mejorar probabilidades), y clasificación (ver mejores constructores).',
createProjectsTitle: 'crear proyectos',
createProjectsDesc:
'comienza creando proyectos en tu panel. vincúlalos a Hackatime (hackatime.hackclub.com) para rastrear automáticamente tu tiempo de programación.',
createFirstProjectTitle: 'crea tu primer proyecto',
createFirstProjectDesc:
'haz clic en el botón "nuevo proyecto" a la derecha para abrir el modal de creación de proyecto.',
fillDetailsTitle: 'completa los detalles del proyecto',
fillDetailsDesc:
'hemos prellenado texto de ejemplo para ti. ¡siéntete libre de personalizarlo o simplemente haz clic en "crear" para continuar!',
projectPageTitle: 'tu página de proyecto',
projectPageDesc:
'¡buen trabajo! esta es tu página de proyecto. puedes ver detalles, editar tu proyecto y enviarlo para revisión cuando estés listo.',
submitReviewTitle: 'enviar para revisión',
submitReviewDesc:
'cuando estés listo para enviar, haz clic en "revisar y enviar" para enviar tu proyecto. ¡una vez aprobado, ganarás scraps según tu tiempo de programación!',
projectTiersTitle: 'niveles de proyecto',
projectTiersDesc:
'al enviar, puedes seleccionar un nivel (1-4) según la complejidad de tu proyecto. niveles más altos ganan más scraps por hora.',
earnScrapsTitle: 'ganar scraps',
earnScrapsDesc:
'ganas scraps por el tiempo que inviertes. ¡cuanto más construyas, más ganas! tu saldo de scraps se muestra aquí.',
shopTitle: 'la tienda',
shopDesc:
'gasta tus scraps en la tienda para probar suerte ganando premios. cada artículo tiene una probabilidad base de éxito.',
refineryTitle: 'la refinería',
refineryDesc:
'invierte scraps en la refinería para aumentar tu probabilidad en artículos específicos. ¡mayor probabilidad = mejores chances!',
strategyTitle: 'hora de estrategia',
strategyDesc:
'tienes una opción: probar suerte con la probabilidad base O invertir primero en la refinería para mejorar tus chances. ¡elige sabiamente!',
readyTitle: '¡estás listo!',
readyDesc:
'aquí tienes 5 scraps de bonificación para empezar. ¡ahora ve a construir algo increíble!'
}
},
admin: {
adminInfo: 'información de admin',
reviewQueue: 'cola de revisión',
projectsWaitingForReview: 'proyectos esperando revisión',
noProjectsWaitingForReview: 'no hay proyectos esperando revisión',
manageUsersAndPermissions: 'administrar usuarios y permisos',
manageShopItemsAndInventory: 'administrar artículos e inventario de la tienda',
manageOrdersAndFulfillment: 'administrar pedidos y envíos de la tienda',
manageAnnouncementsAndUpdates: 'administrar anuncios y actualizaciones',
addItem: 'agregar artículo',
addNews: 'agregar noticia',
editItem: 'editar artículo',
editNews: 'editar noticia',
editUser: 'editar usuario',
confirmDelete: 'confirmar eliminación',
view: 'ver',
fulfill: 'completar',
unfulfill: 'descompletar',
all: 'todos',
pending: 'pendiente',
fulfilled: 'completado',
loadingStats: 'cargando estadísticas...',
page: 'página',
of: 'de'
},
auth: {
error: 'error',
authenticating: 'autenticando...',
pleaseWait: 'por favor espera mientras verificamos tu cuenta',
oops: '¡ups!',
goBackHome: 'volver al inicio',
getHelpOnSlack: 'obtener ayuda en slack',
needsVerification: {
title: 'verifica tu identidad',
description:
'necesitas verificar tu identidad con hack club auth antes de poder usar scraps. haz clic abajo para completar la verificación.',
redirectText: 'verificar con hack club auth'
},
notEligible: {
title: 'no elegible para ysws',
description:
'tu cuenta de hack club no es actualmente elegible para los programas you ship we ship. por favor pide ayuda en el slack de hack club.'
},
authFailed: {
title: 'autenticación fallida',
description: 'no pudimos verificar tu identidad. por favor intenta de nuevo.'
},
unknown: {
title: 'algo salió mal',
description: 'ocurrió un error inesperado. por favor intenta más tarde.'
},
authenticationFailed: 'Autenticación fallida',
errorDuringAuth: 'Ocurrió un error durante la autenticación'
},
about: {
whoIsEligible: '¿quién puede participar?',
whoIsEligibleAnswer:
'¡scraps es para estudiantes de secundaria! necesitas tener entre 13 y 18 años para participar.',
howMuchDoesItCost: '¿cuánto cuesta?',
howMuchDoesItCostAnswer:
'100% gratis - ¡todos los premios son donados o pagados por hack club!',
whatTypesOfProjects: '¿qué tipos de proyectos cuentan?',
whatTypesOfProjectsAnswer:
'¡todo tipo de proyectos técnicos siempre que sean de código abierto en github!',
howManyProjects: '¿cuántos proyectos puedo construir?',
howManyProjectsAnswer: '¡no hay límite! ¡construye todo lo que puedas!',
isThisLegit: '¿esto es legítimo?',
isThisLegitAnswer: '¡sí! hack club ha realizado programas como',
highSeas: 'high seas',
and: 'y',
summerOfMaking: 'summer of making',
similarPrizes: '— ambos dieron premios similares por construir proyectos personales.'
}
} as const;

View file

@ -0,0 +1,38 @@
import { writable, derived } from 'svelte/store';
import en from './en';
import es from './es';
export type Locale = 'en' | 'es';
export type Translations = typeof en;
const translations: Record<Locale, Translations> = { en, es };
function getInitialLocale(): Locale {
if (typeof window === 'undefined') return 'en';
const stored = localStorage.getItem('locale');
if (stored === 'en' || stored === 'es') return stored;
return 'en';
}
function createLocaleStore() {
const { subscribe, set, update } = writable<Locale>(getInitialLocale());
return {
subscribe,
set: (value: Locale) => {
if (typeof window !== 'undefined') {
localStorage.setItem('locale', value);
}
set(value);
},
update
};
}
export const locale = createLocaleStore();
export const t = derived(locale, ($locale) => translations[$locale]);
export function setLocale(lang: Locale) {
locale.set(lang);
}

View file

@ -51,7 +51,10 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="description" content="Build projects, earn scraps, test your luck, get prizes. A Hack Club program for teen coders." />
<meta
name="description"
content="Build projects, earn scraps, test your luck, get prizes. A Hack Club program for teen coders."
/>
<meta name="author" content="Hack Club" />
<meta name="keywords" content="hack club, scraps, ysws, projects, coding, prizes" />
<meta name="theme-color" content="#000000" />
@ -59,13 +62,19 @@
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="scraps - Hack Club" />
<meta property="og:title" content="scraps - Hack Club" />
<meta property="og:description" content="Build projects, earn scraps, test your luck, get prizes. A Hack Club program for teen coders." />
<meta
property="og:description"
content="Build projects, earn scraps, test your luck, get prizes. A Hack Club program for teen coders."
/>
<meta property="og:image" content={favicon} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@hackclub" />
<meta name="twitter:creator" content="@hackclub" />
<meta name="twitter:title" content="scraps - Hack Club" />
<meta name="twitter:description" content="Build projects, earn scraps, test your luck, get prizes. A Hack Club program for teen coders." />
<meta
name="twitter:description"
content="Build projects, earn scraps, test your luck, get prizes. A Hack Club program for teen coders."
/>
<meta name="twitter:image" content={favicon} />
</svelte:head>

View file

@ -5,6 +5,7 @@
import Superscript from '$lib/components/Superscript.svelte';
import { login, getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
let email = $state('');
let emailError = $state('');
@ -24,12 +25,12 @@
emailError = '';
if (!email.trim()) {
emailError = 'please enter your email';
emailError = $t.landing.pleaseEnterEmail;
return;
}
if (!isValidEmail(email)) {
emailError = 'please enter a valid email';
emailError = $t.landing.pleaseEnterValidEmail;
return;
}
@ -169,18 +170,14 @@
</div>
<div class="w-full px-6 py-4 md:absolute md:right-1/4 md:bottom-1/5 md:w-auto md:max-w-lg md:p-0">
<h1 class="mb-4 text-6xl font-bold md:text-8xl">scraps</h1>
<h1 class="mb-4 text-6xl font-bold md:text-8xl">{$t.nav.scraps}</h1>
<p class="mb-1 text-lg md:text-xl">
<strong>you ship:</strong> any project<Superscript
number={1}
tooltip="silly, nonsensical, or fun"
/>
<strong>{$t.landing.youShip}</strong>
{$t.landing.anyProject}<Superscript number={1} tooltip={$t.landing.sillyTooltip} />
</p>
<p class="mb-6 text-lg md:text-xl">
<strong>we ship:</strong> a chance to win something amazing<Superscript
number={2}
tooltip="(including rare stickers)"
/>
<strong>{$t.landing.weShip}</strong>
{$t.landing.chanceToWin}<Superscript number={2} tooltip={$t.landing.rareStickersTooltip} />
</p>
<!-- Auth Section -->
@ -189,7 +186,7 @@
<input
type="email"
bind:value={email}
placeholder="your email"
placeholder={$t.landing.yourEmail}
class="w-full rounded-full border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
/>
{#if emailError}
@ -202,7 +199,7 @@
class="flex cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-8 py-3 font-bold text-white transition-all hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
<Origami size={18} />
<span>{isLoggedIn ? 'go to dashboard' : 'start scrapping'}</span>
<span>{isLoggedIn ? $t.landing.goToDashboard : $t.landing.startScrapping}</span>
</button>
</div>
</div>
@ -212,8 +209,8 @@
<div id="scraps" class="flex min-h-dvh flex-col overflow-hidden">
<div class="px-6 pt-24 pb-8 md:px-12">
<div class="mx-auto max-w-3xl">
<h2 class="mb-2 text-4xl font-bold md:text-6xl">scraps</h2>
<p class="text-lg text-gray-600 md:text-xl">(items up for grabs)</p>
<h2 class="mb-2 text-4xl font-bold md:text-6xl">{$t.nav.scraps}</h2>
<p class="text-lg text-gray-600 md:text-xl">{$t.landing.itemsUpForGrabs}</p>
</div>
</div>
@ -276,64 +273,60 @@
<!-- About Section -->
<div id="about" class="mx-auto min-h-dvh max-w-3xl px-6 pt-24 pb-24 md:px-12">
<h2 class="mb-8 text-4xl font-bold md:text-6xl">about scraps</h2>
<h2 class="mb-8 text-4xl font-bold md:text-6xl">{$t.landing.aboutScraps}</h2>
<div class="prose prose-lg">
<p class="mb-4 text-xl font-bold">tl;dr</p>
<p class="mb-4 text-xl font-bold">{$t.landing.tldr}</p>
<p class="mb-2">
<strong>you ship:</strong> any project <Superscript
number={3}
tooltip="optionally silly, nonsensical, or fun"
/>
<strong>{$t.landing.youShip}</strong>
{$t.landing.anyProject}
<Superscript number={3} tooltip={$t.landing.optionallySillyTooltip} />
</p>
<p class="mb-6">
<strong>we ship:</strong> random items from hq<Superscript
number={4}
tooltip="(including stickers)"
/> (more hours, more stuff)
<strong>{$t.landing.weShip}</strong>
{$t.landing.weShipRandomItems}<Superscript number={4} tooltip={$t.landing.stickersTooltip} />
{$t.landing.moreHoursMoreStuff}
</p>
<p class="mb-6">
it's cold and wintery here<Superscript number={5} tooltip="in vermont" /> at hack club hq. after
prototype, overglade, milkyway, and other hackathons, there are boxes and boxes of items, "scraps,"
if you will. now, dear hack clubber, this ysws is your chance to win the cool leftovers, including
random hardware<Superscript
number={6}
tooltip="sensors, esp32s, arduinos, breadboards, and a singular resistor"
/>, postcards, fudge, and maybe a secret surprise<Superscript
{$t.landing.coldAndWintery}<Superscript number={5} tooltip={$t.landing.vermontTooltip} />
{$t.landing.atHackClubHQ}<Superscript number={6} tooltip={$t.landing.hardwareTooltip} />{$t
.landing.postcardsAndMore}<Superscript
number={7}
tooltip="limited edition stickers"
tooltip={$t.landing.limitedEditionTooltip}
/>!
</p>
<p class="mb-4 text-xl font-bold">but how, you may ask?</p>
<p class="mb-4 text-xl font-bold">{$t.landing.butHow}</p>
<p class="mb-6">
well, it's simple: you just ship any projects that are slightly silly, nonsensical, or fun<Superscript
{$t.landing.simpleExplanation}<Superscript
number={8}
tooltip="or literally any project"
/>, and you will earn scraps for the time you put in! track your time with
tooltip={$t.landing.anyProjectTooltip}
/>{$t.landing.earnScraps}
<a
href="https://hackatime.hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer underline hover:no-underline">hackatime</a
> and watch the scraps roll in.
class="cursor-pointer underline hover:no-underline">{$t.landing.hackatime}</a
>
{$t.landing.watchScrapsRollIn}
</p>
<p class="mb-4 text-xl font-bold">what can you win?</p>
<p class="mb-4 text-xl font-bold">{$t.landing.whatCanYouWin}</p>
<p class="mb-6">
currently, there is a random assortment of hardware left over from prototype, postcards, the
famous vermont fudge<Superscript number={9} tooltip="fudge fudge fudge" />, and more items
planned as events wrap up. oh, and the best part,
<strong>stickers!</strong>
{$t.landing.currentlyRandomAssortment}<Superscript
number={9}
tooltip={$t.landing.fudgeTooltip}
/>{$t.landing.moreItemsPlanned}
<strong>{$t.landing.stickers}</strong>
</p>
<p class="mb-6">
if you have ever wondered how to get the cool stickers from
{$t.landing.wonderedHowToGet}
<a
href="https://stickers.hackclub.com/"
target="_blank"
@ -341,68 +334,69 @@
class="cursor-pointer underline hover:no-underline"
>
stickers.hackclub.com</a
>? well, here is your chance to get any sticker (that we have in stock) to complete your
collection<Superscript
>{$t.landing.hereIsYourChance}<Superscript
number={10}
tooltip="soon to be made collection.hackclub.com to keep track of your sticker collection"
/>! this includes some of the rarest and most sought-after stickers from hack club.
tooltip={$t.landing.collectionTooltip}
/>{$t.landing.rarestStickers}
</p>
<p class="mb-6 text-xl font-bold">frequently asked questions</p>
<p class="mb-6 text-xl font-bold">{$t.landing.faq}</p>
<div class="grid gap-4 sm:grid-cols-2">
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">who is eligible?</p>
<p class="mb-2 text-lg font-bold">{$t.about.whoIsEligible}</p>
<p class="text-gray-600">
scraps is for high schoolers! you need to be 13-18 years old to participate.
{$t.about.whoIsEligibleAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">how much does it cost?</p>
<p class="mb-2 text-lg font-bold">{$t.about.howMuchDoesItCost}</p>
<p class="text-gray-600">
100% free - all the prizes are donated to us or paid for by hack club!
{$t.about.howMuchDoesItCostAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">what types of projects count?</p>
<p class="mb-2 text-lg font-bold">{$t.about.whatTypesOfProjects}</p>
<p class="text-gray-600">
all kinds of technical projects as long as it's open-source on github!
{$t.about.whatTypesOfProjectsAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">how many projects can i build?</p>
<p class="text-gray-600">there's no limit! build as much as you can!</p>
<p class="mb-2 text-lg font-bold">{$t.about.howManyProjects}</p>
<p class="text-gray-600">{$t.about.howManyProjectsAnswer}</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed sm:col-span-2"
>
<p class="mb-2 text-lg font-bold">is this legit?</p>
<p class="mb-2 text-lg font-bold">{$t.about.isThisLegit}</p>
<p class="text-gray-600">
yup! hack club has run programs like <a
{$t.about.isThisLegitAnswer}
<a
href="https://highseas.hackclub.com/"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer underline hover:no-underline">high seas</a
class="cursor-pointer underline hover:no-underline">{$t.about.highSeas}</a
>
and
{$t.about.and}
<a
href="https://summer.hackclub.com/"
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer underline hover:no-underline">summer of making</a
> — both gave out similar prizes for building personal projects.
class="cursor-pointer underline hover:no-underline">{$t.about.summerOfMaking}</a
>
{$t.about.similarPrizes}
</p>
</div>
</div>

View file

@ -4,6 +4,7 @@
import { Users, FolderKanban, Clock, Scale, Hourglass } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
interface Stats {
totalUsers: number;
@ -42,16 +43,16 @@
</script>
<svelte:head>
<title>admin info - scraps</title>
<title>{$t.admin.adminInfo} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
<h1 class="mb-8 text-4xl font-bold md:text-5xl">admin info</h1>
<h1 class="mb-8 text-4xl font-bold md:text-5xl">{$t.admin.adminInfo}</h1>
{#if loading}
<div class="py-12 text-center text-gray-500">loading stats...</div>
<div class="py-12 text-center text-gray-500">{$t.admin.loadingStats}</div>
{:else if error}
<div class="py-12 text-center text-red-600">{error}</div>
<div class="py-12 text-center text-red-600">{$t.common.error}: {error}</div>
{:else if stats}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="flex items-center gap-4 rounded-2xl border-4 border-black p-6">
@ -103,7 +104,9 @@
</div>
<div>
<p class="text-sm font-bold text-gray-500">in progress hours</p>
<p class="text-4xl font-bold text-yellow-600">{stats.inProgressHours.toLocaleString()}h</p>
<p class="text-4xl font-bold text-yellow-600">
{stats.inProgressHours.toLocaleString()}h
</p>
</div>
</div>
@ -123,9 +126,7 @@
</div>
<div class="flex items-center gap-4 rounded-2xl border-4 border-blue-500 bg-blue-50 p-6">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-white"
>
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-white">
<Hourglass size={32} />
</div>
<div>
@ -135,9 +136,7 @@
</div>
<div class="flex items-center gap-4 rounded-2xl border-4 border-blue-500 bg-blue-50 p-6">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-white"
>
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-blue-500 text-white">
<Scale size={32} />
</div>
<div>

View file

@ -4,6 +4,7 @@
import { Plus, Pencil, Trash2, X, Eye, EyeOff } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
interface NewsItem {
id: number;
@ -167,28 +168,28 @@
</script>
<svelte:head>
<title>news editor - admin - scraps</title>
<title>{$t.nav.news} - {$t.nav.admin} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">news</h1>
<p class="text-lg text-gray-600">manage announcements and updates</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.nav.news}</h1>
<p class="text-lg text-gray-600">{$t.admin.manageAnnouncementsAndUpdates}</p>
</div>
<button
onclick={openCreateModal}
class="flex cursor-pointer items-center gap-2 rounded-full bg-black px-6 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800"
>
<Plus size={20} />
add news
{$t.admin.addNews}
</button>
</div>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if items.length === 0}
<div class="py-12 text-center text-gray-500">no news yet</div>
<div class="py-12 text-center text-gray-500">{$t.news.noNewsRightNow}</div>
{:else}
<div class="grid gap-4">
{#each items as item}
@ -242,7 +243,7 @@
class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border-4 border-black bg-white p-6"
>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold">{editingItem ? 'edit news' : 'add news'}</h2>
<h2 class="text-2xl font-bold">{editingItem ? $t.admin.editNews : $t.admin.addNews}</h2>
<button
onclick={closeModal}
class="cursor-pointer rounded-lg p-2 transition-colors hover:bg-gray-100"
@ -295,14 +296,14 @@
disabled={saving}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={handleSubmit}
disabled={saving}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
{saving ? 'saving...' : editingItem ? 'save' : 'create'}
{saving ? $t.common.saving : editingItem ? $t.common.save : $t.common.create}
</button>
</div>
</div>
@ -318,7 +319,7 @@
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">confirm delete</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.admin.confirmDelete}</h2>
<p class="mb-6 text-gray-600">
are you sure you want to delete this news item? <span class="mt-2 block text-red-600"
>this action cannot be undone.</span
@ -329,13 +330,13 @@
onclick={() => (deleteConfirmId = null)}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={confirmDelete}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
delete
{$t.common.delete}
</button>
</div>
</div>

View file

@ -4,6 +4,7 @@
import { Check, X, Package, Clock, Truck, CheckCircle, XCircle } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
interface ShippingAddress {
firstName: string;
@ -161,14 +162,14 @@
</script>
<svelte:head>
<title>orders - admin - scraps</title>
<title>{$t.nav.orders} - {$t.nav.admin} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">orders</h1>
<p class="text-lg text-gray-600">manage shop orders and fulfillment</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.nav.orders}</h1>
<p class="text-lg text-gray-600">{$t.admin.manageOrdersAndFulfillment}</p>
</div>
</div>
@ -181,7 +182,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
all ({orders.length})
{$t.admin.all} ({orders.length})
</button>
<button
onclick={() => (filter = 'pending')}
@ -190,7 +191,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
pending ({orders.filter((o) => !o.isFulfilled).length})
{$t.admin.pending} ({orders.filter((o) => !o.isFulfilled).length})
</button>
<button
onclick={() => (filter = 'fulfilled')}
@ -199,12 +200,12 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
fulfilled ({orders.filter((o) => o.isFulfilled).length})
{$t.admin.fulfilled} ({orders.filter((o) => o.isFulfilled).length})
</button>
</div>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if filteredOrders.length === 0}
<div class="py-12 text-center text-gray-500">no orders found</div>
{:else}
@ -296,10 +297,10 @@
>
{#if order.isFulfilled}
<X size={16} />
unfulfill
{$t.admin.unfulfill}
{:else}
<Check size={16} />
fulfill
{$t.admin.fulfill}
{/if}
</button>
</div>

View file

@ -6,6 +6,7 @@
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
interface Project {
id: number;
@ -73,18 +74,18 @@
</script>
<svelte:head>
<title>reviews - admin - scraps</title>
<title>{$t.nav.reviews} - {$t.nav.admin} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<h1 class="mb-2 text-4xl font-bold md:text-5xl">review queue</h1>
<p class="mb-8 text-lg text-gray-600">projects waiting for review</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.admin.reviewQueue}</h1>
<p class="mb-8 text-lg text-gray-600">{$t.admin.projectsWaitingForReview}</p>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if projects.length === 0}
<div class="py-12 text-center">
<p class="text-xl text-gray-500">no projects waiting for review</p>
<p class="text-xl text-gray-500">{$t.admin.noProjectsWaitingForReview}</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
@ -122,7 +123,10 @@
<ChevronLeft size={20} />
</button>
<span class="font-bold">
page {pagination.page} of {pagination.totalPages}
{$t.admin.page}
{pagination.page}
{$t.admin.of}
{pagination.totalPages}
</span>
<button
onclick={() => goToPage(pagination!.page + 1)}

View file

@ -17,6 +17,7 @@
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
interface Review {
id: number;
@ -275,11 +276,11 @@
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if !project}
<div class="py-12 text-center">
<p class="mb-4 text-xl text-gray-500">project not found</p>
<a href="/admin/reviews" class="font-bold underline">back to reviews</a>
<p class="mb-4 text-xl text-gray-500">{$t.project.projectNotFound}</p>
<a href="/admin/reviews" class="font-bold underline">{$t.project.back}</a>
</div>
{:else if project.deleted || project.status !== 'waiting_for_review'}
<div class="py-12 text-center">
@ -336,14 +337,14 @@
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Github size={18} />
<span>view on github</span>
<span>{$t.project.viewOnGithub}</span>
</a>
{:else}
<span
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Github size={18} />
<span>view on github</span>
<span>{$t.project.viewOnGithub}</span>
</span>
{/if}
{#if project.playableUrl}
@ -354,14 +355,14 @@
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-solid border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Globe size={18} />
<span>try it out</span>
<span>{$t.project.tryItOut}</span>
</a>
{:else}
<span
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Globe size={18} />
<span>try it out</span>
<span>{$t.project.tryItOut}</span>
</span>
{/if}
</div>
@ -409,7 +410,7 @@
disabled={savingNotes}
class="cursor-pointer rounded-full bg-black px-4 py-2 text-sm font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
{savingNotes ? 'saving...' : 'save notes'}
{savingNotes ? $t.common.saving : 'save notes'}
</button>
</div>
</div>
@ -440,7 +441,7 @@
<div class="h-6 w-6 rounded-full border-2 border-black bg-gray-200"></div>
{/if}
<ReviewIcon size={18} class={getReviewIconColor(review.action)} />
<span class="font-bold">{review.reviewerName || 'reviewer'}</span>
<span class="font-bold">{review.reviewerName || $t.project.reviewer}</span>
</a>
<div class="flex items-center gap-2">
<span
@ -590,7 +591,7 @@
disabled={submitting}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={submitReview}

View file

@ -4,6 +4,7 @@
import { Plus, Pencil, Trash2, X, Spool } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
interface ShopItem {
id: number;
@ -81,8 +82,7 @@
if (actualUpgrades <= 0 || upgradeBudget <= 0) {
baseUpgradeCost = Math.round(price * 0.05) || 1;
} else {
const seriesSum =
(Math.pow(multiplierDecimal, actualUpgrades) - 1) / (multiplierDecimal - 1);
const seriesSum = (Math.pow(multiplierDecimal, actualUpgrades) - 1) / (multiplierDecimal - 1);
baseUpgradeCost = Math.max(1, Math.round(upgradeBudget / seriesSum));
}
@ -248,21 +248,21 @@
</script>
<svelte:head>
<title>shop editor - admin - scraps</title>
<title>{$t.nav.shop} - {$t.nav.admin} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">shop</h1>
<p class="text-lg text-gray-600">manage shop items and inventory</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.nav.shop}</h1>
<p class="text-lg text-gray-600">{$t.admin.manageShopItemsAndInventory}</p>
</div>
<button
onclick={openCreateModal}
class="flex cursor-pointer items-center gap-2 rounded-full bg-black px-6 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800"
>
<Plus size={20} />
add item
{$t.admin.addItem}
</button>
</div>
@ -283,9 +283,9 @@
</div>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if items.length === 0}
<div class="py-12 text-center text-gray-500">no items yet</div>
<div class="py-12 text-center text-gray-500">{$t.refinery.noItemsAvailable}</div>
{:else}
<div class="grid gap-4">
{#each items as item}
@ -343,7 +343,7 @@
class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border-4 border-black bg-white p-6"
>
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold">{editingItem ? 'edit item' : 'add item'}</h2>
<h2 class="text-2xl font-bold">{editingItem ? $t.admin.editItem : $t.admin.addItem}</h2>
<button
onclick={closeModal}
class="cursor-pointer rounded-lg p-2 transition-colors hover:bg-gray-100"
@ -401,8 +401,9 @@
class="w-full rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500">
= {formPrice} scraps · {formBaseProbability}% base · +{formBoostAmount}%/upgrade ·
~{(formPrice / SCRAPS_PER_HOUR).toFixed(1)} hrs to earn
= {formPrice} scraps · {formBaseProbability}% base · +{formBoostAmount}%/upgrade · ~{(
formPrice / SCRAPS_PER_HOUR
).toFixed(1)} hrs to earn
</p>
{#if formPrice > 0}
{@const rollCost = Math.max(1, Math.round(formPrice * (formBaseProbability / 100)))}
@ -418,10 +419,13 @@
roll cost: {rollCost} scraps · upgrades to 100%: {Math.round(totalUpgradeCost)} scraps
</p>
<p
class="mt-1 text-xs {totalCost > maxBudget ? 'font-bold text-red-600' : 'text-gray-500'}"
class="mt-1 text-xs {totalCost > maxBudget
? 'font-bold text-red-600'
: 'text-gray-500'}"
>
total: {Math.round(totalCost)} scraps ({upgradesNeeded} upgrades + roll) ·
budget: {Math.round(maxBudget)} (1.5×)
total: {Math.round(totalCost)} scraps ({upgradesNeeded} upgrades + roll) · budget: {Math.round(
maxBudget
)} (1.5×)
{#if totalCost > maxBudget}· ⚠️ over budget!{/if}
</p>
{/if}
@ -517,14 +521,14 @@
disabled={saving}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={handleSubmit}
disabled={saving}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
{saving ? 'saving...' : editingItem ? 'save' : 'create'}
{saving ? $t.common.saving : editingItem ? $t.common.save : $t.common.create}
</button>
</div>
</div>
@ -540,7 +544,7 @@
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">confirm delete</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.admin.confirmDelete}</h2>
<p class="mb-6 text-gray-600">
are you sure you want to delete this item? <span class="mt-2 block text-red-600"
>this action cannot be undone.</span
@ -551,13 +555,13 @@
onclick={() => (deleteConfirmId = null)}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={confirmDelete}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-red-600 px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
delete
{$t.common.delete}
</button>
</div>
</div>
@ -573,13 +577,13 @@
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">error</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.common.error}</h2>
<p class="mb-6 text-gray-600">{errorModal}</p>
<button
onclick={() => (errorModal = null)}
class="w-full cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
ok
{$t.common.ok}
</button>
</div>
</div>

View file

@ -4,6 +4,7 @@
import { ChevronLeft, ChevronRight, Search } from '@lucide/svelte';
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { t } from '$lib/i18n';
interface AdminUser {
id: number;
@ -148,13 +149,13 @@
</script>
<svelte:head>
<title>users - admin - scraps</title>
<title>{$t.nav.users} - {$t.nav.admin} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<div class="mb-8">
<h1 class="mb-2 text-4xl font-bold md:text-5xl">users</h1>
<p class="text-lg text-gray-600">manage users and permissions</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.nav.users}</h1>
<p class="text-lg text-gray-600">{$t.admin.manageUsersAndPermissions}</p>
</div>
<!-- Search -->
@ -172,7 +173,7 @@
</div>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else}
<div class="overflow-x-auto rounded-2xl border-4 border-black">
<table class="w-full min-w-[600px]">
@ -219,13 +220,13 @@
href="/admin/users/{u.id}"
class="cursor-pointer rounded-full border-4 border-black px-3 py-1 text-sm font-bold transition-all duration-200 hover:border-dashed"
>
view
{$t.admin.view}
</a>
<button
onclick={() => startEditing(u)}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1 text-sm font-bold transition-all duration-200 hover:border-dashed"
>
edit
{$t.common.edit}
</button>
</div>
</td>
@ -246,7 +247,10 @@
<ChevronLeft size={20} />
</button>
<span class="font-bold">
page {pagination.page} of {pagination.totalPages}
{$t.admin.page}
{pagination.page}
{$t.admin.of}
{pagination.totalPages}
</span>
<button
onclick={() => goToPage(pagination!.page + 1)}
@ -270,7 +274,9 @@
tabindex="-1"
>
<div class="w-full max-w-lg rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-6 text-2xl font-bold">edit user: {editingUser.username || 'unknown'}</h2>
<h2 class="mb-6 text-2xl font-bold">
{$t.admin.editUser}: {editingUser.username || 'unknown'}
</h2>
<div class="space-y-4">
{#if user?.role === 'admin'}
@ -304,14 +310,14 @@
disabled={saving}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={saveChanges}
disabled={saving}
class="flex-1 cursor-pointer rounded-full bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
{saving ? 'saving...' : 'save'}
{saving ? $t.common.saving : $t.common.save}
</button>
</div>
</div>

View file

@ -14,6 +14,7 @@
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
let { data } = $props();
@ -267,13 +268,13 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to users
{$t.project.back}
</a>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if !targetUser}
<div class="py-12 text-center text-gray-500">user not found</div>
<div class="py-12 text-center text-gray-500">{$t.profile.userNotFound}</div>
{:else}
<!-- User Header -->
<div class="mb-6 rounded-2xl border-4 border-black p-6">
@ -308,7 +309,7 @@
</div>
<div class="text-right">
<p class="text-4xl font-bold">{targetUser.scraps}</p>
<p class="text-sm text-gray-500">scraps</p>
<p class="text-sm text-gray-500">{$t.common.scraps}</p>
{#if currentUser?.role === 'admin'}
<button
onclick={() => (showBonusModal = true)}
@ -386,16 +387,16 @@
disabled={saving}
class="cursor-pointer rounded-full bg-black px-6 py-2 font-bold text-white transition-all hover:bg-gray-800 disabled:opacity-50"
>
{saving ? 'saving...' : 'save changes'}
{saving ? $t.common.saving : $t.project.saveChanges}
</button>
</div>
</div>
<!-- Projects -->
<div class="rounded-2xl border-4 border-black p-6">
<h2 class="mb-4 text-xl font-bold">projects ({projects.length})</h2>
<h2 class="mb-4 text-xl font-bold">{$t.profile.projects} ({projects.length})</h2>
{#if projects.length === 0}
<p class="text-gray-500">no projects yet</p>
<p class="text-gray-500">{$t.profile.noProjectsFound}</p>
{:else}
<div class="space-y-3">
{#each projects as project}
@ -538,14 +539,14 @@
disabled={savingBonus}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={saveBonus}
disabled={savingBonus || !bonusAmount || !bonusReason.trim()}
class="flex-1 cursor-pointer rounded-full bg-green-500 px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{savingBonus ? 'saving...' : 'give bonus'}
{savingBonus ? $t.common.saving : 'give bonus'}
</button>
</div>
</div>

View file

@ -2,13 +2,13 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getUser } from '$lib/auth-client';
import { t } from '$lib/i18n';
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
// Check URL for error from backend
const urlParams = new URLSearchParams(window.location.search);
const authError = urlParams.get('error');
@ -17,16 +17,15 @@
return;
}
// Check if we have a session now
const user = await getUser();
if (user) {
goto('/dashboard');
} else {
error = 'Authentication failed';
error = $t.auth.authenticationFailed;
}
} catch (e) {
console.error('Auth callback error:', e);
error = 'An error occurred during authentication';
error = $t.auth.errorDuringAuth;
} finally {
loading = false;
}
@ -34,24 +33,24 @@
</script>
<svelte:head>
<title>authenticating... - scraps</title>
<title>{$t.auth.authenticating} - scraps</title>
</svelte:head>
<div class="flex min-h-dvh items-center justify-center">
{#if loading}
<div class="text-center">
<h1 class="mb-4 text-4xl font-bold">authenticating...</h1>
<p class="text-gray-600">please wait while we verify your account</p>
<h1 class="mb-4 text-4xl font-bold">{$t.auth.authenticating}</h1>
<p class="text-gray-600">{$t.auth.pleaseWait}</p>
</div>
{:else if error}
<div class="text-center">
<h1 class="mb-4 text-4xl font-bold text-red-600">oops!</h1>
<h1 class="mb-4 text-4xl font-bold text-red-600">{$t.auth.oops}</h1>
<p class="mb-6 text-gray-600">{error}</p>
<a
href="/"
class="cursor-pointer rounded-full border-4 border-black px-6 py-2 font-bold transition-all hover:border-dashed"
>
go back home
{$t.auth.goBackHome}
</a>
</div>
{/if}

View file

@ -1,40 +1,58 @@
<script lang="ts">
import { page } from '$app/state';
import { AlertTriangle } from '@lucide/svelte';
import { t } from '$lib/i18n';
let reason = $derived(page.url.searchParams.get('reason') || 'unknown');
const errorMessages: Record<
const errorConfigs: Record<
string,
{ title: string; description: string; redirectUrl?: string; redirectText?: string }
{ key: 'needsVerification' | 'notEligible' | 'authFailed' | 'unknown'; redirectUrl?: string }
> = {
'needs-verification': {
title: 'verify your identity',
description:
'you need to verify your identity with hack club auth before you can use scraps. click below to complete verification.',
redirectUrl: 'https://auth.hackclub.com',
redirectText: 'verify with hack club auth'
key: 'needsVerification',
redirectUrl: 'https://auth.hackclub.com'
},
'not-eligible': {
title: 'not eligible for ysws',
description:
'your hack club account is not currently eligible for you ship we ship programs. please ask for help in the hack club slack.'
key: 'notEligible'
},
'auth-failed': {
title: 'authentication failed',
description: "we couldn't verify your identity. please try again."
key: 'authFailed'
},
unknown: {
title: 'something went wrong',
description: 'an unexpected error occurred. please try again later.'
key: 'unknown'
}
};
let errorInfo = $derived(errorMessages[reason] || errorMessages['unknown']);
let config = $derived(errorConfigs[reason] || errorConfigs['unknown']);
let errorTitle = $derived(
config.key === 'needsVerification'
? $t.auth.needsVerification.title
: config.key === 'notEligible'
? $t.auth.notEligible.title
: config.key === 'authFailed'
? $t.auth.authFailed.title
: $t.auth.unknown.title
);
let errorDescription = $derived(
config.key === 'needsVerification'
? $t.auth.needsVerification.description
: config.key === 'notEligible'
? $t.auth.notEligible.description
: config.key === 'authFailed'
? $t.auth.authFailed.description
: $t.auth.unknown.description
);
let redirectText = $derived(
config.key === 'needsVerification' ? $t.auth.needsVerification.redirectText : ''
);
</script>
<svelte:head>
<title>error - scraps</title>
<title>{$t.auth.error} - scraps</title>
</svelte:head>
<div class="flex min-h-dvh items-center justify-center px-6">
@ -45,24 +63,24 @@
</div>
</div>
<h1 class="mb-4 text-4xl font-bold md:text-5xl">{errorInfo.title}</h1>
<p class="mb-8 text-lg text-gray-600">{errorInfo.description}</p>
<h1 class="mb-4 text-4xl font-bold md:text-5xl">{errorTitle}</h1>
<p class="mb-8 text-lg text-gray-600">{errorDescription}</p>
<div class="flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="/"
class="cursor-pointer rounded-full border-4 border-black px-6 py-3 font-bold transition-all hover:border-dashed"
>
go back home
{$t.auth.goBackHome}
</a>
{#if errorInfo.redirectUrl}
{#if config.redirectUrl}
<a
href={errorInfo.redirectUrl}
href={config.redirectUrl}
target="_blank"
rel="noopener noreferrer"
class="cursor-pointer rounded-full bg-black px-6 py-3 font-bold text-white transition-all hover:bg-gray-800"
>
{errorInfo.redirectText}
{redirectText}
</a>
{:else}
<a
@ -71,7 +89,7 @@
rel="noopener noreferrer"
class="cursor-pointer rounded-full bg-black px-6 py-3 font-bold text-white transition-all hover:bg-gray-800"
>
get help on slack
{$t.auth.getHelpOnSlack}
</a>
{/if}
</div>

View file

@ -15,19 +15,21 @@
type Project
} from '$lib/stores';
import { formatHours } from '$lib/utils';
import { t, locale } from '$lib/i18n';
const greetingPhrases = [
'ready to scrap?',
'time to build something silly',
'what will you ship today?',
'let the scrapping begin',
'hack away!',
'make something fun',
'create, ship, repeat',
'keep on scrapping'
];
const greetingPhrases = $derived([
$t.dashboard.greetings.readyToScrap,
$t.dashboard.greetings.timeToBuild,
$t.dashboard.greetings.whatWillYouShip,
$t.dashboard.greetings.letTheScrapBegin,
$t.dashboard.greetings.hackAway,
$t.dashboard.greetings.makeSomethingFun,
$t.dashboard.greetings.createShipRepeat,
$t.dashboard.greetings.keepOnScrapping
]);
const randomPhrase = greetingPhrases[Math.floor(Math.random() * greetingPhrases.length)];
let phraseIndex = Math.floor(Math.random() * 8);
let randomPhrase = $derived(greetingPhrases[phraseIndex]);
let user = $state<Awaited<ReturnType<typeof getUser>>>(null);
let showCreateModal = $state(false);
@ -60,7 +62,7 @@
<!-- Greeting -->
{#if user}
<h1 class="mb-2 text-4xl font-bold md:text-5xl">
hello, {(user.username || 'friend').toLocaleLowerCase()}
{$t.dashboard.hello.replace('{name}', (user.username || 'friend').toLocaleLowerCase())}
</h1>
<p class="mb-8 text-lg text-gray-600">{randomPhrase}</p>
{/if}
@ -87,7 +89,8 @@
</div>
<div class="flex items-center justify-between">
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs">
tier {project.tier}
{$t.dashboard.tier}
{project.tier}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs {project.status === 'shipped'
@ -110,7 +113,7 @@
class="flex h-64 w-80 shrink-0 cursor-pointer flex-col items-center justify-center gap-4 rounded-2xl border-4 border-dashed border-black bg-white transition-all hover:border-solid"
>
<FilePlus2 size={64} strokeWidth={1.5} />
<span class="text-2xl font-bold">new project</span>
<span class="text-2xl font-bold">{$t.dashboard.newProject}</span>
</button>
</div>
</div>
@ -120,58 +123,56 @@
<!-- FAQ Section -->
<div class="mt-12">
<h2 class="mb-6 text-3xl font-bold">faq</h2>
<h2 class="mb-6 text-3xl font-bold">{$t.dashboard.faq}</h2>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">how does the shop work?</p>
<p class="mb-2 text-lg font-bold">{$t.dashboard.faqQuestions.howDoesShopWork}</p>
<p class="text-gray-600">
spend scraps to roll for prizes. each item has a probability of winning - boost your odds
in the refinery!
{$t.dashboard.faqQuestions.howDoesShopWorkAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">what is hackatime?</p>
<p class="mb-2 text-lg font-bold">{$t.dashboard.faqQuestions.whatIsHackatime}</p>
<p class="text-gray-600">
<a
href="https://hackatime.hackclub.com"
target="_blank"
rel="noopener noreferrer"
class="underline hover:no-underline">hackatime</a
> is hack club's time tracking tool that automatically logs your coding hours.
>
{$t.dashboard.faqQuestions.whatIsHackatimeAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">what is the refinery?</p>
<p class="mb-2 text-lg font-bold">{$t.dashboard.faqQuestions.whatIsRefinery}</p>
<p class="text-gray-600">
spend scraps to increase your probability of winning an item. each upgrade boosts your
chances!
{$t.dashboard.faqQuestions.whatIsRefineryAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">how long does review take?</p>
<p class="mb-2 text-lg font-bold">{$t.dashboard.faqQuestions.howLongDoesReviewTake}</p>
<p class="text-gray-600">
project reviews typically take a few days. you'll be notified when your project is
approved!
{$t.dashboard.faqQuestions.howLongDoesReviewTakeAnswer}
</p>
</div>
<div
class="rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="mb-2 text-lg font-bold">what happens if i lose a roll?</p>
<p class="mb-2 text-lg font-bold">{$t.dashboard.faqQuestions.whatIfILoseRoll}</p>
<p class="text-gray-600">
you receive consolation scrap paper. your refinery upgrades are kept, so try again!
{$t.dashboard.faqQuestions.whatIfILoseRollAnswer}
</p>
</div>
@ -179,7 +180,7 @@
href="/faq"
class="flex items-center justify-center rounded-2xl border-4 border-black bg-white p-6 transition-all hover:border-dashed"
>
<p class="text-lg font-bold">more questions? →</p>
<p class="text-lg font-bold">{$t.dashboard.faqQuestions.moreQuestions}</p>
</a>
</div>
</div>

View file

@ -4,6 +4,7 @@
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { t } from '$lib/i18n';
interface ExploreProject {
id: number;
@ -38,15 +39,6 @@
let searchTimeout: ReturnType<typeof setTimeout>;
const TIERS = [1, 2, 3, 4];
const STATUSES = [
{ value: 'shipped', label: 'shipped' },
{ value: 'in_progress', label: 'in progress' }
];
const SORT_OPTIONS = [
{ value: 'default', label: 'recent' },
{ value: 'views', label: 'most viewed' },
{ value: 'random', label: 'random' }
] as const;
async function fetchProjects() {
loading = true;
@ -114,11 +106,11 @@
</script>
<svelte:head>
<title>explore - scraps</title>
<title>{$t.explore.explore} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<h1 class="mb-8 text-4xl font-bold md:text-5xl">explore</h1>
<h1 class="mb-8 text-4xl font-bold md:text-5xl">{$t.explore.explore}</h1>
<!-- Search & Filters -->
<div class="mb-8 space-y-3">
@ -129,14 +121,14 @@
type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="search projects..."
placeholder={$t.explore.searchPlaceholder}
class="w-full rounded-full border-4 border-black py-3 pr-4 pl-12 focus:border-dashed focus:outline-none"
/>
</div>
<!-- Tier filters -->
<div class="flex flex-wrap items-center gap-2">
<span class="mr-2 self-center text-sm font-bold">tier:</span>
<span class="mr-2 self-center text-sm font-bold">{$t.explore.tier}</span>
{#each TIERS as tier}
<button
onclick={() => toggleTier(tier)}
@ -152,34 +144,57 @@
<!-- Status filters -->
<div class="flex flex-wrap items-center gap-2">
<span class="mr-2 self-center text-sm font-bold">status:</span>
{#each STATUSES as status}
<button
onclick={() => toggleStatus(status.value)}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {selectedStatus ===
status.value
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{status.label}
</button>
{/each}
<span class="mr-2 self-center text-sm font-bold">{$t.explore.status}</span>
<button
onclick={() => toggleStatus('shipped')}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {selectedStatus ===
'shipped'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.explore.shipped}
</button>
<button
onclick={() => toggleStatus('in_progress')}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {selectedStatus ===
'in_progress'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.explore.inProgress}
</button>
</div>
<!-- Sort options -->
<div class="flex flex-wrap items-center gap-2">
<span class="mr-2 self-center text-sm font-bold">sort:</span>
{#each SORT_OPTIONS as option}
<button
onclick={() => setSortBy(option.value)}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {sortBy ===
option.value
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{option.label}
</button>
{/each}
<span class="mr-2 self-center text-sm font-bold">{$t.explore.sort}</span>
<button
onclick={() => setSortBy('default')}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {sortBy ===
'default'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.explore.recent}
</button>
<button
onclick={() => setSortBy('views')}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {sortBy ===
'views'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.explore.mostViewed}
</button>
<button
onclick={() => setSortBy('random')}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {sortBy ===
'random'
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
{$t.explore.random}
</button>
{#if selectedTier || selectedStatus || sortBy !== 'default'}
<button
onclick={() => {
@ -192,7 +207,7 @@
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 hover:border-dashed sm:px-4 sm:py-2"
>
<X size={16} />
clear
{$t.explore.clear}
</button>
{/if}
</div>
@ -200,13 +215,13 @@
<!-- Results -->
{#if loading}
<div class="py-12 text-center text-gray-500">loading projects...</div>
<div class="py-12 text-center text-gray-500">{$t.explore.loadingProjects}</div>
{:else if error}
<div class="py-12 text-center text-red-600">{error}</div>
{:else if projects.length === 0}
<div class="rounded-2xl border-4 border-dashed border-gray-300 p-12 text-center">
<p class="text-lg text-gray-500">no projects found</p>
<p class="mt-2 text-sm text-gray-400">try adjusting your filters or search query</p>
<p class="text-lg text-gray-500">{$t.explore.noProjectsFound}</p>
<p class="mt-2 text-sm text-gray-400">{$t.explore.tryAdjustingFilters}</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
@ -235,7 +250,9 @@
</div>
<p class="line-clamp-2 flex-1 text-sm text-gray-600">{project.description}</p>
<div class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-500">by {project.username || 'anonymous'}</span>
<span class="text-gray-500"
>{$t.explore.by} {project.username || $t.explore.anonymous}</span
>
<div class="flex items-center gap-3">
<span class="text-gray-500">{formatHours(project.hours)}h</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs">tier {project.tier}</span
@ -259,7 +276,7 @@
disabled={currentPage === 1}
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
prev
{$t.explore.prev}
</button>
<span class="self-center px-4 py-2 font-bold">
{currentPage} / {pagination.totalPages}
@ -269,7 +286,7 @@
disabled={currentPage === pagination.totalPages}
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
next
{$t.explore.next}
</button>
</div>
{/if}

View file

@ -1,58 +1,54 @@
<script lang="ts">
import { ChevronDown } from '@lucide/svelte';
import { t } from '$lib/i18n';
interface FAQItem {
question: string;
answer: string;
}
const faqs: FAQItem[] = [
const faqs: FAQItem[] = $derived([
{
question: 'what is scraps?',
answer: 'scraps is a hack club program where you earn scraps by building projects and spending time coding. you can then spend your scraps in the shop to test your luck and win prizes from hack club hq!'
question: $t.faq.whatIsScraps,
answer: $t.faq.whatIsScrapsAnswer
},
{
question: 'how do i earn scraps?',
answer: 'you earn scraps by submitting projects you\'ve built. each project is reviewed by our team, and you receive scraps based on the hours you\'ve spent working on it. connect your <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a> account to automatically track your coding hours.'
question: $t.faq.howDoIEarnScraps,
answer: $t.faq.howDoIEarnScrapsAnswer
},
{
question: 'what is hackatime?',
answer: '<a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a> is hack club\'s time tracking tool that automatically logs your coding hours. connect it to your projects so we can see how much time you\'ve spent building. visit <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime.hackclub.com</a> to get started.'
question: $t.faq.whatIsHackatime,
answer: $t.faq.whatIsHackatimeAnswer
},
{
question: 'how does the shop work?',
answer: 'in the shop, you spend scraps to roll for prizes. each item has a base probability of winning. if you don\'t win, you get consolation scrap paper. the cost to roll depends on the item\'s rarity.'
question: $t.faq.howDoesShopWork,
answer: $t.faq.howDoesShopWorkAnswer
},
{
question: 'what is the refinery?',
answer: 'the refinery lets you spend scraps to increase your probability of winning an item. each upgrade boosts your chances, but costs increase with each upgrade. it\'s a way to improve your odds before rolling.'
question: $t.faq.whatIsRefinery,
answer: $t.faq.whatIsRefineryAnswer
},
{
question: 'what are project tiers?',
answer: `projects are assigned tiers (1-4) based on complexity and quality. higher tiers earn more scraps per hour:<br><br>
<strong>tier 1 (0.8x)</strong> - simple projects, tutorials, small scripts<br>
<strong>tier 2 (1x)</strong> - moderate complexity, multi-file projects<br>
<strong>tier 3 (1.25x)</strong> - complex features, APIs, integrations<br>
<strong>tier 4 (1.5x)</strong> - full applications, major undertakings<br><br>
when submitting your project, select the tier that best matches your project's scope. reviewers may adjust the tier during review.`
question: $t.faq.whatAreProjectTiers,
answer: $t.faq.whatAreProjectTiersAnswer
},
{
question: 'how long does review take?',
answer: 'project reviews typically take a few days. our team reviews each submission to verify the work and assign an appropriate tier. you\'ll be notified when your project is approved.'
question: $t.faq.howLongDoesReviewTake,
answer: $t.faq.howLongDoesReviewTakeAnswer
},
{
question: 'what happens if i lose a roll?',
answer: 'if you lose a roll, you receive consolation scrap paper as a small prize. your refinery upgrades are kept, so you can try again with improved odds. keep building projects to earn more scraps!'
question: $t.faq.whatIfILoseRoll,
answer: $t.faq.whatIfILoseRollAnswer
},
{
question: 'can i submit any project?',
answer: 'projects should be original work you\'ve built. they need a github repo, description, and tracked hours via <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a>. both new projects and improvements to existing ones count!'
question: $t.faq.canISubmitAnyProject,
answer: $t.faq.canISubmitAnyProjectAnswer
},
{
question: 'how do i get started?',
answer: 'sign in with your hack club account, connect <a href="https://hackatime.hackclub.com" target="_blank" rel="noopener noreferrer" class="underline hover:no-underline">hackatime</a>, and start building! when you\'re ready, submit your project for review. once approved, you\'ll receive scraps based on your hours.'
question: $t.faq.howDoIGetStarted,
answer: $t.faq.howDoIGetStartedAnswer
}
];
]);
let openIndex = $state<number | null>(null);
@ -62,13 +58,13 @@
</script>
<svelte:head>
<title>faq - scraps</title>
<title>{$t.faq.pageTitle}</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
<div class="mb-12 text-center">
<h1 class="mb-4 text-4xl font-bold md:text-5xl">faq</h1>
<p class="text-lg text-gray-600">frequently asked questions about scraps</p>
<h1 class="mb-4 text-4xl font-bold md:text-5xl">{$t.faq.title}</h1>
<p class="text-lg text-gray-600">{$t.faq.subtitle}</p>
</div>
<div class="space-y-4">
@ -81,9 +77,7 @@
<span class="text-lg font-bold">{faq.question}</span>
<ChevronDown
size={24}
class="shrink-0 transition-transform duration-200 {openIndex === i
? 'rotate-180'
: ''}"
class="shrink-0 transition-transform duration-200 {openIndex === i ? 'rotate-180' : ''}"
/>
</button>
{#if openIndex === i}
@ -96,9 +90,9 @@
</div>
<div class="mt-12 rounded-2xl border-4 border-black bg-gray-50 p-8 text-center">
<h2 class="mb-4 text-2xl font-bold">still have questions?</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.faq.stillHaveQuestions}</h2>
<p class="mb-6 text-gray-600">
reach out to us on the hack club slack in the #scraps channel
{$t.faq.reachOutOnSlack}
</p>
<a
href="https://slack.hackclub.com/"
@ -106,7 +100,7 @@
rel="noopener noreferrer"
class="inline-block cursor-pointer rounded-full bg-black px-8 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800"
>
join hack club slack
{$t.faq.joinSlack}
</a>
</div>
</div>

View file

@ -10,6 +10,7 @@
fetchProbabilityLeaders
} from '$lib/stores';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
let activeTab = $state<'scraps' | 'hours' | 'probability'>('scraps');
let sortBy = $derived(activeTab === 'probability' ? 'scraps' : activeTab);
@ -32,12 +33,12 @@
</script>
<svelte:head>
<title>leaderboard - scraps</title>
<title>{$t.leaderboard.leaderboard} - {$t.common.scraps}</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<h1 class="mb-2 text-4xl font-bold md:text-5xl">leaderboard</h1>
<p class="mb-8 text-lg text-gray-600">top scrappers</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.leaderboard.leaderboard}</h1>
<p class="mb-8 text-lg text-gray-600">{$t.leaderboard.topScrappers}</p>
<div class="mb-6 flex flex-wrap gap-2">
<button
@ -47,7 +48,7 @@
: 'hover:border-dashed'}"
onclick={() => setActiveTab('scraps')}
>
scraps
{$t.leaderboard.scraps}
</button>
<button
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 {activeTab ===
@ -56,7 +57,7 @@
: 'hover:border-dashed'}"
onclick={() => setActiveTab('hours')}
>
hours
{$t.leaderboard.hours}
</button>
<button
class="cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 {activeTab ===
@ -65,16 +66,16 @@
: 'hover:border-dashed'}"
onclick={() => setActiveTab('probability')}
>
probability leaders
{$t.leaderboard.probabilityLeaders}
</button>
</div>
{#if activeTab === 'probability'}
<div class="rounded-2xl border-4 border-black p-6">
{#if $probabilityLeadersLoading && probabilityLeaders.length === 0}
<div class="text-center text-gray-500">loading...</div>
<div class="text-center text-gray-500">{$t.common.loading}</div>
{:else if probabilityLeaders.length === 0}
<div class="text-center text-gray-500">no probability leaders yet</div>
<div class="text-center text-gray-500">{$t.leaderboard.noProbabilityLeadersYet}</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each probabilityLeaders as leader (leader.itemId)}
@ -87,7 +88,9 @@
/>
<div>
<div class="font-bold">{leader.itemName}</div>
<div class="text-sm text-gray-500">base: {leader.baseProbability}%</div>
<div class="text-sm text-gray-500">
{$t.leaderboard.base}: {leader.baseProbability}%
</div>
</div>
</div>
{#if leader.topUser}
@ -103,7 +106,7 @@
>
</a>
{:else}
<div class="text-sm text-gray-500">no boosts yet</div>
<div class="text-sm text-gray-500">{$t.leaderboard.noBoostsYet}</div>
{/if}
</div>
{/each}
@ -113,17 +116,17 @@
{:else}
<div class="overflow-hidden rounded-2xl border-4 border-black">
{#if $leaderboardLoading && leaderboard.length === 0}
<div class="p-8 text-center text-gray-500">loading...</div>
<div class="p-8 text-center text-gray-500">{$t.common.loading}</div>
{:else}
<!-- Desktop table -->
<table class="hidden w-full md:table">
<thead>
<tr class="border-b-4 border-black bg-black text-white">
<th class="px-4 py-4 text-left font-bold">rank</th>
<th class="px-4 py-4 text-left font-bold">user</th>
<th class="px-4 py-4 text-right font-bold">hours</th>
<th class="px-4 py-4 text-right font-bold">projects</th>
<th class="px-4 py-4 text-right font-bold">scraps</th>
<th class="px-4 py-4 text-left font-bold">{$t.leaderboard.rank}</th>
<th class="px-4 py-4 text-left font-bold">{$t.leaderboard.user}</th>
<th class="px-4 py-4 text-right font-bold">{$t.leaderboard.hours}</th>
<th class="px-4 py-4 text-right font-bold">{$t.leaderboard.projects}</th>
<th class="px-4 py-4 text-right font-bold">{$t.leaderboard.scraps}</th>
</tr>
</thead>
<tbody>
@ -157,7 +160,9 @@
<td class="px-4 py-4 text-right text-lg">{entry.projectCount}</td>
<td class="px-4 py-4 text-right text-lg">
<span class="font-bold">{entry.scraps}</span>
<span class="ml-1 text-sm text-gray-500">(earned: {entry.scrapsEarned})</span>
<span class="ml-1 text-sm text-gray-500"
>({$t.leaderboard.earned}: {entry.scrapsEarned})</span
>
</td>
</tr>
{/each}
@ -190,12 +195,13 @@
<div class="min-w-0 flex-1">
<p class="truncate font-bold">{entry.username}</p>
<p class="text-sm text-gray-500">
{formatHours(entry.hours)}h · {entry.projectCount} projects
{formatHours(entry.hours)}h · {entry.projectCount}
{$t.leaderboard.projects}
</p>
</div>
<div class="shrink-0 text-right">
<p class="font-bold">{entry.scraps}</p>
<p class="text-xs text-gray-500">scraps</p>
<p class="text-xs text-gray-500">{$t.leaderboard.scraps}</p>
</div>
</a>
{/each}

View file

@ -13,6 +13,7 @@
} from '@lucide/svelte';
import { API_URL } from '$lib/config';
import { getUser } from '$lib/auth-client';
import { t } from '$lib/i18n';
interface Order {
id: number;
@ -75,11 +76,11 @@
function getOrderTypeLabel(orderType: string): string {
switch (orderType) {
case 'luck_win':
return 'won';
return $t.orders.won;
case 'consolation':
return 'consolation';
return $t.orders.consolation;
case 'purchase':
return 'purchased';
return $t.orders.purchased;
default:
return orderType;
}
@ -112,8 +113,17 @@
}
function getStatusLabel(status: string, isFulfilled: boolean): string {
if (isFulfilled) return 'fulfilled';
return status;
if (isFulfilled) return $t.orders.fulfilled;
switch (status) {
case 'pending':
return $t.orders.pending;
case 'shipped':
return $t.orders.shipped;
case 'delivered':
return $t.orders.delivered;
default:
return status;
}
}
interface ParsedAddress {
@ -157,7 +167,7 @@
</script>
<svelte:head>
<title>my orders - scraps</title>
<title>{$t.orders.myOrders} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
@ -166,25 +176,25 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to shop
{$t.orders.backToShop}
</a>
<h1 class="mb-8 text-4xl font-bold md:text-5xl">my orders</h1>
<h1 class="mb-8 text-4xl font-bold md:text-5xl">{$t.orders.myOrders}</h1>
{#if loading}
<div class="py-12 text-center text-gray-500">loading orders...</div>
<div class="py-12 text-center text-gray-500">{$t.orders.loadingOrders}</div>
{:else if error}
<div class="py-12 text-center text-red-600">{error}</div>
{:else if orders.length === 0}
<div class="rounded-2xl border-4 border-dashed border-gray-300 p-12 text-center">
<Package size={48} class="mx-auto mb-4 text-gray-400" />
<p class="text-lg text-gray-500">no orders yet</p>
<p class="mt-2 text-sm text-gray-400">try your luck in the shop to get some goodies!</p>
<p class="text-lg text-gray-500">{$t.orders.noOrdersYet}</p>
<p class="mt-2 text-sm text-gray-400">{$t.orders.tryYourLuck}</p>
<a
href="/shop"
class="mt-6 inline-block cursor-pointer rounded-full bg-black px-6 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800"
>
go to shop
{$t.orders.goToShop}
</a>
</div>
{:else}
@ -213,7 +223,7 @@
<div class="flex items-start justify-between gap-4">
<div>
{#if isConsolation}
<h3 class="text-xl font-bold">paper scraps</h3>
<h3 class="text-xl font-bold">{$t.orders.paperScraps}</h3>
<p class="text-sm text-gray-400 line-through">{order.itemName}</p>
{:else}
<h3 class="text-xl font-bold">{order.itemName}</h3>
@ -239,7 +249,7 @@
{order.totalPrice}
</span>
{#if order.quantity > 1}
<span class="text-gray-600">qty: {order.quantity}</span>
<span class="text-gray-600">{$t.orders.qty}: {order.quantity}</span>
{/if}
</div>
@ -250,7 +260,7 @@
</div>
{:else if !order.isFulfilled}
<p class="mt-3 text-sm font-bold text-yellow-600">
⚠️ no shipping address provided
{$t.orders.noShippingAddress}
</p>
{/if}
</div>

View file

@ -7,6 +7,7 @@
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
interface Review {
id: number;
@ -101,10 +102,10 @@
function getStatusLabel(status: string) {
const labels: Record<string, string> = {
in_progress: 'in progress',
waiting_for_review: 'waiting for review',
shipped: 'shipped',
permanently_rejected: 'rejected'
in_progress: $t.project.inProgress,
waiting_for_review: $t.project.waitingForReview,
shipped: $t.project.shipped,
permanently_rejected: $t.project.rejected
};
return labels[status] || status;
}
@ -126,9 +127,9 @@
<div class="mx-auto max-w-4xl px-6 pt-24 pb-24 md:px-12">
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if !project}
<div class="py-12 text-center text-gray-500">project not found</div>
<div class="py-12 text-center text-gray-500">{$t.project.projectNotFound}</div>
{:else}
<!-- Project Image -->
<div class="mb-8 h-64 w-full overflow-hidden rounded-2xl border-4 border-black md:h-96">
@ -150,7 +151,7 @@
<p class="mb-4 text-lg text-gray-600">{project.description}</p>
<div class="flex items-center gap-4">
<span class="rounded-full px-3 py-1 text-sm font-bold"
>{formatHours(project.hours)}h logged</span
>{$t.project.hoursLogged.replace('{hours}', formatHours(project.hours))}</span
>
{#if project.githubUrl}
<a
@ -159,7 +160,7 @@
rel="noopener noreferrer"
class="cursor-pointer rounded-full px-3 py-1 text-sm font-bold transition-colors hover:bg-gray-200"
>
github
{$t.project.github}
</a>
{/if}
</div>
@ -179,14 +180,14 @@
class="mb-8 flex w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-6 py-4 text-lg font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
<Send size={20} />
<span>{submitting ? 'submitting...' : 'submit for review'}</span>
<span>{submitting ? $t.project.submitting : $t.project.submitForReview}</span>
</button>
{/if}
<!-- Previous Reviews -->
{#if reviews.length > 0}
<div>
<h2 class="mb-4 text-2xl font-bold">review feedback</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.project.reviewFeedback}</h2>
<div class="space-y-4">
{#each reviews as review}
<div class="rounded-2xl border-4 border-black p-6">

View file

@ -22,6 +22,7 @@
import { formatHours } from '$lib/utils';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { tutorialActiveStore } from '$lib/stores';
import { t } from '$lib/i18n';
let { data } = $props();
@ -127,11 +128,11 @@
function getReviewLabel(action: string) {
switch (action) {
case 'approved':
return 'approved';
return $t.project.approved;
case 'denied':
return 'changes requested';
return $t.project.changesRequested;
case 'permanently_rejected':
return 'permanently rejected';
return $t.project.permanentlyRejected;
default:
return action;
}
@ -159,7 +160,7 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to dashboard
{$t.project.backToDashboard}
</a>
{:else if owner}
<a
@ -167,7 +168,7 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to {owner.username}'s profile
{$t.project.backToProfile.replace('{username}', owner.username || '')}
</a>
{:else}
<a
@ -175,18 +176,18 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back
{$t.project.back}
</a>
{/if}
{#if loading}
<div class="py-12 text-center">
<p class="text-lg text-gray-500">loading project...</p>
<p class="text-lg text-gray-500">{$t.project.loadingProject}</p>
</div>
{:else if error && !project}
<div class="py-12 text-center">
<p class="text-lg text-red-600">{error}</p>
<a href="/dashboard" class="mt-4 inline-block font-bold underline">go back</a>
<a href="/dashboard" class="mt-4 inline-block font-bold underline">{$t.project.goBack}</a>
</div>
{:else if project}
<!-- Project Header -->
@ -209,21 +210,21 @@
class="flex shrink-0 items-center gap-1 rounded-full border-2 border-green-600 bg-green-100 px-3 py-1 text-sm font-bold text-green-700"
>
<CheckCircle size={14} />
shipped
{$t.project.shipped}
</span>
{:else if project.status === 'waiting_for_review'}
<span
class="flex shrink-0 items-center gap-1 rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-700"
>
<Clock size={14} />
awaiting review
{$t.project.awaitingReview}
</span>
{:else}
<span
class="flex shrink-0 items-center gap-1 rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-700"
>
<AlertTriangle size={14} />
in progress
{$t.project.inProgress}
</span>
{/if}
</div>
@ -231,14 +232,14 @@
<span
class="rounded-full border-2 border-gray-400 bg-gray-100 px-3 py-1 text-sm font-bold text-gray-700"
>
tier {project.tier}
{$t.project.tier.replace('{value}', String(project.tier))}
</span>
</div>
{#if project.description}
<p class="mb-4 text-lg text-gray-700">{project.description}</p>
{:else}
<p class="mb-4 text-lg text-gray-400 italic">no description yet</p>
<p class="mb-4 text-lg text-gray-400 italic">{$t.project.noDescriptionYet}</p>
{/if}
<div class="mb-3 flex flex-wrap items-center gap-3">
@ -246,14 +247,14 @@
class="flex items-center gap-2 rounded-full border-4 border-black bg-white px-4 py-2 font-bold"
>
<Eye size={18} />
{project.views.toLocaleString()} views
{$t.project.views.replace('{count}', project.views.toLocaleString())}
</span>
{#if project.scrapsAwarded > 0}
<span
class="flex items-center gap-2 rounded-full border-4 border-green-600 bg-green-100 px-4 py-2 font-bold text-green-700"
>
<Spool size={18} />
+{project.scrapsAwarded} scraps earned
{$t.project.scrapsEarned.replace('{count}', String(project.scrapsAwarded))}
</span>
{/if}
</div>
@ -272,14 +273,14 @@
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Github size={18} />
view on github
{$t.project.viewOnGithub}
</a>
{:else}
<span
class="flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Github size={18} />
view on github
{$t.project.viewOnGithub}
</span>
{/if}
{#if project.playableUrl}
@ -290,14 +291,14 @@
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-solid border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Globe size={18} />
try it out
{$t.project.tryItOut}
</a>
{:else}
<span
class="flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Globe size={18} />
try it out
{$t.project.tryItOut}
</span>
{/if}
</div>
@ -307,7 +308,7 @@
<!-- Owner Info (for non-owners) -->
{#if !isOwner && owner}
<div class="mb-8 rounded-2xl border-4 border-black p-6">
<h2 class="mb-4 text-xl font-bold">created by</h2>
<h2 class="mb-4 text-xl font-bold">{$t.project.createdBy}</h2>
<a
href="/users/{owner.id}"
class="flex cursor-pointer items-center gap-4 transition-all duration-200 hover:opacity-80"
@ -317,7 +318,7 @@
{:else}
<div class="h-12 w-12 rounded-full border-2 border-black bg-gray-200"></div>
{/if}
<span class="text-lg font-bold">{owner.username || 'unknown'}</span>
<span class="text-lg font-bold">{owner.username || $t.project.unknown}</span>
</a>
</div>
{/if}
@ -330,7 +331,7 @@
class="flex flex-1 items-center justify-center gap-2 rounded-full border-4 border-black bg-gray-200 px-4 py-3 text-center text-sm font-bold text-gray-600 sm:px-6 sm:text-base"
>
<Pencil size={18} />
edit project
{$t.project.editProject}
</span>
{:else}
<a
@ -338,7 +339,7 @@
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black px-4 py-3 text-center text-sm font-bold transition-all duration-200 hover:border-dashed sm:px-6 sm:text-base"
>
<Pencil size={18} />
edit project
{$t.project.editProject}
</a>
{/if}
{#if project.status === 'waiting_for_review'}
@ -346,14 +347,21 @@
class="flex flex-1 items-center justify-center gap-2 rounded-full border-4 border-black bg-gray-200 px-4 py-3 text-center text-sm font-bold text-gray-600 sm:px-6 sm:text-base"
>
<Send size={18} />
awaiting review
{$t.project.awaitingReview}
</span>
{:else if project.status === 'shipped'}
<span
class="flex flex-1 items-center justify-center gap-2 rounded-full border-4 border-black bg-gray-200 px-4 py-3 text-center text-sm font-bold text-gray-600 sm:px-6 sm:text-base"
>
<Send size={18} />
shipped
{$t.project.shipped}
</span>
{:else if project.status === 'permanently_rejected'}
<span
class="flex flex-1 cursor-not-allowed items-center justify-center gap-2 rounded-full border-4 border-black bg-red-100 px-4 py-3 text-center text-sm font-bold text-red-600 sm:px-6 sm:text-base"
>
<XCircle size={18} />
{$t.project.permanentlyRejected}
</span>
{:else if $tutorialActiveStore}
<span
@ -361,7 +369,7 @@
class="flex flex-1 cursor-not-allowed items-center justify-center gap-2 rounded-full border-4 border-black bg-black px-4 py-3 text-sm font-bold text-white sm:px-6 sm:text-base"
>
<Send size={18} />
review & submit
{$t.project.reviewAndSubmit}
</span>
{:else}
<a
@ -370,7 +378,7 @@
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-black bg-black px-4 py-3 text-sm font-bold text-white transition-all duration-200 hover:bg-gray-800 sm:px-6 sm:text-base"
>
<Send size={18} />
review & submit
{$t.project.reviewAndSubmit}
</a>
{/if}
</div>
@ -383,12 +391,12 @@
<!-- Activity Timeline (only for owner) -->
<div>
<h2 class="mb-6 text-2xl font-bold">activity</h2>
<h2 class="mb-6 text-2xl font-bold">{$t.project.activity}</h2>
{#if activity.length === 0}
<div class="rounded-2xl border-4 border-dashed border-gray-300 p-8 text-center">
<p class="text-gray-500">no activity yet</p>
<p class="mt-2 text-sm text-gray-400">submit your project to get started</p>
<p class="text-gray-500">{$t.project.noActivityYet}</p>
<p class="mt-2 text-sm text-gray-400">{$t.project.submitToGetStarted}</p>
</div>
{:else}
<div class="relative">
@ -432,7 +440,8 @@
></div>
{/if}
<span
>reviewed by <strong>{entry.reviewer.username || 'reviewer'}</strong
>{$t.project.reviewedBy}
<strong>{entry.reviewer.username || $t.project.reviewer}</strong
></span
>
</a>
@ -459,7 +468,7 @@
<PlaneTakeoff size={16} class="text-gray-500" />
</div>
<span class="text-sm text-gray-500"
>submitted for review · {formatDate(entry.createdAt)}</span
>{$t.project.submittedForReview} · {formatDate(entry.createdAt)}</span
>
</div>
{:else if entry.type === 'created'}
@ -470,7 +479,7 @@
<Plus size={16} class="text-gray-500" />
</div>
<span class="text-sm text-gray-500"
>project created · {formatDate(entry.createdAt)}</span
>{$t.project.projectCreated} · {formatDate(entry.createdAt)}</span
>
</div>
{/if}

View file

@ -6,6 +6,7 @@
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { invalidateAllStores } from '$lib/stores';
import { t } from '$lib/i18n';
let { data } = $props();
@ -24,10 +25,10 @@
}
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' }
{ value: 1, descriptionKey: 'tier1' as const },
{ value: 2, descriptionKey: 'tier2' as const },
{ value: 3, descriptionKey: 'tier3' as const },
{ value: 4, descriptionKey: 'tier4' as const }
];
interface HackatimeProject {
@ -122,7 +123,7 @@
if (!file || !project) return;
if (file.size > 5 * 1024 * 1024) {
error = 'Image must be less than 5MB';
error = $t.project.imageMustBeLessThan;
return;
}
@ -251,21 +252,21 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to project
{$t.project.backToProject}
</a>
{#if loading}
<div class="py-12 text-center">
<p class="text-lg text-gray-500">loading project...</p>
<p class="text-lg text-gray-500">{$t.project.loadingProject}</p>
</div>
{:else if error && !project}
<div class="py-12 text-center">
<p class="text-lg text-red-600">{error}</p>
<a href="/dashboard" class="mt-4 inline-block font-bold underline">go back</a>
<a href="/dashboard" class="mt-4 inline-block font-bold underline">{$t.project.goBack}</a>
</div>
{:else if project}
<div class="rounded-2xl border-8 border-black bg-white p-6">
<h1 class="mb-6 text-3xl font-bold">edit project</h1>
<h1 class="mb-6 text-3xl font-bold">{$t.project.editProject}</h1>
{#if error}
<div class="mb-4 rounded-lg border-2 border-red-500 bg-red-100 p-3 text-sm text-red-700">
@ -277,7 +278,7 @@
<!-- Image Upload -->
<div>
<label class="mb-2 block text-sm font-bold"
>image <span class="text-red-500">*</span></label
>{$t.project.image} <span class="text-red-500">*</span></label
>
{#if imagePreview}
<div class="relative h-48 w-full overflow-hidden rounded-lg border-2 border-black">
@ -288,7 +289,7 @@
/>
{#if uploadingImage}
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<span class="font-bold text-white">uploading...</span>
<span class="font-bold text-white">{$t.project.uploading}</span>
</div>
{:else}
<button
@ -305,7 +306,7 @@
class="flex h-40 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-black transition-colors hover:bg-gray-50"
>
<Upload size={32} class="mb-2 text-gray-400" />
<span class="text-sm text-gray-500">click to upload image</span>
<span class="text-sm text-gray-500">{$t.project.clickToUploadImage}</span>
<input type="file" accept="image/*" onchange={handleImageUpload} class="hidden" />
</label>
{/if}
@ -314,7 +315,7 @@
<!-- Name -->
<div>
<label for="name" class="mb-2 block text-sm font-bold"
>name <span class="text-red-500">*</span></label
>{$t.project.name} <span class="text-red-500">*</span></label
>
<input
id="name"
@ -329,7 +330,7 @@
<!-- Description -->
<div>
<label for="description" class="mb-2 block text-sm font-bold"
>description <span class="text-red-500">*</span></label
>{$t.project.description} <span class="text-red-500">*</span></label
>
<textarea
id="description"
@ -350,7 +351,8 @@
<!-- GitHub URL -->
<div>
<label for="githubUrl" class="mb-2 block text-sm font-bold"
>github url <span class="text-gray-400">(optional)</span></label
>{$t.project.githubUrl}
<span class="text-gray-400">({$t.project.optional})</span></label
>
<input
id="githubUrl"
@ -364,7 +366,8 @@
<!-- Playable URL -->
<div>
<label for="playableUrl" class="mb-2 block text-sm font-bold"
>playable url <span class="text-gray-400">(required for submission)</span></label
>{$t.project.playableUrl}
<span class="text-gray-400">({$t.project.requiredForSubmission})</span></label
>
<input
id="playableUrl"
@ -373,13 +376,14 @@
placeholder="https://yourproject.com or https://replit.com/..."
class="w-full rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500">a link where reviewers can try your project</p>
<p class="mt-1 text-xs text-gray-500">{$t.project.playableUrlHint}</p>
</div>
<!-- Hackatime Project Dropdown -->
<div>
<label class="mb-2 block text-sm font-bold"
>hackatime project <span class="text-gray-400">(optional)</span></label
>{$t.project.hackatimeProject}
<span class="text-gray-400">({$t.project.optional})</span></label
>
<div class="relative">
<button
@ -388,14 +392,14 @@
class="flex w-full items-center justify-between rounded-lg border-2 border-black px-4 py-3 text-left focus:border-dashed focus:outline-none"
>
{#if loadingProjects}
<span class="text-gray-500">loading projects...</span>
<span class="text-gray-500">{$t.project.loadingProjects}</span>
{:else if selectedHackatimeName}
<span
>{selectedHackatimeName}
<span class="text-gray-500">({formatHours(project.hours)}h)</span></span
>
{:else}
<span class="text-gray-500">select a project</span>
<span class="text-gray-500">{$t.project.selectAProject}</span>
{/if}
<ChevronDown
size={20}
@ -408,7 +412,7 @@
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border-2 border-black bg-white"
>
{#if hackatimeProjects.length === 0}
<div class="px-4 py-2 text-sm text-gray-500">no projects found</div>
<div class="px-4 py-2 text-sm text-gray-500">{$t.project.noProjectsFound}</div>
{:else}
{#each hackatimeProjects as hp}
<button
@ -428,7 +432,7 @@
<!-- Tier Selector -->
<div>
<label class="mb-2 block text-sm font-bold">project tier</label>
<label class="mb-2 block text-sm font-bold">{$t.project.projectTier}</label>
<div class="grid grid-cols-2 gap-2">
{#each TIERS as tier}
<button
@ -439,13 +443,13 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
<span>tier {tier.value}</span>
<span>{$t.project.tier.replace('{value}', String(tier.value))}</span>
<p
class="mt-1 text-xs {selectedTier === tier.value
? 'text-gray-300'
: 'text-gray-500'}"
>
{tier.description}
{$t.project.tierDescriptions[tier.descriptionKey]}
</p>
</button>
{/each}
@ -459,7 +463,7 @@
href="/projects/{data.id}"
class="flex w-1/2 cursor-pointer items-center justify-center rounded-full border-4 border-black px-4 py-3 text-center font-bold transition-all duration-200 hover:border-dashed"
>
cancel
{$t.common.cancel}
</a>
<button
onclick={handleSave}
@ -467,25 +471,25 @@
class="flex w-1/2 cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-4 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
<Save size={18} />
{saving ? 'saving...' : 'save changes'}
{saving ? $t.project.saving : $t.project.saveChanges}
</button>
</div>
<!-- Danger Zone -->
<div class="mt-12 border-t-4 border-dashed border-gray-300 pt-8">
<h2 class="mb-4 text-xl font-bold text-red-600">danger zone</h2>
<h2 class="mb-4 text-xl font-bold text-red-600">{$t.project.dangerZone}</h2>
<div class="rounded-2xl border-4 border-red-500 p-6">
<div class="flex items-center justify-between">
<div>
<h3 class="font-bold">delete this project</h3>
<p class="text-sm text-gray-600">once deleted, this project cannot be recovered.</p>
<h3 class="font-bold">{$t.project.deleteThisProject}</h3>
<p class="text-sm text-gray-600">{$t.project.deleteWarning}</p>
</div>
<button
onclick={() => (showDeleteConfirm = true)}
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-red-500 px-4 py-2 font-bold text-red-600 transition-all duration-200 hover:bg-red-50"
>
<Trash2 size={18} />
delete
{$t.common.delete}
</button>
</div>
</div>
@ -496,10 +500,9 @@
{#if showDeleteConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6">
<div class="w-full max-w-lg rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">are you sure?</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.project.areYouSure}</h2>
<p class="mb-6 text-gray-700">
this will permanently delete <strong>{project.name}</strong>. this action cannot be
undone.
{$t.project.deleteConfirmation.replace('{name}', project.name)}
</p>
<div class="flex gap-4">
<button
@ -507,7 +510,7 @@
disabled={deleting}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-3 font-bold transition-all duration-200 hover:border-dashed disabled:opacity-50"
>
cancel
{$t.common.cancel}
</button>
<button
onclick={handleDelete}
@ -515,7 +518,7 @@
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full border-4 border-red-600 bg-red-600 px-4 py-3 font-bold text-white transition-all duration-200 hover:bg-red-700 disabled:opacity-50"
>
<Trash2 size={18} />
{deleting ? 'deleting...' : 'delete project'}
{deleting ? $t.project.deleting : $t.project.deleteProject}
</button>
</div>
</div>

View file

@ -5,6 +5,7 @@
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
let { data } = $props();
@ -23,10 +24,10 @@
}
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' }
{ value: 1, descriptionKey: 'tier1' as const },
{ value: 2, descriptionKey: 'tier2' as const },
{ value: 3, descriptionKey: 'tier3' as const },
{ value: 4, descriptionKey: 'tier4' as const }
];
interface HackatimeProject {
@ -199,23 +200,23 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to project
{$t.project.backToProject}
</a>
{#if loading}
<div class="py-12 text-center">
<p class="text-lg text-gray-500">loading project...</p>
<p class="text-lg text-gray-500">{$t.project.loadingProject}</p>
</div>
{:else if error && !project}
<div class="py-12 text-center">
<p class="text-lg text-red-600">{error}</p>
<a href="/dashboard" class="mt-4 inline-block font-bold underline">go back</a>
<a href="/dashboard" class="mt-4 inline-block font-bold underline">{$t.project.goBack}</a>
</div>
{:else if project}
<div class="rounded-2xl border-4 border-black bg-white p-6">
<h1 class="mb-2 text-3xl font-bold">submit for review</h1>
<h1 class="mb-2 text-3xl font-bold">{$t.project.submitForReview}</h1>
<p class="mb-6 text-gray-600">
make sure your project meets all requirements before submitting
{$t.project.submitRequirementsHint}
</p>
{#if error}
@ -226,7 +227,7 @@
<!-- Project Image Preview -->
<div class="mb-6">
<label class="mb-2 block text-sm font-bold">project image</label>
<label class="mb-2 block text-sm font-bold">{$t.project.projectImage}</label>
{#if project.image}
<img
src={project.image}
@ -237,9 +238,7 @@
<div
class="flex h-48 w-full items-center justify-center rounded-lg border-2 border-black bg-gray-200 text-gray-400"
>
no image - <a href="/projects/{project.id}/edit" class="ml-1 underline"
>add one in edit</a
>
{$t.project.noImageAddOne}
</div>
{/if}
</div>
@ -249,7 +248,7 @@
<!-- Name -->
<div>
<label for="name" class="mb-2 block text-sm font-bold"
>name <span class="text-red-500">*</span></label
>{$t.project.name} <span class="text-red-500">*</span></label
>
<input
id="name"
@ -264,7 +263,7 @@
<!-- Description -->
<div>
<label for="description" class="mb-2 block text-sm font-bold"
>description <span class="text-red-500">*</span></label
>{$t.project.description} <span class="text-red-500">*</span></label
>
<textarea
id="description"
@ -285,7 +284,7 @@
<!-- GitHub URL -->
<div>
<label for="githubUrl" class="mb-2 block text-sm font-bold"
>github url <span class="text-red-500">*</span></label
>{$t.project.githubUrl} <span class="text-red-500">*</span></label
>
<input
id="githubUrl"
@ -299,7 +298,7 @@
<!-- Playable URL -->
<div>
<label for="playableUrl" class="mb-2 block text-sm font-bold"
>playable url <span class="text-red-500">*</span></label
>{$t.project.playableUrl} <span class="text-red-500">*</span></label
>
<input
id="playableUrl"
@ -308,13 +307,13 @@
placeholder="https://yourproject.com or https://replit.com/..."
class="w-full rounded-lg border-2 border-black px-4 py-3 focus:border-dashed focus:outline-none"
/>
<p class="mt-1 text-xs text-gray-500">a link where reviewers can try your project</p>
<p class="mt-1 text-xs text-gray-500">{$t.project.playableUrlHint}</p>
</div>
<!-- Hackatime Project Dropdown -->
<div>
<label class="mb-2 block text-sm font-bold"
>hackatime project <span class="text-red-500">*</span></label
>{$t.project.hackatimeProject} <span class="text-red-500">*</span></label
>
<div class="relative">
<button
@ -323,14 +322,14 @@
class="flex w-full cursor-pointer items-center justify-between rounded-lg border-2 border-black px-4 py-3 text-left focus:border-dashed focus:outline-none"
>
{#if loadingProjects}
<span class="text-gray-500">loading projects...</span>
<span class="text-gray-500">{$t.project.loadingProjects}</span>
{:else if selectedHackatimeName}
<span
>{selectedHackatimeName}
<span class="text-gray-500">({formatHours(project.hours)}h)</span></span
>
{:else}
<span class="text-gray-500">select a project</span>
<span class="text-gray-500">{$t.project.selectAProject}</span>
{/if}
<ChevronDown
size={20}
@ -343,7 +342,7 @@
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border-2 border-black bg-white"
>
{#if hackatimeProjects.length === 0}
<div class="px-4 py-2 text-sm text-gray-500">no projects found</div>
<div class="px-4 py-2 text-sm text-gray-500">{$t.project.noProjectsFound}</div>
{:else}
{#each hackatimeProjects as hp}
<button
@ -365,10 +364,10 @@
<!-- Tier Selection -->
<div class="mb-6">
<label class="mb-2 block text-sm font-bold"
>project tier <span class="text-red-500">*</span></label
>{$t.project.projectTier} <span class="text-red-500">*</span></label
>
<p class="mb-3 text-xs text-gray-500">
select the complexity tier that best matches your project
{$t.project.selectComplexityTier}
</p>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each TIERS as tier}
@ -381,14 +380,14 @@
: 'hover:border-dashed'}"
>
<div class="flex items-center justify-between">
<span>tier {tier.value}</span>
<span>{$t.project.tier.replace('{value}', String(tier.value))}</span>
</div>
<p
class="mt-1 text-xs {selectedTier === tier.value
? 'text-gray-300'
: 'text-gray-500'}"
>
{tier.description}
{$t.project.tierDescriptions[tier.descriptionKey]}
</p>
</button>
{/each}
@ -397,7 +396,7 @@
<!-- Requirements Checklist -->
<div class="mb-6 rounded-lg border-2 border-black p-4">
<p class="mb-3 font-bold">requirements checklist</p>
<p class="mb-3 font-bold">{$t.project.requirementsChecklist}</p>
<ul class="space-y-2">
<li class="flex items-center gap-2 text-sm">
<span
@ -407,7 +406,7 @@
>
{#if hasImage}<Check size={12} />{/if}
</span>
<span class={hasImage ? '' : 'text-gray-500'}>project image uploaded</span>
<span class={hasImage ? '' : 'text-gray-500'}>{$t.project.projectImageUploaded}</span>
</li>
<li class="flex items-center gap-2 text-sm">
<span
@ -417,7 +416,9 @@
>
{#if hasName}<Check size={12} />{/if}
</span>
<span class={hasName ? '' : 'text-gray-500'}>project name (max {NAME_MAX} chars)</span>
<span class={hasName ? '' : 'text-gray-500'}
>{$t.project.projectName.replace('{max}', String(NAME_MAX))}</span
>
</li>
<li class="flex items-center gap-2 text-sm">
<span
@ -428,7 +429,9 @@
{#if hasDescription}<Check size={12} />{/if}
</span>
<span class={hasDescription ? '' : 'text-gray-500'}
>description ({DESC_MIN}-{DESC_MAX} chars)</span
>{$t.project.descriptionRequirement
.replace('{min}', String(DESC_MIN))
.replace('{max}', String(DESC_MAX))}</span
>
</li>
<li class="flex items-center gap-2 text-sm">
@ -439,7 +442,8 @@
>
{#if hasGithub}<Check size={12} />{/if}
</span>
<span class={hasGithub ? '' : 'text-gray-500'}>github repository linked</span>
<span class={hasGithub ? '' : 'text-gray-500'}>{$t.project.githubRepositoryLinked}</span
>
</li>
<li class="flex items-center gap-2 text-sm">
<span
@ -449,7 +453,9 @@
>
{#if hasPlayableUrl}<Check size={12} />{/if}
</span>
<span class={hasPlayableUrl ? '' : 'text-gray-500'}>playable url provided</span>
<span class={hasPlayableUrl ? '' : 'text-gray-500'}
>{$t.project.playableUrlProvided}</span
>
</li>
<li class="flex items-center gap-2 text-sm">
<span
@ -459,7 +465,9 @@
>
{#if hasHackatime}<Check size={12} />{/if}
</span>
<span class={hasHackatime ? '' : 'text-gray-500'}>hackatime project selected</span>
<span class={hasHackatime ? '' : 'text-gray-500'}
>{$t.project.hackatimeProjectSelected}</span
>
</li>
</ul>
</div>
@ -470,7 +478,7 @@
href="/projects/{data.id}"
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-3 text-center font-bold transition-all duration-200 hover:border-dashed"
>
cancel
{$t.common.cancel}
</a>
<button
onclick={handleSubmit}
@ -478,7 +486,7 @@
class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-4 py-3 font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
<Send size={18} />
{submitting ? 'submitting...' : 'submit for review'}
{submitting ? $t.project.submitting : $t.project.submitForReview}
</button>
</div>
</div>

View file

@ -13,6 +13,7 @@
import { formatHours } from '$lib/utils';
import { getUser } from '$lib/auth-client';
import ProjectPlaceholder from '$lib/components/ProjectPlaceholder.svelte';
import { t } from '$lib/i18n';
let { data } = $props();
@ -83,7 +84,7 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to {owner.username}'s profile
{$t.project.backToProfile.replace('{username}', owner.username || '')}
</a>
{:else}
<a
@ -91,12 +92,12 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back
{$t.project.back}
</a>
{/if}
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if error}
<div class="py-12 text-center text-gray-500">{error}</div>
{:else if project}
@ -118,14 +119,14 @@
class="flex items-center gap-1 rounded-full border-2 border-green-600 bg-green-100 px-3 py-1 text-sm font-bold text-green-700"
>
<CheckCircle size={14} />
shipped
{$t.project.shipped}
</span>
{:else}
<span
class="flex items-center gap-1 rounded-full border-2 border-yellow-600 bg-yellow-100 px-3 py-1 text-sm font-bold text-yellow-700"
>
<AlertTriangle size={14} />
in progress
{$t.project.inProgress}
</span>
{/if}
</div>
@ -148,14 +149,14 @@
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Github size={18} />
<span>view on github</span>
<span>{$t.project.viewOnGithub}</span>
</a>
{:else}
<span
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Github size={18} />
<span>view on github</span>
<span>{$t.project.viewOnGithub}</span>
</span>
{/if}
{#if project.playableUrl}
@ -166,14 +167,14 @@
class="inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-4 py-2 font-bold transition-all duration-200 hover:border-dashed"
>
<Globe size={18} />
<span>try it out</span>
<span>{$t.project.tryItOut}</span>
</a>
{:else}
<span
class="inline-flex cursor-not-allowed items-center gap-2 rounded-full border-4 border-dashed border-gray-300 px-4 py-2 font-bold text-gray-400"
>
<Globe size={18} />
<span>try it out</span>
<span>{$t.project.tryItOut}</span>
</span>
{/if}
</div>
@ -183,7 +184,7 @@
href="/projects/{project.id}"
class="mt-4 inline-flex cursor-pointer items-center gap-2 rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800"
>
edit project
{$t.project.editProject}
</a>
{/if}
</div>
@ -191,7 +192,7 @@
<!-- Owner Info -->
{#if owner}
<div class="rounded-2xl border-4 border-black p-6">
<h2 class="mb-4 text-xl font-bold">created by</h2>
<h2 class="mb-4 text-xl font-bold">{$t.project.createdBy}</h2>
<a
href="/users/{owner.id}"
class="flex cursor-pointer items-center gap-4 transition-all duration-200 hover:opacity-80"
@ -201,7 +202,7 @@
{:else}
<div class="h-12 w-12 rounded-full border-2 border-black bg-gray-200"></div>
{/if}
<span class="text-lg font-bold">{owner.username || 'unknown'}</span>
<span class="text-lg font-bold">{owner.username || $t.project.unknown}</span>
</a>
</div>
{/if}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { getUser, refreshUserScraps, userScrapsStore } from '$lib/auth-client';
import { shopItemsStore, shopLoading, fetchShopItems, type ShopItem } from '$lib/stores';
import { t } from '$lib/i18n';
let probabilityItems = $derived($shopItemsStore.filter((item) => item.baseProbability > 0));
let upgrading = $state<number | null>(null);
@ -42,7 +43,7 @@
);
await refreshUserScraps();
} catch (e) {
alertMessage = 'Failed to upgrade probability';
alertMessage = $t.refinery.failedToUpgrade;
} finally {
upgrading = null;
}
@ -55,17 +56,17 @@
</script>
<svelte:head>
<title>refinery - scraps</title>
<title>{$t.refinery.refinery} - scraps</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<div class="mb-8">
<h1 class="mb-2 text-4xl font-bold md:text-5xl">refinery</h1>
<p class="text-lg text-gray-600">upgrade your luck</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.refinery.refinery}</h1>
<p class="text-lg text-gray-600">{$t.refinery.upgradeYourLuck}</p>
</div>
{#if $shopLoading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.common.loading}</div>
{:else if probabilityItems.length > 0}
<div class="space-y-6">
{#each probabilityItems as item (item.id)}
@ -100,7 +101,12 @@
{item.effectiveProbability}%
</span>
<span class="text-xs text-gray-600 sm:text-sm">
({item.baseProbability}% + {item.userBoostPercent}%{#if item.adjustedBaseProbability < item.baseProbability}<span class="text-red-500"> - {item.baseProbability - item.adjustedBaseProbability}% from previous buy</span>{/if})
({item.baseProbability}% + {item.userBoostPercent}%{#if item.adjustedBaseProbability < item.baseProbability}<span
class="text-red-500"
>
- {item.baseProbability - item.adjustedBaseProbability}% {$t.refinery
.fromPreviousBuy}</span
>{/if})
</span>
</div>
</div>
@ -109,7 +115,7 @@
{#if maxed}
<span
class="inline-block rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600"
>maxed</span
>{$t.refinery.maxed}</span
>
{:else if nextCost !== null}
<button
@ -118,9 +124,9 @@
class="w-full cursor-pointer rounded-full bg-black px-4 py-2 text-sm font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50 sm:w-auto sm:text-base"
>
{#if upgrading === item.id}
upgrading...
{$t.refinery.upgrading}
{:else}
+{item.boostAmount}% ({nextCost} scraps)
+{item.boostAmount}% ({nextCost} {$t.common.scraps})
{/if}
</button>
{/if}
@ -130,7 +136,7 @@
{/each}
</div>
{:else}
<div class="py-12 text-center text-gray-500">no items available for upgrades</div>
<div class="py-12 text-center text-gray-500">{$t.refinery.noItemsAvailable}</div>
{/if}
</div>
@ -143,13 +149,13 @@
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">error</h2>
<h2 class="mb-4 text-2xl font-bold">{$t.common.error}</h2>
<p class="mb-6 text-gray-600">{alertMessage}</p>
<button
onclick={() => (alertMessage = null)}
class="w-full cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
ok
{$t.refinery.ok}
</button>
</div>
</div>

View file

@ -13,6 +13,7 @@
updateShopItemHeart,
type ShopItem
} from '$lib/stores';
import { t } from '$lib/i18n';
const PHI = (1 + Math.sqrt(5)) / 2;
const MULTIPLIER = 10;
@ -167,14 +168,14 @@
</svelte:head>
<div class="mx-auto max-w-6xl px-6 pt-24 pb-24 md:px-12">
<h1 class="mb-2 text-4xl font-bold md:text-5xl">shop</h1>
<p class="mb-8 text-lg text-gray-600">items up for grabs</p>
<h1 class="mb-2 text-4xl font-bold md:text-5xl">{$t.nav.shop}</h1>
<p class="mb-8 text-lg text-gray-600">{$t.shop.itemsUpForGrabs}</p>
<!-- Filters & Sort -->
<div class="mb-8 space-y-3">
<!-- Category Filter -->
<div class="flex flex-wrap items-center gap-2">
<span class="mr-2 self-center text-sm font-bold">tags:</span>
<span class="mr-2 self-center text-sm font-bold">{$t.shop.tags}</span>
{#each categories as category}
<button
onclick={() => toggleCategory(category)}
@ -193,14 +194,14 @@
class="flex cursor-pointer items-center gap-2 rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 hover:border-dashed sm:px-4 sm:py-2"
>
<X size={16} />
clear
{$t.shop.clear}
</button>
{/if}
</div>
<!-- Sort Options -->
<div class="flex flex-wrap items-center gap-2">
<span class="mr-2 self-center text-sm font-bold">sort:</span>
<span class="mr-2 self-center text-sm font-bold">{$t.shop.sort}</span>
<button
onclick={() => (sortBy = 'default')}
class="cursor-pointer rounded-full border-4 border-black px-3 py-1.5 text-sm font-bold transition-all duration-200 sm:px-4 sm:py-2 {sortBy ===
@ -208,7 +209,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
default
{$t.shop.default}
</button>
<button
onclick={() => (sortBy = 'favorites')}
@ -217,7 +218,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
favorites
{$t.shop.favorites}
</button>
<button
onclick={() => (sortBy = 'probability')}
@ -226,7 +227,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
probability
{$t.shop.probability}
</button>
</div>
</div>
@ -234,11 +235,11 @@
<!-- Loading State -->
{#if $shopLoading}
<div class="py-12 text-center">
<p class="text-gray-600">Loading items...</p>
<p class="text-gray-600">{$t.shop.loadingItems}</p>
</div>
{:else if $shopItemsStore.length === 0}
<div class="py-12 text-center">
<p class="text-gray-600">No items available</p>
<p class="text-gray-600">{$t.shop.noItemsAvailable}</p>
</div>
{:else}
<!-- Items Grid -->
@ -257,7 +258,7 @@
<div
class="translate-x-6 translate-y-3 rotate-45 transform bg-red-600 px-8 py-1 text-xs font-bold text-white shadow-md"
>
sold out
{$t.shop.soldOut}
</div>
</div>
{/if}
@ -268,7 +269,7 @@
item.effectiveProbability
)} {getProbabilityColor(item.effectiveProbability)}"
>
{item.effectiveProbability.toFixed(0)}% chance
{item.effectiveProbability.toFixed(0)}% {$t.shop.chance}
</span>
</div>
<div class={item.count === 0 ? 'opacity-50' : ''}>
@ -292,7 +293,7 @@
</div>
<div class="flex items-center justify-between">
<span class="text-xs {item.count === 0 ? 'font-bold text-red-500' : 'text-gray-500'}"
>{item.count === 0 ? 'sold out' : `${item.count} left`}</span
>{item.count === 0 ? $t.shop.soldOut : `${item.count} ${$t.shop.left}`}</span
>
<HeartButton
count={item.heartCount}
@ -334,7 +335,7 @@
{#if consolationOrderId}
<AddressSelectModal
orderId={consolationOrderId}
itemName="consolation scrap paper"
itemName={$t.shop.consolationScrapPaper}
onClose={() => {
consolationOrderId = null;
consolationRolled = null;
@ -348,13 +349,14 @@
>
{#snippet header()}
<div class="mb-4 rounded-xl border-2 border-yellow-400 bg-yellow-50 p-4">
<p class="font-bold text-yellow-800">better luck next time!</p>
<p class="font-bold text-yellow-800">{$t.shop.betterLuckNextTime}</p>
<p class="mt-1 text-sm text-yellow-700">
you rolled {consolationRolled} but needed {consolationNeeded} or less.
{$t.shop.youRolledButNeeded
.replace('{rolled}', String(consolationRolled))
.replace('{needed}', String(consolationNeeded))}
</p>
<p class="mt-2 text-sm text-yellow-700">
as a consolation, we'll send you a random scrap of paper from hack club hq! just tell us
where to ship it.
{$t.shop.consolationMessage}
</p>
</div>
{/snippet}
@ -366,5 +368,5 @@
class="fixed right-4 bottom-6 z-40 flex cursor-pointer items-center gap-2 rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:bg-gray-800 sm:right-6 sm:px-6 sm:py-3"
>
<PackageCheck size={20} />
<span class="hidden sm:inline">my orders</span>
<span class="hidden sm:inline">{$t.shop.myOrders}</span>
</a>

View file

@ -5,6 +5,7 @@
import { getUser } from '$lib/auth-client';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
interface Project {
id: number;
@ -59,7 +60,7 @@
async function submitProject() {
if (!selectedProject) {
error = 'Please select a project';
error = $t.submit.pleaseSelectProject;
return;
}
@ -79,7 +80,7 @@
goto('/dashboard');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to submit project';
error = e instanceof Error ? e.message : $t.submit.failedToSubmit;
} finally {
submitting = false;
}
@ -87,12 +88,12 @@
</script>
<svelte:head>
<title>submit project - scraps</title>
<title>{$t.submit.pageTitle}</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-6 pt-24 pb-24 md:px-12">
<h1 class="mb-4 text-4xl font-bold md:text-5xl">submit project</h1>
<p class="mb-8 text-lg text-gray-600">submit your project for review to earn scraps</p>
<h1 class="mb-4 text-4xl font-bold md:text-5xl">{$t.submit.title}</h1>
<p class="mb-8 text-lg text-gray-600">{$t.submit.subtitle}</p>
{#if error}
<div class="mb-6 rounded-lg border-2 border-red-500 bg-red-100 p-4 text-red-700">
@ -102,7 +103,7 @@
<div class="space-y-6">
<div>
<label class="mb-2 block text-sm font-bold">select project</label>
<label class="mb-2 block text-sm font-bold">{$t.submit.selectProject}</label>
<div class="relative">
<button
type="button"
@ -112,7 +113,7 @@
{#if selectedProject}
<span class="font-bold">{selectedProject.name}</span>
{:else}
<span class="text-gray-500">choose a project...</span>
<span class="text-gray-500">{$t.submit.chooseProject}</span>
{/if}
<ChevronDown
size={20}
@ -125,7 +126,7 @@
class="absolute top-full right-0 left-0 z-10 mt-2 max-h-64 overflow-y-auto rounded-lg border-4 border-black bg-white"
>
{#if eligibleProjects.length === 0}
<div class="px-4 py-3 text-gray-500">no eligible projects</div>
<div class="px-4 py-3 text-gray-500">{$t.submit.noEligibleProjects}</div>
{:else}
{#each eligibleProjects as project}
<button
@ -152,7 +153,7 @@
<p class="mb-4 text-gray-600">{selectedProject.description}</p>
<div class="flex items-center gap-4 text-sm">
<span class="rounded-full bg-gray-100 px-3 py-1 font-bold"
>{formatHours(selectedProject.hours)}h logged</span
>{$t.submit.hoursLogged.replace('{hours}', formatHours(selectedProject.hours))}</span
>
<span class="rounded-full bg-gray-100 px-3 py-1 font-bold">{selectedProject.status}</span>
</div>
@ -165,7 +166,7 @@
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-full bg-black px-6 py-4 text-lg font-bold text-white transition-all duration-200 hover:bg-gray-800 disabled:opacity-50"
>
<Send size={20} />
<span>{submitting ? 'submitting...' : 'submit for review'}</span>
<span>{submitting ? $t.submit.submitting : $t.submit.submitForReview}</span>
</button>
</div>
</div>

View file

@ -9,10 +9,14 @@
AlertTriangle,
Heart,
Flame,
Origami
Origami,
Pencil,
Shield,
X
} from '@lucide/svelte';
import { API_URL } from '$lib/config';
import { formatHours } from '$lib/utils';
import { t } from '$lib/i18n';
let { data } = $props();
@ -47,6 +51,7 @@
id: number;
username: string;
avatar: string | null;
role: 'admin' | 'reviewer' | 'member' | 'banned';
scraps: number;
createdAt: string;
}
@ -67,6 +72,10 @@
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state<FilterType>('all');
let isAdmin = $state(false);
let showRoleModal = $state(false);
let selectedRole = $state<'admin' | 'reviewer' | 'member' | 'banned'>('member');
let savingRole = $state(false);
let filteredProjects = $derived(
filter === 'all'
@ -88,6 +97,7 @@
heartedItems = result.heartedItems || [];
refinements = result.refinements || [];
stats = result.stats;
isAdmin = result.isAdmin || false;
} else {
error = 'User not found';
}
@ -98,6 +108,49 @@
loading = false;
}
});
function openRoleModal() {
if (profileUser) {
selectedRole = profileUser.role;
showRoleModal = true;
}
}
async function updateRole() {
if (!profileUser) return;
savingRole = true;
try {
const response = await fetch(`${API_URL}/admin/users/${profileUser.id}/role`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ role: selectedRole })
});
if (response.ok) {
profileUser = { ...profileUser, role: selectedRole };
showRoleModal = false;
} else {
console.error('Failed to update role');
}
} catch (e) {
console.error('Failed to update role:', e);
} finally {
savingRole = false;
}
}
function getRoleColor(role: string): string {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-700 border-red-300';
case 'reviewer':
return 'bg-purple-100 text-purple-700 border-purple-300';
case 'banned':
return 'bg-gray-100 text-gray-700 border-gray-300';
default:
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
</script>
<svelte:head>
@ -110,11 +163,11 @@
class="mb-8 inline-flex cursor-pointer items-center gap-2 font-bold hover:underline"
>
<ArrowLeft size={20} />
back to leaderboard
{$t.profile.backToLeaderboard}
</a>
{#if loading}
<div class="py-12 text-center text-gray-500">loading...</div>
<div class="py-12 text-center text-gray-500">{$t.profile.loading}</div>
{:else if error}
<div class="py-12 text-center text-gray-500">{error}</div>
{:else if profileUser}
@ -133,16 +186,37 @@
></div>
{/if}
<div class="min-w-0 flex-1 text-center sm:text-left">
<h1 class="mb-1 truncate text-2xl font-bold sm:mb-2 sm:text-3xl">
{profileUser.username || 'unknown'}
</h1>
<div
class="mb-1 flex flex-wrap items-center justify-center gap-2 sm:mb-2 sm:justify-start"
>
<h1 class="truncate text-2xl font-bold sm:text-3xl">
{profileUser.username || 'unknown'}
</h1>
<span
class="rounded-full border px-2 py-0.5 text-xs font-bold {getRoleColor(
profileUser.role
)}"
>
{$t.profile.roles[profileUser.role]}
</span>
{#if isAdmin}
<button
onclick={openRoleModal}
class="flex cursor-pointer items-center gap-1 rounded-full border-2 border-black px-2 py-0.5 text-xs font-bold transition-all hover:border-dashed"
>
<Pencil size={12} />
{$t.profile.editRole}
</button>
{/if}
</div>
<p class="text-sm text-gray-500">
joined {new Date(profileUser.createdAt).toLocaleDateString()}
{$t.profile.joined}
{new Date(profileUser.createdAt).toLocaleDateString()}
</p>
</div>
<div class="shrink-0 text-center sm:text-right">
<p class="text-3xl font-bold sm:text-4xl">{profileUser.scraps}</p>
<p class="text-sm text-gray-500">scraps</p>
<p class="text-sm text-gray-500">{$t.profile.scraps}</p>
</div>
</div>
</div>
@ -152,15 +226,15 @@
<div class="mb-6 grid grid-cols-3 gap-2 sm:gap-4">
<div class="rounded-2xl border-4 border-black p-2 text-center sm:p-4">
<p class="text-xl font-bold text-green-600 sm:text-3xl">{stats.projectCount}</p>
<p class="text-xs text-gray-500 sm:text-sm">shipped</p>
<p class="text-xs text-gray-500 sm:text-sm">{$t.profile.shipped}</p>
</div>
<div class="rounded-2xl border-4 border-black p-2 text-center sm:p-4">
<p class="text-xl font-bold text-yellow-600 sm:text-3xl">{stats.inProgressCount}</p>
<p class="text-xs text-gray-500 sm:text-sm">in progress</p>
<p class="text-xs text-gray-500 sm:text-sm">{$t.profile.inProgress}</p>
</div>
<div class="rounded-2xl border-4 border-black p-2 text-center sm:p-4">
<p class="text-xl font-bold sm:text-3xl">{stats.totalHours}h</p>
<p class="text-xs text-gray-500 sm:text-sm">total hours</p>
<p class="text-xs text-gray-500 sm:text-sm">{$t.profile.totalHours}</p>
</div>
</div>
{/if}
@ -170,7 +244,7 @@
<div class="mb-4 flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
<div class="flex items-center gap-2">
<Origami size={20} />
<h2 class="text-xl font-bold">projects ({filteredProjects.length})</h2>
<h2 class="text-xl font-bold">{$t.profile.projects} ({filteredProjects.length})</h2>
</div>
<div class="flex gap-2 overflow-x-auto">
<button
@ -180,7 +254,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
all
{$t.profile.all}
</button>
<button
onclick={() => (filter = 'shipped')}
@ -189,7 +263,7 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
shipped
{$t.profile.shipped}
</button>
<button
onclick={() => (filter = 'in_progress')}
@ -198,12 +272,12 @@
? 'bg-black text-white'
: 'hover:border-dashed'}"
>
in progress
{$t.profile.inProgress}
</button>
</div>
</div>
{#if filteredProjects.length === 0}
<p class="text-gray-500">no projects found</p>
<p class="text-gray-500">{$t.profile.noProjectsFound}</p>
{:else}
<div class="grid gap-4">
{#each filteredProjects as project}
@ -233,21 +307,21 @@
class="flex items-center gap-1 rounded-full border border-green-600 bg-green-100 px-2 py-0.5 text-xs font-bold text-green-700"
>
<CheckCircle size={12} />
shipped
{$t.profile.shipped}
</span>
{:else if project.status === 'waiting_for_review'}
<span
class="flex items-center gap-1 rounded-full border border-blue-600 bg-blue-100 px-2 py-0.5 text-xs font-bold text-blue-700"
>
<Clock size={12} />
under review
{$t.profile.underReview}
</span>
{:else}
<span
class="flex items-center gap-1 rounded-full border border-yellow-600 bg-yellow-100 px-2 py-0.5 text-xs font-bold text-yellow-700"
>
<AlertTriangle size={12} />
in progress
{$t.profile.inProgress}
</span>
{/if}
</div>
@ -271,7 +345,7 @@
class="flex cursor-pointer items-center gap-1 transition-colors hover:text-black"
>
<Github size={14} />
github
{$t.profile.github}
</span>
{/if}
</div>
@ -288,7 +362,7 @@
<div class="mt-6 rounded-2xl border-4 border-black p-6">
<div class="mb-4 flex items-center gap-2">
<Heart size={20} class="fill-red-500 text-red-500" />
<h2 class="text-xl font-bold">wishlist ({heartedItems.length})</h2>
<h2 class="text-xl font-bold">{$t.profile.wishlist} ({heartedItems.length})</h2>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{#each heartedItems as item}
@ -309,10 +383,10 @@
<div class="mt-6 rounded-2xl border-4 border-black p-6">
<div class="mb-4 flex items-center gap-2">
<Flame size={20} class="text-orange-500" />
<h2 class="text-xl font-bold">refinements</h2>
<h2 class="text-xl font-bold">{$t.profile.refinements}</h2>
</div>
{#if refinements.length === 0}
<p class="py-4 text-center text-gray-500">no refinements to show</p>
<p class="py-4 text-center text-gray-500">{$t.profile.noRefinements}</p>
{:else}
<div class="space-y-3">
{#each refinements.sort((a, b) => b.totalBoost - a.totalBoost) as refinement}
@ -333,3 +407,72 @@
</div>
{/if}
</div>
<!-- Role Edit Modal -->
{#if showRoleModal && profileUser}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => e.target === e.currentTarget && (showRoleModal = false)}
onkeydown={(e) => e.key === 'Escape' && (showRoleModal = false)}
role="dialog"
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<Shield size={20} />
<h2 class="text-xl font-bold">{$t.profile.changeRole}</h2>
</div>
<button
onclick={() => (showRoleModal = false)}
class="cursor-pointer rounded-lg p-1 transition-colors hover:bg-gray-100"
>
<X size={20} />
</button>
</div>
<p class="mb-4 text-gray-600">
{profileUser.username}
</p>
<div class="mb-6 space-y-2">
{#each ['admin', 'reviewer', 'member', 'banned'] as role}
<button
onclick={() => (selectedRole = role as typeof selectedRole)}
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-4 px-4 py-3 text-left font-bold transition-all {selectedRole ===
role
? 'border-black bg-black text-white'
: 'border-black hover:border-dashed'}"
>
<span
class="h-3 w-3 rounded-full {role === 'admin'
? 'bg-red-500'
: role === 'reviewer'
? 'bg-purple-500'
: role === 'banned'
? 'bg-gray-500'
: 'bg-blue-500'}"
></span>
{$t.profile.roles[role as keyof typeof $t.profile.roles]}
</button>
{/each}
</div>
<div class="flex gap-3">
<button
onclick={() => (showRoleModal = false)}
class="flex-1 cursor-pointer rounded-full border-4 border-black px-4 py-2 font-bold transition-all hover:border-dashed disabled:cursor-not-allowed disabled:opacity-50"
>
{$t.common.cancel}
</button>
<button
onclick={updateRole}
disabled={savingRole || selectedRole === profileUser.role}
class="flex-1 cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{savingRole ? $t.common.saving : $t.profile.updateRole}
</button>
</div>
</div>
</div>
{/if}