wip: planning-slot test

This commit is contained in:
2026-10-16 05:20:52 +02:00
parent 2f8fdb755b
commit 89fb745e74
20 changed files with 849 additions and 17 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 .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"]
+8 -1
View File
@@ -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,41 @@
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 import_planning
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")
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"
)
)
@@ -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"
)
],
},
),
]
+47
View File
@@ -42,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(
@@ -76,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})"
+22 -6
View File
@@ -36,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:
@@ -47,11 +47,7 @@ class SteamGameOut(Schema):
@staticmethod
def resolve_library_image(obj) -> str:
# Vertical capsule, like in the Steam library
return (
obj.library_image
or f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{obj.steam_app_id}/library_600x900.jpg"
)
return obj.library_image_url
@staticmethod
def resolve_short_description(obj) -> str:
@@ -80,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,88 @@
import re
import unicodedata
from ..models import Game, PlanningSlot
def normalize_name(name: str) -> str:
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
no accents, no case, letters and digits only."""
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 _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
@@ -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"),
+10 -4
View File
@@ -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>
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { 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";
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;
const HOUR_PX = 104; // a 2h slot ends up close to the 2:3 cover ratio
const BREAK_PX = 34;
const DAY_PX = 32;
const AXIS_PX = 56;
const COL_PX = 150;
const GAP_PX = 10;
// 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 layout = computed(() => {
const list = (slots.value ?? []).map((slot) => ({
slot,
b: Date.parse(slot.begin),
e: Date.parse(slot.end),
}));
if (!list.length) return null;
// 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: HOUR_PX, kind: "hour" });
y += HOUR_PX;
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 : HOUR_PX * 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)}` }));
// Unused columns are dropped
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
const columns = columnIds.map((id) =>
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)}`,
})),
);
return {
height: y,
width: AXIS_PX + columns.length * (COL_PX + GAP_PX) - GAP_PX,
days,
ticks,
breaks,
columns,
};
});
const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-7xl">
<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>
<div v-else class="overflow-x-auto pb-4">
<div class="relative mx-auto" :style="{ width: `${layout.width}px`, height: `${layout.height}px` }">
<!-- Time axis: a header row per day, hour ticks on the side -->
<div v-for="day in layout.days" :key="`day-${day.y}`"
class="absolute left-0 right-0 border-b border-base-content/20 flex items-end pb-1 text-sm font-semibold uppercase tracking-wide"
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
{{ day.label }}
</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, index) in layout.columns" :key="index" class="absolute top-0 h-full"
:style="{ left: `${columnLeft(index)}px`, width: `${COL_PX}px` }">
<template v-for="{ slot, top, height, when } in column" :key="slot.id">
<!-- Multi-anims: one long card, its games stay in view while scrolling -->
<div v-if="slot.is_multi_anims" class="planning-multi absolute inset-x-0 rounded-md p-2 shadow-xl"
:style="{ top: `${top}px`, height: `${height}px`, '--slot-color': slot.color }">
<div class="font-bold uppercase text-sm tracking-wide mb-2">{{ slot.title }}</div>
<component :is="game.store_url ? 'a' : 'div'" v-for="game in slot.games" :key="game.name"
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
class="sticky top-2 block aspect-[2/3] rounded overflow-hidden mb-3 shadow-lg bg-base-200">
<PlanningCover :game="game" :color="slot.color" />
</component>
</div>
<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);
}
.planning-multi {
background: linear-gradient(180deg,
color-mix(in srgb, var(--slot-color) 30%, var(--color-base-200)),
var(--color-base-200));
}
</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
+8 -1
View File
@@ -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
*/
+83
View File
@@ -986,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
*/
@@ -1997,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>
+6
View File
@@ -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": {
@@ -79,6 +80,11 @@
"play": "Play",
"showPrices": "Show prices"
},
"planning": {
"header": "Planning",
"title": "Planning",
"empty": "No planning for this event yet"
},
"clock": {
"pageTitle": "Beat the Clock",
"header": "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 {
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
+8
View File
@@ -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
+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>