Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf51b187d2
|
||
|
|
b5716d6dcd
|
||
|
|
ad7726d978
|
||
|
|
89fb745e74
|
||
|
|
2f8fdb755b
|
@@ -7,7 +7,7 @@ from django_object_actions import (
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -90,3 +90,10 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
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).")
|
||||
|
||||
|
||||
@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"]
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import List
|
||||
from ninja import Router
|
||||
|
||||
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
|
||||
|
||||
router = Router()
|
||||
@@ -28,3 +29,9 @@ def list_event_steam_games(request, event_path: str):
|
||||
return Game.objects.enabled_for_event(request.event).filter(
|
||||
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"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -15,6 +15,8 @@ class Game(models.Model):
|
||||
custom_path = models.BooleanField(default=False)
|
||||
image = models.URLField(null=True, blank=True)
|
||||
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).
|
||||
# Prices are in cents, in steam_currency.
|
||||
@@ -40,6 +42,19 @@ class Game(models.Model):
|
||||
return f"{self.name} ({self.steam_app_id})"
|
||||
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):
|
||||
event = models.ForeignKey(
|
||||
@@ -74,3 +89,37 @@ class EventGame(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
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})"
|
||||
|
||||
@@ -25,6 +25,7 @@ class SteamGameOut(Schema):
|
||||
steam_app_id: int
|
||||
store_url: str
|
||||
header_image: str
|
||||
library_image: str
|
||||
short_description: str
|
||||
is_free: bool
|
||||
currency: str
|
||||
@@ -35,7 +36,7 @@ class SteamGameOut(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_store_url(obj) -> str:
|
||||
return f"https://store.steampowered.com/app/{obj.steam_app_id}"
|
||||
return obj.store_url
|
||||
|
||||
@staticmethod
|
||||
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"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_library_image(obj) -> str:
|
||||
return obj.library_image_url
|
||||
|
||||
@staticmethod
|
||||
def resolve_short_description(obj) -> str:
|
||||
return obj.steam_short_description
|
||||
@@ -71,3 +76,23 @@ class SteamGameOut(Schema):
|
||||
@staticmethod
|
||||
def resolve_updated_at(obj) -> Optional[datetime]:
|
||||
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,
|
||||
# which is plenty for a handful of games every few hours.
|
||||
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
||||
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
|
||||
|
||||
|
||||
class SteamStoreError(Exception):
|
||||
@@ -37,6 +38,17 @@ def fetch_app_details(steam_app_id: int) -> dict:
|
||||
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:
|
||||
"""Pull the current store info for a Steam game into its steam_* fields."""
|
||||
data = fetch_app_details(game.steam_app_id)
|
||||
|
||||
@@ -95,7 +95,16 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
(
|
||||
"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")}),
|
||||
(
|
||||
|
||||
@@ -378,10 +378,10 @@ SENTRY_DSN = (
|
||||
|
||||
import sentry_sdk # noqa
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
# Set traces_sample_rate to 1.0 to capture 100%
|
||||
# of transactions for tracing.
|
||||
traces_sample_rate=1.0,
|
||||
environment=SENTRY_ENVIRONMENT,
|
||||
)
|
||||
# sentry_sdk.init(
|
||||
# dsn=SENTRY_DSN,
|
||||
# # Set traces_sample_rate to 1.0 to capture 100%
|
||||
# # of transactions for tracing.
|
||||
# traces_sample_rate=1.0,
|
||||
# environment=SENTRY_ENVIRONMENT,
|
||||
# )
|
||||
|
||||
@@ -56,6 +56,12 @@ def games_home(request: HttpRequest, event_path: str):
|
||||
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
|
||||
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||
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("api/", api.urls),
|
||||
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>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||
path("<slug:event_path>/noita", noita_home, name="noita.home"),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useStorage, useTitle } from "@vueuse/core";
|
||||
import { gamesApiListEventSteamGames } from "./api";
|
||||
import type { SteamGameOut } from "./api/types.gen";
|
||||
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
|
||||
import SteamStoreCard from "./components/SteamStoreCard.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -17,6 +18,8 @@ useTitle(computed(() => t("games.title")));
|
||||
|
||||
const games = ref<SteamGameOut[]>([]);
|
||||
const loading = ref(true);
|
||||
// Library (vertical covers) by default, the store cards show the prices
|
||||
const showPrices = useStorage("games.showPrices", true);
|
||||
|
||||
onMounted(async () => {
|
||||
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="w-full max-w-7xl">
|
||||
<!-- 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>
|
||||
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
|
||||
</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 -->
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
@@ -47,9 +57,14 @@ onMounted(async () => {
|
||||
{{ t("games.empty") }}
|
||||
</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" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { eventGamesQuery } from "@/queries";
|
||||
import { eventGamesQuery, eventPlanningQuery } from "@/queries";
|
||||
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +19,8 @@ useTitle(computed(() => t("home.title")));
|
||||
|
||||
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
||||
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 getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
|
||||
@@ -86,9 +88,13 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link to the Steam games page -->
|
||||
<div v-if="!loading && hasSteamGames" class="text-center mt-12">
|
||||
<RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
|
||||
<!-- Links to the planning and the Steam games page -->
|
||||
<div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12">
|
||||
<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>
|
||||
{{ t("home.getTheGames") }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -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 { 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 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 { 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, 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> = [
|
||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||
@@ -551,6 +551,26 @@ export const gamesApiListEventSteamGamesOptions = (options: Options<GamesApiList
|
||||
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);
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||
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> & {
|
||||
/**
|
||||
@@ -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 });
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -952,6 +952,10 @@ export type SteamGameOut = {
|
||||
* Header Image
|
||||
*/
|
||||
header_image: string;
|
||||
/**
|
||||
* Library Image
|
||||
*/
|
||||
library_image: string;
|
||||
/**
|
||||
* Short Description
|
||||
*/
|
||||
@@ -982,6 +986,66 @@ export type SteamGameOut = {
|
||||
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
|
||||
*/
|
||||
@@ -1993,6 +2057,29 @@ export type 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 = {
|
||||
body?: never;
|
||||
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>
|
||||
@@ -66,6 +66,7 @@
|
||||
"subtitle": "Choose a game and submit your best solutions",
|
||||
"submitResults": "Submit results",
|
||||
"getTheGames": "Get the games",
|
||||
"planning": "Planning",
|
||||
"footer": "Select a game above to begin submitting"
|
||||
},
|
||||
"games": {
|
||||
@@ -76,7 +77,13 @@
|
||||
"buy": "Buy {game}",
|
||||
"free": "Free to Play",
|
||||
"viewOnSteam": "View on Steam",
|
||||
"play": "Play"
|
||||
"play": "Play",
|
||||
"showPrices": "Show prices"
|
||||
},
|
||||
"planning": {
|
||||
"header": "Planning",
|
||||
"title": "Planning",
|
||||
"empty": "No planning for this event yet"
|
||||
},
|
||||
"clock": {
|
||||
"pageTitle": "Beat the Clock",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { persistQueryClient } from "@tanstack/query-persist-client-core";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import {
|
||||
gamesApiListEventGamesOptions,
|
||||
gamesApiListEventPlanningOptions,
|
||||
polylanSubmitterApiGetUserInfoOptions,
|
||||
polylanSubmitterApiGetUserInfoQueryKey,
|
||||
} from "@/api/@tanstack/vue-query.gen";
|
||||
@@ -25,6 +26,12 @@ export const eventGamesQuery = (eventPath: string) => ({
|
||||
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"]);
|
||||
|
||||
// Restores user + games from localStorage before the app mounts, so pages
|
||||
|
||||
@@ -5,6 +5,7 @@ import Home from "@/Home.vue";
|
||||
import Market from "@/Market.vue";
|
||||
import Noita from "@/Noita.vue";
|
||||
import OpusMagnum from "@/OpusMagnum.vue";
|
||||
import Planning from "@/Planning.vue";
|
||||
import Place from "@/Place.vue";
|
||||
import PlaceEmbed from "@/PlaceEmbed.vue";
|
||||
import Submissions from "@/Submissions.vue";
|
||||
@@ -45,6 +46,13 @@ export const router = createRouter({
|
||||
props: true,
|
||||
meta: { titleKey: "games.header", getBackRoute: eventHome },
|
||||
},
|
||||
{
|
||||
path: "/:eventPath/planning",
|
||||
name: "planning",
|
||||
component: Planning,
|
||||
props: true,
|
||||
meta: { titleKey: "planning.header", getBackRoute: eventHome },
|
||||
},
|
||||
{
|
||||
path: "/:eventPath/market",
|
||||
name: "market",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
Reference in New Issue
Block a user