feat: load stickers from Airtable

This commit is contained in:
EDRipper 2025-12-07 20:14:10 -05:00
parent 550ba2a6ba
commit 06b0200c1b
13 changed files with 465 additions and 158 deletions

4
.gitignore vendored
View file

@ -21,3 +21,7 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Ruby
backend/vendor/bundle
backend/.bundle

View file

@ -1,38 +1,4 @@
# sv
everything stickers by Nora and Euan
Ruby with Grape + Svelte
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
npm run dev

View file

@ -1,5 +1,5 @@
AIRTABLE_PAT=dummy
AIRTABLE_BASE=dummy
AIRTABLE_API_KEY=dummy
AIRTABLE_BASE_ID=dummy
SESSION_SECRET=dummy
OIDC_ISSUER=dummy
OIDC_CLIENT_ID=dummy

View file

@ -21,3 +21,4 @@ gem 'rack', '~> 3.2'
gem 'omniauth'
gem 'omniauth_openid_connect'
gem 'rack-session'
gem 'rack-cors'

View file

@ -140,6 +140,9 @@ GEM
puma (7.1.0)
nio4r (~> 2.0)
rack (3.2.4)
rack-cors (3.0.0)
logger
rack (>= 3.0.14)
rack-oauth2 (2.3.0)
activesupport
attr_required
@ -188,6 +191,7 @@ DEPENDENCIES
omniauth_openid_connect
puma (~> 7.1)
rack (~> 3.2)
rack-cors
rack-session
rackup
zeitwerk (~> 2.6)

View file

@ -0,0 +1,23 @@
class StickersTable < AirctiveRecord::Base
self.base_key = ENV['AIRTABLE_BASE_ID']
self.table_name = "stickerDB"
end
class AirtableData < Grape::API
format :json
helpers do
def airtable_client
StickersTable
end
end
get '/data' do
records = airtable_client.all
records.map(&:fields)
end
post '/data' do
airtable_client.create(params[:fields])
end
end

View file

@ -2,7 +2,7 @@
class App < Grape::API
format :json
mount AirtableData
mount Auth
mount Stickers
end

View file

@ -5,20 +5,29 @@ class Stickers < Base
resource :stickers do
get do
Sticker.only_active.all.map(&:as_json) + [current_user: current_user]
end
before do
@sticker = Sticker.only_active.where(autonumber: params[:id]).first || error!('not found', 404)
records = StickersTable.all
records.map do |record|
{
id: record.id,
name: record["Sticker Name"],
image: record["CDNURL"],
artist: record["Artist"],
event: record["Event"]
}
end
end
route_param :id, type: String do
before { authenticate! }
get do
@sticker.as_json
end
get '/backwards' do
@sticker.name.reverse
record = StickersTable.find(params[:id])
error!('not found', 404) unless record
{
id: record.id,
name: record["Sticker Name"],
image: record["CDNURL"],
artist: record["Artist"],
event: record["Event"]
}
end
end
end

View file

@ -9,7 +9,7 @@ require 'grape'
Dotenv.load
Norairrecord.api_key = ENV['AIRTABLE_PAT']
Norairrecord.api_key = ENV['AIRTABLE_API_KEY']
loader = Zeitwerk::Loader.new
loader.push_dir("#{__dir__}/models")

View file

@ -8,6 +8,14 @@ require 'grape'
require 'rack/session'
require 'omniauth'
require 'omniauth_openid_connect'
require 'rack/cors'
use Rack::Cors do
allow do
origins 'http://localhost:5173', 'http://localhost:4173'
resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options]
end
end
use Rack::Session::Cookie,
key: 'stickers.session',

View file

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

View file

