try to match to steam game
This commit is contained in:
@@ -5,7 +5,12 @@ from django.core.management.base import BaseCommand, CommandError
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from games.services.planning import import_planning
|
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):
|
class Command(BaseCommand):
|
||||||
@@ -17,6 +22,11 @@ class Command(BaseCommand):
|
|||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
|
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("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):
|
def handle(self, *args, **options):
|
||||||
try:
|
try:
|
||||||
@@ -39,3 +49,16 @@ class Command(BaseCommand):
|
|||||||
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
|
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}")
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,17 +1,101 @@
|
|||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
from ..models import Game, PlanningSlot
|
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:
|
def normalize_name(name: str) -> str:
|
||||||
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
||||||
no accents, no case, letters and digits only."""
|
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 = unicodedata.normalize("NFKD", name)
|
||||||
name = "".join(c for c in name if not unicodedata.combining(c))
|
name = "".join(c for c in name if not unicodedata.combining(c))
|
||||||
return re.sub(r"[^0-9a-z]", "", name.casefold())
|
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:
|
def _game_out(name: str, game: Game | None) -> dict:
|
||||||
if game is None:
|
if game is None:
|
||||||
return {"name": name}
|
return {"name": name}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
|||||||
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
|
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
|
||||||
# which is plenty for a handful of games every few hours.
|
# which is plenty for a handful of games every few hours.
|
||||||
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
||||||
|
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
|
||||||
|
|
||||||
|
|
||||||
class SteamStoreError(Exception):
|
class SteamStoreError(Exception):
|
||||||
@@ -37,6 +38,17 @@ def fetch_app_details(steam_app_id: int) -> dict:
|
|||||||
return entry["data"]
|
return entry["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def search_store(term: str) -> list[dict]:
|
||||||
|
"""Store search results for term, as [{"id": app id, "name": ...}, ...]."""
|
||||||
|
response = httpx.get(
|
||||||
|
STORE_SEARCH_URL,
|
||||||
|
params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return (response.json() or {}).get("items") or []
|
||||||
|
|
||||||
|
|
||||||
def refresh_steam_game(game: Game) -> None:
|
def refresh_steam_game(game: Game) -> None:
|
||||||
"""Pull the current store info for a Steam game into its steam_* fields."""
|
"""Pull the current store info for a Steam game into its steam_* fields."""
|
||||||
data = fetch_app_details(game.steam_app_id)
|
data = fetch_app_details(game.steam_app_id)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/vue-query";
|
|||||||
import { eventPlanningQuery } from "@/queries";
|
import { eventPlanningQuery } from "@/queries";
|
||||||
import type { PlanningSlotOut } from "./api/types.gen";
|
import type { PlanningSlotOut } from "./api/types.gen";
|
||||||
import PlanningCover from "./components/PlanningCover.vue";
|
import PlanningCover from "./components/PlanningCover.vue";
|
||||||
|
import PlanningMultiSlot from "./components/PlanningMultiSlot.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventPath: string;
|
eventPath: string;
|
||||||
@@ -117,28 +118,32 @@ const layout = computed(() => {
|
|||||||
|
|
||||||
// Unused columns are dropped
|
// Unused columns are dropped
|
||||||
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
|
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
|
||||||
const columns = columnIds.map((id) =>
|
let left = AXIS_PX;
|
||||||
list
|
const columns = columnIds.map((id) => {
|
||||||
|
const placed = list
|
||||||
.filter(({ slot }) => slot.column === id)
|
.filter(({ slot }) => slot.column === id)
|
||||||
.map<Placed>(({ slot, b, e }) => ({
|
.map<Placed>(({ slot, b, e }) => ({
|
||||||
slot,
|
slot,
|
||||||
top: yOf(b) + 3,
|
top: yOf(b) + 3,
|
||||||
height: yOf(e) - yOf(b) - 6,
|
height: yOf(e) - yOf(b) - 6,
|
||||||
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
|
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
|
||||||
})),
|
}));
|
||||||
);
|
// The multi-anims column is the big one, twice as wide
|
||||||
|
const width = placed.some(({ slot }) => slot.is_multi_anims) ? 2 * COL_PX + GAP_PX : COL_PX;
|
||||||
|
const column = { left, width, placed };
|
||||||
|
left += width + GAP_PX;
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
height: y,
|
height: y,
|
||||||
width: AXIS_PX + columns.length * (COL_PX + GAP_PX) - GAP_PX,
|
width: left - GAP_PX,
|
||||||
days,
|
days,
|
||||||
ticks,
|
ticks,
|
||||||
breaks,
|
breaks,
|
||||||
columns,
|
columns,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -159,10 +164,13 @@ const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
|||||||
<div v-else class="overflow-x-auto pb-4">
|
<div v-else class="overflow-x-auto pb-4">
|
||||||
<div class="relative mx-auto" :style="{ width: `${layout.width}px`, height: `${layout.height}px` }">
|
<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 -->
|
<!-- 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}`"
|
<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"
|
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` }">
|
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
|
||||||
{{ day.label }}
|
<!-- 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>
|
</div>
|
||||||
<template v-for="tick in layout.ticks" :key="tick.y">
|
<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"
|
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
|
||||||
@@ -181,19 +189,12 @@ const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slots -->
|
<!-- Slots -->
|
||||||
<div v-for="(column, index) in layout.columns" :key="index" class="absolute top-0 h-full"
|
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
|
||||||
:style="{ left: `${columnLeft(index)}px`, width: `${COL_PX}px` }">
|
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
|
||||||
<template v-for="{ slot, top, height, when } in column" :key="slot.id">
|
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
|
||||||
<!-- Multi-anims: one long card, its games stay in view while scrolling -->
|
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
|
||||||
<div v-if="slot.is_multi_anims" class="planning-multi absolute inset-x-0 rounded-md p-2 shadow-xl"
|
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
|
||||||
:style="{ top: `${top}px`, height: `${height}px`, '--slot-color': slot.color }">
|
:style="{ top: `${top}px`, height: `${height}px` }" />
|
||||||
<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
|
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
|
||||||
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||||
@@ -218,10 +219,4 @@ const columnLeft = (index: number) => AXIS_PX + index * (COL_PX + GAP_PX);
|
|||||||
.planning-break {
|
.planning-break {
|
||||||
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
|
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>
|
</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 grid grid-cols-2 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>
|
||||||
Reference in New Issue
Block a user