make the site work and small fixes

This commit is contained in:
End 2026-03-16 15:27:25 -07:00
parent ea94529fc6
commit fc5fc2ff1b
No known key found for this signature in database
26 changed files with 3026 additions and 1882 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
BACKEND_URL=http://localhost:9292
# ORIGIN=

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
# Ruby
backend/vendor/bundle
backend/.bundle
pnpm-lock.yaml

View file

@ -55,8 +55,8 @@
<pre>
git clone https://github.com/hackclub/stickers
cp .env.example .env
npm install
npm run dev
pnpm install
pnpm run dev
In a separate terminal
cd backend
@ -103,6 +103,7 @@ In general we're happy to help you over DM, but please have a glance over the co
<p>
Made with &lt;3 by
<a href="https://github.com/24c02">nora</a> and
<a href="https://github.com/EDripper">euan</a>.
<a href="https://github.com/24c02">nora</a>,
<a href="https://github.com/EDripper">euan</a> and,
<a href="https://github.com/System-End">end</a>.
</p>

View file

@ -1,6 +1,9 @@
# Airtable
AIRTABLE_PAT=your_airtable_personal_access_token
AIRTABLE_BASE_ID=your_airtable_base_id
AIRTABLE_STICKER_DB_TABLE_ID=your_tableid_for_stickers
AIRTABLE_SHOP_TABLE_ID=your_shop_tableid
AIRTABLE_DESIGN_TABLE_ID=your_design_tableid
# Session (generate with: openssl rand -hex 64)
SESSION_SECRET=dummy_session_secret
@ -9,9 +12,9 @@ SESSION_SECRET=dummy_session_secret
OIDC_ISSUER=https://auth.hackclub.com
OIDC_CLIENT_ID=your_oidc_client_id
OIDC_CLIENT_SECRET=your_oidc_client_secret
OIDC_REDIRECT_URI=http://localhost:9292/auth/oidc/callback
OIDC_REDIRECT_URI=http://localhost:5173/auth/oidc/callback
# URLs
FRONTEND_URL=http://localhost:5173
AUTH_SUCCESS_REDIRECT=http://localhost:5173/stickers
AUTH_LOGOUT_REDIRECT=http://localhost:5173
AUTH_SUCCESS_REDIRECT=/stickers
AUTH_LOGOUT_REDIRECT=/

View file

@ -59,7 +59,7 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.4.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.2)
net-http (~> 0.5)

View file

@ -7,13 +7,13 @@ class Designs < Grape::API
get :all do
error!('Unauthorized', 401) unless current_user
user_id = current_user.identifier
DesignsTable.all.map { |d| d.as_approved_json(user_id) }
Design.approved.all.map { |d| d.as_approved_json(user_id: user_id) }
end
get do
error!('Unauthorized', 401) unless current_user
user_id = current_user.identifier
Design.by_user(user_id).all.map { |d| d.as_approved_json(user_id) }
Design.by_user(user_id).all.map { |d| d.as_json(user_id: user_id) }
end
post do
@ -24,17 +24,12 @@ class Designs < Grape::API
safe_fields['Status'] = 'pending'
Design.create(safe_fields)
end
route_param :id do
post :vote do
error!('Unauthorized', 401) unless current_user
user_id = current_user[:slack_id] || current_user[:id]
design = DesignsTable.find(params[:id])
user_id = current_user.identifier
design = Design.find(params[:id])
error!('Design not found', 404) unless design
voted_by = (design['voted_by'] || '').split(',').map(&:strip).reject(&:empty?)

View file

@ -6,8 +6,9 @@ module SessionHelpers
end
def current_user
return nil unless session[:user]
@current_user ||= User.new(session[:user])
return nil unless session[:user]
@current_user ||= User.new(session[:user])
end
def authenticate!

View file

@ -7,14 +7,14 @@ class Stickers < Grape::API
resource :stickers do
get do
user_id = current_user&.identifier
StickerRecord.all.map { |r| r.as_json(user_id: user_id) }
StickerRecord.visible.map { |r| r.as_json(user_id: user_id) }
end
route_param :id, type: String do
before { authenticate! }
get do
record = StickersRecord.find(params[:id])
error!('not found', 404) unless record
record = StickerRecord.find(params[:id])
error!('not found', 404) unless record&.allowed
record.as_detail_json
end
end

View file

@ -14,9 +14,9 @@ use Rack::Cors do
allow do
origins ENV.fetch('FRONTEND_URL', 'http://localhost:5173')
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
headers: :any,
methods: %i[get post put patch delete options head],
credentials: true
end
end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
class ApplicationRecord < AirctiveRecord::Base
self.base_key = ENV['AIRTABLE_BASE_ID']
end

View file