@ -2,6 +2,12 @@
import Peelable from '$lib/components/Peelable.svelte';
import stickersBg from '$lib/assets/images/stickers.jpg';
let showClaimModal = $state(false);
let requiredFingers = $state(Math.floor(Math.random() * 5) + 1);
let uploadedFile = $state(null);
let previewUrl = $state(null);
let isDragging = $state(false);
function goToTrade() {
window.location.href = '/trade';
}
@ -13,8 +19,125 @@
function goToVote() {
window.location.href = '/vote';
}
function openClaimModal() {
requiredFingers = Math.floor(Math.random() * 5) + 1;
showClaimModal = true;
}
function closeClaimModal() {
showClaimModal = false;
clearFile();
}
function handleFileSelect(event) {
const input = event.target;
if (input.files && input.files[0]) {
setFile(input.files[0]);
}
}
function handleDrop(event) {
event.preventDefault();
isDragging = false;
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
setFile(event.dataTransfer.files[0]);
}
}
function handleDragOver(event) {
event.preventDefault();
isDragging = true;
}
function handleDragLeave() {
isDragging = false;
}
function setFile(file) {
if (file.type.startsWith('image/')) {
uploadedFile = file;
previewUrl = URL.createObjectURL(file);
}
}
function clearFile() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
uploadedFile = null;
previewUrl = null;
}
function submitClaim() {
if (!uploadedFile) return;
alert('Claim submitted for verification!');
closeClaimModal();
}
</script>
{#if showClaimModal}
<div class="modal-overlay">
<div class="modal-content">
<button class="close-btn" onclick={closeClaimModal}>✕</button>
<h1><mark>Claim Your Stickers</mark></h1>
<div class="instructions">
<h2>Verification Required</h2>
<p>To verify you have the stickers in your possession, please upload a photo showing:</p>
<ul>
<li>Your sticker(s) clearly visible</li>
<li>Your hand holding up <strong>{requiredFingers}</strong> finger{requiredFingers > 1 ? 's' : ''}</li>
</ul>
<div class="finger-display">
<span class="finger-count">{requiredFingers}</span>
<span class="finger-label">finger{requiredFingers > 1 ? 's' : ''} required</span>
</div>
</div>
<div
class="upload-area"
class:dragging={isDragging}
class:has-preview={previewUrl}
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
role="button"
tabindex="0"
>
{#if previewUrl}
<div class="preview-container">
<img src={previewUrl} alt="Upload preview" class="preview-image" />
<button class="clear-file-btn" onclick={clearFile}>✕</button>
</div>
{:else}
<label class="upload-label">
<input
type="file"
accept="image/*"
onchange={handleFileSelect}
class="file-input"
/>
<div class="upload-content">
<span class="upload-text">Drop your photo here or click to upload</span>
</div>
</label>
{/if}
</div>
<button
class="submit-btn"
onclick={submitClaim}
disabled={!uploadedFile}
>
Submit for Verification
</button>
</div>
</div>
{/if}
<div class="layout">
<div class="left-column">
<h1><mark>What's new</mark></h1>
@ -97,7 +220,7 @@
<div class="right-header">
<h1><mark>Your collection</mark></h1>
<div class="button-group">
<button class="btn-left">Claim stickers</button>
<button class="btn-left" onclick={openClaimModal}>Claim stickers</button>
<button class="btn-right">Place stickers</button>
</div>
</div>
@ -110,6 +233,208 @@
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: rgba(250, 248, 245, 0.98);
padding: 2rem;
border-radius: 0.5rem;
border: 2px solid #333;
max-width: 550px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.close-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #333;
padding: 0.25rem 0.5rem;
}
.close-btn:hover {
color: #000;
}
.modal-content h1 {
font-size: 2rem;
margin: 0 0 1.5rem 0;
}
.instructions {
margin-bottom: 1.5rem;
}
.instructions h2 {
font-size: 1.25rem;
margin: 0 0 0.75rem 0;
}
.instructions p {
font-size: 1rem;
margin: 0 0 0.5rem 0;
}
.instructions ul {
font-size: 1rem;
margin: 0 0 1rem 0;
padding-left: 1.5rem;
}
.instructions li {
margin-bottom: 0.25rem;
}
.finger-display {
display: flex;
align-items: center;
gap: 1rem;
background: #d9c9b6;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
width: fit-content;
}
.finger-count {
font-size: 2.5rem;
font-weight: bold;
color: #333;
}
.finger-label {
font-size: 1rem;
color: #555;
}
.upload-area {
border: 3px dashed #999;
border-radius: 0.5rem;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
margin-bottom: 1rem;
background: #fff;
}
.upload-area.dragging {
border-color: #333;
background: #f0f0f0;
}
.upload-area.has-preview {
border-style: solid;
border-color: #333;
}
.upload-label {
width: 100%;
height: 100%;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.file-input {
display: none;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
color: #666;
}
.upload-icon {
font-size: 2.5rem;
}
.upload-text {
font-size: 1rem;
}
.preview-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.preview-image {
max-width: 100%;
max-height: 250px;
object-fit: contain;
border-radius: 0.25rem;
}
.clear-file-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 2px solid #333;
background: #fff;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.clear-file-btn:hover {
background: #f0f0f0;
}
.submit-btn {
width: 100%;
padding: 0.875rem;
background: #444;
color: white;
border: 2px solid #333;
border-radius: 0.5rem;
font-size: 1rem;
font-family: inherit;
cursor: pointer;
transition: background 0.2s ease;
}
.submit-btn:hover:not(:disabled) {
background: #555;
}
.submit-btn:disabled {
background: #999;
cursor: not-allowed;
}
.layout {
display: flex;
gap: 2rem;

View file

@ -1,36 +1,31 @@
<script>
import { onMount } from 'svelte';
let selectedFilter = $state('all');
let searchQuery = $state('');
let expandedId = $state(null);
let stickers = $state([]);
let loading = $state(true);
let error = $state(null);
const mockDesigns = [
{ id: 1, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 2, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 3, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 4, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 5, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 6, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 7, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 8, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 9, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 10, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 11, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 12, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 13, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 14, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 15, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 16, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 },
{ id: 17, designUrl: 'https://hc-cdn.hel1.your-objectstorage.com/s/v3/3d9f704b3a59ae5109d4fb1d2ca76fac0b6c917d_image.png', name: 'Boba Drops', inStock: 150, price: 2, program: 'Boba Drops', created: '??', artist: 'MSW', tags: ['current'], width: 100, height: 100 }
];
onMount(async () => {
try {
const res = await fetch('http://localhost:9292/stickers');
if (!res.ok) throw new Error('Failed to fetch stickers');
stickers = await res.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
const filteredDesigns = $derived(
mockDesigns.filter(d => {
stickers.filter(d => {
const matchesSearch = searchQuery === '' ||
d.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.artist.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.program.toLowerCase().includes(searchQuery.toLowerCase());
(d.name?.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesFilter = selectedFilter === 'all' || d.tags.includes(selectedFilter);
const matchesFilter = selectedFilter === 'all' || (d.tags && d.tags.includes(selectedFilter));
return matchesSearch && matchesFilter;
})
@ -68,58 +63,48 @@
</div>
</div>
<div class="panels-grid">
{#each filteredDesigns as design}
<div
class="panel"
class:expanded={expandedId === design.id}
onclick={() => toggleExpand(design.id)}
>
<div class="panel-image">
<img src={design.designUrl} alt={design.name} />
</div>
<div class="panel-footer">
<span class="panel-name">{design.name}</span>
<span class="panel-artist">by {design.artist}</span>
</div>
{#if expandedId === design.id}
<div class="panel-details">
<div class="detail-row">
<span class="detail-label">Dimensions:</span>
<span>{design.width}mm × {design.height}mm</span>
</div>
<div class="detail-row">
<span class="detail-label">Program:</span>
<span>{design.program}</span>
</div>
<div class="detail-row">
<span class="detail-label">Created:</span>
<span>{design.created}</span>
</div>
<div class="detail-row">
<span class="detail-label">In Stock:</span>
<span class:out-of-stock={design.inStock === 0}>{design.inStock}</span>
</div>
<div class="detail-row">
<span class="detail-label">Price:</span>
<span>{design.price} hrs</span>
</div>
<div class="detail-row">
<span class="detail-label">Tags:</span>
<span class="tags">
{#each design.tags as tag}
<span class="tag tag-{tag}">{tag}</span>
{/each}
</span>
</div>
{#if loading}
<div class="loading">Loading stickers...</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else}
<div class="panels-grid">
{#each filteredDesigns as design}
<div
class="panel"
class:expanded={expandedId === design.id}
onclick={() => toggleExpand(design.id)}
>
<div class="panel-image">
<img src={design.image} alt={design.name} />
</div>
{/if}
</div>
{/each}
</div>
<div class="panel-footer">
<span class="panel-name">{design.name}</span>
{#if design.artist}
<span class="panel-artist">by {design.artist}</span>
{/if}
</div>
{#if expandedId === design.id}
<div class="panel-details">
{#if design.event}
<div class="detail-row">
<span class="detail-label">Event:</span>
<span>{design.event}</span>
</div>
{/if}
<div class="detail-row">
<span class="detail-label">ID:</span>
<span>{design.id}</span>
</div>
</div>
{/if}
</div>
{/each}
</div>
{#if filteredDesigns.length === 0}
<div class="no-results">No stickers found</div>
{#if filteredDesigns.length === 0}
<div class="no-results">No stickers found</div>
{/if}
{/if}
<style>
@ -196,6 +181,19 @@
padding: 0 0.2rem;
}
.loading, .error {
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;
}
.panels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@ -275,37 +273,6 @@
font-weight: bold;
}
.out-of-stock {
color: #cc0000;
}
.tags {
display: flex;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
.tag-current {
background: #c8f7c5;
}
.tag-old {
background: #d0d0d0;
}
.tag-special {
background: #fff3cd;
}
.tag-in-person {
background: #cce5ff;
}
.no-results {
text-align: center;
padding: 3rem;