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, AttackCost,
Card, Card,
CardSet, CardSet,
CardSetColorMapping,
CardType, CardType,
Energy, Energy,
Pack, Pack,
@ -50,15 +51,28 @@ class PrefetchedSortedRelatedFieldListFilter(RelatedFieldListFilter):
def parse_set_details(set_string): 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) match = re.match(r"^(.*?)\s*\(([A-Za-z0-9]+)\)$", set_string)
if match: if match:
name = match.group(1).strip() name = match.group(1).strip()
set_id = match.group(2) set_id = match.group(2)
set_id = set_id[0].upper() + set_id[1:]
return name, set_id return name, set_id
match = re.match(r"^Promo-(.*?)$", set_string) match = re.match(r"^Promo-(.*?)$", set_string)
if match: if match:
name = set_string name = set_string
set_id = "P-" + match.group(1) set_id = "P" + match.group(1)
return name, set_id return name, set_id
return set_string, None return set_string, None
@ -207,7 +221,6 @@ def _update_card_packs(card_obj, card_data, card_set):
defaults={ defaults={
"name": pack_name_from_json, "name": pack_name_from_json,
"full_name": pack_full_name, "full_name": pack_full_name,
"hex_color": "#FFFFFF",
}, },
) )
card_obj.packs.add(pack_obj) 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( 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. Processes a single card's data from a JSON file, creating or updating
Updates stats_accumulator with newly_imported_count, updated_count, or skipped_count. the card and its related objects in the database.
error_tracking is a dict {'file_name': ..., 'card_id': ...} for precise error reporting.
""" """
card_id = card_data["id"] card_id = card_data["id"]
incoming_checksum = calculate_card_checksum(card_data) incoming_checksum = calculate_card_checksum(card_data)
@ -412,37 +428,30 @@ def _fetch_card_data_from_local_files():
def perform_card_import_logic() -> ImportResult: def perform_card_import_logic() -> ImportResult:
""" """
Main importer logic. Iterates through JSON files and processes them. Main logic to perform the card import process.
In DEBUG mode, it reads from local files. Otherwise, fetches from a remote GitHub repo. This can be triggered from an admin view or a management command.
Halts and rolls back on any error.
""" """
print("Card import process started.") stats = ImportResult()
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.")
try: 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: 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" source_message = "local files"
else: else:
# Fetch card data from the GitHub zip archive card_files_source = _fetch_card_data_from_github_zip()
card_data_iterator = _fetch_card_data_from_github_zip()
source_message = "the GitHub archive" 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) total_files = len(all_files_data)
if not all_files_data: if not all_files_data:
result.message = f"No JSON files found in {source_message} to import." stats.message = f"No JSON files found in {source_message} to import."
print(result.message) print(stats.message)
return result return stats
print(f"Found {total_files} JSON files to process from {source_message}.") 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): for idx, (file_name, data) in enumerate(all_files_data):
error_tracking["file_name"] = file_name error_tracking = {"file_name": file_name, "card_id": "N/A"}
error_tracking["card_id"] = "N/A"
print(f"Processing file: {file_name} ({idx + 1}/{total_files})") print(f"Processing file: {file_name} ({idx + 1}/{total_files})")
if not data: if not data:
raise ValueError( print(f"Skipping empty file: {file_name}")
f"JSON file {file_name} is empty or contains no data." continue
)
result.files_processed_count += 1 stats.files_processed_count += 1
first_card_data = data[0] set_name_from_file = os.path.splitext(file_name)[0]
set_info_str = first_card_data.get("set") parsed_set_name, parsed_set_id = parse_set_details(set_name_from_file)
if not set_info_str:
raise ValueError(
f"Could not determine set information from first card in {file_name}."
)
parsed_set_name, parsed_set_id = parse_set_details(set_info_str)
if not parsed_set_id: if not parsed_set_id:
raise ValueError( 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_defaults = {"name": parsed_set_name, "file_name": file_name}
card_set, _ = CardSet.objects.language("en").update_or_create( card_set, card_set_created = CardSet.objects.language(
id=parsed_set_id, defaults=card_set_defaults "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: 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( _process_single_card_data(
card_data_item, card_data_item,
card_set, card_set,
stats_accumulator, stats_accumulator,
error_tracking, error_tracking,
rarity_mappings_dict, rarity_mappings,
) )
print(f"Finished processing file: {file_name}") print(f"Finished processing file: {file_name}")
result.newly_imported_count = stats_accumulator["newly_imported_count"] stats.newly_imported_count = stats_accumulator["newly_imported_count"]
result.updated_count = stats_accumulator["updated_count"] stats.updated_count = stats_accumulator["updated_count"]
result.skipped_count = stats_accumulator["skipped_count"] stats.skipped_count = stats_accumulator["skipped_count"]
result.message = ( stats.message = (
f"Import completed successfully. Processed {result.files_processed_count} files. " f"Import completed successfully. Processed {stats.files_processed_count} files. "
f"Imported {result.newly_imported_count} new cards. " f"Imported {stats.newly_imported_count} new cards. "
f"Updated {result.updated_count} existing cards. " f"Updated {stats.updated_count} existing cards. "
f"Skipped {result.skipped_count} unchanged cards." f"Skipped {stats.skipped_count} unchanged cards."
) )
print("Committing transaction.") print("Committing transaction.")
transaction.on_commit(lambda: print(result.message)) transaction.on_commit(lambda: print(stats.message))
return result return stats
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
# Handle network-related errors for the download # Handle network-related errors for the download
result.has_error = True stats.has_error = True
result.message = f"Failed to download card data from GitHub: {e}" stats.message = f"Failed to download card data from GitHub: {e}"
print(result.message) print(stats.message)
return result return stats
except Exception as e: except Exception as e:
# Any other exception during the process will cause the transaction to roll back. # 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)}" error_detail = f"Error during import (file: {error_tracking['file_name']}, card: {error_tracking['card_id']}): {str(e)}"
result.has_error = True stats.has_error = True
result.message = ( stats.message = (
f"Import HALTED. All changes rolled back. Reason: {error_detail}" f"Import HALTED. All changes rolled back. Reason: {error_detail}"
) )
print(result.message) print(stats.message)
return result return stats
@admin.register(CardSet) @admin.register(CardSet)
class CardSetAdmin(TranslatableAdmin): class CardSetAdmin(TranslatableAdmin):
list_display = ("id", "name", "file_name") list_display = ("id", "name", "file_name", "hex_color")
search_fields = ("translations__name",) search_fields = ("translations__name",)
readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at") readonly_fields = ("id", "file_name", "created_at", "updated_at", "deleted_at")
@ -540,7 +548,7 @@ class CardSetAdmin(TranslatableAdmin):
@admin.register(Pack) @admin.register(Pack)
class PackAdmin(TranslatableAdmin): class PackAdmin(TranslatableAdmin):
list_display = ("id", "full_name", "name", "cardset", "hex_color") list_display = ("id", "full_name", "name", "cardset")
list_filter = ("cardset",) list_filter = ("cardset",)
search_fields = ("translations__name", "translations__full_name") search_fields = ("translations__name", "translations__full_name")
readonly_fields = ("id", "created_at", "updated_at") readonly_fields = ("id", "created_at", "updated_at")
@ -664,6 +672,19 @@ class RarityMappingAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at", "deleted_at") 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 get_admin_urls(urls):
def importer_view(request): def importer_view(request):
context = { 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 django.db.models.deletion
import parler.fields import parler.fields
@ -79,6 +79,35 @@ class Migration(migrations.Migration):
}, },
bases=(parler.models.TranslatableModelMixin, models.Model), 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( migrations.CreateModel(
name="CardType", name="CardType",
fields=[ fields=[
@ -216,7 +245,6 @@ class Migration(migrations.Migration):
name="Pack", name="Pack",
fields=[ fields=[
("id", models.AutoField(primary_key=True, serialize=False)), ("id", models.AutoField(primary_key=True, serialize=False)),
("hex_color", models.CharField(max_length=9)),
("created_at", models.DateTimeField(auto_now_add=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)), ("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)), ("deleted_at", models.DateTimeField(blank=True, null=True)),
@ -271,14 +299,6 @@ class Migration(migrations.Migration):
null=True, 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)), ("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)), ("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)), ("deleted_at", models.DateTimeField(blank=True, null=True)),
@ -470,6 +490,15 @@ class Migration(migrations.Migration):
max_length=32, 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", "master",
parler.fields.TranslationsForeignKey( parler.fields.TranslationsForeignKey(

View file

@ -42,7 +42,13 @@ class CardSet(TranslatableModel):
name=models.CharField( name=models.CharField(
max_length=32, max_length=32,
help_text=_("The full name of the set, e.g., 'Genetic Apex'."), 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( id = models.CharField(
max_length=3, max_length=3,
@ -80,7 +86,6 @@ class Pack(TranslatableModel):
), ),
) )
id = models.AutoField(primary_key=True) id = models.AutoField(primary_key=True)
hex_color = models.CharField(max_length=9)
cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="packs") cardset = models.ForeignKey(CardSet, on_delete=models.CASCADE, related_name="packs")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -302,12 +307,6 @@ class Card(TranslatableModel):
attacks = models.ManyToManyField(Attack, related_name="cards") attacks = models.ManyToManyField(Attack, related_name="cards")
rarity = models.ForeignKey(Rarity, on_delete=models.CASCADE, 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
deleted_at = models.DateTimeField(null=True, blank=True) deleted_at = models.DateTimeField(null=True, blank=True)
@ -354,3 +353,31 @@ class RarityMapping(models.Model):
def __str__(self): def __str__(self):
return f"'{self.original_name}' -> '{self.mapped_name}' (L{self.level}, {self.icon})" 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): def color_is_dark(bg_color):
""" """
Determine if a given hexadecimal color is dark. 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) brightness = (r * 0.299) + (g * 0.587) + (b * 0.114)
return brightness <= 200 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() 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) @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. 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 = { tag_context = {
"quantity": quantity, "quantity": quantity,
"style": card.style, "style": style,
"name": card.name, "name": card.name,
"rarity": card.rarity.icon, "rarity": card.rarity.icon,
"cardset": card.cardset.id, "cardset": card.cardset.id,
"expanded": expanded, "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, "url": url,
"clickable": clickable,
"closeable": True,
"CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT, "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
} }
context.update(tag_context) 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. 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 = { tag_context = {
"quantity": quantity, "quantity": quantity,
"style": card.style, "style": style,
"name": card.name, "name": card.name,
"rarity": card.rarity, "rarity": card.rarity,
"cardset": card.cardset, "cardset": card.cardset,
"expanded": True, "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, "CACHE_LONG_TIMEOUT": settings.CACHE_LONG_TIMEOUT,
"url": url, "url": url,
} }

View file

@ -1,10 +1,10 @@
import hashlib
import json import json
import logging
import uuid
from django import template 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 from pkmntrade_club.cards.models import Card
@ -28,57 +28,60 @@ def card_multiselect(
context, field_name, label, placeholder, cards=None, selected_values=None context, field_name, label, placeholder, cards=None, selected_values=None
): ):
""" """
Prepares context for rendering a card multiselect input. Renders a card multiselect widget with client-side searching.
Database querying and rendering are handled within the template's cache block.
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: if selected_values is None:
selected_values = [] selected_values = []
selected_cards = {} # Fetch full objects for any pre-selected cards for initial display.
for val in selected_values: initial_selected_cards = []
parts = str(val).split(":") if selected_values:
if len(parts) >= 1 and parts[0]: card_ids = [str(val) for val in selected_values]
card_id = parts[0] initial_selected_cards = list(
quantity = parts[1] if len(parts) > 1 else 1 Card.objects.with_details().filter(id__in=card_ids)
selected_cards[str(card_id)] = quantity )
effective_field_name = field_name if field_name is not None else "card_multiselect" # `cards` is for the non-JS fallback search result.
effective_label = label if label is not None else "Card" non_js_search_results = cards if isinstance(cards, QuerySet) else []
effective_placeholder = placeholder if placeholder is not None else "Select Cards"
selected_cards_key_part = json.dumps(selected_cards, sort_keys=True) 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),
}
)
has_passed_cards = isinstance(cards, QuerySet) initial_selected_cards_json = json.dumps(initial_cards_data)
if has_passed_cards: context.update(
try: {
query_string = str(cards.query) "field_name": field_name,
passed_cards_identifier = hashlib.sha256( "field_id": f"id_{field_name}",
query_string.encode("utf-8") "label": label,
).hexdigest() "placeholder": placeholder,
except Exception as e: "initial_selected_cards": initial_selected_cards,
logging.warning( "initial_selected_cards_json": initial_selected_cards_json,
f"Could not generate query hash for card_multiselect. Error: {e}" "non_js_search_results": non_js_search_results,
) "has_non_js_results": bool(non_js_search_results),
passed_cards_identifier = "specific_qs_fallback_" + str(uuid.uuid4()) }
else: )
passed_cards_identifier = "all_cards" return context
# 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,
}
# Update the original context with the tag-specific variables
# This preserves CACHE_TIMEOUT and other parent context variables
context.update(tag_specific_context)
return context # Return the MODIFIED original context

