Compare commits

..
5 Commits
27 changed files with 1222 additions and 24 deletions
+8 -1
View File
@@ -7,7 +7,7 @@ from django_object_actions import (
from submissions.tasks import schedule_scope_sync, stop_scope_sync from submissions.tasks import schedule_scope_sync, stop_scope_sync
from .models import EventGame, Game from .models import EventGame, Game, PlanningSlot
from .tasks import refresh_steam_games_task from .tasks import refresh_steam_games_task
@@ -90,3 +90,10 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset) stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).") self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
@admin.register(PlanningSlot)
class PlanningSlotAdmin(admin.ModelAdmin):
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
list_filter = ["event", "is_multi_anims"]
search_fields = ["title"]
+8 -1
View File
@@ -3,7 +3,8 @@ from typing import List
from ninja import Router from ninja import Router
from .models import Game from .models import Game
from .schemas import GameOut, SteamGameOut from .schemas import GameOut, PlanningSlotOut, SteamGameOut
from .services.planning import planning_with_games
from .tasks import schedule_steam_refresh from .tasks import schedule_steam_refresh
router = Router() router = Router()
@@ -28,3 +29,9 @@ def list_event_steam_games(request, event_path: str):
return Game.objects.enabled_for_event(request.event).filter( return Game.objects.enabled_for_event(request.event).filter(
steam_app_id__isnull=False steam_app_id__isnull=False
) )
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
def list_event_planning(request, event_path: str):
"""The event planning, with the covers of the games we know."""
return planning_with_games(request.event.planning_slots.all())
@@ -0,0 +1,64 @@
import json
import sys
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from events.models import Event
from games.services.planning import (
create_missing_steam_games,
import_planning,
slot_game_names,
)
from games.tasks import refresh_steam_games_task
class Command(BaseCommand):
help = (
"Import an event planning from the legacy PolyLAN planning JSON "
"(slots missing from the file are deleted)"
)
def add_arguments(self, parser):
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
parser.add_argument(
"--no-steam",
action="store_true",
help="Don't look up unknown games on the Steam store",
)
def handle(self, *args, **options):
try:
event = Event.objects.get(path=options["event_path"])
except Event.DoesNotExist:
raise CommandError(f"No event with path {options['event_path']!r}")
if options["json_file"] == "-":
data = json.load(sys.stdin)
else:
with open(options["json_file"], encoding="utf-8") as f:
data = json.load(f)
entries = data["list"] if isinstance(data, dict) else data
with transaction.atomic():
created, updated, deleted = import_planning(event, entries)
self.stdout.write(
self.style.SUCCESS(
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
)
)
if options["no_steam"]:
return
games = create_missing_steam_games(event, slot_game_names(entries))
for game in games:
self.stdout.write(f"+ {game.name} (Steam app {game.steam_app_id})")
if games:
# Prices, blurb, ... for the games page
refresh_steam_games_task.enqueue(game_ids=[game.id for game in games])
self.stdout.write(
self.style.SUCCESS(f"{len(games)} game(s) found on Steam and added to {event}")
)
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-10-16 00:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0010_game_steam_store_info"),
]
operations = [
migrations.AddField(
model_name="game",
name="library_image",
field=models.URLField(blank=True, default="", max_length=500),
),
]
@@ -0,0 +1,53 @@
# Generated by Django 6.0.5 on 2026-10-16 03:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0003_event_is_available"),
("games", "0011_game_library_image"),
]
operations = [
migrations.CreateModel(
name="PlanningSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("source_pk", models.PositiveIntegerField()),
("title", models.CharField(max_length=255)),
("begin", models.DateTimeField()),
("end", models.DateTimeField()),
("column", models.PositiveSmallIntegerField(default=0)),
("color", models.CharField(blank=True, default="", max_length=7)),
("is_multi_anims", models.BooleanField(default=False)),
("game_names", models.JSONField(blank=True, default=list)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="planning_slots",
to="events.event",
),
),
],
options={
"ordering": ["begin", "column"],
"constraints": [
models.UniqueConstraint(
fields=("event", "source_pk"), name="unique_event_planning_slot"
)
],
},
),
]
+49
View File
@@ -15,6 +15,8 @@ class Game(models.Model):
custom_path = models.BooleanField(default=False) custom_path = models.BooleanField(default=False)
image = models.URLField(null=True, blank=True) image = models.URLField(null=True, blank=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
library_image = models.URLField(max_length=500, blank=True, default="")
# Steam store info, refreshed in the background (see games.tasks). # Steam store info, refreshed in the background (see games.tasks).
# Prices are in cents, in steam_currency. # Prices are in cents, in steam_currency.
@@ -40,6 +42,19 @@ class Game(models.Model):
return f"{self.name} ({self.steam_app_id})" return f"{self.name} ({self.steam_app_id})"
return self.name return self.name
@property
def store_url(self) -> str:
if not self.steam_app_id:
return ""
return f"https://store.steampowered.com/app/{self.steam_app_id}"
@property
def library_image_url(self) -> str:
"""Vertical cover, like in the Steam library. Empty if there's none."""
if self.library_image or not self.steam_app_id:
return self.library_image
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
class EventGame(models.Model): class EventGame(models.Model):
event = models.ForeignKey( event = models.ForeignKey(
@@ -74,3 +89,37 @@ class EventGame(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})" return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
class PlanningSlot(models.Model):
"""One slot of the event planning, imported from the legacy PolyLAN planning
(see the import_planning command)."""
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="planning_slots",
)
# pk of the slot in the legacy planning, to update it on re-import
source_pk = models.PositiveIntegerField()
title = models.CharField(max_length=255)
begin = models.DateTimeField()
end = models.DateTimeField()
column = models.PositiveSmallIntegerField(default=0)
color = models.CharField(max_length=7, blank=True, default="")
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
is_multi_anims = models.BooleanField(default=False)
# Names of the games run in this slot (the legacy "aandts")
game_names = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["begin", "column"]
constraints = [
models.UniqueConstraint(
fields=["event", "source_pk"],
name="unique_event_planning_slot",
),
]
def __str__(self) -> str:
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
+26 -1
View File
@@ -25,6 +25,7 @@ class SteamGameOut(Schema):
steam_app_id: int steam_app_id: int
store_url: str store_url: str
header_image: str header_image: str
library_image: str
short_description: str short_description: str
is_free: bool is_free: bool
currency: str currency: str
@@ -35,7 +36,7 @@ class SteamGameOut(Schema):
@staticmethod @staticmethod
def resolve_store_url(obj) -> str: def resolve_store_url(obj) -> str:
return f"https://store.steampowered.com/app/{obj.steam_app_id}" return obj.store_url
@staticmethod @staticmethod
def resolve_header_image(obj) -> str: def resolve_header_image(obj) -> str:
@@ -44,6 +45,10 @@ class SteamGameOut(Schema):
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg" or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
) )
@staticmethod
def resolve_library_image(obj) -> str:
return obj.library_image_url
@staticmethod @staticmethod
def resolve_short_description(obj) -> str: def resolve_short_description(obj) -> str:
return obj.steam_short_description return obj.steam_short_description
@@ -71,3 +76,23 @@ class SteamGameOut(Schema):
@staticmethod @staticmethod
def resolve_updated_at(obj) -> Optional[datetime]: def resolve_updated_at(obj) -> Optional[datetime]:
return obj.steam_info_updated_at return obj.steam_info_updated_at
class PlanningGameOut(Schema):
name: str
# Empty when the name didn't match any of our games
library_image: str = ""
store_url: str = ""
steam_app_id: Optional[int] = None
class PlanningSlotOut(Schema):
id: int
title: str
begin: datetime
end: datetime
column: int
color: str
is_multi_anims: bool
# Single slot: the slot's game. Multi-anims: each of their games.
games: list[PlanningGameOut]
@@ -0,0 +1,172 @@
import logging
import re
import unicodedata
import httpx
from django.utils.text import slugify
from ..models import EventGame, Game, PlanningSlot
from .steam_store import search_store
logger = logging.getLogger(__name__)
# "Cosmoteer: Starship Architect & Commander" is still "Cosmoteer"
SUBTITLE_SEPARATOR = re.compile(r"\s*(?::|\s-\s|\s–\s|\s—\s)")
def normalize_name(name: str) -> str:
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
no accents, no case, letters and digits only."""
# Before NFKD, which turns ™ into "TM"
name = re.sub(r"[™®©]", "", name)
name = unicodedata.normalize("NFKD", name)
name = "".join(c for c in name if not unicodedata.combining(c))
return re.sub(r"[^0-9a-z]", "", name.casefold())
def _steam_match(name: str) -> dict | None:
"""The Steam store app named name (or name + a subtitle), if there's one.
Strict on purpose: "Jass" or "Crochet" must not end up as some random game."""
wanted = normalize_name(name)
if not wanted:
return None
# Steam's search chokes on punctuation ("Puyo Puyo — Tetris" finds nothing)
term = " ".join(re.sub(r"[^\w\s]", " ", name).split())
for item in search_store(term):
steam_name = item.get("name") or ""
if wanted in (
normalize_name(steam_name),
normalize_name(SUBTITLE_SEPARATOR.split(steam_name, maxsplit=1)[0]),
):
return item
return None
def _unique_path(name: str) -> str:
base = slugify(name)[:90] or "game"
path, n = base, 2
while Game.objects.filter(path=path).exists():
path, n = f"{base}-{n}", n + 1
return path
def create_missing_steam_games(event, names: list[str]) -> list[Game]:
"""For each name without a Game, look it up on the Steam store and create
the Game + its EventGame when found. Returns the created games."""
known = {normalize_name(game.name) for game in Game.objects.all()}
created = []
for name in dict.fromkeys(names): # unique, keeps order
if normalize_name(name) in known:
continue
try:
item = _steam_match(name)
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Steam store search failed for %r: %s", name, exc)
continue
if item is None:
continue
if Game.objects.filter(steam_app_id=item["id"]).exists():
# Already there under another name, the planning won't match it
logger.warning(
"%r is Steam app %s, already a Game under another name",
name,
item["id"],
)
continue
# Named like in the planning, so the slot matches it
game = Game.objects.create(
name=name, steam_app_id=item["id"], path=_unique_path(name)
)
EventGame.objects.get_or_create(event=event, game=game)
known.add(normalize_name(name))
created.append(game)
return created
def slot_game_names(entries: list[dict]) -> list[str]:
"""Names to look for in a planning: titles of single slots, game names of
multi-anims (their title, like "Fils rouges", isn't a game)."""
names = []
for entry in entries:
if not entry.get("is_multi_anims"):
names.append(entry["title"])
names += [a["name"] for a in entry.get("aandts") or []]
return names
def _game_out(name: str, game: Game | None) -> dict:
if game is None:
return {"name": name}
return {
"name": game.name,
"library_image": game.library_image_url,
"store_url": game.store_url,
"steam_app_id": game.steam_app_id,
}
def planning_with_games(slots) -> list[dict]:
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
def match(name: str) -> Game | None:
return games_by_name.get(normalize_name(name))
out = []
for slot in slots:
if slot.is_multi_anims:
games = [_game_out(name, match(name)) for name in slot.game_names]
else:
# The title is the usual match, the legacy game names are a fallback
# ("Golf it!" slot running the "Golf It!" game)
game = next(
filter(None, map(match, [slot.title, *slot.game_names])), None
)
games = [_game_out(slot.title, game)]
out.append(
{
"id": slot.id,
"title": slot.title,
"begin": slot.begin,
"end": slot.end,
"column": slot.column,
"color": slot.color,
"is_multi_anims": slot.is_multi_anims,
"games": games,
}
)
return out
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
"""Sync the event planning with the legacy planning entries (its "list").
Returns (created, updated, deleted)."""
created = updated = 0
for entry in entries:
_, was_created = PlanningSlot.objects.update_or_create(
event=event,
source_pk=entry["pk"],
defaults={
"title": entry["title"],
"begin": entry["begin"],
"end": entry["end"],
"column": entry.get("colonneS") or 0,
"color": entry.get("color_code") or "",
"is_multi_anims": bool(entry.get("is_multi_anims")),
"game_names": [a["name"] for a in entry.get("aandts") or []],
},
)
if was_created:
created += 1
else:
updated += 1
deleted, _ = (
PlanningSlot.objects.filter(event=event)
.exclude(source_pk__in=[entry["pk"] for entry in entries])
.delete()
)
return created, updated, deleted
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min, # Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
# which is plenty for a handful of games every few hours. # which is plenty for a handful of games every few hours.
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails" APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
class SteamStoreError(Exception): class SteamStoreError(Exception):
@@ -37,6 +38,17 @@ def fetch_app_details(steam_app_id: int) -> dict:
return entry["data"] return entry["data"]
def search_store(term: str) -> list[dict]:
"""Store search results for term, as [{"id": app id, "name": ...}, ...]."""
response = httpx.get(
STORE_SEARCH_URL,
params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"},
timeout=10,
)
response.raise_for_status()
return (response.json() or {}).get("items") or []
def refresh_steam_game(game: Game) -> None: def refresh_steam_game(game: Game) -> None:
"""Pull the current store info for a Steam game into its steam_* fields.""" """Pull the current store info for a Steam game into its steam_* fields."""
data = fetch_app_details(game.steam_app_id) data = fetch_app_details(game.steam_app_id)
+10 -1
View File
@@ -95,7 +95,16 @@ class SteamCollectionAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
( (
"Basic Information", "Basic Information",
{"fields": ("steam_id", "url", "title", "description", "is_active")}, {
"fields": (
"event",
"steam_id",
"url",
"title",
"description",
"is_active",
)
},
), ),
("Author Information", {"fields": ("author_name", "author_steam_id")}), ("Author Information", {"fields": ("author_name", "author_steam_id")}),
( (
@@ -378,10 +378,10 @@ SENTRY_DSN = (
import sentry_sdk # noqa import sentry_sdk # noqa
sentry_sdk.init( # sentry_sdk.init(
dsn=SENTRY_DSN, # dsn=SENTRY_DSN,
# Set traces_sample_rate to 1.0 to capture 100% # # Set traces_sample_rate to 1.0 to capture 100%
# of transactions for tracing. # # of transactions for tracing.
traces_sample_rate=1.0, # traces_sample_rate=1.0,
environment=SENTRY_ENVIRONMENT, # environment=SENTRY_ENVIRONMENT,
) # )
@@ -56,6 +56,12 @@ def games_home(request: HttpRequest, event_path: str):
return render(request, "app.html") return render(request, "app.html")
@login_required
def planning_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path)
return render(request, "app.html")
@login_required @login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID) @require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest, event_path: str): def opus_magnum_home(request: HttpRequest, event_path: str):
@@ -102,6 +108,7 @@ urlpatterns = [
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls), path("api/", api.urls),
path("<slug:event_path>/games", games_home, name="games.home"), path("<slug:event_path>/games", games_home, name="games.home"),
path("<slug:event_path>/planning", planning_home, name="planning.home"),
path("<slug:event_path>/market", market_home, name="market.home"), path("<slug:event_path>/market", market_home, name="market.home"),
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("<slug:event_path>/noita", noita_home, name="noita.home"), path("<slug:event_path>/noita", noita_home, name="noita.home"),
+18 -3
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useStorage, useTitle } from "@vueuse/core";
import { gamesApiListEventSteamGames } from "./api"; import { gamesApiListEventSteamGames } from "./api";
import type { SteamGameOut } from "./api/types.gen"; import type { SteamGameOut } from "./api/types.gen";
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
import SteamStoreCard from "./components/SteamStoreCard.vue"; import SteamStoreCard from "./components/SteamStoreCard.vue";
interface Props { interface Props {
@@ -17,6 +18,8 @@ useTitle(computed(() => t("games.title")));
const games = ref<SteamGameOut[]>([]); const games = ref<SteamGameOut[]>([]);
const loading = ref(true); const loading = ref(true);
// Library (vertical covers) by default, the store cards show the prices
const showPrices = useStorage("games.showPrices", true);
onMounted(async () => { onMounted(async () => {
const response = await gamesApiListEventSteamGames({ const response = await gamesApiListEventSteamGames({
@@ -33,11 +36,18 @@ onMounted(async () => {
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12"> <div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-7xl"> <div class="w-full max-w-7xl">
<!-- Header --> <!-- Header -->
<div class="text-center mb-12"> <div class="text-center mb-8">
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1> <h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p> <p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
</div> </div>
<div class="flex justify-end mb-6">
<label class="label cursor-pointer gap-2">
<span>{{ t("games.showPrices") }}</span>
<input v-model="showPrices" type="checkbox" class="toggle toggle-primary" />
</label>
</div>
<!-- Loading --> <!-- Loading -->
<div v-if="loading" class="flex justify-center py-20"> <div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
@@ -47,9 +57,14 @@ onMounted(async () => {
{{ t("games.empty") }} {{ t("games.empty") }}
</p> </p>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div v-else-if="showPrices" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" /> <SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
</div> </div>
<div v-else
class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4 sm:gap-6">
<SteamLibraryCard v-for="game in games" :key="game.id" :game="game" />
</div>
</div> </div>
</div> </div>
</template> </template>
+10 -4
View File
@@ -4,7 +4,7 @@ import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import { eventGamesQuery } from "@/queries"; import { eventGamesQuery, eventPlanningQuery } from "@/queries";
import type { GamesApiListEventGamesResponse } from "./api/types.gen"; import type { GamesApiListEventGamesResponse } from "./api/types.gen";
interface Props { interface Props {
@@ -19,6 +19,8 @@ useTitle(computed(() => t("home.title")));
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath)); const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false); const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
const { data: planning } = useQuery(eventPlanningQuery(props.eventPath));
const hasPlanning = computed(() => !!planning.value?.length);
const imageErrors = ref<Set<number>>(new Set()); const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
@@ -86,9 +88,13 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
</div> </div>
</div> </div>
<!-- Link to the Steam games page --> <!-- Links to the planning and the Steam games page -->
<div v-if="!loading && hasSteamGames" class="text-center mt-12"> <div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12">
<RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline"> <RouterLink v-if="hasPlanning" :to="{ name: 'planning', params: { eventPath } }" class="btn btn-outline">
<i class="mdi mdi-calendar-clock mr-2"></i>
{{ t("home.planning") }}
</RouterLink>
<RouterLink v-if="hasSteamGames" :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
<i class="mdi mdi-steam mr-2"></i> <i class="mdi mdi-steam mr-2"></i>
{{ t("home.getTheGames") }} {{ t("home.getTheGames") }}
</RouterLink> </RouterLink>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import { useI18n } from "vue-i18n";
import { useElementSize, useTitle } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query";
import { eventPlanningQuery } from "@/queries";
import type { PlanningSlotOut } from "./api/types.gen";
import PlanningCover from "./components/PlanningCover.vue";
import PlanningMultiSlot from "./components/PlanningMultiSlot.vue";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("planning.title")));
const { data: slots, isPending: loading } = useQuery(eventPlanningQuery(props.eventPath));
const HOUR = 3600e3;
// Hour height per px of column width: a 2h slot ends up close to the 2:3 cover ratio
const HOUR_RATIO = 104 / 150;
const BREAK_PX = 34;
const DAY_PX = 32;
const AXIS_PX = 56;
// Columns shrink to fit the page, the planning only scrolls sideways below the min
const MIN_COL_PX = 80;
const MAX_COL_PX = 150;
const GAP_PX = 10;
// The multi-anims column is the big one, its covers 1.75x the size of the others
const WIDE_RATIO = 1.75;
// Room on the left for the day names, they stick out of the time axis
const DAY_LABEL_ROOM_PX = 120;
// Empty runs at least this long (nights) get squashed into a thin break
const MIN_BREAK_HOURS = 3;
const fmtTime = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
const fmtDay = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "short" });
interface Segment {
from: number;
to: number;
y: number;
h: number;
kind: "hour" | "idle" | "break" | "day";
label?: string;
}
interface Placed {
slot: PlanningSlotOut;
top: number;
height: number;
when: string;
}
const scroller = useTemplateRef<HTMLElement>("scroller");
const { width: available } = useElementSize(scroller);
const layout = computed(() => {
const list = (slots.value ?? []).map((slot) => ({
slot,
b: Date.parse(slot.begin),
e: Date.parse(slot.end),
}));
if (!list.length) return null;
// Unused columns are dropped, the multi-anims ones are wider
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
const isWide = (id: number) => list.some(({ slot }) => slot.column === id && slot.is_multi_anims);
const wideCount = columnIds.filter(isWide).length;
const fit =
(available.value - DAY_LABEL_ROOM_PX - AXIS_PX - (columnIds.length - 1) * GAP_PX) /
(columnIds.length + (WIDE_RATIO - 1) * wideCount);
const colPx = available.value ? Math.min(MAX_COL_PX, Math.max(MIN_COL_PX, fit)) : MAX_COL_PX;
const hourPx = colPx * HOUR_RATIO;
// Multi-anims run the whole event, only the other slots decide what's busy
const singles = list.filter(({ slot }) => !slot.is_multi_anims);
const busy = (t: number) => singles.some(({ b, e }) => b < t + HOUR && e > t);
const start = Math.floor(Math.min(...list.map(({ b }) => b)) / HOUR) * HOUR;
const end = Math.ceil(Math.max(...list.map(({ e }) => e)) / HOUR) * HOUR;
const segments: Segment[] = [];
let y = 0;
let lastDay = "";
for (let t = start; t < end;) {
if (busy(t)) {
// Each day gets its own header row, takes no time
const day = fmtDay.format(t);
if (day !== lastDay) {
segments.push({ from: t, to: t, y, h: DAY_PX, kind: "day", label: day });
y += DAY_PX;
lastDay = day;
}
segments.push({ from: t, to: t + HOUR, y, h: hourPx, kind: "hour" });
y += hourPx;
t += HOUR;
continue;
}
let u = t;
while (u < end && !busy(u)) u += HOUR;
const hours = (u - t) / HOUR;
const isBreak = hours >= MIN_BREAK_HOURS;
const h = isBreak ? BREAK_PX : hourPx * hours;
segments.push({ from: t, to: u, y, h, kind: isBreak ? "break" : "idle" });
y += h;
t = u;
}
// Day headers have no duration, they never contain a time
const timed = segments.filter((s) => s.kind !== "day");
const yOf = (t: number) => {
const s = timed.find((s) => t >= s.from && t < s.to) ?? timed[timed.length - 1]!;
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
};
const days = segments
.filter((s) => s.kind === "day")
.map((s) => ({ y: s.y, label: s.label! }));
// No tick right under a day header, it would sit on its line
const dayEnds = new Set(days.map((d) => d.y + DAY_PX));
const ticks = timed
.filter((s) => s.kind !== "break")
.flatMap((s) => {
const out = [];
for (let t = s.from; t < s.to; t += HOUR) out.push({ y: yOf(t), label: fmtTime.format(t) });
return out;
})
.filter((tick) => !dayEnds.has(tick.y));
const breaks = segments
.filter((s) => s.kind === "break")
.map((s) => ({ y: s.y, h: s.h, label: `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}` }));
let left = AXIS_PX;
const columns = columnIds.map((id) => {
const placed = list
.filter(({ slot }) => slot.column === id)
.map<Placed>(({ slot, b, e }) => ({
slot,
top: yOf(b) + 3,
height: yOf(e) - yOf(b) - 6,
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
}));
const width = isWide(id) ? WIDE_RATIO * colPx : colPx;
const column = { left, width, placed };
left += width + GAP_PX;
return column;
});
return {
height: y,
width: left - GAP_PX,
days,
ticks,
breaks,
columns,
};
});
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<!-- min-w-0: lets the planning scroll sideways instead of overflowing the page -->
<div class="w-full max-w-7xl min-w-0">
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("planning.title") }}</h1>
</div>
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<p v-else-if="!layout" class="text-center text-base-content/50 py-20">
{{ t("planning.empty") }}
</p>
<!-- pr/pt/pb: room for the Fils rouges glow, the scroll box would clip it -->
<div v-else ref="scroller" class="overflow-x-auto pr-10 pt-4 pb-10">
<div class="relative" :style="{
width: `${layout.width}px`,
height: `${layout.height}px`,
marginLeft: `max(${DAY_LABEL_ROOM_PX}px, (100% - ${layout.width}px) / 2)`,
}">
<!-- Time axis: a header row per day, hour ticks on the side -->
<!-- Above the cards: slots running past midnight go over the header row -->
<div v-for="day in layout.days" :key="`day-${day.y}`"
class="absolute left-0 right-0 z-20 pointer-events-none border-b border-base-content/20 flex items-end text-sm font-semibold uppercase tracking-wide"
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
<!-- Ends at the time axis and grows to the left, never over the cards -->
<span class="absolute bottom-0 whitespace-nowrap bg-base-300 rounded-t px-2 py-0.5"
:style="{ right: `calc(100% - ${AXIS_PX}px)` }">{{ day.label }}</span>
</div>
<template v-for="tick in layout.ticks" :key="tick.y">
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
:style="{ top: `${tick.y}px`, width: `${AXIS_PX}px` }">
{{ tick.label }}
</span>
<div class="absolute right-0 border-t border-dashed border-base-content/5"
:style="{ top: `${tick.y}px`, left: `${AXIS_PX}px` }"></div>
</template>
<!-- Nights -->
<div v-for="b in layout.breaks" :key="`break-${b.y}`"
class="planning-break absolute left-0 right-0 rounded flex items-center justify-center text-xs text-base-content/40"
:style="{ top: `${b.y}px`, height: `${b.h}px` }">
{{ b.label }}
</div>
<!-- Slots -->
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
:style="{ top: `${top}px`, height: `${height}px` }" />
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
class="group absolute inset-x-0 block rounded-md overflow-hidden bg-base-200 hover:bg-base-100
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05] hover:z-10"
:style="{ top: `${top}px`, height: `${height}px` }">
<PlanningCover :game="slot.games[0] ?? { name: slot.title }" :color="slot.color" :when="when" />
<span class="absolute top-1.5 left-1.5 text-xs px-1.5 rounded bg-black/70 text-white opacity-0
group-hover:opacity-100 transition-opacity">
{{ when }}
</span>
</component>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.planning-break {
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
}
</style>
@@ -3,8 +3,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query'; import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.gen'; import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventPlanning, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } from '../sdk.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionError, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionError, OpusMagnumApiDeleteSubmissionResponse, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheError, PolylanSubmitterApiClearCacheResponse, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponse, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropError, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropError, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionError, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameError, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrError, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrError, SubmissionsApiProcessFileOcrResponse, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } from '../types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventPlanningData, GamesApiListEventPlanningResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionError, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionError, OpusMagnumApiDeleteSubmissionResponse, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationError, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoError, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseError, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionError, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheError, PolylanSubmitterApiClearCacheResponse, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponse, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropError, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropError, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionError, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameError, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrError, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrError, SubmissionsApiProcessFileOcrResponse, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -551,6 +551,26 @@ export const gamesApiListEventSteamGamesOptions = (options: Options<GamesApiList
queryKey: gamesApiListEventSteamGamesQueryKey(options) queryKey: gamesApiListEventSteamGamesQueryKey(options)
}); });
export const gamesApiListEventPlanningQueryKey = (options: Options<GamesApiListEventPlanningData>) => createQueryKey('gamesApiListEventPlanning', options);
/**
* List Event Planning
*
* The event planning, with the covers of the games we know.
*/
export const gamesApiListEventPlanningOptions = (options: Options<GamesApiListEventPlanningData>) => queryOptions<GamesApiListEventPlanningResponse, DefaultError, GamesApiListEventPlanningResponse, ReturnType<typeof gamesApiListEventPlanningQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await gamesApiListEventPlanning({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: gamesApiListEventPlanningQueryKey(options)
});
export const submissionsApiListMyScopesQueryKey = (options: Options<SubmissionsApiListMyScopesData>) => createQueryKey('submissionsApiListMyScopes', options); export const submissionsApiListMyScopesQueryKey = (options: Options<SubmissionsApiListMyScopesData>) => createQueryKey('submissionsApiListMyScopes', options);
/** /**
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventPlanningData, GamesApiListEventPlanningResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -203,6 +203,13 @@ export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(opt
*/ */
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options }); export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
/**
* List Event Planning
*
* The event planning, with the covers of the games we know.
*/
export const gamesApiListEventPlanning = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventPlanningData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventPlanningResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/planning', ...options });
/** /**
* List My Scopes * List My Scopes
*/ */
+87
View File
@@ -952,6 +952,10 @@ export type SteamGameOut = {
* Header Image * Header Image
*/ */
header_image: string; header_image: string;
/**
* Library Image
*/
library_image: string;
/** /**
* Short Description * Short Description
*/ */
@@ -982,6 +986,66 @@ export type SteamGameOut = {
updated_at?: string | null; updated_at?: string | null;
}; };
/**
* PlanningGameOut
*/
export type PlanningGameOut = {
/**
* Name
*/
name: string;
/**
* Library Image
*/
library_image?: string;
/**
* Store Url
*/
store_url?: string;
/**
* Steam App Id
*/
steam_app_id?: number | null;
};
/**
* PlanningSlotOut
*/
export type PlanningSlotOut = {
/**
* Id
*/
id: number;
/**
* Title
*/
title: string;
/**
* Begin
*/
begin: string;
/**
* End
*/
end: string;
/**
* Column
*/
column: number;
/**
* Color
*/
color: string;
/**
* Is Multi Anims
*/
is_multi_anims: boolean;
/**
* Games
*/
games: Array<PlanningGameOut>;
};
/** /**
* ScopeOut * ScopeOut
*/ */
@@ -1993,6 +2057,29 @@ export type GamesApiListEventSteamGamesResponses = {
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses]; export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
export type GamesApiListEventPlanningData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/planning';
};
export type GamesApiListEventPlanningResponses = {
/**
* Response
*
* OK
*/
200: Array<PlanningSlotOut>;
};
export type GamesApiListEventPlanningResponse = GamesApiListEventPlanningResponses[keyof GamesApiListEventPlanningResponses];
export type SubmissionsApiListMyScopesData = { export type SubmissionsApiListMyScopesData = {
body?: never; body?: never;
path: { path: {
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from "vue";
import type { PlanningGameOut } from "../api/types.gen";
defineProps<{
game: PlanningGameOut;
color: string;
// Slot time, shown on the colored card (the cover has its own hover badge)
when?: string;
}>();
// Not every game has a library capsule on Steam, fall back to the colored card
const coverFailed = ref(false);
</script>
<template>
<img v-if="game.library_image && !coverFailed" :src="game.library_image" :alt="game.name" :title="game.name"
loading="lazy" class="w-full h-full object-cover object-top" @error="coverFailed = true" />
<div v-else class="planning-fallback w-full h-full flex flex-col justify-center gap-1 p-3 border-l-4"
:style="{ '--slot-color': color }">
<span class="font-semibold leading-tight text-base-content">{{ game.name }}</span>
<span v-if="when" class="text-xs text-base-content/70">{{ when }}</span>
</div>
</template>
<style scoped>
.planning-fallback {
border-color: var(--slot-color);
background: linear-gradient(160deg,
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
var(--color-base-200));
}
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import { useElementBounding, useElementSize, useWindowSize } from "@vueuse/core";
import type { PlanningSlotOut } from "../api/types.gen";
import PlanningCover from "./PlanningCover.vue";
defineProps<{ entry: PlanningSlotOut }>();
const track = useTemplateRef<HTMLElement>("track");
const stack = useTemplateRef<HTMLElement>("stack");
const { top: trackTop, height: trackHeight } = useElementBounding(track);
const { height: stackHeight } = useElementSize(stack);
const { height: windowHeight } = useWindowSize();
// The games follow the scroll: kept in the middle of the screen, but never
// leaving the slot (stuck to its top/bottom when it's only partly visible)
const offset = computed(() => {
const wanted = windowHeight.value / 2 - stackHeight.value / 2 - trackTop.value;
const max = Math.max(0, trackHeight.value - stackHeight.value);
return Math.min(max, Math.max(0, wanted));
});
</script>
<template>
<div class="planning-multi h-full rounded-md p-[2px]" :style="{ '--slot-color': entry.color }">
<div class="planning-multi-inner relative h-full rounded-[5px] p-2 flex flex-col overflow-hidden">
<div class="planning-multi-title font-black uppercase text-sm tracking-widest mb-2">
<i class="mdi mdi-star-four-points mr-1"></i>{{ entry.title }}
</div>
<div ref="track" class="relative flex-1">
<div ref="stack" class="absolute inset-x-0 flex flex-col gap-3"
:style="{ transform: `translateY(${offset}px)` }">
<component :is="game.store_url ? 'a' : 'div'" v-for="game in entry.games" :key="game.name"
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
class="block aspect-[2/3] rounded overflow-hidden shadow-lg bg-base-200
transition-transform hover:scale-[1.05]">
<PlanningCover :game="game" :color="entry.color" />
</component>
</div>
</div>
<!-- Light sweeping down the whole slot -->
<div class="planning-multi-sheen pointer-events-none absolute inset-0"></div>
</div>
</div>
</template>
<style scoped>
@property --planning-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
/* Rotating gradient border + glow in the slot color */
.planning-multi {
background: conic-gradient(from var(--planning-angle),
var(--slot-color), #ffd76a, var(--slot-color), #ffffff, var(--slot-color));
box-shadow: 0 0 24px -4px var(--slot-color);
animation: planning-spin 6s linear infinite, planning-glow 3s ease-in-out infinite alternate;
}
.planning-multi-inner {
background: linear-gradient(180deg,
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
var(--color-base-200) 40%,
color-mix(in srgb, var(--slot-color) 20%, var(--color-base-200)));
}
.planning-multi-title {
background: linear-gradient(90deg, #fff 20%, #ffd76a 40%, #fff 60%);
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: planning-shimmer 3s linear infinite;
}
.planning-multi-sheen {
background: linear-gradient(180deg, transparent 0%, rgb(255 255 255 / 0.12) 50%, transparent 100%);
background-size: 100% 600px;
background-repeat: no-repeat;
animation: planning-sheen 7s ease-in-out infinite;
}
@keyframes planning-spin {
to {
--planning-angle: 360deg;
}
}
@keyframes planning-glow {
to {
box-shadow: 0 0 40px 0 var(--slot-color);
}
}
@keyframes planning-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
@keyframes planning-sheen {
from {
background-position: 0 -600px;
}
to {
background-position: 0 calc(100% + 600px);
}
}
@media (prefers-reduced-motion: reduce) {
.planning-multi,
.planning-multi-title,
.planning-multi-sheen {
animation: none;
}
}
</style>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import type { SteamGameOut } from "../api/types.gen";
defineProps<{ game: SteamGameOut }>();
const { t } = useI18n();
// Not every game has a library capsule on Steam, fall back to the header + name
const capsuleFailed = ref(false);
</script>
<template>
<div class="group relative aspect-[2/3] rounded-md overflow-hidden bg-base-200 hover:bg-base-100
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" :title="game.name" class="block w-full h-full">
<img v-if="!capsuleFailed" :src="game.library_image" :alt="game.name" loading="lazy"
class="w-full h-full object-cover" @error="capsuleFailed = true" />
<div v-else class="w-full h-full flex flex-col justify-center gap-4 p-3 bg-gradient-to-b from-base-100 to-base-300">
<img :src="game.header_image" :alt="game.name" loading="lazy" class="w-full rounded-sm" />
<span class="text-center font-medium line-clamp-3">{{ game.name }}</span>
</div>
</a>
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
<a :href="`steam://run/${game.steam_app_id}`" :title="t('games.play')"
class="steam-play absolute bottom-2 right-2 rounded-full w-10 h-10 flex items-center justify-center text-2xl
opacity-0 translate-y-1 transition-all group-hover:opacity-100 group-hover:translate-y-0 focus:opacity-100">
<i class="mdi mdi-play"></i>
</a>
</div>
</template>
<style scoped>
.steam-play {
color: #ffffff;
background: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
}
.steam-play:hover {
background: linear-gradient(to right, #6fceff 5%, #2d5fd8 95%);
}
</style>
+8 -1
View File
@@ -66,6 +66,7 @@
"subtitle": "Choose a game and submit your best solutions", "subtitle": "Choose a game and submit your best solutions",
"submitResults": "Submit results", "submitResults": "Submit results",
"getTheGames": "Get the games", "getTheGames": "Get the games",
"planning": "Planning",
"footer": "Select a game above to begin submitting" "footer": "Select a game above to begin submitting"
}, },
"games": { "games": {
@@ -76,7 +77,13 @@
"buy": "Buy {game}", "buy": "Buy {game}",
"free": "Free to Play", "free": "Free to Play",
"viewOnSteam": "View on Steam", "viewOnSteam": "View on Steam",
"play": "Play" "play": "Play",
"showPrices": "Show prices"
},
"planning": {
"header": "Planning",
"title": "Planning",
"empty": "No planning for this event yet"
}, },
"clock": { "clock": {
"pageTitle": "Beat the Clock", "pageTitle": "Beat the Clock",
+7
View File
@@ -3,6 +3,7 @@ import { persistQueryClient } from "@tanstack/query-persist-client-core";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { import {
gamesApiListEventGamesOptions, gamesApiListEventGamesOptions,
gamesApiListEventPlanningOptions,
polylanSubmitterApiGetUserInfoOptions, polylanSubmitterApiGetUserInfoOptions,
polylanSubmitterApiGetUserInfoQueryKey, polylanSubmitterApiGetUserInfoQueryKey,
} from "@/api/@tanstack/vue-query.gen"; } from "@/api/@tanstack/vue-query.gen";
@@ -25,6 +26,12 @@ export const eventGamesQuery = (eventPath: string) => ({
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
// Imported by staff a handful of times per event.
export const eventPlanningQuery = (eventPath: string) => ({
...gamesApiListEventPlanningOptions({ path: { event_path: eventPath } }),
staleTime: 5 * 60 * 1000,
});
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]); const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
// Restores user + games from localStorage before the app mounts, so pages // Restores user + games from localStorage before the app mounts, so pages
+8
View File
@@ -5,6 +5,7 @@ import Home from "@/Home.vue";
import Market from "@/Market.vue"; import Market from "@/Market.vue";
import Noita from "@/Noita.vue"; import Noita from "@/Noita.vue";
import OpusMagnum from "@/OpusMagnum.vue"; import OpusMagnum from "@/OpusMagnum.vue";
import Planning from "@/Planning.vue";
import Place from "@/Place.vue"; import Place from "@/Place.vue";
import PlaceEmbed from "@/PlaceEmbed.vue"; import PlaceEmbed from "@/PlaceEmbed.vue";
import Submissions from "@/Submissions.vue"; import Submissions from "@/Submissions.vue";
@@ -45,6 +46,13 @@ export const router = createRouter({
props: true, props: true,
meta: { titleKey: "games.header", getBackRoute: eventHome }, meta: { titleKey: "games.header", getBackRoute: eventHome },
}, },
{
path: "/:eventPath/planning",
name: "planning",
component: Planning,
props: true,
meta: { titleKey: "planning.header", getBackRoute: eventHome },
},
{ {
path: "/:eventPath/market", path: "/:eventPath/market",
name: "market", name: "market",
File diff suppressed because one or more lines are too long
+168
View File
@@ -0,0 +1,168 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Games Planning Preview</title>
<style>
:root {
--bg: #1d232a; --surface: #2a323c; --text: #a6adbb; --muted: #6b7280; --line: #ffffff10;
--hour: 104px; --col: 150px; --gap: 10px; --break: 34px;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.page { max-width: 1280px; margin: 0 auto; padding: 48px 16px; }
h1 { text-align: center; font-size: 3rem; margin: 0 0 16px; color: #e5e7eb; }
.sub { text-align: center; font-size: 1.25rem; margin: 0 0 32px; }
.toolbar { display: flex; justify-content: flex-end; gap: 8px; align-items: center; margin-bottom: 24px; }
.toggle { width: 44px; height: 24px; border-radius: 999px; border: 2px solid var(--muted); position: relative; }
.toggle::after { content: ""; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); }
.scroller { overflow-x: auto; }
.planning { display: grid; gap: var(--gap); width: max-content; margin: 0 auto; }
.axis, .column { position: relative; }
.axis { width: 56px; }
.tick { position: absolute; right: 8px; font-size: 12px; color: var(--muted); transform: translateY(-50%); }
.day { position: absolute; left: 0; right: calc(-1 * (var(--col) + var(--gap)) * 6); border-top: 1px solid #ffffff30;
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #e5e7eb; padding-top: 2px; }
.hourline { position: absolute; left: 56px; right: 0; border-top: 1px dashed var(--line); }
.break { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center;
font-size: 11px; color: var(--muted); background: repeating-linear-gradient(45deg, #ffffff06 0 6px, transparent 6px 12px); border-radius: 4px; }
.column { width: var(--col); }
.card { position: absolute; left: 0; right: 0; border-radius: 6px; overflow: hidden; background: var(--surface);
box-shadow: 0 20px 25px -5px #0006; transition: all .15s; cursor: pointer; }
.card:hover { transform: translateY(-8px) scale(1.05); box-shadow: 0 25px 50px -12px #000a; z-index: 5; }
.card img.cover { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
.card .time { position: absolute; top: 6px; left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: #000a; color: #fff; opacity: 0; transition: opacity .15s; }
.card:hover .time { opacity: 1; }
.fallback { height: 100%; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 10px;
border-left: 4px solid var(--c); background: linear-gradient(160deg, color-mix(in srgb, var(--c) 35%, var(--surface)), var(--surface)); }
.fallback .title { color: #f3f4f6; font-weight: 600; line-height: 1.2; }
.fallback .when { font-size: 12px; color: #d1d5db; }
/* multi-anims: one long card, its games stacked & sticky while you scroll */
.multi { background: linear-gradient(180deg, color-mix(in srgb, var(--c) 30%, var(--surface)), var(--surface)); padding: 8px; overflow: visible; }
.multi:hover { transform: none; }
.multi .head { color: #f3f4f6; font-weight: 700; text-transform: uppercase; font-size: 13px; margin-bottom: 8px; letter-spacing: .05em; }
.multi .sub-card { position: sticky; top: 8px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; margin-bottom: 12px;
box-shadow: 0 10px 15px -3px #0008; background: var(--surface); }
.multi .sub-card .fallback { border-left: none; }
</style>
</head>
<body>
<div class="page">
<h1>Get the games</h1>
<p class="sub">Don't own them yet? Grab them on Steam</p>
<div class="toolbar"><span>Show prices</span><span class="toggle"></span></div>
<div class="scroller"><div class="planning" id="planning"></div></div>
</div>
<script src="data.js"></script>
<script>
// Preview only: in the app this comes from Game.name matching + library_image
const STEAM = {
"factorio": 427520, "fall guys": 1097150, "lethal company": 1966720, "rocket league": 252950,
"r.e.p.o.": 3241660, "tricky towers": 437920, "rounds": 1557740, "cosmoteer": 799600,
"golf it!": 571740, "towerfall: ascension": 251470, "puyo puyo — tetris": 546050,
"peak": 3527290, "straftat": 2386720, "trackmania": 2225070, "slay the spire 2": 2868840,
};
const coverOf = (name) => {
const id = STEAM[name.toLowerCase()];
return id && `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/library_600x900.jpg`;
};
const HOUR = 3600e3;
const css = getComputedStyle(document.documentElement);
const HOUR_PX = parseFloat(css.getPropertyValue("--hour"));
const BREAK_PX = parseFloat(css.getPropertyValue("--break"));
const TZ = "Europe/Zurich";
const fmtTime = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" });
const fmtDay = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "long", day: "numeric", month: "short" });
const slots = PLANNING.list.map((s) => ({ ...s, b: Date.parse(s.begin), e: Date.parse(s.end) }));
const singles = slots.filter((s) => !s.is_multi_anims);
// Hours with something going on; long empty runs (nights) get squashed into a thin break
const start = Math.floor(Math.min(...slots.map((s) => s.b)) / HOUR) * HOUR;
const end = Math.ceil(Math.max(...slots.map((s) => s.e)) / HOUR) * HOUR;
const busy = (t) => singles.some((s) => s.b < t + HOUR && s.e > t);
const segments = []; // {from, to, y, h, kind}
let y = 0;
for (let t = start; t < end;) {
if (busy(t)) { segments.push({ from: t, to: t + HOUR, y, h: HOUR_PX, kind: "hour" }); y += HOUR_PX; t += HOUR; continue; }
let u = t; while (u < end && !busy(u)) u += HOUR;
const short = (u - t) / HOUR < 3;
const h = short ? HOUR_PX * (u - t) / HOUR : BREAK_PX;
segments.push({ from: t, to: u, y, h, kind: short ? "idle" : "break" }); y += h; t = u;
}
const total = y;
const yOf = (t) => {
const s = segments.find((s) => t >= s.from && t <= s.to) || segments.at(-1);
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
};
const cols = [...new Set(slots.map((s) => s.colonneS))].sort((a, b) => a - b); // drop unused columns
const root = document.getElementById("planning");
root.style.gridTemplateColumns = `56px repeat(${cols.length}, var(--col))`;
// Time axis + day headers + hour lines
const axis = document.createElement("div");
axis.className = "axis"; axis.style.height = total + "px";
root.appendChild(axis);
let lastDay = null;
for (const s of segments) {
if (s.kind === "break") {
const el = document.createElement("div");
el.className = "break"; el.style.top = s.y + "px"; el.style.height = s.h + "px";
el.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
el.textContent = `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}`;
axis.appendChild(el);
continue;
}
for (let t = s.from; t < s.to; t += HOUR) {
const ty = yOf(t), day = fmtDay.format(t);
if (day !== lastDay) {
const d = document.createElement("div");
d.className = "day"; d.style.top = ty + "px"; d.textContent = day;
d.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
axis.appendChild(d); lastDay = day;
} else {
const tick = document.createElement("div");
tick.className = "tick"; tick.style.top = ty + "px"; tick.textContent = fmtTime.format(t);
axis.appendChild(tick);
}
}
}
const fallback = (title, when, color) =>
`<div class="fallback" style="--c:${color}"><div class="title">${title}</div>${when ? `<div class="when">${when}</div>` : ""}</div>`;
const coverOrFallback = (name, when, color) => {
const src = coverOf(name);
if (!src) return fallback(name, when, color);
// Not every game has a library capsule, swap to the colored card if it 404s
return `<img class="cover" src="${src}" alt="${name}" title="${name}"
onerror="this.outerHTML = decodeURIComponent('${encodeURIComponent(fallback(name, when, color))}')">`;
};
for (const c of cols) {
const col = document.createElement("div");
col.className = "column"; col.style.height = total + "px";
for (const s of slots.filter((s) => s.colonneS === c)) {
const top = yOf(s.b) + 3, height = yOf(s.e) - yOf(s.b) - 6;
const when = `${fmtTime.format(s.b)} – ${fmtTime.format(s.e)}`;
const card = document.createElement("div");
card.style.top = top + "px"; card.style.height = height + "px";
if (s.is_multi_anims) {
card.className = "card multi"; card.style.setProperty("--c", s.color_code);
card.innerHTML = `<div class="head">${s.title}</div>` +
s.aandts.map((a) => `<div class="sub-card">${coverOrFallback(a.name, "", s.color_code)}</div>`).join("");
} else {
card.className = "card";
card.innerHTML = coverOrFallback(s.title, when, s.color_code) + `<span class="time">${when}</span>`;
}
col.appendChild(card);
}
root.appendChild(col);
}
</script>
</body>
</html>