@ -2,7 +2,7 @@
class Design < AirctiveRecord::Base
self.base_key = ENV['AIRTABLE_BASE_ID']
self.table_name = 'designs'
self.table_name = ENV['AIRTABLE_DESIGN_TABLE_ID']
field :name, 'Name'
field :description, 'Description'
@ -21,7 +21,7 @@ class Design < AirctiveRecord::Base
def vote!(user_id)
voted_users = voted_list
if voted_users.include?(user_id)
voted_users.delete(user_id)
self.votes = [votes.to_i - 1, 0].max
@ -29,7 +29,7 @@ class Design < AirctiveRecord::Base
voted_users << user_id
self.votes = votes.to_i + 1
end
self.voted_by = voted_users.join(',')
save
end
@ -68,4 +68,4 @@ class Design < AirctiveRecord::Base
def voted_list
(voted_by || '').split(',').map(&:strip).reject(&:empty?)
end
end
end

View file

@ -1,16 +1,15 @@
# frozen_string_literal: true
class ShopRecord < AirctiveRecord::Base
self.base_key = ENV['AIRTABLE_BASE_ID']
self.table_name = 'shop'
self.table_name = ENV['AIRTABLE_SHOP_TABLE_ID']
field :name, 'Name'
field :name, 'Sticker Name'
field :image, 'CDN_URL'
field :price, 'Cost'
field :description, 'Description'
def as_json(options = nil)
def as_json(_options = nil)
{
id: id,
name: name,
@ -19,4 +18,4 @@ class ShopRecord < AirctiveRecord::Base
description: description
}
end
end
end

View file

@ -1,4 +1,5 @@
class Sticker < ApplicationRecord
self.base_key = ENV['AIRTABLE_BASE_ID']
self.table_name = "tbl9kLyUrZNCJWf3L"
field :name, "Name"
@ -20,4 +21,4 @@ class Sticker < ApplicationRecord
def image = image_attachment&.dig(0, "url")
end
end

View file

@ -2,13 +2,17 @@
class StickerRecord < AirctiveRecord::Base
self.base_key = ENV['AIRTABLE_BASE_ID']
self.table_name = 'stickerDB'
self.table_name = ENV['AIRTABLE_STICKER_DB_TABLE_ID']
field :name, 'Sticker Name'
field :image_attachment, 'image_preview'
field :artist, 'Artist'
field :event, 'Event'
field :owned_by, 'owned_by'
field :allowed, 'permission to show', type: :boolean
field :event_url, 'event_URL'
scope :visible, -> { where(allowed: true) }
def image = image_attachment&.dig(0, "url")
@ -21,6 +25,7 @@ class StickerRecord < AirctiveRecord::Base
image: image,
artist: artist,
event: event,
event_URL: event_url,
owned_by: owned_by,
owned: user_id && owners.include?(user_id)
}
@ -32,7 +37,8 @@ class StickerRecord < AirctiveRecord::Base
name: name,
image: image,
artist: artist,
event: event
event: event,
event_URL: event_url
}
end
end
end

View file

@ -4,10 +4,10 @@ class User
attr_accessor :id, :email, :name, :slack_id
def initialize(attrs = {})
@id = attrs[:id]
@email = attrs[:email]
@name = attrs[:name]
@slack_id = attrs[:slack_id]
@id = attrs[:id] || attrs['id']
@email = attrs[:email] || attrs['email']
@name = attrs[:name] || attrs['name']
@slack_id = attrs[:slack_id] || attrs['slack_id']
end
def self.from_omniauth(auth)
@ -26,4 +26,4 @@ class User
def to_h
{ id: id, email: email, name: name, slack_id: slack_id }
end
end
end

