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,
|
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 = {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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}'"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
passed_cards_identifier = "specific_qs_fallback_" + str(uuid.uuid4())
|
|
||||||
else:
|
|
||||||
passed_cards_identifier = "all_cards"
|
|
||||||
|
|
||||||
# Define the variables specific to this tag
|
# `cards` is for the non-JS fallback search result.
|
||||||
tag_specific_context = {
|
non_js_search_results = cards if isinstance(cards, QuerySet) else []
|
||||||
"field_name": effective_field_name,
|
|
||||||
"field_id": effective_field_name,
|
initial_cards_data = []
|
||||||
"label": effective_label,
|
for card in initial_selected_cards:
|
||||||
"placeholder": effective_placeholder,
|
badge_context = {
|
||||||
"passed_cards": cards if has_passed_cards else None,
|
"card": card,
|
||||||
"has_passed_cards": has_passed_cards,
|
"quantity": None,
|
||||||
"selected_cards": selected_cards,
|
"expanded": False,
|
||||||
"selected_cards_key_part": selected_cards_key_part,
|
"url": reverse("cards:detail", args=[card.pk]),
|
||||||
"passed_cards_identifier": passed_cards_identifier,
|
"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
|
initial_selected_cards_json = json.dumps(initial_cards_data)
|
||||||
# This preserves CACHE_TIMEOUT and other parent context variables
|
|
||||||
context.update(tag_specific_context)
|
|
||||||
|
|
||||||
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 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(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
{% 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>
|
<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">
|
||||||
|
×
|
||||||
|
</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">
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
{% if clickable %}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% endcache %}
|
{% endcache %}
|
||||||
|
|
@ -1,16 +1,267 @@
|
||||||
{% load cache card_badge %}
|
{% load i18n card_badge %}
|
||||||
{% load cache card_multiselect %}
|
|
||||||
|
|
||||||
<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>
|
<span class="label-text">{{ label }}</span>
|
||||||
</label>
|
</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 %}
|
{# Hidden select that holds the actual form values #}
|
||||||
{% if has_passed_cards %}
|
<select
|
||||||
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=passed_cards selected_cards=selected_cards placeholder=placeholder %}
|
multiple
|
||||||
{% else %}
|
:name="fieldName"
|
||||||
{% fetch_all_cards as all_db_cards %}
|
:id="fieldId"
|
||||||
{% include "templatetags/_card_multiselect_options.html" with cards_to_render=all_db_cards selected_cards=selected_cards placeholder=placeholder %}
|
class="hidden"
|
||||||
{% endif %}
|
x-ref="selectElement"
|
||||||
{% endcache %}
|
>
|
||||||
|
<template x-for="card in selectedCards" :key="card.id">
|
||||||
|
<option :value="card.id" selected></option>
|
||||||
|
</template>
|
||||||
</select>
|
</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 %}
|
||||||
|
|
||||||
|
{% 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