🐐
This commit is contained in:
@@ -95,6 +95,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q24862": CardType.artwork, # film
|
"Q24862": CardType.artwork, # film
|
||||||
"Q11032": CardType.artwork, # newspaper
|
"Q11032": CardType.artwork, # newspaper
|
||||||
"Q25379": CardType.artwork, # play
|
"Q25379": CardType.artwork, # play
|
||||||
|
"Q41298": CardType.artwork, # magazine
|
||||||
"Q482994": CardType.artwork, # album
|
"Q482994": CardType.artwork, # album
|
||||||
"Q134556": CardType.artwork, # single
|
"Q134556": CardType.artwork, # single
|
||||||
"Q169930": CardType.artwork, # EP
|
"Q169930": CardType.artwork, # EP
|
||||||
@@ -130,6 +131,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q3624078": CardType.location, # sovereign state
|
"Q3624078": CardType.location, # sovereign state
|
||||||
"Q1093829": CardType.location, # city in the United States
|
"Q1093829": CardType.location, # city in the United States
|
||||||
"Q7930989": CardType.location, # city/town
|
"Q7930989": CardType.location, # city/town
|
||||||
|
"Q3146899": CardType.location, # diocese of the catholic church
|
||||||
"Q35145263": CardType.location, # natural geographic object
|
"Q35145263": CardType.location, # natural geographic object
|
||||||
|
|
||||||
"Q16521": CardType.life_form, # taxon
|
"Q16521": CardType.life_form, # taxon
|
||||||
@@ -155,6 +157,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q5419137": CardType.group, # veterans' organization
|
"Q5419137": CardType.group, # veterans' organization
|
||||||
"Q12973014": CardType.group, # sports team
|
"Q12973014": CardType.group, # sports team
|
||||||
"Q11446438": CardType.group, # female idol group
|
"Q11446438": CardType.group, # female idol group
|
||||||
|
"Q10517054": CardType.group, # handball team
|
||||||
"Q135408445": CardType.group, # men's national association football team
|
"Q135408445": CardType.group, # men's national association football team
|
||||||
|
|
||||||
"Q7187": CardType.science_thing, # gene
|
"Q7187": CardType.science_thing, # gene
|
||||||
@@ -164,6 +167,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = {
|
|||||||
"Q11276": CardType.science_thing, # globular cluster
|
"Q11276": CardType.science_thing, # globular cluster
|
||||||
"Q898273": CardType.science_thing, # protein domain
|
"Q898273": CardType.science_thing, # protein domain
|
||||||
"Q168845": CardType.science_thing, # star cluster
|
"Q168845": CardType.science_thing, # star cluster
|
||||||
|
"Q1341811": CardType.science_thing, # astronomical maser
|
||||||
"Q1840368": CardType.science_thing, # cloud type
|
"Q1840368": CardType.science_thing, # cloud type
|
||||||
"Q113145171": CardType.science_thing, # type of chemical entity
|
"Q113145171": CardType.science_thing, # type of chemical entity
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from card import _get_cards_async
|
from card import _get_cards_async
|
||||||
from models import Card as CardModel
|
from models import Card as CardModel
|
||||||
|
from models import User as UserModel
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
|
|
||||||
logger = logging.getLogger("app")
|
logger = logging.getLogger("app")
|
||||||
@@ -59,3 +61,27 @@ async def fill_card_pool():
|
|||||||
finally:
|
finally:
|
||||||
pool_filling = False
|
pool_filling = False
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
BOOSTER_MAX = 5
|
||||||
|
BOOSTER_COOLDOWN_HOURS = 5
|
||||||
|
|
||||||
|
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
|
||||||
|
if user.boosters_countdown is None:
|
||||||
|
user.boosters = 5
|
||||||
|
db.commit()
|
||||||
|
return (user.boosters, user.boosters_countdown)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
countdown = user.boosters_countdown
|
||||||
|
|
||||||
|
while user.boosters < BOOSTER_MAX:
|
||||||
|
next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS)
|
||||||
|
if now >= next_tick:
|
||||||
|
user.boosters += 1
|
||||||
|
countdown = next_tick
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None
|
||||||
|
db.commit()
|
||||||
|
return (user.boosters, user.boosters_countdown)
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ loggers:
|
|||||||
handlers:
|
handlers:
|
||||||
- default
|
- default
|
||||||
propagate: no
|
propagate: no
|
||||||
|
passlib.utils.compat:
|
||||||
|
level: INFO
|
||||||
|
handlers:
|
||||||
|
- default
|
||||||
|
propagate: no
|
||||||
|
python_multipart.multipart:
|
||||||
|
level: INFO
|
||||||
|
handlers:
|
||||||
|
- default
|
||||||
|
propagate: no
|
||||||
app:
|
app:
|
||||||
level: INFO
|
level: INFO
|
||||||
handlers:
|
handlers:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from fastapi import FastAPI, Depends, HTTPException, status
|
from fastapi import FastAPI, Depends, HTTPException, status
|
||||||
@@ -9,11 +10,10 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from database import SessionLocal, get_db
|
from database import get_db
|
||||||
from database_functions import fill_card_pool
|
from database_functions import fill_card_pool, check_boosters, BOOSTER_MAX
|
||||||
from models import Card as CardModel
|
from models import Card as CardModel
|
||||||
from models import User as UserModel
|
from models import User as UserModel
|
||||||
from card import _get_cards_async
|
|
||||||
from auth import hash_password, verify_password, create_access_token, decode_access_token
|
from auth import hash_password, verify_password, create_access_token, decode_access_token
|
||||||
|
|
||||||
logger = logging.getLogger("app")
|
logger = logging.getLogger("app")
|
||||||
@@ -49,16 +49,6 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/pack/{size}")
|
|
||||||
async def open_pack(size: int = 10, user: UserModel = Depends(get_current_user)):
|
|
||||||
cards = await _get_cards_async(size)
|
|
||||||
return [
|
|
||||||
{**card._asdict(),
|
|
||||||
"card_type": card.card_type.name,
|
|
||||||
"card_rarity": card.card_rarity.name}
|
|
||||||
for card in cards
|
|
||||||
]
|
|
||||||
|
|
||||||
@app.post("/register")
|
@app.post("/register")
|
||||||
def register(req: RegisterRequest, db: Session = Depends(get_db)):
|
def register(req: RegisterRequest, db: Session = Depends(get_db)):
|
||||||
if db.query(UserModel).filter(UserModel.username == req.username).first():
|
if db.query(UserModel).filter(UserModel.username == req.username).first():
|
||||||
@@ -82,3 +72,43 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get
|
|||||||
raise HTTPException(status_code=400, detail="Invalid username or password")
|
raise HTTPException(status_code=400, detail="Invalid username or password")
|
||||||
token = create_access_token(str(user.id))
|
token = create_access_token(str(user.id))
|
||||||
return {"access_token": token, "token_type": "bearer"}
|
return {"access_token": token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@app.get("/boosters")
|
||||||
|
def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> tuple[int,datetime|None]:
|
||||||
|
return check_boosters(user, db)
|
||||||
|
|
||||||
|
@app.post("/open_pack")
|
||||||
|
async def open_pack(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
check_boosters(user, db)
|
||||||
|
|
||||||
|
if user.boosters == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="No booster packs available")
|
||||||
|
|
||||||
|
cards = (
|
||||||
|
db.query(CardModel)
|
||||||
|
.filter(CardModel.user_id == None)
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(cards) < 5:
|
||||||
|
raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly")
|
||||||
|
|
||||||
|
for card in cards:
|
||||||
|
card.user_id = user.id
|
||||||
|
|
||||||
|
# was_full = user.boosters == BOOSTER_MAX
|
||||||
|
# user.boosters -= 1
|
||||||
|
# if was_full:
|
||||||
|
# user.boosters_countdown = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
asyncio.create_task(fill_card_pool())
|
||||||
|
|
||||||
|
return [
|
||||||
|
{**{c.name: getattr(card, c.name) for c in card.__table__.columns},
|
||||||
|
"card_rarity": card.card_rarity,
|
||||||
|
"card_type": card.card_type}
|
||||||
|
for card in cards
|
||||||
|
]
|
||||||
@@ -13,6 +13,8 @@ class User(Base):
|
|||||||
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
email: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||||
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
password_hash: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||||
|
boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||||
|
boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
cards: Mapped[list["Card"]] = relationship(back_populates="user")
|
cards: Mapped[list["Card"]] = relationship(back_populates="user")
|
||||||
decks: Mapped[list["Deck"]] = relationship(back_populates="user")
|
decks: Mapped[list["Deck"]] = relationship(back_populates="user")
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.135.1
|
||||||
|
httpx==0.28.1
|
||||||
|
passlib==1.7.4
|
||||||
|
pydantic==2.12.5
|
||||||
|
python_jose==3.5.0
|
||||||
|
SQLAlchemy==2.0.48
|
||||||
|
python-multipart
|
||||||
|
bcrypt==4.3.0
|
||||||
@@ -7,3 +7,8 @@
|
|||||||
body {
|
body {
|
||||||
background: #0d0a04;
|
background: #0d0a04;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -146,11 +146,11 @@
|
|||||||
.card.foil.legendary::before {
|
.card.foil.legendary::before {
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
105deg,
|
105deg,
|
||||||
rgba(255,215,0,0.30) 0%,
|
rgba(255,215,0,0.35) 0%,
|
||||||
rgba(255,180,0,0.15) 15%,
|
rgba(255,180,0,0.15) 15%,
|
||||||
rgba(255,255,180,0.35) 30%,
|
rgba(255,255,180,0.40) 30%,
|
||||||
rgba(255,200,0,0.15) 45%,
|
rgba(255,200,0,0.15) 45%,
|
||||||
rgba(255,215,0,0.30) 60%
|
rgba(255,215,0,0.35) 60%
|
||||||
);
|
);
|
||||||
animation-duration: 1.8s;
|
animation-duration: 1.8s;
|
||||||
background-size: 300% 300%;
|
background-size: 300% 300%;
|
||||||
|
|||||||
193
frontend/src/lib/header.svelte
Normal file
193
frontend/src/lib/header.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
goto('/auth');
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: '/', label: 'Booster Packs' },
|
||||||
|
{ href: '/cards', label: 'Cards' },
|
||||||
|
{ href: '/decks', label: 'Decks' },
|
||||||
|
{ href: '/play', label: 'Play' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function close() { menuOpen = false; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<a href="/" class="logo" onclick={close}>WikiTCG</a>
|
||||||
|
|
||||||
|
<nav class="desktop">
|
||||||
|
{#each links as link}
|
||||||
|
<a href={link.href} class:active={$page.url.pathname === link.href}>{link.label}</a>
|
||||||
|
{/each}
|
||||||
|
<button class="logout" onclick={logout}>Log out</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button class="hamburger" onclick={() => menuOpen = !menuOpen} aria-label="Toggle menu">
|
||||||
|
<span class:open={menuOpen}></span>
|
||||||
|
<span class:open={menuOpen}></span>
|
||||||
|
<span class:open={menuOpen}></span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if menuOpen}
|
||||||
|
<div class="mobile-backdrop" onclick={close}></div>
|
||||||
|
<nav class="mobile" class:open={menuOpen}>
|
||||||
|
{#each links as link}
|
||||||
|
<a href={link.href} class:active={$page.url.pathname === link.href} onclick={close}>{link.label}</a>
|
||||||
|
{/each}
|
||||||
|
<button class="logout mobile-logout" onclick={logout}>Log out</button>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2rem;
|
||||||
|
height: 56px;
|
||||||
|
background: #1a1008;
|
||||||
|
border-bottom: 2px solid #6b4c1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0d080;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.desktop {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.desktop a {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240, 180, 80, 0.8);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1.5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.desktop a:hover,
|
||||||
|
nav.desktop a.active {
|
||||||
|
color: #f0d080;
|
||||||
|
border-bottom-color: #f0d080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger span {
|
||||||
|
display: block;
|
||||||
|
width: 22px;
|
||||||
|
height: 2px;
|
||||||
|
background: #f0d080;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger span:nth-child(1).open { transform: translateY(7px) rotate(45deg); }
|
||||||
|
.hamburger span:nth-child(2).open { opacity: 0; }
|
||||||
|
.hamburger span:nth-child(3).open { transform: translateY(-7px) rotate(-45deg); }
|
||||||
|
|
||||||
|
.mobile-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 56px 0 0 0;
|
||||||
|
z-index: 99;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.mobile {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 240px;
|
||||||
|
z-index: 100;
|
||||||
|
background: #1a1008;
|
||||||
|
border-left: 2px solid #6b4c1e;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.mobile a {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(240, 180, 80, 0.6);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.mobile a:hover,
|
||||||
|
nav.mobile a.active {
|
||||||
|
color: #f0d080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(200, 80, 80, 0.7);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1.5px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
width: auto;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
color: #c84040;
|
||||||
|
border-bottom-color: #c84040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-logout {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid rgba(107, 76, 30, 0.3);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
nav.desktop { display: none; }
|
||||||
|
.hamburger { display: flex; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import Header from "$lib/header.svelte";
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if page.url.pathname !== '/auth'}
|
||||||
|
<Header />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
@@ -5,67 +5,530 @@
|
|||||||
|
|
||||||
let cards = $state([]);
|
let cards = $state([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let boosters = $state(null);
|
||||||
|
let countdown = $state(null);
|
||||||
|
let countdownDisplay = $state('');
|
||||||
|
let countdownInterval = null;
|
||||||
|
|
||||||
onMount(() => {
|
let phase = $state('idle');
|
||||||
if (!localStorage.getItem('token')) goto('/auth');
|
let flippedCards = $state([]);
|
||||||
|
let fanVisible = $state(false);
|
||||||
|
let packRef = $state(null);
|
||||||
|
let overlayPackRef = $state(null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!localStorage.getItem('token')) { goto('/auth'); return; }
|
||||||
|
await fetchBoosters();
|
||||||
|
return () => clearInterval(countdownInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openPack() {
|
async function fetchBoosters() {
|
||||||
loading = true;
|
const res = await fetch('http://localhost:8000/boosters', {
|
||||||
try {
|
|
||||||
const res = await fetch('http://localhost:8000/pack/10', {
|
|
||||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||||
});
|
});
|
||||||
if (res.status === 401) { goto('/auth'); return; }
|
if (res.status === 401) { goto('/auth'); return; }
|
||||||
|
const [count, countdownTs] = await res.json();
|
||||||
|
boosters = count;
|
||||||
|
countdown = countdownTs ? new Date(countdownTs) : null;
|
||||||
|
startCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
if (!countdown || boosters >= 5) return;
|
||||||
|
countdownInterval = setInterval(() => {
|
||||||
|
const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000);
|
||||||
|
const diff = nextTick - Date.now();
|
||||||
|
if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; }
|
||||||
|
const h = Math.floor(diff / 3600000);
|
||||||
|
const m = Math.floor((diff % 3600000) / 60000);
|
||||||
|
const s = Math.floor((diff % 60000) / 1000);
|
||||||
|
countdownDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||||
|
|
||||||
|
// Get the screen position of the idle pack so the overlay pack starts there
|
||||||
|
function getPackRect() {
|
||||||
|
if (!packRef) return { top: window.innerHeight / 2, left: window.innerWidth / 2 };
|
||||||
|
const rect = packRef.getBoundingClientRect();
|
||||||
|
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlayPackStyle = $state('');
|
||||||
|
|
||||||
|
async function openPack() {
|
||||||
|
if (loading || boosters === 0 || phase !== 'idle') return;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const rect = getPackRect();
|
||||||
|
overlayPackStyle = `position:fixed; top:${rect.top}px; left:${rect.left}px; width:${rect.width}px; height:${rect.height}px;`;
|
||||||
|
|
||||||
|
phase = 'darkening';
|
||||||
|
await delay(600);
|
||||||
|
|
||||||
|
phase = 'ripping';
|
||||||
|
await delay(900);
|
||||||
|
|
||||||
|
phase = 'dropping';
|
||||||
|
|
||||||
|
// Fetch while pack is sliding away
|
||||||
|
const fetchPromise = fetch('http://localhost:8000/open_pack', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
await delay(700);
|
||||||
|
|
||||||
|
const res = await fetchPromise;
|
||||||
|
if (!res.ok) { phase = 'idle'; loading = false; return; }
|
||||||
cards = await res.json();
|
cards = await res.json();
|
||||||
} catch (e) {
|
flippedCards = new Array(cards.length).fill(false);
|
||||||
console.error(e);
|
boosters -= 1;
|
||||||
} finally {
|
if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); }
|
||||||
|
|
||||||
|
phase = 'fanning';
|
||||||
|
await delay(50);
|
||||||
|
fanVisible = true;
|
||||||
|
await delay(800);
|
||||||
|
|
||||||
|
phase = 'flipping';
|
||||||
|
const isMobile = window.innerWidth <= 640;
|
||||||
|
const indices = isMobile
|
||||||
|
? [...Array(cards.length).keys()] // 0,1,2,3,4 top to bottom
|
||||||
|
: [...Array(cards.length).keys()].reverse(); // 4,3,2,1,0 right to left
|
||||||
|
|
||||||
|
for (let i of indices) {
|
||||||
|
await delay(350);
|
||||||
|
flippedCards = flippedCards.map((v, idx) => idx === i ? true : v);
|
||||||
|
}
|
||||||
|
|
||||||
|
phase = 'done';
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
phase = 'idle';
|
||||||
|
cards = [];
|
||||||
|
flippedCards = [];
|
||||||
|
fanVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']);
|
||||||
|
|
||||||
|
// Compute fan positions for each card
|
||||||
|
function fanStyle(i, total) {
|
||||||
|
const isMobile = window.innerWidth <= 640;
|
||||||
|
if (isMobile) {
|
||||||
|
return `--tx: 0px; --ty: 0px;`;
|
||||||
|
}
|
||||||
|
// Cards fan out horizontally from center
|
||||||
|
const spread = Math.min(320, (window.innerWidth - 100) / total);
|
||||||
|
const offset = (i - (total - 1) / 2) * spread;
|
||||||
|
return `--tx: ${offset}px; --ty: 0px;`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<button onclick={openPack} disabled={loading}>
|
<div class="top">
|
||||||
{loading ? "Opening..." : "Open Pack"}
|
<h1 class="pack-count">
|
||||||
</button>
|
{#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if}
|
||||||
|
</h1>
|
||||||
|
{#if boosters !== null && boosters < 5 && countdownDisplay}
|
||||||
|
<p class="countdown">{countdownDisplay} until next pack</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pack">
|
<!-- Idle pack -->
|
||||||
{#each cards as card}
|
{#if boosters !== null && boosters > 0 && phase === 'idle'}
|
||||||
<Card {card} />
|
<div class="pack-wrap" bind:this={packRef}>
|
||||||
|
<button class="pack-btn" onclick={openPack}>
|
||||||
|
<div class="booster-pack">
|
||||||
|
<div class="pack-shine"></div>
|
||||||
|
<div class="pack-stripe top-stripe"></div>
|
||||||
|
<div class="pack-stripe bottom-stripe"></div>
|
||||||
|
<div class="pack-logo">
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/en/8/80/Wikipedia-logo-v2.svg" alt="W" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if phase !== 'idle'}
|
||||||
|
<div class="overlay" class:visible={phase !== 'idle'}>
|
||||||
|
|
||||||
|
{#if phase === 'darkening' || phase === 'ripping' || phase === 'dropping'}
|
||||||
|
<div class="overlay-pack-wrap" style={overlayPackStyle} class:dropping={phase === 'dropping'}>
|
||||||
|
<div class="pack-top-flap" class:rip={phase === 'ripping' || phase === 'dropping'}></div>
|
||||||
|
<div class="booster-pack overlay-pack" class:no-top={phase === 'ripping' || phase === 'dropping'}>
|
||||||
|
<div class="pack-shine"></div>
|
||||||
|
<div class="pack-stripe bottom-stripe"></div>
|
||||||
|
<div class="pack-logo">
|
||||||
|
<img src="https://upload.wikimedia.org/wikipedia/en/8/80/Wikipedia-logo-v2.svg" alt="W" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if phase === 'fanning' || phase === 'flipping' || phase === 'done'}
|
||||||
|
<div class="fan-wrap">
|
||||||
|
{#each cards as card, i}
|
||||||
|
{@const flipped = flippedCards[i]}
|
||||||
|
{@const foil = FOIL_RARITIES.has(card.card_rarity)}
|
||||||
|
<div
|
||||||
|
class="fan-card"
|
||||||
|
class:fan-visible={fanVisible}
|
||||||
|
class:foil-reveal={flipped && foil}
|
||||||
|
style="--i: {i};"
|
||||||
|
>
|
||||||
|
<div class="card-flipper" class:flipped>
|
||||||
|
<div class="card-face back"><div class="card-back-face"></div></div>
|
||||||
|
<div class="card-face front"><Card {card} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if phase === 'done'}
|
||||||
|
<button class="close-btn" onclick={close}>Close</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700;900&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap');
|
||||||
|
|
||||||
main {
|
main {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: #887859;
|
overflow: hidden;
|
||||||
|
background: #0d0a04;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
padding: 10px 28px;
|
|
||||||
font-family: 'Cinzel', serif;
|
|
||||||
font-size: 14px;
|
|
||||||
background: #2e1c05;
|
|
||||||
color: #f0d080;
|
|
||||||
border: 1px solid #6b4c1e;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
|
||||||
background: #3d2507;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pack {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-count {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: clamp(16px, 3vw, 26px);
|
||||||
|
font-weight: 900;
|
||||||
|
color: #f0d080;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
font-family: 'Crimson Text', serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-style: italic;
|
||||||
|
color: rgba(240, 180, 80, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-wrap {
|
||||||
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pack-btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Booster pack ── */
|
||||||
|
.booster-pack {
|
||||||
|
position: relative;
|
||||||
|
width: 300px;
|
||||||
|
height: 460px;
|
||||||
|
background: #dfdbcf;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
rgba(255,255,255,0.35) 5%,
|
||||||
|
transparent 8%,
|
||||||
|
transparent 92%,
|
||||||
|
rgba(0,0,0,0.12) 95%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 0 1.5px rgba(0,0,0,0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-btn:hover .booster-pack {
|
||||||
|
transform: translateY(-6px) scale(1.02);
|
||||||
|
box-shadow: 0 16px 48px rgba(0,0,0,0.6), 0 0 0 1.5px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-shine {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255,255,255,0.08) 0%,
|
||||||
|
rgba(255,255,255,0.08) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-stripe {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 9%;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255,255,255,0.25),
|
||||||
|
rgba(0,0,0,0.15) 2%,
|
||||||
|
rgba(0,0,0,0.08) 3%,
|
||||||
|
transparent 3%,
|
||||||
|
transparent 4%
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 12px 8px rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-stripe { top: 0; }
|
||||||
|
.bottom-stripe { bottom: 0; }
|
||||||
|
|
||||||
|
.pack-logo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-logo img {
|
||||||
|
width: 100px;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* no-top clips the pack body below the tear line */
|
||||||
|
.booster-pack.no-top {
|
||||||
|
clip-path: inset(60px 0 0 0 round 0 0 10px 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlay ── */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0,0,0,0);
|
||||||
|
transition: background 0.6s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.visible {
|
||||||
|
background: rgba(0,0,0,0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Overlay pack ── */
|
||||||
|
.overlay-pack-wrap {
|
||||||
|
z-index: 210;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-pack {
|
||||||
|
box-shadow:
|
||||||
|
0 0 60px 20px rgba(240,200,80,0.2),
|
||||||
|
0 8px 32px rgba(0,0,0,0.6);
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top flap */
|
||||||
|
.pack-top-flap {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 300px;
|
||||||
|
height: 60px;
|
||||||
|
background: #dfdbcf;
|
||||||
|
background-image: linear-gradient(to right, transparent, rgba(255,255,255,0.35) 5%, transparent 8%);
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255,255,255,0.18) 0%,
|
||||||
|
rgba(255,255,255,0.18) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
z-index: 220;
|
||||||
|
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s;
|
||||||
|
box-shadow: 0 0 0 1.5px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-top-flap.rip {
|
||||||
|
transform: rotate(-40deg) translateX(-20px) translateY(-40px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pack slides straight down */
|
||||||
|
.overlay-pack-wrap.dropping {
|
||||||
|
animation: pack-drop 0.7s cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pack-drop {
|
||||||
|
0% { transform: translateY(0); opacity: 1; }
|
||||||
|
100% { transform: translateY(110vh); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card back ── */
|
||||||
|
.card-back-face {
|
||||||
|
width: 300px;
|
||||||
|
height: 412px;
|
||||||
|
background: linear-gradient(135deg, #d3d0c4 0%, #dfdbcf 100%);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 10px solid rgba(255,255,255,1);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-back-face::after {
|
||||||
|
content: '';
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
background: url('https://upload.wikimedia.org/wikipedia/en/8/80/Wikipedia-logo-v2.svg') center/contain no-repeat;
|
||||||
|
opacity: 0.8;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards fly in from the bottom */
|
||||||
|
.fan-card {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(80vh);
|
||||||
|
transition: opacity 0.5s ease, transform 0.7s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||||
|
transition-delay: calc(var(--i) * 0.1s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card.fan-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 220;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
/* ── Card flip ── */
|
||||||
|
.card-flipper {
|
||||||
|
width: 300px;
|
||||||
|
height: 412px;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-flipper.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face.back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face.front {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card.foil-reveal {
|
||||||
|
animation: foil-flash 1.4s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes foil-flash {
|
||||||
|
0% { filter: brightness(1); }
|
||||||
|
40% { filter: brightness(2.8) saturate(2.5); }
|
||||||
|
100% { filter: brightness(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Close ── */
|
||||||
|
.close-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 10px 32px;
|
||||||
|
background: rgba(60,30,5,0.85);
|
||||||
|
color: #f0d080;
|
||||||
|
border: 1.5px solid #c8861a;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 230;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(100,60,10,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fan-wrap {
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 1rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-flipper {
|
||||||
|
width: 280px;
|
||||||
|
height: 430px;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-flipper.flipped {
|
||||||
|
transform: rotateX(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face.front {
|
||||||
|
transform: rotateX(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user