View file

@ -1,16 +1,19 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
CardDetailView, CardDetailView,
CardListView,
TradeOfferHaveCardListView, TradeOfferHaveCardListView,
TradeOfferWantCardListView, TradeOfferWantCardListView,
CardListView, card_search,
) )
app_name = "cards" app_name = "cards"
urlpatterns = [ urlpatterns = [
path("", CardListView.as_view(), name="card_list"), path("", view=CardListView.as_view(), name="list"),
path("<int:pk>/", CardDetailView.as_view(), name="card_detail"), path("<str:pk>/", view=CardDetailView.as_view(), name="detail"),
path("api/search/", card_search, name="api_search"),
path( path(
"<int:pk>/trade-offers-have/", "<int:pk>/trade-offers-have/",
TradeOfferHaveCardListView.as_view(), 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.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 import View
from django.views.generic import ( from django.views.generic import (
DetailView, DetailView,
@ -169,3 +174,49 @@ class CardListView(ReusablePaginationMixin, ListView):
context["page_obj"] = pagination_context context["page_obj"] = pagination_context
context["object_list"] = queryset context["object_list"] = queryset
return context 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 %} {% load static tailwind_tags gravatar %}
{% url 'home' as home_url %} {% url 'home' as home_url %}
{% url 'trade_offer_list' as trade_offer_list_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 %} {% url 'dashboard' as dashboard_url %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@ -65,7 +65,7 @@
<div class="navbar-center hidden sm:flex"> <div class="navbar-center hidden sm:flex">
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal px-1">
<li><a href="{% url 'home' %}">Home</a></li> <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> <li><a href="{% url 'trade_offer_list' %}">Trades</a></li>
</ul> </ul>
</div> </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"> <div class="mx-4 grid gap-3 grid-cols-[repeat(auto-fit,minmax(150px,1fr))] justify-items-center">
{% for card in cards %} {% for card in cards %}
{% card_badge card quantity=card.offer_count expanded=True %} {% card_badge card quantity=card.offer_count expanded=True %}
<div class="text-sm font-semibold">{{ card.rarity.icon }}</div>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% 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 %} {% load cache %}
{% cache CACHE_LONG_TIMEOUT "card_badge" card.pk %} {% cache CACHE_LONG_TIMEOUT cache_key %}
<a href="{{ url }}" @click.stop> <!-- clickable: {{ clickable }} -->
<!-- closeable: {{ closeable }} -->
{% if clickable %}
<a href="{{ url }}" @click.stop>
{% endif %}
<div class="relative block"> <div class="relative block">
{% if not expanded %} {% if not expanded %}
<div class="flex flex-row items-center h-[32px] p-1.5 w-40 text-white shadow-lg" style="{{ style }}"> <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="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 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> </div>
{% elif closeable == True %}
<div class="grow-0 shrink-0 relative w-fit ps-1">
&times;
</div>
{% endif %} {% endif %}
</div> </div>
{% else %} {% 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="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 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> </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 %} {% 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-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 class="row-start-2 col-start-4 col-span-1 self-end text-right truncate font-semibold leading-tight text-sm">{{ cardset }}</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</a> {% if clickable %}
</a>
{% endif %}
{% endcache %} {% endcache %}

View file

@ -1,16 +1,267 @@
{% load cache card_badge %} {% load i18n card_badge %}
{% load cache card_multiselect %}
<label for="{{ field_id }}" class="label"> <div
<span class="label-text">{{ label }}</span> x-data="cardMultiSelect({{ initial_selected_cards_json }})"
</label> x-init="init()"
<select name="{{ field_name }}" id="{{ field_id }}" class="select select-bordered w-full card-multiselect" data-placeholder="{{ placeholder }}" multiple x-cloak> @select-card.window="selectCard($event.detail)"
{% cache CACHE_LONG_TIMEOUT "card_multiselect" field_name label placeholder passed_cards_identifier selected_cards_key_part %} class="card-multiselect-component"
{% if has_passed_cards %} >
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %} <label :for="fieldId + '_search'" class="label">
{% else %} <span class="label-text">{{ label }}</span>
{% fetch_all_cards as all_db_cards %} </label>
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=all_db_cards selected_cards=selected_cards placeholder=placeholder %}
{% endif %} {# Hidden select that holds the actual form values #}
{% endcache %} <select
</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 %}
{% 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 %}