feat(cards): Refactor card styling and implement JS multiselect

Refactors card styling by moving color data to the `CardSet` model, removing the `Card.style` and `Pack.hex_color` fields.

- Adds `hex_color` to `CardSet` and a `CardSetColorMapping` model to populate it during import.
- Card importer now uses correct filename regexes to identify sets and apply color mappings.
- Card badge styling is now derived from the `CardSet`'s color, simplifying the data model.

Replaces the old clunky card multiselect with a dynamic Alpine.js component for an improved user experience.

- Introduces an API endpoint (`cards/api/search/`) for asynchronous searching.
- Provides a modern search-as-you-type interface with a `<noscript>` fallback.
This commit is contained in:
badblocks 2025-06-20 00:30:03 -07:00
parent af2f48a491
commit ecb060af6d
No known key found for this signature in database
15 changed files with 668 additions and 533 deletions

View file

@ -0,0 +1,79 @@
[
{
"model": "cards.cardsetcolormapping",
"pk": 1,
"fields": {
"cardset_id": "A3a",
"hex_color": "#FA1A1A",
"created_at": "2025-06-20T05:48:33.579Z",
"updated_at": "2025-06-20T06:07:05.636Z",
"deleted_at": null
}
},
{
"model": "cards.cardsetcolormapping",
"pk": 2,
"fields": {
"cardset_id": "A3",
"hex_color": "#0B47C6",
"created_at": "2025-06-20T05:49:48.100Z",
"updated_at": "2025-06-20T06:06:37.342Z",
"deleted_at": null
}
},
{
"model": "cards.cardsetcolormapping",
"pk": 3,
"fields": {
"cardset_id": "A2b",
"hex_color": "#B3D6EE",
"created_at": "2025-06-20T05:57:08.639Z",
"updated_at": "2025-06-20T06:06:19.207Z",
"deleted_at": null
}
},
{
"model": "cards.cardsetcolormapping",
"pk": 4,
"fields": {
"cardset_id": "A2a",
"hex_color": "#EA9706",
"created_at": "2025-06-20T05:58:45.284Z",
"updated_at": "2025-06-20T06:05:40.057Z",
"deleted_at": null
}
},
{
"model": "cards.cardsetcolormapping",
"pk": 5,
"fields": {
"cardset_id": "A2",
"hex_color": "#7A8696",
"created_at": "2025-06-20T05:59:26.177Z",
"updated_at": "2025-06-20T06:05:23.890Z",
"deleted_at": null
}
},
{
"model": "cards.cardsetcolormapping",
"pk": 6,
"fields": {
"cardset_id": "A1a",
"hex_color": "#31DDAA",
"created_at": "2025-06-20T06:01:35.316Z",
"updated_at": "2025-06-20T06:05:06.221Z",
"deleted_at": null
}
},
{
"model": "cards.cardsetcolormapping",
"pk": 7,
"fields": {
"cardset_id": "A1",
"hex_color": "#7911F0",
"created_at": "2025-06-20T06:03:51.759Z",
"updated_at": "2025-06-20T06:04:48.969Z",
"deleted_at": null
}
}
]

View file

