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:
parent
af2f48a491
commit
ecb060af6d
15 changed files with 668 additions and 533 deletions
79
seed/0003_CardSetColorMappings.json
Normal file
79
seed/0003_CardSetColorMappings.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}'"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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 %}
|
||||
|
|
@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
|
@ -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 %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue