add in backend

This commit is contained in:
Nathan 2026-01-29 18:01:52 -05:00
parent a4d9325ea6
commit aa957dc93f
10 changed files with 545 additions and 9 deletions

View file

@ -8,6 +8,7 @@
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.22",
"pg": "^8.17.2",
"redis": "^5.10.0",
},
"devDependencies": {
"@types/bun": "^1.3.8",
@ -81,6 +82,16 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="],
"@redis/client": ["@redis/client@5.10.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA=="],
"@redis/json": ["@redis/json@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA=="],
"@redis/search": ["@redis/search@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg=="],
"@redis/time-series": ["@redis/time-series@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
@ -97,6 +108,8 @@
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@ -155,6 +168,8 @@
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

View file

@ -1,5 +1,12 @@
import { Elysia } from 'elysia'
import projects from './routes/projects'
import news from './routes/news'
const app = new Elysia();
const app = new Elysia()
.use(projects)
.use(news)
.listen(3000)
export default app;
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`)
export default app

View file

@ -0,0 +1,26 @@
export const RequestAccessToken = async function(code: string): Promise<{
access_token?: string,
failure_reason?: string
}> {
try {
const response = await fetch("https://auth.hackclub.com/oauth/token", {
method: "POST",
body: JSON.stringify({
code,
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
grant_type: "authorization_code",
redirect_uri: process.env.REDIRECT_URI,
})
});
if (response.status !== 200)
return { failure_reason: "" };
} catch (err) {
return { failure_reason: "Internal Error" };
}
}

View file

@ -4,11 +4,11 @@
"type": "module",
"private": true,
"scripts": {
"dev": "bun --watch src/index.ts",
"build": "bun build src/index.ts --target bun --outdir ./dist",
"start": "NODE_ENV=production bun dist/index.js",
"test": "bun test"
},
"dev": "bun --watch src/index.ts",
"build": "bun build src/index.ts --target bun --outdir ./dist",
"start": "NODE_ENV=production bun dist/index.js",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "^1.3.8",
"@types/pg": "^8.16.0",
@ -22,6 +22,7 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"elysia": "^1.4.22",
"pg": "^8.17.2"
"pg": "^8.17.2",
"redis": "^5.10.0"
}
}

33
backend/routes/news.ts Normal file
View file

@ -0,0 +1,33 @@
import { Elysia } from "elysia";
const news = new Elysia({
prefix: "/news"
});
// GET /news - Get all news items
news.get("/", async () => {
// TODO: Fetch news from database
// const newsItems = await db.select().from(newsTable).orderBy(desc(newsTable.date))
// return newsItems
// Dummy data for now
return [
{
id: 1,
date: "jan 21, 2026",
content: "remember to stay drafty!"
},
{
id: 2,
date: "jan 15, 2026",
content: "new items added to the shop!"
},
{
id: 3,
date: "jan 10, 2026",
content: "scraps is now live!"
}
]
});
export default news;

View file

@ -4,8 +4,54 @@ const projects = new Elysia({
prefix: "/projects"
});
// project routes n stuff
// GET /projects - Get all projects for the authenticated user
projects.get("/", async ({ headers }) => {
// TODO: Get user from auth header and fetch their projects from database
// const userId = await getUserFromToken(headers.authorization)
// const userProjects = await db.select().from(projectsTable).where(eq(projectsTable.userId, userId))
// return userProjects
// Dummy data for now
return [
{
id: 1,
userId: 1,
name: "Blueprint",
description: "A hackathon project for AMD",
imageUrl: "/hero.png",
githubUrl: "https://github.com/hackclub/blueprint",
hours: 24,
hackatimeUrl: "https://hackatime.hackclub.com/projects/blueprint"
},
{
id: 2,
userId: 1,
name: "Flavortown",
description: "A food discovery app",
imageUrl: "/hero.png",
githubUrl: "https://github.com/hackclub/flavortown",
hours: 18,
hackatimeUrl: "https://hackatime.hackclub.com/projects/flavortown"
}
]
});
// PUT /projects/:id - Update a project
projects.put("/:id", async ({ params, body }) => {
// TODO: Validate user owns this project and update in database
// const project = await db.update(projectsTable).set(body).where(eq(projectsTable.id, params.id)).returning()
// return project
return { success: true, ...body }
});
// POST /projects - Create a new project
projects.post("/", async ({ body }) => {
// TODO: Get user from auth and create project in database
// const project = await db.insert(projectsTable).values({ ...body, userId }).returning()
// return project
return { success: true, id: Date.now(), ...body }
});
export default projects;

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { LayoutDashboard, Trophy, Store, Nut } from '@lucide/svelte'
let { screws = 0 }: { screws?: number } = $props()
let activeTab = $state('dashboard')
</script>
<nav
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4 md:w-3/4 md:mx-auto md:left-1/2 md:-translate-x-1/2"
>
<a href="/">
<img src="/flag-standalone-bw.png" alt="Hack Club" class="h-8 md:h-10" />
</a>
<div class="flex items-center gap-2">
<button
onclick={() => (activeTab = 'dashboard')}
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 {activeTab ===
'dashboard'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<LayoutDashboard size={18} />
<span class="text-lg font-bold">dashboard</span>
</button>
<button
onclick={() => (activeTab = 'leaderboard')}
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 {activeTab ===
'leaderboard'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<Trophy size={18} />
<span class="text-lg font-bold">leaderboard</span>
</button>
<button
onclick={() => (activeTab = 'shop')}
class="flex items-center gap-2 px-6 py-2 border-4 rounded-full transition-all duration-300 {activeTab ===
'shop'
? 'bg-black text-white border-black'
: 'border-black hover:border-dashed'}"
>
<Store size={18} />
<span class="text-lg font-bold">shop</span>
</button>
</div>
<div class="flex items-center gap-2">
<Nut size={24} />
<span class="text-xl font-bold">{screws}</span>
</div>
</nav>

View file

@ -0,0 +1,141 @@
<script lang="ts">
import { X } from '@lucide/svelte'
interface Project {
id: number
userId: number
name: string
description: string
imageUrl: string
githubUrl: string
hours: number
hackatimeUrl: string
}
let {
project = $bindable<Project | null>(null),
onClose,
onSave
}: {
project: Project | null
onClose: () => void
onSave: (project: Project) => void
} = $props()
let editedProject = $state<Project | null>(null)
$effect(() => {
if (project) {
editedProject = { ...project }
}
})
function handleSave() {
if (editedProject) {
// TODO: Call API to update project
onSave(editedProject)
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose()
}
}
</script>
{#if project && editedProject}
<div
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
tabindex="-1"
>
<div class="bg-white rounded-2xl w-full max-w-lg p-6 border-4 border-black">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold">edit draft</h2>
<button onclick={onClose} class="p-1 hover:bg-gray-100 rounded-lg transition-colors">
<X size={24} />
</button>
</div>
<div class="space-y-4">
<div>
<label for="name" class="block text-sm font-bold mb-1">name</label>
<input
id="name"
type="text"
bind:value={editedProject.name}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="description" class="block text-sm font-bold mb-1">description</label>
<textarea
id="description"
bind:value={editedProject.description}
rows="3"
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed resize-none"
></textarea>
</div>
<div>
<label for="imageUrl" class="block text-sm font-bold mb-1">image url</label>
<input
id="imageUrl"
type="url"
bind:value={editedProject.imageUrl}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="githubUrl" class="block text-sm font-bold mb-1">github url</label>
<input
id="githubUrl"
type="url"
bind:value={editedProject.githubUrl}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="hackatimeUrl" class="block text-sm font-bold mb-1">hackatime url</label>
<input
id="hackatimeUrl"
type="url"
bind:value={editedProject.hackatimeUrl}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
<div>
<label for="hours" class="block text-sm font-bold mb-1">hours</label>
<input
id="hours"
type="number"
bind:value={editedProject.hours}
class="w-full px-4 py-2 border-2 border-black rounded-lg focus:outline-none focus:border-dashed"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
onclick={onClose}
class="flex-1 px-4 py-2 border-2 border-black rounded-full font-bold hover:border-dashed transition-all"
>
cancel
</button>
<button
onclick={handleSave}
class="flex-1 px-4 py-2 bg-black text-white rounded-full font-bold hover:bg-gray-800 transition-all"
>
save
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
const phrases = [
'fantasy football',
'scrap that, ship this',
'turning leftovers into legendary',
'one persons scraps, anothers treasure',
'built different, shipped anyway',
'ctrl+z your regrets',
'powered by fudge and caffeine',
'404 boredom not found',
'shipping since 2025'
]
const randomPhrase = phrases[Math.floor(Math.random() * phrases.length)]
</script>
<span>{randomPhrase}</span>

View file

@ -0,0 +1,195 @@
<script lang="ts">
import { onMount } from 'svelte'
import { FilePlus2, Pencil } from '@lucide/svelte'
import DashboardNavbar from '$lib/components/DashboardNavbar.svelte'
import ProjectModal from '$lib/components/ProjectModal.svelte'
import RandomPhrase from '$lib/components/RandomPhrase.svelte'
interface Project {
id: number
userId: number
name: string
description: string
imageUrl: string
githubUrl: string
hours: number
hackatimeUrl: string
}
interface NewsItem {
id: number
date: string
content: string
}
let projects = $state<Project[]>([])
let news = $state<NewsItem[]>([])
let selectedProject = $state<Project | null>(null)
let screws = $state(42)
const dummyProjects: Project[] = [
{
id: 1,
userId: 1,
name: 'Blueprint',
description: 'A hackathon project for AMD',
imageUrl: '/hero.png',
githubUrl: 'https://github.com/hackclub/blueprint',
hours: 24,
hackatimeUrl: 'https://hackatime.hackclub.com/projects/blueprint'
},
{
id: 2,
userId: 1,
name: 'Flavortown',
description: 'A food discovery app',
imageUrl: '/hero.png',
githubUrl: 'https://github.com/hackclub/flavortown',
hours: 18,
hackatimeUrl: 'https://hackatime.hackclub.com/projects/flavortown'
}
]
const dummyNews: NewsItem[] = [
{
id: 1,
date: 'jan 21, 2026',
content: 'remember to stay drafty!'
},
{
id: 2,
date: 'jan 15, 2026',
content: 'new items added to the shop!'
}
]
onMount(async () => {
// TODO: Replace with actual API call to /api/projects
// const response = await fetch('/api/projects', {
// headers: { 'Authorization': `Bearer ${userToken}` }
// })
// projects = await response.json()
projects = dummyProjects
// TODO: Replace with actual API call to /api/news
// const newsResponse = await fetch('/api/news')
// news = await newsResponse.json()
news = dummyNews
// TODO: Fetch user's screw count
// const userResponse = await fetch('/api/user')
// screws = (await userResponse.json()).screws
})
function openEditModal(project: Project) {
selectedProject = project
}
function closeModal() {
selectedProject = null
}
function handleSaveProject(updatedProject: Project) {
// TODO: Call API to update project
// await fetch(`/api/projects/${updatedProject.id}`, {
// method: 'PUT',
// body: JSON.stringify(updatedProject)
// })
projects = projects.map((p) => (p.id === updatedProject.id ? updatedProject : p))
closeModal()
}
function createNewProject() {
// TODO: Navigate to project creation or open modal
console.log('Create new project')
}
</script>
<svelte:head>
<title>dashboard | scraps</title>
</svelte:head>
<DashboardNavbar {screws} />
<div class="pt-24 px-6 md:px-12 max-w-6xl mx-auto pb-24">
<!-- Projects Section -->
<div class="mb-12">
<div class="flex gap-6 overflow-x-auto pb-4 scrollbar-hide">
{#each projects as project (project.id)}
<button
onclick={() => openEditModal(project)}
class="shrink-0 w-80 h-64 rounded-2xl border-4 border-black overflow-hidden relative group bg-[#1a365d] cursor-pointer transition-all hover:border-dashed"
>
<div class="absolute inset-0 flex items-center justify-center p-6">
<img
src={project.imageUrl}
alt={project.name}
class="max-w-full max-h-full object-contain"
/>
</div>
<div
class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 border-2 border-dashed border-black/50 rounded-full bg-white/90 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil size={16} />
<span class="font-bold">edit draft</span>
</div>
</button>
{/each}
<!-- New Draft Card -->
<button
onclick={createNewProject}
class="shrink-0 w-80 h-64 rounded-2xl border-4 border-black flex flex-col items-center justify-center gap-4 cursor-pointer transition-all hover:border-dashed bg-white"
>
<FilePlus2 size={64} strokeWidth={1.5} />
<span class="text-2xl font-bold">new draft</span>
</button>
</div>
</div>
<!-- News Section -->
<div class="mb-16">
{#each news as item (item.id)}
<div class="border-4 border-black rounded-2xl p-8 text-center mb-4">
<p class="text-lg font-bold mb-2">{item.date}</p>
<p class="text-2xl md:text-3xl font-bold">{item.content}</p>
</div>
{/each}
</div>
<!-- Shop Sections -->
<div class="mb-16">
<h2 class="text-4xl font-bold mb-6">shop</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="border-4 border-black rounded-2xl p-6">
<h3 class="text-2xl font-bold mb-2">items</h3>
<p class="text-gray-600">browse available scraps</p>
</div>
<div class="border-4 border-black rounded-2xl p-6">
<h3 class="text-2xl font-bold mb-2">refinery</h3>
<p class="text-gray-600">upgrade your scraps</p>
</div>
</div>
</div>
<!-- Footer Branding -->
<div class="mt-16">
<h1 class="text-6xl md:text-8xl font-bold mb-2">scraps</h1>
<p class="text-xl md:text-2xl mb-4">
<RandomPhrase />
</p>
<p class="text-sm text-gray-600">made with &lt;3 by hack club</p>
</div>
</div>
<ProjectModal project={selectedProject} onClose={closeModal} onSave={handleSaveProject} />
<style>
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>