@ -22,6 +22,7 @@ from .models import (
AttackCost,
Card,
CardSet,
CardSetColorMapping,
CardType,
Energy,
Pack,
@ -50,15 +51,28 @@ class PrefetchedSortedRelatedFieldListFilter(RelatedFieldListFilter):
def parse_set_details(set_string):
# Handle filenames like 'a1-genetic-apex'
match = re.match(r"^([a-zA-Z0-9]+)-(.+)$", set_string)
if match:
set_id = match.group(1)
set_id = set_id[0].upper() + set_id[1:]
name = match.group(2).replace("-", " ").title()
return name, set_id
# Handle 'promo' filename, assuming 'PRO' as a 3-char ID
if set_string == "promo":
return "Promo-A", "PA"
match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string)
if match:
name = match.group(1).strip()
set_id = match.group(2)
set_id = set_id[0].upper() + set_id[1:]
return name, set_id
match = re.match(r"^Promo-(.*?)$", set_string)
if match:
name = set_string
set_id = "P-" + match.group(1)
set_id = "P" + match.group(1)
return name, set_id
return set_string, None
@ -207,7 +221,6 @@ def _update_card_packs(card_obj, card_data, card_set):
defaults={
"name": pack_name_from_json,
"full_name": pack_full_name,
"hex_color": "#FFFFFF",
},
)
card_obj.packs.add(pack_obj)
@ -277,12 +290,15 @@ def _update_card_attacks_and_costs(card_obj, card_data):
def _process_single_card_data(
card_data, card_set, stats_accumulator, error_tracking, rarity_mappings_dict
card_data,
card_set,
stats_accumulator,
error_tracking,
rarity_mappings_dict,
):
"""
Processes a single card's data from the JSON.
Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count.
error_tracking is a dict {'file_name': ..., 'card_id': ...} for precise error reporting.
Processes a single card's data from a JSON file, creating or updating
the card and its related objects in the database.
"""
card_id = card_data["id"]
incoming_checksum = calculate_card_checksum(card_data)
@ -412,37 +428,30 @@ def _fetch_card_data_from_local_files():
def perform_card_import_logic() -> ImportResult:
"""
Main importer logic. Iterates through JSON files and processes them.
In DEBUG mode, it reads from local files. Otherwise, fetches from a remote GitHub repo.
Halts and rolls back on any error.
Main logic to perform the card import process.
This can be triggered from an admin view or a management command.
"""
print("Card import process started.")
result = ImportResult()
error_tracking = {"file_name": "N/A", "card_id": "N/A"}
# Fetch all rarity mappings once
rarity_mappings = RarityMapping.objects.all()
rarity_mappings_dict = {
mapping.original_name: mapping for mapping in rarity_mappings
}
print(f"Loaded {len(rarity_mappings_dict)} rarity mappings.")
stats = ImportResult()
try:
# Step 1: Pre-fetch all RarityMapping and ColorMapping objects into dictionaries
rarity_mappings = {m.original_name: m for m in RarityMapping.objects.all()}
color_mappings = {m.cardset_id: m for m in CardSetColorMapping.objects.all()}
# Step 2: Decide whether to fetch from GitHub or local files
if settings.DEBUG:
card_data_iterator = _fetch_card_data_from_local_files()
card_files_source = _fetch_card_data_from_local_files()
source_message = "local files"
else:
# Fetch card data from the GitHub zip archive
card_data_iterator = _fetch_card_data_from_github_zip()
card_files_source = _fetch_card_data_from_github_zip()
source_message = "the GitHub archive"
all_files_data = list(card_data_iterator)
all_files_data = list(card_files_source)
total_files = len(all_files_data)
if not all_files_data:
result.message = f"No JSON files found in {source_message} to import."
print(result.message)
return result
stats.message = f"No JSON files found in {source_message} to import."
print(stats.message)
return stats
print(f"Found {total_files} JSON files to process from {source_message}.")
@ -454,83 +463,82 @@ def perform_card_import_logic() -> ImportResult:
}
for idx, (file_name, data) in enumerate(all_files_data):
error_tracking["file_name"] = file_name
error_tracking["card_id"] = "N/A"
error_tracking = {"file_name": file_name, "card_id": "N/A"}
print(f"Processing file: {file_name} ({idx + 1}/{total_files})")
if not data:
raise ValueError(
f"JSON file {file_name} is empty or contains no data."
)
print(f"Skipping empty file: {file_name}")
continue
result.files_processed_count += 1
stats.files_processed_count += 1
first_card_data = data[0]
set_info_str = first_card_data.get("set")
if not set_info_str:
raise ValueError(
f"Could not determine set information from first card in {file_name}."
)
set_name_from_file = os.path.splitext(file_name)[0]
parsed_set_name, parsed_set_id = parse_set_details(set_name_from_file)
parsed_set_name, parsed_set_id = parse_set_details(set_info_str)
if not parsed_set_id:
raise ValueError(
f"Could not parse set ID from '{set_info_str}' in {file_name}."
f"Could not parse set ID from file name '{file_name}'."
)
card_set_defaults = {"name": parsed_set_name, "file_name": file_name}
card_set, _ = CardSet.objects.language("en").update_or_create(
id=parsed_set_id, defaults=card_set_defaults
)
card_set, card_set_created = CardSet.objects.language(
"en"
).update_or_create(id=parsed_set_id, defaults=card_set_defaults)
# If the card set color mapping exists and is different, update it.
color_mapping = color_mappings.get(card_set.id)
if color_mapping and card_set.hex_color != color_mapping.hex_color:
card_set.hex_color = color_mapping.hex_color
card_set.save()
for card_data_item in data:
print("Processing card: ", card_data_item["id"])
# print("Processing card: ", card_data_item["id"]) # This is very verbose
_process_single_card_data(
card_data_item,
card_set,
stats_accumulator,
error_tracking,
rarity_mappings_dict,
rarity_mappings,
)
print(f"Finished processing file: {file_name}")
result.newly_imported_count = stats_accumulator["newly_imported_count"]
result.updated_count = stats_accumulator["updated_count"]
result.skipped_count = stats_accumulator["skipped_count"]
stats.newly_imported_count = stats_accumulator["newly_imported_count"]
stats.updated_count = stats_accumulator["updated_count"]
stats.skipped_count = stats_accumulator["skipped_count"]
result.message = (
f"Import completed successfully. Processed {result.files_processed_count} files. "
f"Imported {result.newly_imported_count} new cards. "
f"Updated {result.updated_count} existing cards. "
f"Skipped {result.skipped_count} unchanged cards."
stats.message = (
f"Import completed successfully. Processed {stats.files_processed_count} files. "
f"Imported {stats.newly_imported_count} new cards. "
f"Updated {stats.updated_count} existing cards. "
f"Skipped {stats.skipped_count} unchanged cards."
)
print("Committing transaction.")
transaction.on_commit(lambda: print(result.message))
return result
transaction.on_commit(lambda: print(stats.message))
return stats
except requests.exceptions.RequestException as e:
# Handle network-related errors for the download
result.has_error = True
result.message = f"Failed to download card data from GitHub: {e}"
print(result.message)
return result
stats.has_error = True
stats.message = f"Failed to download card data from GitHub: {e}"
print(stats.message)
return stats
except Exception as e:
# Any other exception during the process will cause the transaction to roll back.
error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}"
result.has_error = True
result.message = (
stats.has_error = True
stats.message = (
f"Import HALTED. All changes rolled back. Reason: {error_detail}"
)
print(result.message)
return result
print(stats.message)
return stats
@admin.register(CardSet)
class CardSetAdmin(TranslatableAdmin):
list_display = ("id", "name", "file_name")
list_display = ("id", "name", "file_name", "hex_color")
search_fields = ("translations__name",)
readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at")
@ -540,7 +548,7 @@ class CardSetAdmin(TranslatableAdmin):
@admin.register(Pack)
class PackAdmin(TranslatableAdmin):
list_display = ("id", "full_name", "name", "cardset", "hex_color")
list_display = ("id", "full_name", "name", "cardset")
list_filter = ("cardset",)
search_fields = ("translations__name", "translations__full_name")
readonly_fields = ("id", "created_at", "updated_at")
@ -664,6 +672,19 @@ class RarityMappingAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at", "deleted_at")
@admin.register(CardSetColorMapping)
class CardSetColorMappingAdmin(admin.ModelAdmin):
list_display = (
"cardset_id",
"hex_color",
"created_at",
"updated_at",
"deleted_at",
)
search_fields = ("cardset_id", "hex_color")
readonly_fields = ("created_at", "updated_at", "deleted_at")
def get_admin_urls(urls):
def importer_view(request):
context = {

View file

@ -1,4 +1,4 @@
# Generated by Django 5.1 on 2025-06-15 03:44
# Generated by Django 5.1 on 2025-06-20 07:14
import django.db.models.deletion
import parler.fields
@ -79,6 +79,35 @@ class Migration(migrations.Migration):
},
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name="CardSetColorMapping",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
(
"cardset_id",
models.CharField(
help_text="The cardset ID to match (e.g., 'A1').",
max_length=10,
unique=True,
),
),
(
"hex_color",
models.CharField(
help_text="The hex color code to use for this cardset.",
max_length=9,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
],
options={
"verbose_name": "Cardset Color Mapping",
"verbose_name_plural": "Cardset Color Mappings",
"ordering": ["cardset_id"],
},
),
migrations.CreateModel(
name="CardType",
fields=[
@ -216,7 +245,6 @@ class Migration(migrations.Migration):
name="Pack",
fields=[
("id", models.AutoField(primary_key=True, serialize=False)),
("hex_color", models.CharField(max_length=9)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
@ -271,14 +299,6 @@ class Migration(migrations.Migration):
null=True,
),
),
(
"style",
models.CharField(
blank=True,
help_text="Inline CSS style for the card, used for dynamic styling.",
max_length=255,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
@ -470,6 +490,15 @@ class Migration(migrations.Migration):
max_length=32,
),
),
(
"hex_color",
models.CharField(
blank=True,
help_text="The hex color code associated with this card set.",
max_length=9,
null=True,
),
),
(
"master",
parler.fields.TranslationsForeignKey(

View file

@ -42,7 +42,13 @@ class CardSet(TranslatableModel):
name=models.CharField(
max_length=32,
help_text=_("The full name of the set, e.g., 'Genetic Apex'."),
)
),
hex_color=models.CharField(
max_length=9,
null=True,
blank=True,
help_text=_("The hex color code associated with this card set."),
),
)
id = models.CharField(
max_length=3,
@ -80,7 +86,6 @@ class Pack(TranslatableModel):
),
)
id = models.AutoField(primary_key=True)
hex_color = models.CharField(max_length=9)
cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="packs")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@ -302,12 +307,6 @@ class Card(TranslatableModel):
attacks = models.ManyToManyField(Attack, related_name="cards")
rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, related_name="cards")
style = models.CharField(
max_length=255,
blank=True,
help_text=_("Inline CSS style for the card, used for dynamic styling."),
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(null=True, blank=True)
@ -354,3 +353,31 @@ class RarityMapping(models.Model):
def __str__(self):
return f"'{self.original_name}' -> '{self.mapped_name}' (L{self.level}, {self.icon})"
class CardSetColorMapping(models.Model):
"""
Maps a cardset ID to a hex color code. This is used to pre-map cardset
colors when an original hex color is not available from the import source.
"""
id = models.AutoField(primary_key=True)
cardset_id = models.CharField(
max_length=10,
unique=True,
help_text=_("The cardset ID to match (e.g., 'A1')."),
)
hex_color = models.CharField(
max_length=9, help_text=_("The hex color code to use for this cardset.")
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name = _("Cardset Color Mapping")
verbose_name_plural = _("Cardset Color Mappings")
ordering = ["cardset_id"]
def __str__(self):
return f"'{self.cardset_id}' -> '{self.hex_color}'"

View file

@ -1,12 +1,3 @@
from django.core.cache import cache
from django.db.models.signals import m2m_changed, post_delete, post_save
from django.dispatch import receiver
from pkmntrade_club.trades.models import TradeOfferHaveCard, TradeOfferWantCard
from .models import Card
def color_is_dark(bg_color):
"""
Determine if a given hexadecimal color is dark.
@ -36,70 +27,3 @@ def color_is_dark(bg_color):
brightness = (r * 0.299) + (g * 0.587) + (b * 0.114)
return brightness <= 200
@receiver(m2m_changed, sender=Card.packs.through)
def update_card_style(sender, instance, action, **kwargs):
if action == "post_add":
packs = instance.packs.all()
num_packs = packs.count()
style_parts = []
if num_packs == 0:
style_parts.append(
"background: linear-gradient(to right, #AAAAAA, #AAAAAA, #AAAAAA);"
)
style_parts.append("text-shadow: 0 0 0 #fff;")
else:
if num_packs == 1:
style_parts.append(f"background-color: {packs.first().hex_color};")
else: # num_packs >= 2
hex_colors = [pack.hex_color for pack in packs]
gradient = f"linear-gradient(to right, {', '.join(hex_colors)})"
style_parts.append(f"background: {gradient};")
if not color_is_dark(packs.first().hex_color):
style_parts.append("color: var(--color-gray-700);")
style_parts.append("text-shadow: 0 0 0 var(--color-gray-700);")
else:
style_parts.append("text-shadow: 0 0 0 #fff;")
instance.style = "".join(style_parts)
instance.save(update_fields=["style"])
def invalidate_card_cache(instance):
"""Invalidate cache for a card."""
# Invalidate the card_badge cache
# The key is constructed as "card_badge_<card_id>"
cache.delete(f"card_badge_{instance.pk}")
# Invalidate card_multiselect cache by clearing all of them using a pattern.
# This is necessary as we can't easily reconstruct all possible keys in the signal.
cache.delete_pattern("card_multiselect_*")
# Invalidate trade offers that contain this card in either have or want lists.
have_offers_pks = TradeOfferHaveCard.objects.filter(card=instance).values_list(
"trade_offer_id", flat=True
)
want_offers_pks = TradeOfferWantCard.objects.filter(card=instance).values_list(
"trade_offer_id", flat=True
)
all_offer_pks = set(have_offers_pks) | set(want_offers_pks)
for offer_pk in all_offer_pks:
cache.delete(f"trade_offer_{offer_pk}")
@receiver(post_save, sender=Card)
def on_card_save(sender, instance, **kwargs):
"""Invalidate cache for a card when it's updated."""
invalidate_card_cache(instance)
@receiver(post_delete, sender=Card)
def on_card_delete(sender, instance, **kwargs):
"""Invalidate cache for a card when it's deleted."""
invalidate_card_cache(instance)

View file

@ -7,21 +7,46 @@ from django.utils.safestring import mark_safe
register = template.Library()
def _get_gradient_style(hex_color):
"""
Generates a gradient style from a single hex color.
"""
if not hex_color:
return ""
try:
hex_color = hex_color.lstrip("#")
r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
# Create a slightly darker shade for the gradient
darker_r = max(0, r - 30)
darker_g = max(0, g - 30)
darker_b = max(0, b - 30)
darker_color = f"#{darker_r:02x}{darker_g:02x}{darker_b:02x}"
return f"background-image: linear-gradient(to bottom right, #{hex_color}, {darker_color});"
except (ValueError, IndexError):
return ""
@register.inclusion_tag("templatetags/card_badge.html", takes_context=True)
def card_badge(context, card, quantity=None, expanded=False):
def card_badge(context, card, quantity=None, expanded=False, clickable=True):
"""
Renders a card badge.
"""
url = reverse_lazy("cards:card_detail", args=[card.pk])
url = reverse_lazy("cards:detail", args=[card.pk])
style = _get_gradient_style(card.cardset.hex_color)
tag_context = {
"quantity": quantity,
"style": card.style,
"style": style,
"name": card.name,
"rarity": card.rarity.icon,
"cardset": card.cardset.id,
"expanded": expanded,
"cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}",
"cache_key": f"card_badge_{card.pk}_{quantity}_{expanded}_{card.cardset.hex_color}",
"url": url,
"clickable": clickable,
"closeable": True,
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
}
context.update(tag_context)
@ -33,15 +58,16 @@ def card_badge_inline(card, quantity=None):
"""
Renders an inline card badge by directly rendering the template.
"""
url = reverse_lazy("cards:card_detail", args=[card.pk])
url = reverse_lazy("cards:detail", args=[card.pk])
style = _get_gradient_style(card.cardset.hex_color)
tag_context = {
"quantity": quantity,
"style": card.style,
"style": style,
"name": card.name,
"rarity": card.rarity,
"cardset": card.cardset,
"expanded": True,
"cache_key": f"card_badge_{card.pk}_{quantity}_{True}",
"cache_key": f"card_badge_{card.pk}_{quantity}_{True}_{card.cardset.hex_color}",
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
"url": url,
}

View file

@ -1,10 +1,10 @@
import hashlib
import json
import logging
import uuid
from django import template
from django.db.models.query import QuerySet
from django.conf import settings
from django.db.models import QuerySet
from django.template.loader import render_to_string
from django.urls import reverse
from pkmntrade_club.cards.models import Card
@ -28,57 +28,60 @@ def card_multiselect(
context, field_name, label, placeholder, cards=None, selected_values=None
):
"""
Prepares context for rendering a card multiselect input.
Database querying and rendering are handled within the template's cache block.
Renders a card multiselect widget with client-side searching.
For the JS-driven component, it prepares initial selected cards.
For the non-JS fallback, it accepts a `cards` queryset from a
server-side search.
"""
if selected_values is None:
selected_values = []
selected_cards = {}
for val in selected_values:
parts = str(val).split(":")
if len(parts) >= 1 and parts[0]:
card_id = parts[0]
quantity = parts[1] if len(parts) > 1 else 1
selected_cards[str(card_id)] = quantity
effective_field_name = field_name if field_name is not None else "card_multiselect"
effective_label = label if label is not None else "Card"
effective_placeholder = placeholder if placeholder is not None else "Select Cards"
selected_cards_key_part = json.dumps(selected_cards, sort_keys=True)
has_passed_cards = isinstance(cards, QuerySet)
if has_passed_cards:
try:
query_string = str(cards.query)
passed_cards_identifier = hashlib.sha256(
query_string.encode("utf-8")
).hexdigest()
except Exception as e:
logging.warning(
f"Could not generate query hash for card_multiselect. Error: {e}"
# Fetch full objects for any pre-selected cards for initial display.
initial_selected_cards = []
if selected_values:
card_ids = [str(val) for val in selected_values]
initial_selected_cards = list(
Card.objects.with_details().filter(id__in=card_ids)
)
passed_cards_identifier = "specific_qs_fallback_" + str(uuid.uuid4())
else:
passed_cards_identifier = "all_cards"
# Define the variables specific to this tag
tag_specific_context = {
"field_name": effective_field_name,
"field_id": effective_field_name,
"label": effective_label,
"placeholder": effective_placeholder,
"passed_cards": cards if has_passed_cards else None,
"has_passed_cards": has_passed_cards,
"selected_cards": selected_cards,
"selected_cards_key_part": selected_cards_key_part,
"passed_cards_identifier": passed_cards_identifier,
# `cards` is for the non-JS fallback search result.
non_js_search_results = cards if isinstance(cards, QuerySet) else []
initial_cards_data = []
for card in initial_selected_cards:
badge_context = {
"card": card,
"quantity": None,
"expanded": False,
"url": reverse("cards:detail", args=[card.pk]),
"style": card.style,
"name": card.name,
"rarity": card.rarity.icon if card.rarity else "",
"cardset": card.cardset.id,
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
}
initial_cards_data.append(
{
"id": str(card.id),
"name": card.name,
"set_name": card.cardset.name,
"html": render_to_string("templatetags/card_badge.html", badge_context),
}
)
# Update the original context with the tag-specific variables
# This preserves CACHE_TIMEOUT and other parent context variables
context.update(tag_specific_context)
initial_selected_cards_json = json.dumps(initial_cards_data)
return context # Return the MODIFIED original context
context.update(
{
"field_name": field_name,
"field_id": f"id_{field_name}",
"label": label,
"placeholder": placeholder,
"initial_selected_cards": initial_selected_cards,
"initial_selected_cards_json": initial_selected_cards_json,
"non_js_search_results": non_js_search_results,
"has_non_js_results": bool(non_js_search_results),
}
)
return context

View file

@ -1,16 +1,19 @@
from django.urls import path
from .views import (
CardDetailView,
CardListView,
TradeOfferHaveCardListView,
TradeOfferWantCardListView,
CardListView,
card_search,
)
app_name = "cards"
urlpatterns = [
path("", CardListView.as_view(), name="card_list"),
path("<int:pk>/", CardDetailView.as_view(), name="card_detail"),
path("", view=CardListView.as_view(), name="list"),
path("<str:pk>/", view=CardDetailView.as_view(), name="detail"),
path("api/search/", card_search, name="api_search"),
path(
"<int:pk>/trade-offers-have/",
TradeOfferHaveCardListView.as_view(),

View file

@ -1,4 +1,9 @@
from django.conf import settings
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.views import View
from django.views.generic import (
DetailView,
@ -169,3 +174,49 @@ class CardListView(ReusablePaginationMixin, ListView):
context["page_obj"] = pagination_context
context["object_list"] = queryset
return context
def card_search(request):
"""
Searches for cards and returns a JSON list of objects, where each object
contains card data and its pre-rendered HTML badge.
"""
query = request.GET.get("q", "").strip()
results = []
if query and len(query) >= 2:
selected_ids = request.GET.getlist("selected_ids[]")
cards = (
Card.objects.with_details()
.filter(
Q(translations__name__icontains=query)
| Q(cardset__translations__name__icontains=query)
)
.exclude(id__in=selected_ids)
.order_by("translations__name", "cardset__translations__name")[:20]
)
for card in cards:
# Prepare the full context required by the card_badge.html template
badge_context = {
"card": card,
"quantity": None,
"expanded": True,
"clickable": False,
"url": reverse("cards:detail", args=[card.pk]),
"name": card.name,
"rarity": card.rarity.icon if card.rarity else "",
"cardset": card.cardset.id,
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
}
results.append(
{
"id": str(card.id),
"name": card.name,
"set_name": card.cardset.name,
"html": render_to_string(
"templatetags/card_badge.html", badge_context
),
}
)
return JsonResponse(results, safe=False)

View file

@ -1,7 +1,7 @@
{% load static tailwind_tags gravatar %}
{% url 'home' as home_url %}
{% url 'trade_offer_list' as trade_offer_list_url %}
{% url 'cards:card_list' as cards_list_url %}
{% url 'cards:list' as cards_list_url %}
{% url 'dashboard' as dashboard_url %}
<!DOCTYPE html>
<html lang="en">
@ -65,7 +65,7 @@
<div class="navbar-center hidden sm:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="{% url 'home' %}">Home</a></li>
<li><a href="{% url 'cards:card_list' %}">Cards</a></li>
<li><a href=" {% url 'cards:list' %} ">Cards</a></li>
<li><a href="{% url 'trade_offer_list' %}">Trades</a></li>
</ul>
</div>

View file

@ -3,7 +3,6 @@
<div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
{% for card in cards %}
{% card_badge card quantity=card.offer_count expanded=True %}
<div class="text-sm font-semibold">{{ card.rarity.icon }}</div>
{% endfor %}
</div>
{% else %}

View file

@ -1,32 +0,0 @@
{% load card_badge card_multiselect %}
<option value="" disabled>{{ placeholder }}</option>
{% for card in cards_to_render %}
{% with card_id_str=card.pk|stringformat:"s" %} {# Ensure card PK is string for lookup #}
{% if card_id_str in selected_cards %}
<option
value="{{ card.pk }}:{{ selected_cards|get_item:card_id_str }}"
data-card-id="{{ card.pk }}"
data-quantity="{{ selected_cards|get_item:card_id_str }}"
data-rarity="{{ card.rarity.level }}"
data-cardset="{{ card.cardset.id }}"
data-packs="{% for pack in card.packs.all %}{{ pack.id }},{% endfor %}"
selected
data-html-content='<div class="m-2">{{ card|card_badge_inline:selected_cards|get_item:card_id_str }}</div>'
data-name="{{ card.name }}">
{{ card.name }} ({{ card.id }})
</option>
{% else %}
<option
value="{{ card.pk }}:1"
data-card-id="{{ card.pk }}"
data-quantity="1"
data-rarity="{{ card.rarity.level }}"
data-cardset="{{ card.cardset.id }}"
data-packs="{% for pack in card.packs.all %}{{ pack.id }},{% endfor %}"
data-html-content='<div class="m-2">{{ card|card_badge_inline:"" }}</div>'
data-name="{{ card.name }}">
{{ card.name }} ({{ card.id }})
</option>
{% endif %}
{% endwith %}
{% endfor %}

View file

@ -1,6 +1,10 @@
{% load cache %}
{% cache CACHE_LONG_TIMEOUT "card_badge" card.pk %}
{% cache CACHE_LONG_TIMEOUT cache_key %}
<!-- clickable: {{ clickable }} -->
<!-- closeable: {{ closeable }} -->
{% if clickable %}
<a href="{{ url }}" @click.stop>
{% endif %}
<div class="relative block">
{% if not expanded %}
<div class="flex flex-row items-center h-[32px] p-1.5 w-40 text-white shadow-lg" style="{{ style }}">
@ -10,6 +14,10 @@
<div class="grow-0 shrink-0 relative w-fit ps-1">
<div class="card-quantity-badge relative bg-gray-600 text-white text-sm font-semibold rounded-full text-center size-max px-1.5">{{ quantity }}</div>
</div>
{% elif closeable == True %}
<div class="grow-0 shrink-0 relative w-fit ps-1">
&times;
</div>
{% endif %}
</div>
{% else %}
@ -19,11 +27,17 @@
<div class="row-start-1 col-start-4 col-span-1 self-start ms-auto leading-tight relative w-fit ps-1">
<div class="card-quantity-badge relative bg-gray-600 text-white text-sm font-semibold rounded-full text-center size-max px-1.5">{{ quantity }}</div>
</div>
{% elif closeable == True %}
<div class="row-start-1 col-start-4 col-span-1 self-start ms-auto leading-tight relative w-fit ps-1">
&times;
</div>
{% endif %}
<div class="row-start-2 col-start-1 col-span-3 truncate self-end text-xs text-transparent">{{ rarity }}</div>
<div class="row-start-2 col-start-4 col-span-1 self-end text-right truncate font-semibold leading-tight text-sm">{{ cardset }}</div>
</div>
{% endif %}
</div>
{% if clickable %}
</a>
{% endif %}
{% endcache %}

View file

@ -1,16 +1,267 @@
{% load cache card_badge %}
{% load cache card_multiselect %}
{% load i18n card_badge %}
<label for="{{ field_id }}" class="label">
<div
x-data="cardMultiSelect({{ initial_selected_cards_json }})"
x-init="init()"
@select-card.window="selectCard($event.detail)"
class="card-multiselect-component"
>
<label :for="fieldId + '_search'" class="label">
<span class="label-text">{{ label }}</span>
</label>
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak>
{% cache CACHE_LONG_TIMEOUT "card_multiselect" field_name label placeholder passed_cards_identifier selected_cards_key_part %}
{% if has_passed_cards %}
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
{% else %}
{% fetch_all_cards as all_db_cards %}
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=all_db_cards selected_cards=selected_cards placeholder=placeholder %}
</label>
{# Hidden select that holds the actual form values #}
<select
multiple
:name="fieldName"
:id="fieldId"
class="hidden"
x-ref="selectElement"
>
<template x-for="card in selectedCards" :key="card.id">
<option :value="card.id" selected></option>
</template>
</select>
{# JS-powered search input and selected card display #}
<div x-cloak class="js-enabled-section">
<div class="relative">
<input
type="text"
x-ref="searchInput"
:id="fieldId + '_search'"
class="input input-bordered w-full"
:placeholder="placeholder"
x-model="searchQuery"
@input.debounce.300ms="search()"
@keydown.down.prevent="highlightNext()"
@keydown.up.prevent="highlightPrev()"
@keydown.enter.prevent="selectHighlighted()"
@keydown.esc.prevent="closeAndClear()"
@focus="openPopover()"
popovertarget="search-results-popover"
/>
<div id="search-results-popover" popover class="w-full bg-base-100 border border-base-300 rounded-box shadow-lg mt-1" :style="`width: ${popoverWidth}px`">
<ul>
<template x-for="(card, index) in searchResults" :key="card.id">
<li
class="p-2 cursor-pointer hover:bg-base-200"
:class="{ 'bg-base-300': highlightedIndex === index }"
@mouseenter="highlightedIndex = index"
@click="selectCard(card)"
>
<div x-html="card.html"></div>
</li>
</template>
</ul>
<div x-show="isLoading" class="p-4 text-center">{% trans "Searching..." %}</div>
<div x-show="!isLoading && searchQuery.length > 1 && searchResults.length === 0" class="p-4 text-center">
{% trans "No results found." %}
</div>
</div>
</div>
<div x-show="selectedCards.length > 0" class="mt-2 flex flex-wrap gap-2">
<template x-for="card in selectedCards" :key="'selected-' + card.id">
<div class="relative">
<div x-html="card.html"></div>
<button
type="button"
@click.stop="removeCard(card.id)"
class="absolute -top-1 -right-1 btn btn-xs btn-circle btn-error text-white"
title="Remove Card"
>
&times;
</button>
</div>
</template>
</div>
</div>
{# Non-JS fallback search form #}
<noscript>
<div class="non-js-section">
<div class="flex items-end gap-2">
<div class="grow">
<input
type="search"
name="q"
class="input input-bordered w-full"
placeholder="{% trans 'Search for a card name...' %}"
value="{{ request.GET.q|default:'' }}"
/>
</div>
<div>
<button type="submit" class="btn btn-primary">{% trans 'Search' %}</button>
</div>
</div>
{% if has_non_js_results %}
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full mt-2" multiple>
{% for card in non_js_search_results %}
<option value="{{ card.id }}">
{{ card.name }} - {{ card.set.name }}
</option>
{% endfor %}
</select>
<p class="text-sm mt-1">{% trans "Select cards from the list above and continue filling out the form." %}</p>
{% elif request.GET.q %}
<p class="mt-2">{% trans "No cards found for your search." %}</p>
{% endif %}
{% endcache %}
</select>
{% if initial_selected_cards %}
<div class="mt-4">
<h4 class="font-bold">{% trans "Currently Selected" %}</h4>
<div class="mt-2 flex flex-wrap gap-2">
{% for card in initial_selected_cards %}
<div class="badge badge-lg">{{ card.name }}</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</noscript>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('cardMultiSelect', (initialSelectedCards) => ({
searchQuery: '',
searchResults: [],
selectedCards: initialSelectedCards || [],
isLoading: false,
highlightedIndex: -1,
abortController: null,
fieldId: '',
fieldName: '',
placeholder: '',
popoverWidth: 0,
init() {
this.fieldId = this.$el.querySelector('select').id;
this.fieldName = this.$el.querySelector('select').name;
this.placeholder = this.$el.querySelector('input[type=text]').placeholder;
this.$nextTick(() => {
this.repositionPopover();
});
// Also resize on window resize
window.addEventListener('resize', () => {
this.repositionPopover();
});
},
repositionPopover() {
if (!this.$refs.searchInput) return;
const inputRect = this.$refs.searchInput.getBoundingClientRect();
this.popoverWidth = inputRect.width;
const popover = document.getElementById('search-results-popover');
if (popover) {
popover.style.left = `${inputRect.left}px`;
popover.style.top = `${inputRect.bottom + window.scrollY}px`;
}
},
openPopover() {
const popover = document.getElementById('search-results-popover');
if (popover && typeof popover.showPopover === 'function') {
popover.showPopover();
this.repositionPopover(); // Reposition after showing
}
},
closePopover() {
const popover = document.getElementById('search-results-popover');
if (popover && typeof popover.hidePopover === 'function') {
popover.hidePopover();
}
},
closeAndClear() {
this.closePopover();
this.searchQuery = '';
this.searchResults = [];
this.highlightedIndex = -1;
},
search() {
if (this.searchQuery.length < 2) {
this.searchResults = [];
this.isLoading = false;
if(this.abortController) this.abortController.abort();
return;
}
this.isLoading = true;
this.highlightedIndex = -1;
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
const selectedIds = new URLSearchParams();
this.selectedCards.forEach(card => selectedIds.append('selected_ids[]', card.id));
fetch(`/cards/api/search/?q=${encodeURIComponent(this.searchQuery)}&${selectedIds.toString()}`, {
signal: this.abortController.signal
})
.then(response => response.json())
.then(data => {
this.searchResults = data;
this.isLoading = false;
if (this.searchResults.length > 0) {
this.openPopover();
}
})
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Search error:', error);
this.isLoading = false;
}
});
},
selectCard(card) {
if (!this.selectedCards.some(c => c.id === card.id)) {
this.selectedCards.push(card);
}
this.closeAndClear();
},
removeCard(cardId) {
this.selectedCards = this.selectedCards.filter(c => c.id !== cardId);
},
highlightNext() {
if (this.highlightedIndex < this.searchResults.length - 1) {
this.highlightedIndex++;
}
},
highlightPrev() {
if (this.highlightedIndex > 0) {
this.highlightedIndex--;
}
},
selectHighlighted() {
if (this.highlightedIndex > -1 && this.searchResults[this.highlightedIndex]) {
this.selectCard(this.searchResults[this.highlightedIndex]);
}
}
}));
});
</script>
<style>
[x-cloak] { display: none !important; }
/* Basic popover styles */
#search-results-popover {
width: var(--popover-width, 100%); /* Default width */
margin-top: 0.5rem;
}
</style>

View file

@ -1,260 +0,0 @@
{% load gravatar card_badge cache %}
{% cache 60 trade_offer offer_pk %}
<div class="trade-offer-card m-2 h-full w-auto flex justify-center">
<div x-data="tradeOfferCard()" x-init="defaultExpanded = {{expanded|lower}}; badgeExpanded = defaultExpanded; acceptanceExpanded = defaultExpanded; flipped = {{flipped|lower}}" class="transition-all duration-500 trade-offer-card my-auto"
@toggle-all.window="setBadge($event.detail.expanded)">
<!-- Flip container providing perspective -->
<div class="flip-container" style="perspective: 1000px;">
<!--
The rotating element (.flip-inner) now uses CSS Grid to stack its children in a single cell.
Persistent border, shadow, and rounding are applied here and the card rotates entirely.
-->
<div class="flip-inner freeze-bg-color grid grid-cols-1 grid-rows-1 card bg-base-100 card-border shadow-lg w-84 transform transition-transform duration-700 ease-in-out"
:class="{'rotate-y-180': flipped}">
<!-- Front Face: Trade Offer -->
<!-- Using grid placement classes (col-start-1 row-start-1) ensures both faces overlap -->
<div class="flip-face front col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between">
<!-- Header -->
<div class="flip-face-header self-start">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<!-- Set this container as relative to position the avatar absolutely -->
<div class="relative mt-6 mb-4 mx-2 sm:mx-4">
<!-- Two-column grid for the labels -->
<div class="grid grid-cols-2 items-center">
<span class="text-sm font-semibold text-center">Has</span>
<span class="text-sm font-semibold text-center">Wants</span>
</div>
<!-- The avatar is placed absolutely and centered -->
<div class="absolute inset-x-0 top-1/2 transform -translate-y-1/2 flex justify-center">
<div class="avatar">
<div class="w-10 rounded-full">
{{ initiated_by_email|gravatar:40 }}
</div>
</div>
</div>
</div>
</a>
</div>
<!-- Main Trade Offer Row -->
<div class="flip-face-body self-start">
{% if not flipped %}
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<div class="px-2 main-badges pb-0">
<!-- Normal mode: just use an outer grid with 2 columns -->
<div class="flex flex-row gap-2 justify-around">
<!-- Has Side -->
<div class="flex flex-col gap-2">
{% for card in have_cards_available|slice:"0:1" %}
{% card_badge card.card card.quantity %}
{% endfor %}
</div>
<!-- Wants Side -->
<div class="flex flex-col gap-2">
{% for card in want_cards_available|slice:"0:1" %}
{% card_badge card.card card.quantity %}
{% endfor %}
</div>
</div>
</div>
</a>
{% else %}
<div class="flex justify-center mt-8">
<div class="text-sm">
All cards have been accepted.
</div>
</div>
{% endif %}
<!-- Extra Card Badges (Collapsible) -->
<div x-show="badgeExpanded" x-collapse.duration.500ms x-cloak class="px-2 extra-badges">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline block">
<div class="flex flex-row gap-2 justify-around">
<!-- Has Side Extra Badges -->
<div class="flex flex-col gap-2">
{% for card in have_cards_available|slice:"1:" %}
{% card_badge card.card card.quantity %}
{% endfor %}
</div>
<!-- Wants Side Extra Badges -->
<div class="flex flex-col gap-2">
{% for card in want_cards_available|slice:"1:" %}
{% card_badge card.card card.quantity %}
{% endfor %}
</div>
</div>
</a>
</div>
</div>
{% if have_cards_available|length > 1 or want_cards_available|length > 1 %}
<div @click="badgeExpanded = !badgeExpanded" class="flex justify-center h-5 cursor-pointer">
<svg x-bind:class="{ 'rotate-180': badgeExpanded }"
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</div>
{% else %}
<div class="h-5"></div>
{% endif %}
<div class="flip-face-footer self-end">
<div class="flex justify-between px-2 pb-2">
<div class="text-gray-500 text-sm tooltip tooltip-right" data-tip="ID: {{ offer_hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
</div>
<!-- Front-to-back flip button -->
<div class="cursor-pointer text-gray-500" @click="flipped = true; acceptanceExpanded = defaultExpanded">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061A1.125 1.125 0 0 1 3 16.811V8.69ZM12.75 8.689c0-.864.933-1.406 1.683-.977l7.108 4.061a1.125 1.125 0 0 1 0 1.954l-7.108 4.061a1.125 1.125 0 0 1-1.683-.977V8.69Z" />
</svg>
</div>
</div>
</div>
</div>
<!-- Back Face: Acceptances View -->
<!-- Placed in the same grid cell as the front face -->
<div class="flip-face back col-start-1 row-start-1 grid grid-cols-1 auto-rows-min gap-2 content-between" style="transform: rotateY(180deg);">
<div class="self-start">
<a href="{% url 'trade_offer_detail' pk=offer_pk %}" class="no-underline">
<div class="py-4 mx-2 sm:mx-4">
<div class="grid grid-cols-3 items-center">
<div class="flex justify-center items-center">
<span class="text-sm font-semibold">Has</span>
</div>
<div class="flex justify-center items-center">
<div class="avatar">
<div class="w-10 rounded-full">
{{ initiated_by_email|gravatar:40 }}
</div>
</div>
</div>
<div class="flex justify-center items-center">
<span class="text-sm font-semibold">Wants</span>
</div>
</div>
</div>
</a>
</div>
<div class="self-start">
<div class="px-2 pb-0">
<div class="overflow-hidden">
{% if acceptances.0 %}
<div class="space-y-3">
{% with acceptance=acceptances.0 %}
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
data-tooltip-html='<div class="flex items-center space-x-2">
<div class="avatar">
<div class="w-10 rounded-full">
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
</div>
</div>
<div class="flex flex-col">
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
<span class="text-sm">State: {{ acceptance.state }}</span>
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
</div>
</div>'>
<div class="grid grid-cols-2 gap-4 items-center">
<div>
{% card_badge acceptance.requested_card %}
</div>
<div>
{% card_badge acceptance.offered_card %}
</div>
</div>
</a>
{% endwith %}
</div>
{% endif %}
</div>
<div x-show="acceptanceExpanded" x-collapse.duration.500ms class="space-y-3">
{% for acceptance in acceptances|slice:"1:" %}
<a href="{% url 'trade_acceptance_update' pk=acceptance.pk %}" class="no-underline"
data-tooltip-html='<div class="flex items-center space-x-2">
<div class="avatar">
<div class="w-10 rounded-full">
{{ acceptance.accepted_by.user.email|gravatar:"40" }}
</div>
</div>
<div class="flex flex-col">
<span class="text-sm">Accepted by: {{ acceptance.accepted_by.user.username }}</span>
<span class="text-sm">State: {{ acceptance.state }}</span>
<span class="text-sm">Acceptance ID: {{ acceptance.hash }}</span>
</div>
</div>'>
<div class="grid grid-cols-2 gap-4 items-center">
<div>
{% card_badge acceptance.requested_card %}
</div>
<div>
{% card_badge acceptance.offered_card %}
</div>
</div>
</a>
{% endfor %}
</div>
</div>
<div class="flex justify-center h-5">
{% if acceptances|length > 1 %}
<svg @click="acceptanceExpanded = !acceptanceExpanded"
x-bind:class="{ 'rotate-180': acceptanceExpanded }"
class="transition-transform duration-500 h-5 w-5 cursor-pointer"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
{% endif %}
</div>
</div>
<div class="flex justify-between px-2 pb-2 self-end">
<!-- Back-to-front flip button -->
<div class="text-gray-500 cursor-pointer" @click="flipped = false; badgeExpanded = defaultExpanded">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061A1.125 1.125 0 0 1 21 8.689v8.122ZM11.25 16.811c0 .864-.933 1.406-1.683.977l-7.108-4.061a1.125 1.125 0 0 1 0-1.954l7.108-4.061a1.125 1.125 0 0 1 1.683.977v8.122Z" />
</svg>
</div>
<div class="px-1 text-center">
<span class="text-sm font-semibold">
Acceptances ({{ acceptances|length }})
</span>
</div>
<div class="text-gray-500 text-sm tooltip tooltip-left" data-tip="ID: {{ offer_hash }}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
/* Ensure proper 3D transformations on the rotating element */
.flip-inner {
transform-style: preserve-3d;
}
/* Hide the back face of each card side */
.flip-face {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* The front face is unrotated by default */
.flip-face.front {
transform: rotateY(0);
}
/* The .rotate-y-180 class rotates the entire element by 180deg */
.rotate-y-180 {
transform: rotateY(180deg);
}
</style>
{% endcache %}