diff --git a/Dockerfile b/Dockerfile index 6607bb6..03993ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,9 @@ COPY .env.production /code/.env ENV HOME=/code # Install NPM & node.js -RUN apt-get update && apt-get install -y nodejs npm +RUN apt-get update && apt-get install -y nodejs npm xvfb libnss3 libnspr4 libasound2t64 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libcairo-gobject2 libdbus-glib-1-2 libfontconfig1 libfreetype6 libgdk-pixbuf-2.0-0 libgtk-3-0 libharfbuzz0b libpangocairo-1.0-0 libx11-xcb1 libxcb-shm0 libxcursor1 libxi6 libxrender1 libxtst6 libsoup-3.0-0 gstreamer1.0-libav gstreamer1.0-plugins-base gstreamer1.0-plugins-good libegl1 libenchant-2-2 libepoxy0 libevdev2 libgles2 libglx0 libgstreamer-gl1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer1.0-0 libgtk-4-1 libgudev-1.0-0 libharfbuzz-icu0 libhyphen0 libicu72 libjpeg62-turbo liblcms2-2 libmanette-0.2-0 libnotify4 libopengl0 libopenjp2-7 libopus0 libpng16-16 libproxy1v5 libsecret-1-0 libwayland-client0 libwayland-egl1 libwayland-server0 libwebp7 libwebpdemux2 libwoff1 libxml2 libxslt1.1 libatomic1 libevent-2.1-7 libavif16 xvfb fonts-noto-color-emoji fonts-unifont xfonts-scalable fonts-liberation fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf fonts-freefont-ttf + +RUN playwright install-deps && playwright install # Expose port 8000 EXPOSE 8000 @@ -32,5 +34,7 @@ EXPOSE 8000 RUN python manage.py collectstatic --noinput +#RUN python manage.py createcachetable django_cache + # Use gunicorn on port 8000 CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "django_project.wsgi"] diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index dfc7763..0cab225 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2025-03-17 20:39 +# Generated by Django 5.1.2 on 2025-03-20 00:08 import django.contrib.auth.models import django.contrib.auth.validators diff --git a/accounts/templatetags/gravatar.py b/accounts/templatetags/gravatar.py index ea47edc..3aab3ba 100644 --- a/accounts/templatetags/gravatar.py +++ b/accounts/templatetags/gravatar.py @@ -21,7 +21,7 @@ def gravatar_url(email, size=20): Returns the Gravatar URL for a given email. The URL includes parameters for the default image and the size. """ - default = "wavatar" + default = "retro" email_hash = gravatar_hash(email) params = urlencode({'d': default, 's': str(size)}) return f"https://www.gravatar.com/avatar/{email_hash}?{params}" diff --git a/cards/migrations/0001_initial.py b/cards/migrations/0001_initial.py index 552e7ea..6cb7d2a 100644 --- a/cards/migrations/0001_initial.py +++ b/cards/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2025-03-17 20:39 +# Generated by Django 5.1.2 on 2025-03-20 00:08 import django.db.models.deletion from django.db import migrations, models diff --git a/django_project/settings.py b/django_project/settings.py index 4a36451..f0f1182 100644 --- a/django_project/settings.py +++ b/django_project/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ "cards", "home", "trades.apps.TradesConfig", + "meta", #"silk", ] @@ -67,6 +68,11 @@ SILKY_META = True TAILWIND_APP_NAME = 'theme' +META_SITE_NAME = 'PKMN Trade Club' +META_SITE_PROTOCOL = 'https' +META_USE_SITES = True +META_IMAGE_URL = 'https://pkmntrade.club/' + # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -299,6 +305,6 @@ else: CACHES = { "default": { "BACKEND": "django.core.cache.backends.db.DatabaseCache", - "LOCATION": "site-cache", + "LOCATION": "django_cache", } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 401a9af..90a69f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ services: -# web: -# build: . -# command: python /code/manage.py runserver 0.0.0.0:8000 -# volumes: -# - .:/code:z -# ports: -# - 8000:8000 -# depends_on: -# - db + # web: + # build: . + # command: python /code/manage.py runserver 0.0.0.0:8000 + # volumes: + # - .:/code:z + # ports: + # - 8000:8000 + # depends_on: + # - db db: image: postgres:16 ports: diff --git a/requirements.txt b/requirements.txt index 759a4bf..55d4b3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,11 +14,13 @@ django-daisy==1.0.13 django-debug-toolbar==4.4.6 django-el-pagination==4.1.2 django-environ==0.12.0 +django-meta==2.4.2 django-silk==5.3.1 django-tailwind-4[reload]==0.1.4 django-widget-tweaks==1.5.0 gunicorn==23.0.0 idna==3.4 +imgkit==1.2.3 oauthlib==3.2.2 packaging==23.1 psycopg==3.2.3 @@ -26,6 +28,7 @@ psycopg-binary==3.2.3 pycparser==2.21 PyJWT==2.6.0 python3-openid==3.2.0 +playwright==1.51.0 requests==2.28.2 requests-oauthlib==1.3.1 sqlparse==0.4.3 diff --git a/theme/templates/account/profile.html b/theme/templates/account/profile.html index 50ad857..087badc 100644 --- a/theme/templates/account/profile.html +++ b/theme/templates/account/profile.html @@ -10,13 +10,19 @@
{{ user.email|gravatar:100 }}

