Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b403311ce1
|
||
|
|
1ca4643774
|
@@ -12,6 +12,7 @@ acts.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
@@ -30,13 +31,20 @@ HEARTBEAT_INTERVAL_SECONDS = 1
|
|||||||
# One chain ticks every event with the game enabled - this lock keeps a
|
# One chain ticks every event with the game enabled - this lock keeps a
|
||||||
# second chain from starting underneath an already-running one (e.g. two
|
# second chain from starting underneath an already-running one (e.g. two
|
||||||
# EventGame saves close together). Refreshed every tick, wide enough that a
|
# EventGame saves close together). Refreshed every tick, wide enough that a
|
||||||
# single slow tick doesn't let a second chain slip in.
|
# single slow tick doesn't let a second chain slip in. The lock holds the
|
||||||
|
# chain id: if it expires anyway (worker down/restarted for a few seconds) and
|
||||||
|
# a new chain starts, the old one stops at its next tick (instead of both
|
||||||
|
# ticking forever).
|
||||||
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
|
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
|
||||||
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
|
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def clock_heartbeat_task():
|
def clock_heartbeat_task(chain_id: str):
|
||||||
|
if cache.get(HEARTBEAT_LOCK_KEY) != chain_id:
|
||||||
|
# Another chain owns the lock (or it expired), stop this one
|
||||||
|
return
|
||||||
|
|
||||||
event_games = list(
|
event_games = list(
|
||||||
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
|
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
|
||||||
"event"
|
"event"
|
||||||
@@ -46,6 +54,7 @@ def clock_heartbeat_task():
|
|||||||
if not event_games:
|
if not event_games:
|
||||||
# Nothing left to tick - let the chain die. start_clock_heartbeat()
|
# Nothing left to tick - let the chain die. start_clock_heartbeat()
|
||||||
# starts a fresh one next time an event enables the game.
|
# starts a fresh one next time an event enables the game.
|
||||||
|
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
|
||||||
cache.delete(HEARTBEAT_LOCK_KEY)
|
cache.delete(HEARTBEAT_LOCK_KEY)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -67,13 +76,14 @@ def clock_heartbeat_task():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
||||||
clock_heartbeat_task.using(
|
clock_heartbeat_task.using(
|
||||||
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
||||||
).enqueue()
|
).enqueue(chain_id)
|
||||||
|
|
||||||
|
|
||||||
def start_clock_heartbeat():
|
def start_clock_heartbeat():
|
||||||
"""Enqueues the heartbeat chain, unless one is already running."""
|
"""Enqueues the heartbeat chain, unless one is already running."""
|
||||||
if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
chain_id = uuid4().hex
|
||||||
clock_heartbeat_task.enqueue()
|
if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
||||||
|
clock_heartbeat_task.enqueue(chain_id)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import EventGame, Game
|
||||||
|
|
||||||
|
from .tasks import HEARTBEAT_LOCK_KEY, clock_heartbeat_task, start_clock_heartbeat
|
||||||
|
|
||||||
|
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CACHES=LOCMEM_CACHE)
|
||||||
|
@mock.patch("clock.tasks.get_channel_layer")
|
||||||
|
class ClockHeartbeatTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
event = Event.objects.create(name="Event", path="event")
|
||||||
|
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
|
||||||
|
self.event_game = EventGame.objects.create(event=event, game=clock)
|
||||||
|
cache.clear() # post_save signal can start a chain
|
||||||
|
|
||||||
|
@mock.patch("clock.tasks.clock_heartbeat_task")
|
||||||
|
def test_only_one_chain_is_started(self, task, _):
|
||||||
|
start_clock_heartbeat()
|
||||||
|
start_clock_heartbeat()
|
||||||
|
|
||||||
|
task.enqueue.assert_called_once_with(cache.get(HEARTBEAT_LOCK_KEY))
|
||||||
|
|
||||||
|
def test_owning_chain_reschedules_itself(self, channel_layer):
|
||||||
|
channel_layer.return_value.group_send = mock.AsyncMock()
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, "chain")
|
||||||
|
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
|
||||||
|
clock_heartbeat_task.call("chain")
|
||||||
|
|
||||||
|
patched.using.return_value.enqueue.assert_called_once_with("chain")
|
||||||
|
channel_layer.return_value.group_send.assert_awaited_once()
|
||||||
|
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "chain")
|
||||||
|
|
||||||
|
def test_superseded_chain_stops(self, channel_layer):
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, "new-chain")
|
||||||
|
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
|
||||||
|
clock_heartbeat_task.call("old-chain")
|
||||||
|
|
||||||
|
patched.using.assert_not_called()
|
||||||
|
channel_layer.assert_not_called()
|
||||||
|
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "new-chain")
|
||||||
|
|
||||||
|
def test_chain_dies_and_releases_lock_when_game_disabled(self, _):
|
||||||
|
EventGame.objects.filter(pk=self.event_game.pk).update(enabled=False)
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, "chain")
|
||||||
|
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
|
||||||
|
clock_heartbeat_task.call("chain")
|
||||||
|
|
||||||
|
patched.using.assert_not_called()
|
||||||
|
self.assertIsNone(cache.get(HEARTBEAT_LOCK_KEY))
|
||||||
@@ -40,7 +40,7 @@ class GameAdmin(admin.ModelAdmin):
|
|||||||
game_ids = list(
|
game_ids = list(
|
||||||
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
|
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
refresh_steam_games_task.enqueue(game_ids=game_ids, reschedule=False)
|
refresh_steam_games_task.enqueue(game_ids=game_ids)
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
|
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -10,7 +11,9 @@ from .services.steam_store import refresh_steam_games
|
|||||||
# Set while a periodic refresh is queued, so there's only ever one loop running,
|
# Set while a periodic refresh is queued, so there's only ever one loop running,
|
||||||
# whatever the number of workers/containers trying to start it. It expires on
|
# whatever the number of workers/containers trying to start it. It expires on
|
||||||
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
|
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
|
||||||
# letting the next schedule_steam_refresh() start a new one.
|
# letting the next schedule_steam_refresh() start a new one. The guard holds the
|
||||||
|
# loop id, so if it expires while the old loop is just late, the old one stops
|
||||||
|
# at its next run (instead of 2 loops running side by side).
|
||||||
STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled"
|
STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled"
|
||||||
|
|
||||||
|
|
||||||
@@ -25,28 +28,34 @@ def _guard_timeout() -> int:
|
|||||||
def schedule_steam_refresh() -> bool:
|
def schedule_steam_refresh() -> bool:
|
||||||
"""Start the periodic refresh loop right away, unless one is already queued.
|
"""Start the periodic refresh loop right away, unless one is already queued.
|
||||||
Safe to call from anywhere, any number of times."""
|
Safe to call from anywhere, any number of times."""
|
||||||
if not cache.add(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout()):
|
loop_id = uuid4().hex
|
||||||
|
if not cache.add(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout()):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
refresh_steam_games_task.enqueue()
|
refresh_steam_games_task.enqueue(loop_id=loop_id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def refresh_steam_games_task(
|
def refresh_steam_games_task(
|
||||||
game_ids: list[int] | None = None, reschedule: bool = True
|
game_ids: list[int] | None = None, loop_id: str | None = None
|
||||||
):
|
):
|
||||||
"""Refresh Steam store info, then re-enqueue itself every
|
"""Refresh Steam store info, then re-enqueue itself every
|
||||||
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
|
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
|
||||||
sync_event_game_scopes_task, no external scheduler needed).
|
sync_event_game_scopes_task, no external scheduler needed).
|
||||||
|
|
||||||
reschedule=False is a one-off run (e.g. the admin action) that leaves the
|
Without loop_id it's a one-off run (e.g. the admin action) that leaves the
|
||||||
periodic loop alone."""
|
periodic loop alone. With loop_id, it only continues while the guard still
|
||||||
|
holds this loop_id."""
|
||||||
|
if loop_id is not None and cache.get(STEAM_REFRESH_GUARD_KEY) != loop_id:
|
||||||
|
# Another loop owns the guard (or it expired), stop this one
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
refresh_steam_games(game_ids)
|
refresh_steam_games(game_ids)
|
||||||
finally:
|
finally:
|
||||||
if reschedule:
|
if loop_id is not None:
|
||||||
cache.set(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout())
|
cache.set(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout())
|
||||||
refresh_steam_games_task.using(
|
refresh_steam_games_task.using(
|
||||||
run_after=timezone.now() + steam_refresh_interval()
|
run_after=timezone.now() + steam_refresh_interval()
|
||||||
).enqueue()
|
).enqueue(loop_id=loop_id)
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ from events.models import Event
|
|||||||
|
|
||||||
from .models import EventGame, Game
|
from .models import EventGame, Game
|
||||||
from .services.steam_store import refresh_steam_games
|
from .services.steam_store import refresh_steam_games
|
||||||
from .tasks import refresh_steam_games_task, schedule_steam_refresh
|
from .tasks import (
|
||||||
|
STEAM_REFRESH_GUARD_KEY,
|
||||||
|
refresh_steam_games_task,
|
||||||
|
schedule_steam_refresh,
|
||||||
|
)
|
||||||
|
|
||||||
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
|
|
||||||
@@ -83,21 +87,32 @@ class ScheduleSteamRefreshTests(TestCase):
|
|||||||
def test_only_one_loop_is_scheduled(self, task):
|
def test_only_one_loop_is_scheduled(self, task):
|
||||||
self.assertTrue(schedule_steam_refresh())
|
self.assertTrue(schedule_steam_refresh())
|
||||||
self.assertFalse(schedule_steam_refresh())
|
self.assertFalse(schedule_steam_refresh())
|
||||||
task.enqueue.assert_called_once_with()
|
task.enqueue.assert_called_once_with(loop_id=cache.get(STEAM_REFRESH_GUARD_KEY))
|
||||||
|
|
||||||
@mock.patch("games.tasks.refresh_steam_games")
|
@mock.patch("games.tasks.refresh_steam_games")
|
||||||
def test_task_reschedules_itself_even_on_failure(self, refresh):
|
def test_task_reschedules_itself_even_on_failure(self, refresh):
|
||||||
|
cache.set(STEAM_REFRESH_GUARD_KEY, "loop")
|
||||||
refresh.side_effect = RuntimeError("boom")
|
refresh.side_effect = RuntimeError("boom")
|
||||||
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
|
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
refresh_steam_games_task.call()
|
refresh_steam_games_task.call(loop_id="loop")
|
||||||
|
|
||||||
patched.using.return_value.enqueue.assert_called_once_with()
|
patched.using.return_value.enqueue.assert_called_once_with(loop_id="loop")
|
||||||
|
|
||||||
|
@mock.patch("games.tasks.refresh_steam_games")
|
||||||
|
def test_superseded_loop_stops(self, refresh):
|
||||||
|
cache.set(STEAM_REFRESH_GUARD_KEY, "new-loop")
|
||||||
|
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
|
||||||
|
refresh_steam_games_task.call(loop_id="old-loop")
|
||||||
|
|
||||||
|
refresh.assert_not_called()
|
||||||
|
patched.using.assert_not_called()
|
||||||
|
self.assertEqual(cache.get(STEAM_REFRESH_GUARD_KEY), "new-loop")
|
||||||
|
|
||||||
@mock.patch("games.tasks.refresh_steam_games")
|
@mock.patch("games.tasks.refresh_steam_games")
|
||||||
def test_one_off_run_does_not_reschedule(self, refresh):
|
def test_one_off_run_does_not_reschedule(self, refresh):
|
||||||
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
|
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
|
||||||
refresh_steam_games_task.call(game_ids=[1], reschedule=False)
|
refresh_steam_games_task.call(game_ids=[1])
|
||||||
|
|
||||||
refresh.assert_called_once_with([1])
|
refresh.assert_called_once_with([1])
|
||||||
patched.using.assert_not_called()
|
patched.using.assert_not_called()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||||
<div class="w-full max-w-6xl">
|
<div class="w-full max-w-7xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-12">
|
||||||
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
||||||
@@ -47,7 +47,7 @@ onMounted(async () => {
|
|||||||
{{ t("games.empty") }}
|
{{ t("games.empty") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 bg-base-300 flex items-center justify-center px-4">
|
<div class="flex-1 bg-base-300 flex items-center justify-center px-4">
|
||||||
<div class="w-full max-w-6xl">
|
<div class="w-full max-w-7xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-12">
|
||||||
<h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1>
|
<h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1>
|
||||||
|
|||||||
@@ -20,41 +20,41 @@ const isDiscounted = computed(() => hasPrice.value && props.game.discount_percen
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="steam-card flex flex-col overflow-hidden rounded-lg shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]">
|
class="card card-xl bg-base-200 hover:bg-base-100 overflow-hidden 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">
|
<a :href="game.store_url" target="_blank" rel="noopener noreferrer">
|
||||||
<img :src="game.header_image" :alt="game.name" loading="lazy"
|
<img :src="game.header_image" :alt="game.name" loading="lazy"
|
||||||
class="w-full aspect-[460/215] object-cover" />
|
class="w-full aspect-[460/215] object-cover" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex flex-col flex-1 min-w-0 p-6 gap-3">
|
<div class="card-body min-w-0">
|
||||||
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
|
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
|
||||||
class="steam-title text-2xl font-semibold truncate hover:underline">
|
class="card-title text-2xl block truncate hover:underline">
|
||||||
{{ t("games.buy", { game: game.name }) }}
|
{{ t("games.buy", { game: game.name }) }}
|
||||||
</a>
|
</a>
|
||||||
<p v-if="game.short_description" class="steam-text line-clamp-3">
|
<p v-if="game.short_description" class="text-sm text-base-content/70 line-clamp-3">
|
||||||
{{ game.short_description }}
|
{{ game.short_description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-auto pt-2 flex flex-wrap justify-end gap-2">
|
<div class="mt-auto pt-2 flex flex-wrap sm:flex-nowrap justify-end gap-2 whitespace-nowrap">
|
||||||
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
|
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
|
||||||
<a :href="`steam://run/${game.steam_app_id}`"
|
<a :href="`steam://run/${game.steam_app_id}`"
|
||||||
class="steam-play rounded-sm px-5 py-2 flex items-center gap-1">
|
class="steam-play rounded-sm px-5 py-2 flex items-center gap-1">
|
||||||
<i class="mdi mdi-play"></i>
|
<i class="mdi mdi-play"></i>
|
||||||
{{ t("games.play") }}
|
{{ t("games.play") }}
|
||||||
</a>
|
</a>
|
||||||
<div class="steam-purchase flex items-stretch rounded-sm overflow-hidden">
|
<div class="bg-base-300 flex items-stretch rounded-sm overflow-hidden">
|
||||||
<template v-if="game.is_free">
|
<template v-if="game.is_free">
|
||||||
<span class="steam-price px-3 py-2 flex items-center">{{ t("games.free") }}</span>
|
<span class="px-3 py-2 flex items-center">{{ t("games.free") }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="hasPrice">
|
<template v-else-if="hasPrice">
|
||||||
<span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium">
|
<span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium">
|
||||||
-{{ game.discount_percent }}%
|
-{{ game.discount_percent }}%
|
||||||
</span>
|
</span>
|
||||||
<span class="steam-price px-3 flex flex-col justify-center leading-tight">
|
<span class="px-3 flex flex-col justify-center leading-tight">
|
||||||
<span v-if="isDiscounted" class="steam-original text-xs line-through">
|
<span v-if="isDiscounted" class="text-base-content/50 text-xs line-through">
|
||||||
{{ formatPrice(game.price_initial!) }}
|
{{ formatPrice(game.price_initial!) }}
|
||||||
</span>
|
</span>
|
||||||
<span :class="{ 'steam-discounted': isDiscounted }">{{ formatPrice(game.price_final!) }}</span>
|
<span :class="{ 'text-success font-medium': isDiscounted }">{{ formatPrice(game.price_final!) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
|
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
|
||||||
@@ -69,45 +69,12 @@ const isDiscounted = computed(() => hasPrice.value && props.game.discount_percen
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Steam store palette, on purpose regardless of the site theme */
|
/* Steam palette for the purchase buttons, the card itself follows the site theme */
|
||||||
.steam-card {
|
|
||||||
background: linear-gradient(135deg, #1b2838 0%, #2a475e 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lightens on hover, like the homepage cards' hover:bg-base-100 */
|
|
||||||
.steam-card:hover {
|
|
||||||
background: linear-gradient(135deg, #22344a 0%, #335672 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-title {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-text {
|
|
||||||
color: #c6d4df;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-purchase {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-discount {
|
.steam-discount {
|
||||||
background: #4c6b22;
|
background: #4c6b22;
|
||||||
color: #beee11;
|
color: #beee11;
|
||||||
}
|
}
|
||||||
|
|
||||||
.steam-price {
|
|
||||||
color: #c6d4df;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-original {
|
|
||||||
color: #738895;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-discounted {
|
|
||||||
color: #beee11;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steam-button {
|
.steam-button {
|
||||||
color: #d2efa9;
|
color: #d2efa9;
|
||||||
background: linear-gradient(to right, #75b022 5%, #588a1b 95%);
|
background: linear-gradient(to right, #75b022 5%, #588a1b 95%);
|
||||||
|
|||||||
Reference in New Issue
Block a user