1255
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,189 +1,171 @@
<script>
import hackClubLogo from '$lib/assets/images/hackClubLogo.png';
let { active = '' } = $props();
let menuOpen = $state(false);
import hackClubLogo from "$lib/assets/images/hackClubLogo.png";
function toggleMenu() {
menuOpen = !menuOpen;
}
let { active = "" } = $props();
let menuOpen = $state(false);
function toggleMenu() {
menuOpen = !menuOpen;
}
</script>
<nav class="navbar">
<a class="navbar-brand" href="/dash">
<img src={hackClubLogo} alt="Hack Club Logo" draggable="false"/>
</a>
<button class="navbar-toggler" onclick={toggleMenu} aria-label="Toggle navigation">
<span class="toggler-icon"></span>
<span class="toggler-icon"></span>
<span class="toggler-icon"></span>
</button>
<a class="navbar-brand" href="/stickers">
<img src={hackClubLogo} alt="Hack Club Logo" draggable="false" />
</a>
<div class="navbar-collapse" class:open={menuOpen}>
<ul class="navbar-nav">
<li class="nav-item" class:active={active === 'dash'}>
<a class="nav-link" href="/dash">|Dashboard </a>
</li>
<li class="nav-item" class:active={active === 'earn'}>
<a class="nav-link" href="/earn">|Get </a>
</li>
<li class="nav-item" class:active={active === 'leaderboard'}>
<a class="nav-link" href="/leaderboard">|Leaderboard </a>
</li>
<li class="nav-item" class:active={active === 'stickers'}>
<a class="nav-link" href="/stickers">|Stickers </a>
</li>
<li class="nav-item" class:active={active === 'my-designs'}>
<a class="nav-link" href="/my-designs">|My Designs </a>
</li>
<li class="nav-item" class:active={active === 'vote'}>
<a class="nav-link" href="/vote">|Vote </a>
</li>
<button
class="navbar-toggler"
onclick={toggleMenu}
aria-label="Toggle navigation"
>
<span class="toggler-icon"></span>
<span class="toggler-icon"></span>
<span class="toggler-icon"></span>
</button>
<!-- <li class="nav-item" class:active={active === 'trade'}>
<div class="navbar-collapse" class:open={menuOpen}>
<ul class="navbar-nav">
<!-- <li class="nav-item" class:active={active === "dash"}>
<a class="nav-link" href="/dash">|Dashboard </a>
</li> -->
<!-- <li class="nav-item" class:active={active === "earn"}>
<a class="nav-link" href="/earn">|Get </a>
</li> -->
<li class="nav-item" class:active={active === "leaderboard"}>
<a class="nav-link" href="/leaderboard">|Leaderboard </a>
</li>
<li class="nav-item" class:active={active === "stickers"}>
<a class="nav-link" href="/stickers">|Stickers </a>
</li>
<li class="nav-item" class:active={active === "my-designs"}>
<a class="nav-link" href="/my-designs">|My Designs </a>
</li>
<li class="nav-item" class:active={active === "vote"}>
<a class="nav-link" href="/vote">|Vote </a>
</li>
<!-- <li class="nav-item" class:active={active === 'trade'}>
<a class="nav-link disabled">|Trade (soon)</a>
</li>-->
<li class="nav-item logout">
<a class="nav-link" href="/auth/logout">|Logout </a>
</li>
</ul>
<span class="help-text">Made with &lt;3 by Nora and Euan</span>
</div>
<li class="nav-item logout">
<a class="nav-link" href="/auth/logout">|Logout </a>
</li>
</ul>
</div>
</nav>
<style>
.navbar {
font-family: 'Departure Mono', monospace;
padding: 1rem 2rem;
background: #f8f9fa;
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.navbar-brand img {
height: 70px;
width: auto;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
-webkit-user-drag: none;
-webkit-touch-callout: none;
}
.navbar-toggler {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: 2px solid #333;
border-radius: 4px;
padding: 8px;
cursor: pointer;
margin-left: auto;
}
.toggler-icon {
width: 24px;
height: 3px;
background: #333;
display: block;
}
.navbar-collapse {
display: flex;
align-items: center;
flex: 1;
}
.navbar-nav {
display: flex;
flex-direction: row;
list-style: none;
margin: 0;
padding: 0;
}
.nav-item {
margin: 0;
}
.nav-link {
display: block;
font-size: 1.25rem;
padding: 0.75rem 1.25rem;
color: #333;
text-decoration: none;
}
.nav-link:hover {
color: #000;
}
.nav-item.active .nav-link {
font-weight: bold;
}
.help-text {
margin-left: auto;
font-size: 1.1rem;
font-weight: bold;
color: #666;
white-space: nowrap;
}
@media (max-width: 1400px) {
.help-text {
display: none;
}
}
@media (max-width: 992px) {
.navbar {
flex-wrap: wrap;
font-family: "Departure Mono", monospace;
padding: 1rem 2rem;
background: #f8f9fa;
display: flex;
align-items: center;
flex-wrap: nowrap;
}
.navbar-brand img {
height: 70px;
width: auto;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
-webkit-user-drag: none;
-webkit-touch-callout: none;
}
.navbar-toggler {
display: flex;
padding: 12px;
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: 2px solid #333;
border-radius: 4px;
padding: 8px;
cursor: pointer;
margin-left: auto;
}
.toggler-icon {
width: 28px;
height: 4px;
width: 24px;
height: 3px;
background: #333;
display: block;
}
.navbar-collapse {
display: none;
width: 100%;
flex-direction: column;
align-items: flex-start;
padding: 1.5rem 1rem;
}
.navbar-collapse.open {
display: flex;
display: flex;
align-items: center;
flex: 1;
}
.navbar-nav {
flex-direction: column;
width: 100%;
display: flex;
flex-direction: row;
list-style: none;
margin: 0;
padding: 0;
}
.nav-item {
margin: 0;
}
.nav-link {
padding: 1rem 1rem;
border-bottom: 1px solid #ddd;
font-size: 1.5rem;
display: block;
font-size: 1.25rem;
padding: 0.75rem 1.25rem;
color: #333;
text-decoration: none;
}
.help-text {
margin-left: 0;
margin-top: 1rem;
white-space: normal;
font-size: 0.9rem;
.nav-link:hover {
color: #000;
}
.nav-item.active .nav-link {
font-weight: bold;
}
@media (max-width: 1400px) {
.navbar {
flex-wrap: wrap;
}
.navbar-toggler {
display: flex;
padding: 12px;
}
.toggler-icon {
width: 28px;
height: 4px;
}
.navbar-collapse {
display: none;
width: 100%;
flex-direction: column;
align-items: flex-start;
padding: 1.5rem 1rem;
}
.navbar-collapse.open {
display: flex;
}
.navbar-nav {
flex-direction: column;
width: 100%;
}
.nav-link {
padding: 1rem 1rem;
border-bottom: 1px solid #ddd;
font-size: 1.5rem;
}
}
}
</style>

View file

@ -9,3 +9,33 @@
</svelte:head>
{@render children()}
<footer class="site-footer">
<span>Made with &lt;3 by Nora, Euan, and End</span>
<a href="https://hackclub.com" target="_blank" rel="noopener noreferrer">hackclub.com</a>
</footer>
<style>
.site-footer {
font-family: 'Departure Mono', monospace;
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
font-size: 0.9rem;
color: #666;
border-top: 1px solid #ddd;
margin-top: 2rem;
}
.site-footer a {
color: #ec3750;
text-decoration: none;
font-weight: bold;
}
.site-footer a:hover {
text-decoration: underline;
}
</style>

View file

@ -1,258 +1,322 @@
<script>
import { dev } from '$app/environment';
import Peelable from '$lib/components/Peelable.svelte';
import Background from '$lib/components/Background.svelte';
import Logo from '$lib/assets/images/hackClubLogo.png';
import Peelable from "$lib/components/Peelable.svelte";
import Background from "$lib/components/Background.svelte";
import Logo from "$lib/assets/images/hackClubLogo.png";
function handleLogin() {
const authUrl = dev ? 'http://localhost:9292/auth/login' : '/auth/login';
//console.log('called handleLogin');
window.location.href = authUrl;
}
function handleLogin() {
window.location.href = "/auth/login";
}
</script>
<Background />
<div class="page-wrapper">
<img src={Logo} alt="Hack Club Stickers" class="hero-logo" title="Logo by Charlie @Dumbhog" draggable="false" on:dragstart|preventDefault />
<div class="content-container">
<h1><u>Hack Club Stickers</u></h1>
<p>Every Hack Clubber gets free high quality stickers, and can code or trade to earn a collection.</p>
<img
src={Logo}
alt="Hack Club Stickers"
class="hero-logo"
title="Logo by Charlie @Dumbhog"
draggable="false"
on:dragstart|preventDefault
/>
<div class="content-container">
<h1><u>Hack Club Stickers</u></h1>
<p>
Every Hack Clubber gets free high quality stickers, and can code or
trade to earn a collection.
</p>
<Peelable
class="login-sticker"
corner="bottom-right"
peelOnHover={true}
hoverPeelAmount={0.4}
peelOnClick={true}
peelAwayDuration={1000}
onPeelComplete={handleLogin}
borderRadius="0.5rem"
>
{#snippet topContent()}
<div class="sticker-face">Sign in!</div>
{/snippet}
{#snippet backContent()}
<div class="sticker-back"></div>
{/snippet}
{#snippet bottomContent()}
<div class="sticker-surface">
<span>Loading...</span>
</div>
{/snippet}
</Peelable>
<Peelable
class="login-sticker"
corner="bottom-right"
peelOnHover={true}
hoverPeelAmount={0.4}
peelOnClick={true}
peelAwayDuration={1000}
onPeelComplete={handleLogin}
borderRadius="0.5rem"
>
{#snippet topContent()}
<div class="sticker-face">Sign in!</div>
{/snippet}
{#snippet backContent()}
<div class="sticker-back"></div>
{/snippet}
{#snippet bottomContent()}
<div class="sticker-surface">
<span>Loading...</span>
</div>
{/snippet}
</Peelable>
<Peelable
class="see-stickers-sticker"
corner="bottom-right"
peelOnHover={true}
hoverPeelAmount={0.4}
peelOnClick={true}
peelAwayDuration={1000}
onPeelComplete={() => window.location.href = '/archive'}
borderRadius="0.5rem"
>
{#snippet topContent()}
<div class="see-stickers-face">
View Stickers
<span class="subtext">no sign in needed</span>
</div>
{/snippet}
{#snippet backContent()}
<div class="sticker-back"></div>
{/snippet}
{#snippet bottomContent()}
<div class="sticker-surface">
<span>Loading...</span>
</div>
{/snippet}
</Peelable>
<Peelable
class="see-stickers-sticker"
corner="bottom-right"
peelOnHover={true}
hoverPeelAmount={0.4}
peelOnClick={true}
peelAwayDuration={1000}
onPeelComplete={() => (window.location.href = "/archive")}
borderRadius="0.5rem"
>
{#snippet topContent()}
<div class="see-stickers-face">
View Stickers
<span class="subtext">no sign in needed</span>
</div>
{/snippet}
{#snippet backContent()}
<div class="sticker-back"></div>
{/snippet}
{#snippet bottomContent()}
<div class="sticker-surface">
<span>Loading...</span>
</div>
{/snippet}
</Peelable>
<footer><a href="https://hackclub.com/privacy-and-terms/" target="_blank" rel="noopener noreferrer">Privacy and Terms</a></footer>
</div>
<div class="construction-notice">
🚧 Site still Under Construction!
</div>
<div class="bottom-footers">
<footer class="open-source-footer">
<a href="https://github.com/hackclub/stickers?tab=readme-ov-file" target="_blank" rel="noopener noreferrer">open source, drop us a <h3 style="display:inline"></h3></a>
</footer>
</div>
<footer>
<a
href="https://hackclub.com/privacy-and-terms/"
target="_blank"
rel="noopener noreferrer">Privacy and Terms</a
>
</footer>
</div>
<div class="construction-notice">🚧 Site still Under Construction!</div>
<div class="bottom-footers">
<footer class="open-source-footer">
<a
href="https://github.com/hackclub/stickers?tab=readme-ov-file"
target="_blank"
rel="noopener noreferrer"
>open source, drop us a <h3 style="display:inline"></h3></a
>
</footer>
</div>
</div>
<style>
@font-face {
font-family: 'Departure Mono';
src: url('$lib/assets/fonts/DepartureMono-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Departure Mono";
src: url("$lib/assets/fonts/DepartureMono-Regular.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
}
:global(html, body) {
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
}
:global(html, body) {
overflow: hidden;
height: 100%;
margin: 0;
padding: 0;
}
.page-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: 'Departure Mono', monospace;
padding: 2rem;
box-sizing: border-box;
position: relative;
}
.page-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: "Departure Mono", monospace;
padding: 2rem;
box-sizing: border-box;
position: relative;
}
.bottom-footers {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.bottom-footers {
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.hero-logo {
width: clamp(180px, 20vw, 300px);
height: auto;
margin-bottom: 2rem;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
pointer-events: none;
}
.hero-logo {
width: clamp(180px, 20vw, 300px);
height: auto;
margin-bottom: 2rem;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
pointer-events: none;
}
.content-container {
background: rgba(255, 255, 255, 0.95);
border: 2px solid #333;
border-radius: 0.5rem;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 440px;
}
.content-container {
background: rgba(255, 255, 255, 0.95);
border: 2px solid #333;
border-radius: 0.5rem;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 440px;
}
p {
font-size: clamp(1rem, 3.5vw, 1.5rem);
margin: 0 0 1rem 0;
}
p {
font-size: clamp(1rem, 3.5vw, 1.5rem);
margin: 0 0 1rem 0;
}
.content-container h1 {
font-size: clamp(1.5rem, 6vw, 2.5rem);
margin: 0 0 1rem 0;
}
.content-container h1 {
font-size: clamp(1.5rem, 6vw, 2.5rem);
margin: 0 0 1rem 0;
}
a {
color: #333;
text-decoration: underline;
}
:global(.login-sticker) {
width: clamp(150px, 20vw, 300px);
min-height: clamp(45px, 6vw, 80px);
margin: 1rem 0;
cursor: pointer;
}
a {
color: #333;
text-decoration: underline;
}
:global(.login-sticker) {
width: clamp(150px, 20vw, 300px);
min-height: clamp(45px, 6vw, 80px);
margin: 1rem 0;
cursor: pointer;
}
.sticker-face {
width: 100%;
height: 100%;
background: #9cada6;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-family: 'Departure Mono', monospace;
font-weight: 400;
font-size: clamp(1.2rem, 4vw, 2rem);
padding: 0.5rem;
box-sizing: border-box;
word-break: break-word;
text-align: center;
}
.sticker-face {
width: 100%;
height: 100%;
background: #9cada6;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-family: "Departure Mono", monospace;
font-weight: 400;
font-size: clamp(1.2rem, 4vw, 2rem);
padding: 0.5rem;
box-sizing: border-box;
word-break: break-word;
text-align: center;
}
.sticker-back {
width: 100%;
height: 100%;
background: #d9c9b6;
border-radius: 0.5rem;
}
.sticker-back {
width: 100%;
height: 100%;
background: #d9c9b6;
border-radius: 0.5rem;
}
.sticker-surface {
width: 100%;
height: 100%;
background: #ffffff;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: #333;
font-family: 'Departure Mono', monospace;
font-size: 0.9rem;
}
.sticker-surface {
width: 100%;
height: 100%;
background: #ffffff;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: #333;
font-family: "Departure Mono", monospace;
font-size: 0.9rem;
}
footer {
font-size: 1rem;
color: #666;
margin-top: 1rem;
}
.construction-notice {
position: absolute;
top: 1rem;
left: 1rem;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
font-family: 'Departure Mono', monospace;
color: #666;
}
.open-source-footer {
font-weight: bold;
color: rgba(0, 0, 0);
text-align: center;
padding: 0.5rem;
font-size: 0.9rem;
font-family: sans-serif;
text-decoration: none;
}
footer {
font-size: 1rem;
color: #666;
margin-top: 1rem;
}
.construction-notice {
position: absolute;
top: 1rem;
left: 1rem;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
font-family: "Departure Mono", monospace;
color: #666;
}
.open-source-footer {
font-weight: bold;
color: rgba(0, 0, 0);
text-align: center;
padding: 0.5rem;
font-size: 0.9rem;
font-family: sans-serif;
text-decoration: none;
}
:global(.see-stickers-sticker) {
width: clamp(150px, 20vw, 300px);
min-height: clamp(45px, 6vw, 80px);
cursor: pointer;
}
:global(.see-stickers-sticker) {
width: clamp(150px, 20vw, 300px);
min-height: clamp(45px, 6vw, 80px);
cursor: pointer;
}
.see-stickers-face {
width: 100%;
height: 100%;
background: #8a9bb0;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-family: 'Departure Mono', monospace;
font-size: clamp(1rem, 3vw, 1.5rem);
padding: 0.5rem;
box-sizing: border-box;
}
.see-stickers-face {
width: 100%;
height: 100%;
background: #8a9bb0;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-family: "Departure Mono", monospace;
font-size: clamp(1rem, 3vw, 1.5rem);
padding: 0.5rem;
box-sizing: border-box;
}
.see-stickers-face .subtext {
font-size: clamp(0.5rem, 1.5vw, 0.7rem);
color: rgba(255, 255, 255, 0.85);
margin-top: 0.2rem;
}
.see-stickers-face .subtext {
font-size: clamp(0.5rem, 1.5vw, 0.7rem);
color: rgba(255, 255, 255, 0.85);
margin-top: 0.2rem;
}
@media (max-height: 700px) {
.hero-logo {
width: clamp(80px, 12vw, 150px);
margin-bottom: 0.75rem;
}
.content-container {
padding: 1rem;
}
.content-container h1 {
font-size: clamp(1.2rem, 4vw, 1.8rem);
margin: 0 0 0.5rem 0;
}
p {
font-size: clamp(0.8rem, 2.5vw, 1.1rem);
margin: 0.4rem 0;
}
.sticker-face {
font-size: clamp(0.9rem, 3vw, 1.3rem);
}
.see-stickers-face {
font-size: clamp(0.8rem, 2.5vw, 1.1rem);
}
.page-wrapper {
padding: 1rem;
}
.bottom-footers {
position: static;
margin-top: 0.5rem;
}
.construction-notice {
top: 0.5rem;
left: 0.5rem;
font-size: 0.7rem;
padding: 0.3rem 0.5rem;
}
footer {
font-size: 0.8rem;
margin-top: 0.5rem;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
<script>
let selectedRarity = $state('all');
let searchQuery = $state('');
const mockDesigns = [];
</script>

View file

@ -1,255 +0,0 @@
<script>
let sortBy = $state('total');
const mockUsers = [
{ rank: 1, username: 'MSW', totalStickers: 42, uniques: 33, rares: 4 },
{ rank: 2, username: 'ZRL', totalStickers: 30, uniques: 20, rares: 4 },
{ rank: 3, username: 'LFD', totalStickers: 28, uniques: 18, rares: 3 },
{ rank: 4, username: 'Nora', totalStickers: 19, uniques: 15, rares: 3 },
{ rank: 5, username: 'Euan', totalStickers: 17, uniques: 16, rares: 3 },
{ rank: 1192, username: 'You', totalStickers: 2, uniques: 2, rares: 0 }
];
const youUser = mockUsers.find(u => u.username === 'You');
const sortedUsers = $derived(
[...mockUsers].filter(u => u.username !== 'You').sort((a, b) => {
if (sortBy === 'total') return b.totalStickers - a.totalStickers;
if (sortBy === 'uniques') return b.uniques - a.uniques;
if (sortBy === 'rares') return b.rares - a.rares;
return 0;
}).map((user, i) => ({ ...user, rank: i + 1 }))
);
</script>
<h1><mark>Leaderboard</mark></h1>
<div class="content-row">
<div class="card info-card">
<p>Top Sticker collectors! This really is a bragging right.</p>
</div>
<div class="card filter-card">
<label for="sort">Sort by:</label>
<select id="sort" bind:value={sortBy}>
<option value="total">Total Stickers</option>
<option value="uniques">Unique Stickers</option>
<option value="rares">Rare Stickers</option>
</select>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Username</th>
<th>Total Stickers</th>
<th>Uniques</th>
<th>Rares</th>
</tr>
</thead>
<tbody>
{#each sortedUsers as user}
<tr class:top-three={user.rank <= 3}>
<td class="rank rank-{user.rank}">{user.rank}</td>
<td class="username">{user.username}</td>
<td>{user.totalStickers}</td>
<td>{user.uniques}</td>
<td>{user.rares}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<br />
{#if youUser}
<div class="table-container you-container">
<table>
<thead class="hidden-header">
<tr>
<th>Rank</th>
<th>Username</th>
<th>Total Stickers</th>
<th>Uniques</th>
<th>Rares</th>
</tr>
</thead>
<tbody>
<tr class="you-row">
<td class="rank">{youUser.rank}</td>
<td class="username">{youUser.username}</td>
<td>{youUser.totalStickers}</td>
<td>{youUser.uniques}</td>
<td>{youUser.rares}</td>
</tr>
</tbody>
</table>
</div>
{/if}
<style>
h1 {
font-size: 3rem;
margin: 0 0 2rem 0;
}
.content-row {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
align-items: stretch;
}
.card {
background: rgba(255, 255, 255, 0.95);
padding: 1.5rem;
border-radius: 0.5rem;
border: 2px solid #333;
}
.info-card {
flex: 0 0 auto;
}
.filter-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex: 0 0 auto;
}
.filter-card label {
font-size: 1.25rem;
white-space: nowrap;
}
.filter-card select {
font-family: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
border: 2px solid #333;
border-radius: 0.5rem;
background: rgba(250, 248, 245, 0.95);
cursor: pointer;
}
p {
font-size: 1.5rem;
margin: 0;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.content-row {
flex-direction: column;
}
p {
font-size: 1rem;
}
.card {
padding: 1rem;
}
.filter-card label {
font-size: 1rem;
}
table {
font-size: 0.9rem;
}
th, td {
padding: 0.5rem;
}
}
mark {
background-color: #d9c9b6;
padding: 0 0.2rem;
}
.table-container {
margin-top: 2rem;
background: rgba(255, 255, 255, 0.95);
border: 2px solid #333;
border-radius: 0.5rem;
overflow: hidden;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 1.1rem;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #333;
}
th {
background: rgba(217, 201, 182, 0.5);
font-weight: normal;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: rgba(250, 248, 245, 0.5);
}
.top-three {
font-weight: bold;
}
.rank {
font-family: 'Departure Mono', monospace;
font-size: 1.25rem;
}
.rank-1 {
color: #ffd700;
}
.rank-2 {
color: #c0c0c0;
}
.rank-3 {
color: #cd7f32;
}
.username {
font-weight: bold;
}
.you-container {
background: #d4edda;
}
.hidden-header {
visibility: collapse;
}
.you-row:hover {
background: #c3e6cb;
}
.you-row td {
border-bottom: none;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -20,20 +20,19 @@
function openMakeDeal() {
showModal = true;
showModal()
}
function closeModal() {
showModal = false;
}
if (showModal) {
document.body.style.overflow = 'hidden';
alert('test');
} else {
document.body.style.overflow = 'auto';
}
$effect(() => {
if (showModal) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'auto';
}
});
</script>
<h1><mark>Trade</mark></h1>

View file

@ -1,305 +1,315 @@
<script>
import { onMount } from 'svelte';
import LazyImage from '$lib/components/LazyImage.svelte';
import { onMount } from "svelte";
import LazyImage from "$lib/components/LazyImage.svelte";
/** @typedef {{ id: string, name: string, cdn_url: string, votes: number, voted?: boolean }} Design */
/** @typedef {{ id: string, name: string, cdn_url: string, votes: number, voted?: boolean }} Design */
/** @type {Design[]} */
let designs = $state([]);
let loading = $state(true);
/** @type {string | null} */
let error = $state(null);
/** @type {string | null} */
let votingId = $state(null);
/** @type {Design[]} */
let designs = $state([]);
let loading = $state(true);
/** @type {string | null} */
let error = $state(null);
/** @type {string | null} */
let votingId = $state(null);
onMount(async () => {
try {
const res = await fetch('/api/designs/all');
if (!res.ok) throw new Error('Failed to fetch designs');
designs = await res.json();
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading = false;
onMount(async () => {
try {
const res = await fetch("/api/designs/all");
if (!res.ok) throw new Error("Failed to fetch designs");
designs = await res.json();
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error";
} finally {
loading = false;
}
});
/** @param {Design} design */
async function toggleVote(design) {
if (votingId) return;
votingId = design.id;
try {
const res = await fetch(`/api/designs/${design.id}/vote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) throw new Error("Failed to submit vote");
const result = await res.json();
designs = designs.map((d) =>
d.id === design.id
? { ...d, votes: result.votes, voted: result.voted }
: d,
);
} catch (e) {
alert(
"Error: " + (e instanceof Error ? e.message : "Unknown error"),
);
} finally {
votingId = null;
}
}
});
/** @param {Design} design */
async function toggleVote(design) {
if (votingId) return;
votingId = design.id;
try {
const res = await fetch(`/api/designs/${design.id}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error('Failed to submit vote');
const result = await res.json();
designs = designs.map(d =>
d.id === design.id
? { ...d, votes: result.votes, voted: result.voted }
: d
);
} catch (e) {
alert('Error: ' + (e instanceof Error ? e.message : 'Unknown error'));
} finally {
votingId = null;
}
}
const myVotesCount = $derived(designs.filter(d => d.voted).length);
const myVotesCount = $derived(designs.filter((d) => d.voted).length);
</script>
<h1><mark>Vote</mark></h1>
<div class="content-row">
<div class="card info-card">
<p>Vote for your favorite designs with a click, The highest voted design will be included in the monthly sticker box and all will be added to the shop for artists to earn commission.</p>
</div>
<div class="card info-card">
<p>
Vote for your favorite designs with a click, The highest voted
design will be included in the monthly sticker box and all will be
added to the shop for artists to earn commission.
</p>
</div>
<div class="card votes-card">
<span class="votes-count">Your votes: {myVotesCount}</span>
</div>
<div class="card votes-card">
<span class="votes-count">Your votes: {myVotesCount}</span>
</div>
</div>
{#if loading}
<div class="loading">Loading designs...</div>
<div class="loading">Loading designs...</div>
{:else if error}
<div class="error">Error: {error}</div>
<div class="error">Error: {error}</div>
{:else if designs.length === 0}
<div class="empty-state">
<p>No approved designs to vote on yet.</p>
</div>
<div class="empty-state">
<p>No approved designs to vote on yet.</p>
</div>
{:else}
<div class="designs-grid">
{#each designs as design}
<button
class="design-card"
class:voted={design.voted}
class:voting={votingId === design.id}
onclick={() => toggleVote(design)}
disabled={votingId !== null}
>
<div class="design-image">
<LazyImage src={design.cdn_url} alt={design.name} />
</div>
<div class="design-footer">
<span class="design-name">{design.name || 'Untitled'}</span>
<span class="design-votes">{design.votes} vote{design.votes !== 1 ? 's' : ''}</span>
</div>
{#if design.voted}
<script>
console.log('voted');
</script>
{/if}
{#if votingId === design.id}
<div class="voting-overlay">...</div>
{/if}
</button>
{/each}
</div>
<div class="designs-grid">
{#each designs as design}
<button
class="design-card"
class:voted={design.voted}
class:voting={votingId === design.id}
onclick={() => toggleVote(design)}
disabled={votingId !== null}
>
<div class="design-image">
<LazyImage src={design.cdn_url} alt={design.name} />
</div>
<div class="design-footer">
<span class="design-name">{design.name || "Untitled"}</span>
<span class="design-votes"
>{design.votes} vote{design.votes !== 1
? "s"
: ""}</span
>
</div>
{#if votingId === design.id}
<div class="voting-overlay">...</div>
{/if}
</button>
{/each}
</div>
{/if}
<style>
h1 {
font-size: 3rem;
margin: 0 0 2rem 0;
}
.content-row {
display: flex;
gap: 1.5rem;
flex-wrap: nowrap;
align-items: stretch;
margin-bottom: 2rem;
}
.card {
background: rgba(255, 255, 255, 0.95);
padding: 1.5rem;
border-radius: 0.5rem;
border: 2px solid #333;
}
.info-card {
flex: 1 1 auto;
}
.info-card p {
font-size: 1.5rem;
margin: 0;
}
.votes-card {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.votes-count {
font-size: 1.25rem;
white-space: nowrap;
}
mark {
background-color: #d9c9b6;
padding: 0 0.2rem;
}
.loading, .error, .empty-state {
text-align: center;
padding: 3rem;
background: rgba(255, 255, 255, 0.95);
border: 2px solid #333;
border-radius: 0.5rem;
font-size: 1.25rem;
}
.error {
color: #cc0000;
}
.designs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.design-card {
background: rgba(255, 255, 255, 0.95);
border: 3px solid #333;
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
position: relative;
padding: 0;
text-align: left;
font-family: inherit;
}
.design-card:hover:not(:disabled) {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.design-card.voted {
border-color: #28a745;
background: rgba(200, 247, 197, 0.95);
}
.design-card.voting {
opacity: 0.7;
}
.design-card:disabled {
cursor: wait;
}
.design-image {
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
padding: 1rem;
}
.design-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.design-footer {
padding: 1rem;
border-top: 1px solid #333;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.design-name {
font-size: 1.25rem;
font-weight: bold;
}
.design-votes {
font-size: 0.9rem;
color: #666;
}
.voted-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: #28a745;
color: white;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.voting-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
font-size: 3rem;
margin: 0 0 2rem 0;
}
.content-row {
flex-direction: column;
}
.info-card {
flex: 0 0 auto;
}
.info-card p {
font-size: 1rem;
display: flex;
gap: 1.5rem;
flex-wrap: nowrap;
align-items: stretch;
margin-bottom: 2rem;
}
.card {
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
padding: 1.5rem;
border-radius: 0.5rem;
border: 2px solid #333;
}
.info-card {
flex: 1 1 auto;
}
.info-card p {
font-size: 1.5rem;
margin: 0;
}
.votes-card {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.votes-count {
font-size: 1rem;
font-size: 1.25rem;
white-space: nowrap;
}
mark {
background-color: #d9c9b6;
padding: 0 0.2rem;
}
.loading,
.error,
.empty-state {
text-align: center;
padding: 3rem;
background: rgba(255, 255, 255, 0.95);
border: 2px solid #333;
border-radius: 0.5rem;
font-size: 1.25rem;
}
.error {
color: #cc0000;
}
.designs-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.design-card {
background: rgba(255, 255, 255, 0.95);
border: 3px solid #333;
border-radius: 0.5rem;
overflow: hidden;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s,
border-color 0.2s;
position: relative;
padding: 0;
text-align: left;
font-family: inherit;
}
.design-card:hover:not(:disabled) {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.design-card.voted {
border-color: #28a745;
background: rgba(200, 247, 197, 0.95);
}
.design-card.voting {
opacity: 0.7;
}
.design-card:disabled {
cursor: wait;
}
.design-image {
height: 150px;
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
padding: 1rem;
}
.design-name {
font-size: 1rem;
.design-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.design-footer {
padding: 0.75rem;
padding: 1rem;
border-top: 1px solid #333;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.design-name {
font-size: 1.25rem;
font-weight: bold;
}
.design-votes {
font-size: 0.9rem;
color: #666;
}
.voted-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: #28a745;
color: white;
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.voting-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.content-row {
flex-direction: column;
}
.info-card {
flex: 0 0 auto;
}
.info-card p {
font-size: 1rem;
}
.card {
padding: 1rem;
}
.votes-count {
font-size: 1rem;
}
.designs-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.design-image {
height: 150px;
}
.design-name {
font-size: 1rem;
}
.design-footer {
padding: 0.75rem;
}
}
}
</style>