All profile information is managed through Gravatar.

-

Edit Profile on Gravatar

+

+ + Edit Profile on Gravatar + + + +

What is Gravatar?

Gravatar (Globally Recognized Avatar) is a free service that links your email address to a profile picture and, optionally, a profile. Many websites, including this one, use Gravatar to display your avatar and profile automatically.

How does it work?

-

If you’ve set up a Gravatar, your profile picture will appear here whenever you use your email on supported sites. When someone hovers over or clicks on your avatar, your Gravatar profile will appear if you have one. If you don’t have a Gravatar yet, you’ll see a default image instead.

+

If you've set up a Gravatar, your profile picture will appear here whenever you use your email on supported sites. When someone hovers over or clicks on your avatar, your Gravatar profile will also appear if you have one. If you don't have a Gravatar yet, you'll see a default image instead.

Want to update or add a Gravatar?

Go to Gravatar.com to set up or change your avatar or profile. Your updates will appear here once saved!

diff --git a/theme/templates/base.html b/theme/templates/base.html index 42ca995..e03166e 100644 --- a/theme/templates/base.html +++ b/theme/templates/base.html @@ -9,7 +9,8 @@ - + {% include 'meta/meta.html' %} + [PᴋMɴ Trade Club] {% block title %}{% endblock title %} + @@ -113,12 +115,7 @@ class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-32 p-2 shadow">
  • -
    Profile
    -
    - - - -
    + Profile
  • diff --git a/theme/templates/trades/trade_offer_detail.html b/theme/templates/trades/trade_offer_detail.html index 73de7e8..74c1f9f 100644 --- a/theme/templates/trades/trade_offer_detail.html +++ b/theme/templates/trades/trade_offer_detail.html @@ -1,13 +1,17 @@ {% extends 'base.html' %} {% load trade_offer_tags %} -{% block title %}Trade Offer Detail{% endblock title %} +{% block title %}{{title}}{% endblock title %} {% block content %}

    Trade Offer Details

    - {% render_trade_offer object %} + {% if screenshot_mode == "true" %} + {% render_trade_offer object True %} + {% else %} + {% render_trade_offer object %} + {% endif %}
    {% if acceptance_form %}
    @@ -50,7 +54,7 @@ Back to Trade Offers {% if is_initiator %} Delete/Close Trade Offer - {% else %} + {% elif request.user.is_authenticated %} {% endif %} diff --git a/theme/templatetags/card_badge.html b/theme/templatetags/card_badge.html index db661e4..ee51b01 100644 --- a/theme/templatetags/card_badge.html +++ b/theme/templatetags/card_badge.html @@ -1,10 +1,10 @@ -
    -
    -
    {{ name }}
    -
    {{ rarity }}
    -
    {{ cardset }}
    +
    +
    +
    {{ name }}
    +
    {{ rarity }}
    +
    {{ cardset }}
    - - {{ quantity }} - + + {{ quantity }} +
    \ No newline at end of file diff --git a/theme/templatetags/trade_offer.html b/theme/templatetags/trade_offer.html index efd5a60..84dbc64 100644 --- a/theme/templatetags/trade_offer.html +++ b/theme/templatetags/trade_offer.html @@ -16,8 +16,24 @@ } } - -
    +
    @@ -26,85 +42,136 @@ 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. --> -
    -
    +
    -
    +
    -
    -
    -
    - Has -
    -
    - -
    +
    -
    -
    -
    - {% if have_cards_available %} - {% with first_have=have_cards_available.0 %} - {% card_badge first_have.card first_have.quantity %} - {% endwith %} - {% endif %} +
    + {% if screenshot_mode and num_cards_available >= 4 %} + +
    + +
    + {% for card in have_cards_available|slice:"0:2" %} + {% card_badge card.card card.quantity %} + {% endfor %} +
    + +
    +
    +
    + +
    + {% for card in want_cards_available|slice:"0:2" %} + {% card_badge card.card card.quantity %} + {% endfor %} +
    -
    - {% if want_cards_available %} - {% with first_want=want_cards_available.0 %} - {% card_badge first_want.card first_want.quantity %} - {% endwith %} - {% endif %} + {% else %} + +
    + +
    + {% for card in have_cards_available|slice:"0:1" %} + {% card_badge card.card card.quantity %} + {% endfor %} +
    + +
    + {% for card in want_cards_available|slice:"0:1" %} + {% card_badge card.card card.quantity %} + {% endfor %} +
    +
    + {% endif %} +
    +
    + + + {% if screenshot_mode and num_cards_available >= 4 %} +
    + +
    + +
    + {% for card in have_cards_available|slice:"2:" %} + {% card_badge card.card card.quantity %} + {% endfor %} +
    + +
    +
    +
    + +
    + {% for card in want_cards_available|slice:"2:" %} + {% card_badge card.card card.quantity %} + {% endfor %}
    - - -
    -
    -
    - {% for th in have_cards_available|slice:"1:" %} - {% card_badge th.card th.quantity %} + {% else %} + -
    + {% if not screenshot_mode %} +
    + {% if have_cards_available|length > 1 or want_cards_available|length > 1 %} + + + + {% endif %} +
    + {% endif %} + {% if not screenshot_mode %} + + {% if not screenshot_mode %} -
    - {% if acceptances|length > 1 %} - - - - {% endif %} -
    +
    + {% if acceptances|length > 1 %} + + + + {% endif %} +
    @@ -242,11 +317,11 @@
    - + {% endif %}
    - +
    {% endcache %} \ No newline at end of file diff --git a/trades/migrations/0001_initial.py b/trades/migrations/0001_initial.py index 2bff7fc..a3c7c90 100644 --- a/trades/migrations/0001_initial.py +++ b/trades/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2025-03-17 20:39 +# Generated by Django 5.1.2 on 2025-03-20 00:08 import django.db.models.deletion from django.db import migrations, models diff --git a/trades/templatetags/trade_offer_tags.py b/trades/templatetags/trade_offer_tags.py index 75d3b48..bc9e352 100644 --- a/trades/templatetags/trade_offer_tags.py +++ b/trades/templatetags/trade_offer_tags.py @@ -3,7 +3,7 @@ from django import template register = template.Library() @register.inclusion_tag('templatetags/trade_offer.html', takes_context=True) -def render_trade_offer(context, offer): +def render_trade_offer(context, offer, screenshot_mode=False): """ Renders a trade offer including detailed trade acceptance information. Freezes the through-model querysets to avoid extra DB hits. @@ -33,6 +33,10 @@ def render_trade_offer(context, offer): 'acceptances': acceptances, 'have_cards_available': have_cards_available, 'want_cards_available': want_cards_available, + 'screenshot_mode': screenshot_mode, + 'in_game_name': offer.initiated_by.in_game_name, + 'friend_code': offer.initiated_by.friend_code, + 'num_cards_available': len(have_cards_available) + len(want_cards_available), } @register.inclusion_tag('templatetags/trade_acceptance.html', takes_context=True) diff --git a/trades/urls.py b/trades/urls.py index a7d2338..70170f7 100644 --- a/trades/urls.py +++ b/trades/urls.py @@ -9,6 +9,7 @@ from .views import ( TradeAcceptanceUpdateView, TradeOfferDeleteView, TradeOfferSearchView, + TradeOfferPNGView, ) urlpatterns = [ @@ -17,6 +18,7 @@ urlpatterns = [ path("my/", TradeOfferMyListView.as_view(), name="trade_offer_my_list"), path("search/", TradeOfferSearchView.as_view(), name="trade_offer_search"), path("/", TradeOfferDetailView.as_view(), name="trade_offer_detail"), + path(".png", TradeOfferPNGView.as_view(), name="trade_offer_png"), path("delete//", TradeOfferDeleteView.as_view(), name="trade_offer_delete"), path("offer/", TradeAcceptanceCreateView.as_view(), name="trade_acceptance_create"), path("accept//", TradeAcceptanceUpdateView.as_view(), name="trade_acceptance_update"), diff --git a/trades/views.py b/trades/views.py index 1c95d89..549b294 100644 --- a/trades/views.py +++ b/trades/views.py @@ -1,4 +1,5 @@ from django.views.generic import TemplateView, DeleteView, CreateView, ListView, DetailView, UpdateView, FormView +from django.views import View from django.urls import reverse_lazy from django.http import HttpResponseRedirect, JsonResponse from django.contrib.auth.mixins import LoginRequiredMixin @@ -12,11 +13,18 @@ from django.views.decorators.http import require_http_methods from django.core.paginator import Paginator from django.contrib import messages +from meta.views import Meta from .models import TradeOffer, TradeAcceptance from .forms import (TradeOfferAcceptForm, TradeAcceptanceCreateForm, TradeOfferCreateForm, TradeAcceptanceTransitionForm) from cards.models import Card -#from silk.profiling.profiler import silk_profile +import imgkit +from django.http import HttpResponse, Http404 +from django.template.loader import render_to_string +from trades.templatetags.trade_offer_tags import render_trade_offer +from django.template import RequestContext +from playwright.sync_api import sync_playwright +from django.conf import settings class TradeOfferCreateView(LoginRequiredMixin, CreateView): model = TradeOffer @@ -383,7 +391,7 @@ class TradeOfferSearchView(ListView): else: return super().render_to_response(context, **response_kwargs) -class TradeOfferDetailView(LoginRequiredMixin, DetailView): +class TradeOfferDetailView(DetailView): """ Displays the details of a TradeOffer along with its active acceptances. If the offer is still open and the current user is not its initiator, @@ -396,6 +404,57 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) trade_offer = self.get_object() + screenshot_mode = self.request.GET.get("screenshot_mode") + context["screenshot_mode"] = screenshot_mode + + # Calculate the number of cards in each category. + num_has = trade_offer.trade_offer_have_cards.count() + num_wants = trade_offer.trade_offer_want_cards.count() + num_cards = max(num_has, num_wants) + + # Define the aspect ratio. + aspect_ratio = 1.91 + + # Calculate a base height using our previous assumptions: + # - 80px per card row (with rows computed as round(num_cards/2)) + # - plus 138px for header/footer. + base_height = (round(num_cards / 2) * 80) + 138 + + # Calculate a base width by assuming two columns of card badges. + # Here we assume each card badge is 80px wide plus the same horizontal offset of 138px. + if (num_wants + num_has) >= 3: + base_width = ((num_wants + num_has) * 144) + 96 + else: + base_width = (4 * 144) + 96 + + if base_height > base_width: + # The trade-offer card is taller than wide; + # compute the width from the height. + image_height = base_height + image_width = int(round(image_height * aspect_ratio)) + 1 + else: + # The trade-offer card is wider than tall; + # compute the height from the width. + image_width = base_width + image_height = int(round(image_width / aspect_ratio)) + + # Build the meta tags with the computed dimensions. + title = f'Trade Offer from {trade_offer.initiated_by.in_game_name} ({trade_offer.initiated_by.friend_code})' + context["meta"] = Meta( + title=title, + description=f'Has: {", ".join([card.card.name for card in trade_offer.trade_offer_have_cards.all()])} • \nWants: {", ".join([card.card.name for card in trade_offer.trade_offer_want_cards.all()])}', + image_object={ + "url": f'http://localhost:8000{reverse_lazy("trade_offer_png", kwargs={"pk": trade_offer.pk})}', + "type": "image/png", + "width": image_width, + "height": image_height, + }, + twitter_type="summary_large_image", + use_og=True, + use_twitter=True, + use_facebook=True, + use_schemaorg=True, + ) # Define terminal (closed) acceptance states based on our new system: terminal_states = [ @@ -412,26 +471,31 @@ class TradeOfferDetailView(LoginRequiredMixin, DetailView): # Option 1: Filter active acceptances using the queryset lookup. context["active_acceptances"] = trade_offer.acceptances.exclude(state__in=terminal_states) - - user_friend_codes = self.request.user.friend_codes.all() + if self.request.user.is_authenticated: + user_friend_codes = self.request.user.friend_codes.all() - # Add context flag and deletion URL if the current user is the initiator - if trade_offer.initiated_by in user_friend_codes: - context["is_initiator"] = True - context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk}) + # Add context flag and deletion URL if the current user is the initiator + if trade_offer.initiated_by in user_friend_codes: + context["is_initiator"] = True + context["delete_close_url"] = reverse_lazy("trade_offer_delete", kwargs={"pk": trade_offer.pk}) + else: + context["is_initiator"] = False + + # Determine the user's default friend code (or fallback as needed). + default_friend_code = self.request.user.default_friend_code or user_friend_codes.first() + + # If the current user is not the initiator and the offer is open, allow a new acceptance. + if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed: + context["acceptance_form"] = TradeAcceptanceCreateForm( + trade_offer=trade_offer, + friend_codes=user_friend_codes, + default_friend_code=default_friend_code + ) else: context["is_initiator"] = False + context["delete_close_url"] = None + context["acceptance_form"] = None - # Determine the user's default friend code (or fallback as needed). - default_friend_code = self.request.user.default_friend_code or user_friend_codes.first() - - # If the current user is not the initiator and the offer is open, allow a new acceptance. - if trade_offer.initiated_by not in user_friend_codes and not trade_offer.is_closed: - context["acceptance_form"] = TradeAcceptanceCreateForm( - trade_offer=trade_offer, - friend_codes=user_friend_codes, - default_friend_code=default_friend_code - ) return context class TradeAcceptanceCreateView(LoginRequiredMixin, CreateView): @@ -539,3 +603,82 @@ class TradeAcceptanceUpdateView(LoginRequiredMixin, UpdateView): def get_success_url(self): return reverse_lazy("trade_offer_detail", kwargs={"pk": self.object.trade_offer.pk}) +class TradeOfferPNGView(View): + """ + Generate a PNG screenshot of the rendered trade offer detail page using Playwright. + + This view loads the trade offer detail page, waits for the trade offer element to render, + simulates a click to expand extra badges, and then screenshots only the trade offer element. + """ + def get(self, request, *args, **kwargs): + # For demonstration purposes, get the first trade offer. + # In production, you might want to identify the offer via a URL parameter. + trade_offer = TradeOffer.objects.get(pk=kwargs['pk']) + if not trade_offer: + raise Http404("Trade offer not found") + + # Get the URL for the trade offer detail view. + detail_url = request.build_absolute_uri( + reverse_lazy("trade_offer_detail", kwargs={"pk": trade_offer.pk})+"?screenshot_mode=true" + ) + + + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": 1280, "height": 800}, + ) + + # If the request contains a Django session cookie, + # add it to the browser context to bypass the login screen. + session_cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME) + if session_cookie: + cookie = { + "name": settings.SESSION_COOKIE_NAME, + "value": session_cookie, + "domain": request.get_host().split(':')[0], + "path": "/", + "httpOnly": True, + "secure": not settings.DEBUG, + } + context.add_cookies([cookie]) + + # Open a new page and navigate to the detail view. + page = context.new_page() + page.goto(detail_url, wait_until="networkidle") + + # Inject CSS to force transparency. + page.add_style_tag(content=""" + html, body, .bg-base-200 { + background-color: rgba(255, 255, 255, 0) !important; + } + """) + + trade_offer_selector = ".trade-offer-card-screenshot" + page.wait_for_selector(trade_offer_selector) + + # Simulate a click on the toggle button within the trade offer element + # to force the extra details (e.g., extra badges) to expand. + # We use a selector that targets the first svg with a "cursor-pointer" class inside the trade offer card. + toggle_selector = f"{trade_offer_selector} svg.cursor-pointer" + try: + toggle = page.query_selector(toggle_selector) + if toggle: + toggle.click() + # Wait for the CSS animation to complete (600ms as in your template) + page.wait_for_timeout(600) + except Exception: + # If the toggle is not found or clicking fails, proceed without expansion. + pass + + # Locate the element containing the trade offer and capture its screenshot. + element = page.query_selector(trade_offer_selector) + if not element: + browser.close() + raise Http404("Trade offer element not found on page") + + png_bytes = element.screenshot(type="png", omit_background=True) + browser.close() + + return HttpResponse(png_bytes, content_type="image/png")