feat(games): show games in library mode also
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,6 +15,8 @@ class Game(models.Model):
|
|||||||
custom_path = models.BooleanField(default=False)
|
custom_path = models.BooleanField(default=False)
|
||||||
image = models.URLField(null=True, blank=True)
|
image = models.URLField(null=True, blank=True)
|
||||||
description = models.TextField(blank=True, default="")
|
description = models.TextField(blank=True, default="")
|
||||||
|
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
|
||||||
|
library_image = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
|
||||||
# Steam store info, refreshed in the background (see games.tasks).
|
# Steam store info, refreshed in the background (see games.tasks).
|
||||||
# Prices are in cents, in steam_currency.
|
# Prices are in cents, in steam_currency.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class SteamGameOut(Schema):
|
|||||||
steam_app_id: int
|
steam_app_id: int
|
||||||
store_url: str
|
store_url: str
|
||||||
header_image: str
|
header_image: str
|
||||||
|
library_image: str
|
||||||
short_description: str
|
short_description: str
|
||||||
is_free: bool
|
is_free: bool
|
||||||
currency: str
|
currency: str
|
||||||
@@ -44,6 +45,14 @@ class SteamGameOut(Schema):
|
|||||||
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
|
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_library_image(obj) -> str:
|
||||||
|
# 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"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_short_description(obj) -> str:
|
def resolve_short_description(obj) -> str:
|
||||||
return obj.steam_short_description
|
return obj.steam_short_description
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref, onMounted, computed } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useTitle } from "@vueuse/core";
|
import { useStorage, useTitle } from "@vueuse/core";
|
||||||
import { gamesApiListEventSteamGames } from "./api";
|
import { gamesApiListEventSteamGames } from "./api";
|
||||||
import type { SteamGameOut } from "./api/types.gen";
|
import type { SteamGameOut } from "./api/types.gen";
|
||||||
|
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
|
||||||
import SteamStoreCard from "./components/SteamStoreCard.vue";
|
import SteamStoreCard from "./components/SteamStoreCard.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -17,6 +18,8 @@ useTitle(computed(() => t("games.title")));
|
|||||||
|
|
||||||
const games = ref<SteamGameOut[]>([]);
|
const games = ref<SteamGameOut[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
// Library (vertical covers) by default, the store cards show the prices
|
||||||
|
const showPrices = useStorage("games.showPrices", true);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const response = await gamesApiListEventSteamGames({
|
const response = await gamesApiListEventSteamGames({
|
||||||
@@ -33,11 +36,18 @@ onMounted(async () => {
|
|||||||
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||||
<div class="w-full max-w-7xl">
|
<div class="w-full max-w-7xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="text-center mb-12">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
||||||
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
|
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mb-6">
|
||||||
|
<label class="label cursor-pointer gap-2">
|
||||||
|
<span>{{ t("games.showPrices") }}</span>
|
||||||
|
<input v-model="showPrices" type="checkbox" class="toggle toggle-primary" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex justify-center py-20">
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
@@ -47,9 +57,14 @@ onMounted(async () => {
|
|||||||
{{ t("games.empty") }}
|
{{ t("games.empty") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div v-else-if="showPrices" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else
|
||||||
|
class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4 sm:gap-6">
|
||||||
|
<SteamLibraryCard v-for="game in games" :key="game.id" :game="game" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -952,6 +952,10 @@ export type SteamGameOut = {
|
|||||||
* Header Image
|
* Header Image
|
||||||
*/
|
*/
|
||||||
header_image: string;
|
header_image: string;
|
||||||
|
/**
|
||||||
|
* Library Image
|
||||||
|
*/
|
||||||
|
library_image: string;
|
||||||
/**
|
/**
|
||||||
* Short Description
|
* Short Description
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -76,7 +76,8 @@
|
|||||||
"buy": "Buy {game}",
|
"buy": "Buy {game}",
|
||||||
"free": "Free to Play",
|
"free": "Free to Play",
|
||||||
"viewOnSteam": "View on Steam",
|
"viewOnSteam": "View on Steam",
|
||||||
"play": "Play"
|
"play": "Play",
|
||||||
|
"showPrices": "Show prices"
|
||||||
},
|
},
|
||||||
"clock": {
|
"clock": {
|
||||||
"pageTitle": "Beat the Clock",
|
"pageTitle": "Beat the Clock",
|
||||||
|
|||||||
Reference in New Issue
Block a user