diff --git a/apps/bot_manager/templates/bot_manager/add_subscription.html b/apps/bot_manager/templates/bot_manager/add_subscription.html index bc5e83c..c60256b 100644 --- a/apps/bot_manager/templates/bot_manager/add_subscription.html +++ b/apps/bot_manager/templates/bot_manager/add_subscription.html @@ -5,12 +5,6 @@

Add New Subscription

← Back -{% if messages %} -{% for message in messages %} -
{{ message }}
-{% endfor %} -{% endif %} -
{% csrf_token %} diff --git a/apps/bot_manager/templates/bot_manager/edit_subscription.html b/apps/bot_manager/templates/bot_manager/edit_subscription.html index 94ab067..c8da527 100644 --- a/apps/bot_manager/templates/bot_manager/edit_subscription.html +++ b/apps/bot_manager/templates/bot_manager/edit_subscription.html @@ -5,12 +5,6 @@

Edit Subscription

← Back -{% if messages %} -{% for message in messages %} -
{{ message }}
-{% endfor %} -{% endif %} - {% csrf_token %} diff --git a/apps/bot_manager/templates/bot_manager/guild_detail.html b/apps/bot_manager/templates/bot_manager/guild_detail.html index 3e22c37..57b9458 100644 --- a/apps/bot_manager/templates/bot_manager/guild_detail.html +++ b/apps/bot_manager/templates/bot_manager/guild_detail.html @@ -110,12 +110,6 @@

Recent Logs

{% endif %} -{% if messages %} -{% for message in messages %} -
{{ message }}
-{% endfor %} -{% endif %} -

Guild Info

diff --git a/apps/custompages/migrations/0002_alter_custompage_path.py b/apps/custompages/migrations/0002_alter_custompage_path.py new file mode 100644 index 0000000..3ac16c2 --- /dev/null +++ b/apps/custompages/migrations/0002_alter_custompage_path.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.11 on 2026-05-26 19:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("custompages", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="custompage", + name="path", + field=models.SlugField( + help_text="URL path for this page (e.g., 'my-page' will be accessible at /cust/my-page)", + max_length=200, + unique=True, + ), + ), + ] diff --git a/apps/notifications/static/notifications/js/notifications.js b/apps/notifications/static/notifications/js/notifications.js index 9542547..d9c33f3 100644 --- a/apps/notifications/static/notifications/js/notifications.js +++ b/apps/notifications/static/notifications/js/notifications.js @@ -124,7 +124,17 @@ class NotificationSystem { // Icon const icon = document.createElement('span'); icon.className = 'notification-icon'; - icon.innerHTML = this.getIcon(type); + const stickerSrc = this.getStickerSrc(type); + if (stickerSrc) { + const img = document.createElement('img'); + img.className = 'notification-sticker'; + img.src = stickerSrc; + img.alt = ''; + img.decoding = 'async'; + icon.appendChild(img); + } else { + icon.innerHTML = this.getIcon(type); + } // Message const messageSpan = document.createElement('span'); @@ -148,7 +158,21 @@ class NotificationSystem { } /** - * Get icon for notification type + * Static sticker URLs (matches {% static 'stickers/…' %} with STATIC_URL /static/) + */ + getStickerSrc(type) { + const base = '/static/stickers/'; + if (type === 'success') { + return `${base}bep_bounce.gif`; + } + if (type === 'error') { + return `${base}foxi-sticker-ERROR.png`; + } + return null; + } + + /** + * Fallback emoji icon for notification types without stickers */ getIcon(type) { const icons = { @@ -186,6 +210,7 @@ class NotificationSystem { if (message.classList.contains('message-error')) return 'error'; if (message.classList.contains('message-warning')) return 'warning'; if (message.classList.contains('message-info')) return 'info'; + if (message.classList.contains('message-debug')) return 'info'; return 'info'; } diff --git a/apps/tanks_manager/admin.py b/apps/tanks_manager/admin.py index bdb2959..161b4e6 100644 --- a/apps/tanks_manager/admin.py +++ b/apps/tanks_manager/admin.py @@ -1,13 +1,28 @@ from django.contrib import admin -from .models import TankClone, TankLiquid, TankLog, TankSettings +from .models import TankLiquid, TankLog, TankSite -@admin.register(TankSettings) -class TankSettingsAdmin(admin.ModelAdmin): - list_display = ["id", "tank_top_offset", "tank_bottom_offset"] +@admin.register(TankSite) +class TankSiteAdmin(admin.ModelAdmin): + list_display = [ + "slug", + "owner", + "character_name", + "tank_top_offset", + "tank_bottom_offset", + ] + search_fields = ["slug", "owner__username"] + raw_id_fields = ["owner"] -admin.site.register(TankClone) -admin.site.register(TankLiquid) -admin.site.register(TankLog) +@admin.register(TankLiquid) +class TankLiquidAdmin(admin.ModelAdmin): + list_display = ["name", "tank_site", "volume", "sort_order"] + list_filter = ["tank_site"] + + +@admin.register(TankLog) +class TankLogAdmin(admin.ModelAdmin): + list_display = ["tank_site", "date", "text"] + list_filter = ["tank_site"] diff --git a/apps/tanks_manager/foreground_labels.py b/apps/tanks_manager/foreground_labels.py new file mode 100644 index 0000000..f86dd7e --- /dev/null +++ b/apps/tanks_manager/foreground_labels.py @@ -0,0 +1,190 @@ +""" +Tool for figuring out the overlay stuff! +""" + +from __future__ import annotations + +from typing import BinaryIO, List, Tuple + +from PIL import Image, ImageOps + +# Image width, height +STAGE_W = 1200 +STAGE_H = 850 + +# Pixels with alpha below this count as transparent +ALPHA_THRESHOLD = 48 + +# Vertical sampling step (rows) +ROW_STEP = 4 + +# Rows qualify as interior if the widest transparent run is at least this wide +MIN_WIDE_RUN_RATIO = 0.18 +MIN_WIDE_RUN_PX = 120 + +# Ignore auto-bounds when the overall vertical extent (top→bottom) is too small. +MIN_TANK_HEIGHT_ROWS = 64 + + +def _lanczos(): + try: + return Image.Resampling.LANCZOS + except AttributeError: + return Image.LANCZOS + + +def _widest_transparent_run_bounds( + pixels, y: int, width: int +) -> tuple[int, int] | None: + runs: list[tuple[int, int]] = [] + start: int | None = None + for x in range(width): + pix = pixels[x, y] + a = pix[3] if len(pix) >= 4 else 255 + if a < ALPHA_THRESHOLD: + if start is None: + start = x + elif start is not None: + runs.append((start, x)) + start = None + if start is not None: + runs.append((start, width)) + if not runs: + return None + return max(runs, key=lambda r: r[1] - r[0]) + + +def _widest_transparent_run_center_x(pixels, y: int, width: int) -> float | None: + """Center x (0..width) of the widest contiguous transparent run on this row.""" + b = _widest_transparent_run_bounds(pixels, y, width) + if b is None: + return None + lo, hi = b + return (lo + hi) / 2.0 + + +def _widest_transparent_run_width(pixels, y: int, width: int) -> int: + b = _widest_transparent_run_bounds(pixels, y, width) + if b is None: + return 0 + return b[1] - b[0] + + +def _fit_stage_foreground_rgba(file_obj: BinaryIO) -> Image.Image: + file_obj.seek(0) + img = Image.open(file_obj) + if getattr(img, "n_frames", 1) > 1: + img.seek(0) + img = img.convert("RGBA") + return ImageOps.fit(img, (STAGE_W, STAGE_H), method=_lanczos()) + + +def _row_qualifies_for_tank_band(pixels, y: int) -> bool: + need = max(MIN_WIDE_RUN_PX, int(STAGE_W * MIN_WIDE_RUN_RATIO)) + return _widest_transparent_run_width(pixels, y, STAGE_W) >= need + + +def detect_tank_vertical_offsets_from_pixels(pixels) -> Tuple[int, int] | None: + """ + Infer tank_top_offset and tank_bottom_offset (pixels in STAGE_H space). + + Rows qualify when they have a wide transparent run (same threshold as label holes). + Uses the overall vertical extent: topmost qualifying row through bottommost qualifying + row, so multiple separate alpha zones still yield one tank span. + """ + ymin: int | None = None + ymax: int | None = None + for y in range(STAGE_H): + if not _row_qualifies_for_tank_band(pixels, y): + continue + if ymin is None: + ymin = ymax = y + else: + ymin = min(ymin, y) + ymax = max(ymax, y) + + if ymin is None or ymax is None: + return None + + extent = ymax - ymin + 1 + if extent < MIN_TANK_HEIGHT_ROWS: + return None + + tank_top = ymin + tank_bottom = STAGE_H - (ymax + 1) + if tank_top + tank_bottom >= STAGE_H: + return None + return (tank_top, tank_bottom) + + +def _build_label_profile_from_pixels(pixels) -> List[List[float]]: + samples: List[List[float]] = [] + for y in range(0, STAGE_H, ROW_STEP): + cx = _widest_transparent_run_center_x(pixels, y, STAGE_W) + if cx is None: + x_pct = 50.0 + else: + x_pct = (cx / STAGE_W) * 100.0 + y_pct = (y / STAGE_H) * 100.0 if STAGE_H else 0.0 + samples.append([round(y_pct, 4), round(x_pct, 4)]) + return samples + + +def analyze_stage_foreground( + file_obj: BinaryIO, +) -> tuple[List[List[float]], Tuple[int, int] | None]: + """ + Fit image to stage size (object-fit: cover), then compute label profile and optional + tank vertical margins from transparency. + """ + fitted = _fit_stage_foreground_rgba(file_obj) + pixels = fitted.load() + profile = _build_label_profile_from_pixels(pixels) + margins = detect_tank_vertical_offsets_from_pixels(pixels) + return profile, margins + + +def compute_foreground_label_profile(file_obj: BinaryIO) -> List[List[float]]: + """ + Build [[y_pct_from_top, x_pct_from_left], ...] for the stage coordinate system. + + The image is fitted to STAGE_W×STAGE_H the same way CSS object-fit: cover does + (center crop), then each sampled row picks the horizontal center of the widest + transparent segment so labels can sit in visible “holes” in the overlay. + """ + profile, _ = analyze_stage_foreground(file_obj) + return profile + + +def interpolate_stage_x_pct(samples: List[List[float]], y_pct: float) -> float: + """Linear interpolation of stage X% (0=left, 100=right) at vertical position y_pct (from top).""" + if not samples: + return 50.0 + if y_pct <= samples[0][0]: + return samples[0][1] + if y_pct >= samples[-1][0]: + return samples[-1][1] + for i in range(len(samples) - 1): + y0, x0 = samples[i] + y1, x1 = samples[i + 1] + if y0 <= y_pct <= y1: + if y1 == y0: + return x0 + t = (y_pct - y0) / (y1 - y0) + return x0 + t * (x1 - x0) + return 50.0 + + +def band_anchor_stage_x_pct( + samples: List[List[float]], y_top_lo: float, y_top_hi: float +) -> float: + """Average interpolated X at band top, middle, and bottom for stability.""" + if y_top_hi < y_top_lo: + y_top_lo, y_top_hi = y_top_hi, y_top_lo + mid = (y_top_lo + y_top_hi) / 2.0 + xs = [ + interpolate_stage_x_pct(samples, y_top_lo), + interpolate_stage_x_pct(samples, mid), + interpolate_stage_x_pct(samples, y_top_hi), + ] + return sum(xs) / len(xs) diff --git a/apps/tanks_manager/migrations/0001_initial.py b/apps/tanks_manager/migrations/0001_initial.py index a92bc6e..bc85f28 100644 --- a/apps/tanks_manager/migrations/0001_initial.py +++ b/apps/tanks_manager/migrations/0001_initial.py @@ -11,7 +11,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TankSettings", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("tank_top_offset", models.PositiveIntegerField(default=360)), ("tank_bottom_offset", models.PositiveIntegerField(default=101)), ], @@ -22,7 +30,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TankClone", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("sort_order", models.PositiveIntegerField(default=0)), ("name", models.CharField(max_length=200)), ("banner", models.CharField(blank=True, max_length=200)), @@ -52,7 +68,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TankLiquid", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("sort_order", models.PositiveIntegerField(default=0)), ("name", models.CharField(max_length=200)), ("volume", models.PositiveSmallIntegerField(default=0)), @@ -76,7 +100,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="TankLog", fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), ("date", models.PositiveIntegerField(help_text="Unix timestamp")), ("text", models.TextField()), ], diff --git a/apps/tanks_manager/migrations/0002_tanksite_schema.py b/apps/tanks_manager/migrations/0002_tanksite_schema.py new file mode 100644 index 0000000..64ed24e --- /dev/null +++ b/apps/tanks_manager/migrations/0002_tanksite_schema.py @@ -0,0 +1,207 @@ +import os + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from django.core.files import File +from django.db import migrations, models +import django.db.models.deletion + +_PLACEHOLDER_USERNAME = "tanks-migration-placeholder" + + +def forwards(apps, schema_editor): + TankSite = apps.get_model("tanks_manager", "TankSite") + TankSettings = apps.get_model("tanks_manager", "TankSettings") + TankLiquid = apps.get_model("tanks_manager", "TankLiquid") + TankLog = apps.get_model("tanks_manager", "TankLog") + CustomUser = apps.get_model("users", "CustomUser") + + owner = ( + CustomUser.objects.filter(username="vixi").first() + or CustomUser.objects.filter(is_superuser=True).first() + or CustomUser.objects.order_by("pk").first() + ) + if not owner: + # Fresh databases (like CI) may have no users yet; create an unusable + # placeholder so TankSite.owner can be satisfied before tests create data. + owner, _ = CustomUser.objects.get_or_create( + username=_PLACEHOLDER_USERNAME, + defaults={"password": make_password(None)}, + ) + + ts = TankSettings.objects.filter(pk=1).first() + top, bot = 360, 101 + if ts: + top, bot = ts.tank_top_offset, ts.tank_bottom_offset + + site = TankSite( + owner_id=owner.pk, + slug="vixi", + tank_top_offset=top, + tank_bottom_offset=bot, + character_name="", + character_url="", + ) + site.save() + + if ts and getattr(ts, "stage_background", None): + try: + with ts.stage_background.open("rb") as fh: + site.stage_background.save( + os.path.basename(ts.stage_background.name), File(fh), save=True + ) + except OSError: + pass + if ts and getattr(ts, "stage_foreground", None): + try: + with ts.stage_foreground.open("rb") as fh: + site.stage_foreground.save( + os.path.basename(ts.stage_foreground.name), File(fh), save=True + ) + except OSError: + pass + + TankLiquid.objects.all().update(tank_site_id=site.pk) + TankLog.objects.all().update(tank_site_id=site.pk) + + +def backwards(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("tanks_manager", "0001_initial"), + ("users", "0004_customuser_email_verification_sent_at_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="TankSite", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "slug", + models.SlugField( + db_index=True, + help_text="URL segment, e.g. vixi → /tanks/vixi/", + max_length=50, + unique=True, + ), + ), + ("tank_top_offset", models.PositiveIntegerField(default=360)), + ("tank_bottom_offset", models.PositiveIntegerField(default=101)), + ( + "character_name", + models.CharField( + blank=True, + help_text="Shown as the main title on the public tank page when set.", + max_length=200, + ), + ), + ( + "character_url", + models.CharField( + blank=True, + help_text="Optional; makes the title a link (e.g. profile or ref sheet).", + max_length=500, + ), + ), + ( + "stage_background", + models.ImageField( + blank=True, + help_text="Optional; sample Alice tank art is used when empty.", + max_length=500, + null=True, + upload_to="tanks/stage/", + ), + ), + ( + "stage_foreground", + models.ImageField( + blank=True, + help_text="Optional; sample overlay is used when empty.", + max_length=500, + null=True, + upload_to="tanks/stage/", + ), + ), + ( + "owner", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="tank_site", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["slug"], + }, + ), + migrations.AddField( + model_name="tankliquid", + name="tank_site", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="liquids", + to="tanks_manager.tanksite", + ), + ), + migrations.AddField( + model_name="tanklog", + name="tank_site", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="logs", + to="tanks_manager.tanksite", + ), + ), + migrations.RunPython(forwards, backwards), + migrations.AlterField( + model_name="tankliquid", + name="tank_site", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="liquids", + to="tanks_manager.tanksite", + ), + ), + migrations.AlterField( + model_name="tanklog", + name="tank_site", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="logs", + to="tanks_manager.tanksite", + ), + ), + migrations.AlterField( + model_name="tanksite", + name="owner", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="tank_site", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.DeleteModel( + name="TankClone", + ), + migrations.DeleteModel( + name="TankSettings", + ), + ] diff --git a/apps/tanks_manager/migrations/0003_alter_tanksite_owner_fk.py b/apps/tanks_manager/migrations/0003_alter_tanksite_owner_fk.py new file mode 100644 index 0000000..24c266b --- /dev/null +++ b/apps/tanks_manager/migrations/0003_alter_tanksite_owner_fk.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("tanks_manager", "0002_tanksite_schema"), + ] + + operations = [ + migrations.AlterField( + model_name="tanksite", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tank_sites", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py b/apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py new file mode 100644 index 0000000..408a15d --- /dev/null +++ b/apps/tanks_manager/migrations/0004_tanksite_stage_fg_label_profile.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tanks_manager", "0003_alter_tanksite_owner_fk"), + ] + + operations = [ + migrations.AddField( + model_name="tanksite", + name="stage_fg_label_profile", + field=models.JSONField( + blank=True, + default=list, + help_text="Cached [[y_pct_from_top, x_pct], ...] from transparent scanlines; rebuilt on FG upload.", + ), + ), + ] diff --git a/apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py b/apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py new file mode 100644 index 0000000..07be264 --- /dev/null +++ b/apps/tanks_manager/migrations/0005_alter_tanksite_stage_fg_label_profile.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.11 on 2026-05-26 19:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("tanks_manager", "0004_tanksite_stage_fg_label_profile"), + ] + + operations = [ + migrations.AlterField( + model_name="tanksite", + name="stage_fg_label_profile", + field=models.JSONField( + blank=True, + default=list, + help_text="Cached [[y_pct_from_top, x_pct], …] from transparent scanlines; rebuilt on FG upload.", + ), + ), + ] diff --git a/apps/tanks_manager/models.py b/apps/tanks_manager/models.py index 32c1637..dea1427 100644 --- a/apps/tanks_manager/models.py +++ b/apps/tanks_manager/models.py @@ -1,33 +1,66 @@ +from django.conf import settings from django.db import models -class TankSettings(models.Model): +class TankSite(models.Model): + """Public tank page; URL is /tanks/<slug>/. Owners may have several.""" + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="tank_sites", + ) + slug = models.SlugField( + max_length=50, + unique=True, + db_index=True, + help_text="URL segment, e.g. vixi → /tanks/vixi/", + ) tank_top_offset = models.PositiveIntegerField(default=360) tank_bottom_offset = models.PositiveIntegerField(default=101) - - class Meta: - verbose_name_plural = "Tank settings" - - -class TankClone(models.Model): - sort_order = models.PositiveIntegerField(default=0) - name = models.CharField(max_length=200) - banner = models.CharField(max_length=200, blank=True) - image = models.CharField( + character_name = models.CharField( + max_length=200, + blank=True, + help_text="Shown as the main title on the public tank page when set.", + ) + character_url = models.CharField( max_length=500, blank=True, - help_text="Relative path or URL if not using upload", + help_text="Optional; makes the title a link (e.g. profile or ref sheet).", ) - image_file = models.ImageField( - upload_to="tanks/clones/", blank=True, null=True, max_length=500 + stage_background = models.ImageField( + upload_to="tanks/stage/", + blank=True, + null=True, + max_length=500, + help_text="Optional; sample Alice tank art is used when empty.", + ) + stage_foreground = models.ImageField( + upload_to="tanks/stage/", + blank=True, + null=True, + max_length=500, + help_text="Optional; sample overlay is used when empty.", + ) + stage_fg_label_profile = models.JSONField( + default=list, + blank=True, + help_text="Cached [[y_pct_from_top, x_pct], …] from transparent scanlines; rebuilt on FG upload.", ) - url = models.CharField(max_length=500, blank=True) class Meta: - ordering = ["sort_order", "id"] + ordering = ["slug"] + + def __str__(self): + return f"/tanks/{self.slug}/ ({self.owner})" class TankLiquid(models.Model): + tank_site = models.ForeignKey( + TankSite, + on_delete=models.CASCADE, + related_name="liquids", + ) sort_order = models.PositiveIntegerField(default=0) name = models.CharField(max_length=200) volume = models.PositiveSmallIntegerField(default=0) @@ -43,8 +76,20 @@ class Meta: class TankLog(models.Model): + tank_site = models.ForeignKey( + TankSite, + on_delete=models.CASCADE, + related_name="logs", + ) date = models.PositiveIntegerField(help_text="Unix timestamp") text = models.TextField() class Meta: ordering = ["date", "id"] + + +def tanks_for_user(user): + """TankSite queryset for this user; empty if anonymous.""" + if not getattr(user, "is_authenticated", False): + return TankSite.objects.none() + return TankSite.objects.filter(owner=user).order_by("slug") diff --git a/apps/tanks_manager/static/tanks_manager/Alice_close_up_sheath_background.png b/apps/tanks_manager/static/tanks_manager/Alice_close_up_sheath_background.png new file mode 100644 index 0000000..f999585 Binary files /dev/null and b/apps/tanks_manager/static/tanks_manager/Alice_close_up_sheath_background.png differ diff --git a/apps/tanks_manager/static/tanks_manager/Alice_close_up_sheath_shot.png b/apps/tanks_manager/static/tanks_manager/Alice_close_up_sheath_shot.png new file mode 100644 index 0000000..921f409 Binary files /dev/null and b/apps/tanks_manager/static/tanks_manager/Alice_close_up_sheath_shot.png differ diff --git a/apps/tanks_manager/static/tanks_manager/css/tank_page.css b/apps/tanks_manager/static/tanks_manager/css/tank_page.css new file mode 100644 index 0000000..345f06d --- /dev/null +++ b/apps/tanks_manager/static/tanks_manager/css/tank_page.css @@ -0,0 +1,280 @@ +/* Tank show page — bg/fg use object-fit; liquid stays width-fill + clip only */ + +:root { + color-scheme: dark; +} + +.tank-page { + margin: 0; + background: #23232b; + font-family: Inter, Arial, sans-serif; + min-height: 100vh; + color: #e6e6ff; +} + +.tank-page *, +.tank-page *::before, +.tank-page *::after { + box-sizing: border-box; +} + +.tank-page-inner { + padding: 32px; + display: flex; + flex-direction: column; + gap: 24px; + max-width: 100%; +} + +.tank-page-title { + text-align: center; + margin: 0; +} + +.tank-page-title h1 { + margin: 0; + font-size: clamp(1.25rem, 3vw, 1.85rem); + font-weight: 700; + line-height: 1.2; +} + +.tank-page-title a { + color: inherit; + text-decoration: none; +} + +.tank-page-title a:hover { + text-decoration: underline; + color: #aacfff; +} + +/* DOM: .tank-stage, .tank-logbook, .tank-home-link — order sets desktop vs mobile */ +.tank-layout { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 24px; + width: 100%; + align-items: flex-start; +} + +.tank-logbook { + width: 240px; + max-width: 30vw; + flex-shrink: 0; + padding: 16px 12px; + background: #1e1e28f2; + border: 3px solid #000; + display: flex; + flex-direction: column; + gap: 8px; + max-height: 850px; + overflow-y: auto; +} + +.tank-logbook ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.tank-stage { + position: relative; + flex: 1 1 0; + min-width: 0; + width: 100%; + max-width: 1200px; + margin: 0 auto; + aspect-ratio: 1200 / 850; + overflow: hidden; + background: #14141c; +} + +/* Fill the stage frame (fixes letterboxing / “half empty” sides on phones) */ +.tank-stage > .tank-bg, +.tank-stage > .tank-fg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + object-fit: cover; + object-position: center; + pointer-events: none; +} + +.tank-stage > .tank-bg { + z-index: 1; +} + +.tank-stage > .tank-fg { + z-index: 3; +} + +.tank-liquid-layer { + position: absolute; + left: 0; + width: 100%; + border-radius: 4px; + overflow: hidden; + z-index: 2; +} + +.tank-liquid-img { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: auto; + display: block; + pointer-events: none; +} + +.tank-liquid-label { + position: absolute; + top: 50%; + left: 50%; + z-index: 1; + transform: translate(-50%, -50%); + padding: 4px 8px; + background: #00000073; + color: #fff; + font-weight: 600; + font-size: clamp(10px, 2.8vw, 17px); + border-radius: 4px; + white-space: nowrap; + text-align: center; + text-decoration: none; +} + +.tank-liquid-label:hover { + text-decoration: underline; +} + +.tank-liquid-label.is-nudged { + left: 18%; + transform: translate(60%, -50%); +} + +@media (min-width: 901px) { + .tank-logbook { + order: 1; + } + + .tank-stage { + order: 2; + } + + .tank-home-link { + order: 3; + } + + .tank-liquid-label { + padding: 6px 12px; + font-size: clamp(13px, 1.6vw, 24px); + border-radius: 6px; + } +} + +.tank-home-link { + width: 220px; + flex-shrink: 0; + display: flex; + justify-content: flex-start; +} + +.tank-home-link a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + background: #000000bf; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + text-decoration: none; + font-weight: 600; + transition: background 0.2s ease, color 0.2s ease; +} + +.tank-home-link a:hover { + background: #ffffffe6; + color: #1f1f29; +} + +li.tank-log-new { + font-weight: 700; + background: linear-gradient(90deg, #fff, #f6f6ff, #ececff, #fff); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + filter: drop-shadow(0 0 3px #fff6); +} + +.tank-footer { + font-size: 0.75rem; + text-align: center; + color: #b3b3c3; +} + +.tank-footer a { + color: inherit; +} + +@media (max-width: 900px) { + .tank-page-inner { + padding: 24px 16px; + } + + .tank-layout { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + /* Same DOM as desktop; explicit order so tank is on top */ + .tank-stage { + order: 1; + flex: none; + width: 100%; + max-width: none; + margin: 0; + align-self: stretch; + } + + .tank-logbook { + order: 2; + width: 100%; + max-width: none; + max-height: min(45vh, 320px); + font-size: 0.9rem; + -webkit-overflow-scrolling: touch; + } + + .tank-home-link { + order: 3; + width: 100%; + max-width: none; + justify-content: center; + } +} + +@media (max-width: 480px) { + .tank-page-inner { + padding: 16px 12px; + } + + .tank-liquid-label { + white-space: normal; + max-width: 90%; + line-height: 1.25; + } + + .tank-liquid-label.is-nudged { + left: 50%; + transform: translate(-50%, -50%); + } +} diff --git a/apps/tanks_manager/static/tanks_manager/css/tanks_editor.css b/apps/tanks_manager/static/tanks_manager/css/tanks_editor.css new file mode 100644 index 0000000..abcdab3 --- /dev/null +++ b/apps/tanks_manager/static/tanks_manager/css/tanks_editor.css @@ -0,0 +1,196 @@ +/* Tank editor (/tanks//edit/) */ + +.tanks-meta { + font-size: 0.9rem; + opacity: 0.85; + margin-bottom: 1rem; +} + +.tanks-meta a { + color: var(--link-color, #6cf); +} + +.tanks-card { + background: var(--tile-background, #1a1a22); + border: 1px solid var(--border-color, #333); + border-radius: 8px; + padding: 0.75rem; + margin: 0.5rem 0; + position: relative; +} + +.tanks-grid { + display: grid; + gap: 0.5rem 0.75rem; + align-items: end; +} + +@media (min-width: 640px) { + .tanks-g5 { + grid-template-columns: 1fr 5rem 7rem 1fr 8rem; + } + + .tanks-g3 { + grid-template-columns: 8rem 1fr auto; + } + + .tanks-g2 { + grid-template-columns: 1fr 1fr; + } +} + +.tanks-card label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; +} + +.tanks-span-2 { + grid-column: span 2; +} + +.tanks-card input { + padding: 0.35rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--background-color); + color: inherit; + width: 100%; + box-sizing: border-box; +} + +.tanks-card input[type="file"] { + width: auto; + max-width: 100%; +} + +.tanks-card input[type="number"] { + width: 100%; + max-width: 8rem; +} + +.tanks-card input[type="color"] { + width: 100%; + max-width: 7rem; + height: 2.35rem; + padding: 0.2rem; + cursor: pointer; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--background-color); +} + +.tanks-h2 { + margin: 1.25rem 0 0.5rem; + font-size: 1.05rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.25rem; +} + +.tanks-actions button { + margin: 0.25rem 0.5rem 0.25rem 0; + padding: 0.45rem 0.75rem; + border-radius: 4px; + border: none; + cursor: pointer; + background: #2a4a6a; + color: #fff; +} + +.tanks-actions button.secondary { + background: #444; +} + +.tanks-thumb { + max-height: 48px; + max-width: 120px; + object-fit: contain; + border-radius: 4px; + vertical-align: middle; +} + +.tanks-upload-tools { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-top: 0.5rem; + font-size: 0.85rem; +} + +.tanks-default-dl { + font-size: 0.85rem; +} + +.tanks-default-dl a { + color: var(--link-color, #6cf); +} + +.tanks-cb-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.4rem; + margin-top: 0.35rem; + position: relative; + z-index: 3; + pointer-events: auto; + font-size: 0.85rem; +} + +.tanks-cb-row input[type="checkbox"] { + width: 1.15rem !important; + height: 1.15rem !important; + min-width: 1.15rem !important; + min-height: 1.15rem !important; + margin: 0; + flex-shrink: 0; + cursor: pointer; + accent-color: #9cf; +} + +.tanks-cb-row label { + flex-direction: row !important; + align-items: center; + gap: 0.35rem; + cursor: pointer; + margin: 0 !important; + width: auto !important; + font-weight: normal; +} + +button.tanks-remove-row { + background: none; + border: none; + padding: 0; + margin-top: 0.4rem; + color: var(--link-color, #9cf); + text-decoration: underline; + cursor: pointer; + font: inherit; + font-size: 0.85rem; +} + +button.tanks-remove-row:hover { + opacity: 0.85; +} + +.tanks-h2-danger { + border-bottom-color: #5c2323; + color: #e8a0a0; +} + +button.tanks-delete-page { + padding: 0.45rem 0.85rem; + border-radius: 4px; + border: 1px solid #7a3030; + cursor: pointer; + background: #5c2323; + color: #fff; + font: inherit; +} + +button.tanks-delete-page:hover { + background: #722828; +} diff --git a/apps/tanks_manager/static/tanks_manager/css/tanks_hub.css b/apps/tanks_manager/static/tanks_manager/css/tanks_hub.css new file mode 100644 index 0000000..48cffb4 --- /dev/null +++ b/apps/tanks_manager/static/tanks_manager/css/tanks_hub.css @@ -0,0 +1,122 @@ +/* /tanks/ hub — light layout next to main site chrome */ + +.tanks-hub { + max-width: 42rem; + margin: 0 auto; + padding: 1rem 0 2rem; +} + +.tanks-hub-title { + margin: 0 0 0.5rem; + font-size: clamp(1.5rem, 4vw, 2rem); +} + +.tanks-hub-lead { + margin: 0 0 1.5rem; + color: var(--muted-text-color, #888); + line-height: 1.5; +} + +.tanks-hub-card { + padding: 1.25rem; + border-radius: 8px; + border: 1px solid var(--border-color, #333); + background: var(--card-bg, #1a1a22); +} + +.tanks-hub-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1rem 0; +} + +.tanks-hub-muted { + font-size: 0.9rem; + color: var(--muted-text-color, #888); + margin: 0.5rem 0 0; +} + +.tanks-hub-section { + margin-bottom: 2rem; +} + +.tanks-hub-section h2 { + font-size: 1.15rem; + margin: 0 0 0.75rem; +} + +.tanks-hub-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tanks-hub-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 8px; + border: 1px solid var(--border-color, #333); + background: var(--card-bg, #1a1a22); +} + +.tanks-hub-row-main { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.tanks-hub-slug { + font-size: 0.85rem; + word-break: break-all; +} + +.tanks-hub-row-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.tanks-hub-inline-form { + display: inline; + margin: 0; +} + +.tanks-hub-inline-form .tanks-hub-btn-delete { + background: #5c2323; + border: 1px solid #7a3030; + color: #fff; +} + +.tanks-hub-inline-form .tanks-hub-btn-delete:hover { + background: #722828; +} + +.tanks-hub-create { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 28rem; +} + +.tanks-hub-label { + font-weight: 600; +} + +.tanks-hub-input { + padding: 0.5rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-color, #444); + background: var(--bg-color, #121218); + color: inherit; + font-size: 1rem; +} diff --git a/apps/tanks_manager/templates/tanks_manager/edit.html b/apps/tanks_manager/templates/tanks_manager/edit.html index 432f95c..ba8b812 100644 --- a/apps/tanks_manager/templates/tanks_manager/edit.html +++ b/apps/tanks_manager/templates/tanks_manager/edit.html @@ -1,97 +1,66 @@ {% extends "base.html" %} -{% block title %}Tanks data · Snowsune{% endblock %} +{% load static tanks_manager_tags %} +{% block title %}Tank editor ({{ tank_site.slug }}) · Snowsune{% endblock %} {% block extra_css %} - + {% endblock %} {% block content %} -

Cumtanks data

+

Tank editor · /tanks/{{ tank_site.slug }}/

+

← All my vore pages

- Hey if you're reading this! You're an admin :P this is just a simple table editor i made to edit the data that - https://cumtanks.snowsune.net/ and https://cumtanks.snowsune.net/vixiclones uses.

-{% if err %}
{{ err }}
{% endif %} -{% if messages %}{% endif %} + Cumtanks! A lot of people mentioned that they wanted to be able to add their own/upload their own art + so.. tada! +
+ If you fill out the tables below, you should see a public sharable page here! +
+ And if you have any questions, ask me on discord or email ofc vixi@snowsune.net +

{% csrf_token %}

Settings

- - + + +
+

Uploading a custom foreground will reset these settings! Tweak if needed afterwards!

+
+ +
-

Clones

-
- {% for c in data.clones %} -
- -
- - - - +

Stage art

+

Optional custom art! Kinda not tested. Defaults/examples are linked below!

+
+
+ +
+ + Default background + {% if data.settings.stage_background_url %}{% endif %} +
+
-
- - {% if c.upload_preview_url %}{% endif %} - {% if c.upload_preview_url %} -
- - +
+ +
+ + Default foreground + {% if data.settings.stage_foreground_url %}{% endif %} +
- {% endif %}
- -
- {% endfor %}
-

Liquids

+

Logs will be autogenerated based on liquid changes! (But also add more below too if you want!)

{% for l in data.liquids %}
@@ -99,7 +68,7 @@

Liquids

- +
@@ -125,7 +94,7 @@

Logs

- +
@@ -136,65 +105,25 @@

Logs

Save

+ +

Delete page

+

Remove this page. I can't really recover it if you do :

+
+ {% csrf_token %} +
+ + {% endblock %} diff --git a/apps/tanks_manager/templates/tanks_manager/hub.html b/apps/tanks_manager/templates/tanks_manager/hub.html new file mode 100644 index 0000000..d74a489 --- /dev/null +++ b/apps/tanks_manager/templates/tanks_manager/hub.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Tanks · Snowsune{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+

Vore Page Editor

+

+ Ever wanted your own version of VorePageEditor? Now you can! +
+ This is all still pretty experimental! Please report any bugs or feedback you have! vixi@snowsune.net +
+ And thank you to Seth for the initial artwork! +

+ + {% if not user.is_authenticated %} +
+

You need to log in to create or edit vore pages for your character!

+ +

+ Log in + Register +

+
+ {% else %} + + {% if tanks_owned %} +
+

Your pages

+
    + {% for site in tanks_owned %} +
  • + + {{ site.character_name|default:site.slug }} + /tanks/{{ site.slug }}/ + + + Edit + View +
    + {% csrf_token %} + + +
    +
  • + {% endfor %} +
+
+ {% endif %} + +
+ +

{% if tanks_owned %}Add another character{% else %}Create your first page{% endif %}

+ +
+ {% csrf_token %} + + +

Leave blank to auto-pick from your username ({{ username_slug_hint }} or similar).

+ + +
+ {% endif %} +
+{% endblock %} diff --git a/apps/tanks_manager/templates/tanks_manager/tank_show.html b/apps/tanks_manager/templates/tanks_manager/tank_show.html new file mode 100644 index 0000000..7618882 --- /dev/null +++ b/apps/tanks_manager/templates/tanks_manager/tank_show.html @@ -0,0 +1,64 @@ +{% load humanize %} +{% load static %} + + + + + + {% if tank_site.character_name %}{{ tank_site.character_name }} · Tank{% else %}Tank · /tanks/{{ tank_site.slug }}/{% endif %} + + + +
+ {% if tank_site.character_name %} +
+ {% if tank_site.character_url %} +

{{ tank_site.character_name }}

+ {% else %} +

{{ tank_site.character_name }}

+ {% endif %} +
+ {% endif %} +
+
+ + {% for row in liquid_layers %} +
+ {% if row.image %} + + {% endif %} + {% if row.url %} + {{ row.name }} ({{ row.volume }}%) + {% else %} + {{ row.name }} ({{ row.volume }}%) + {% endif %} +
+ {% endfor %} + +
+ + +
+ +
+ + diff --git a/apps/tanks_manager/templatetags/__init__.py b/apps/tanks_manager/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tanks_manager/templatetags/tanks_manager_tags.py b/apps/tanks_manager/templatetags/tanks_manager_tags.py new file mode 100644 index 0000000..2a146c1 --- /dev/null +++ b/apps/tanks_manager/templatetags/tanks_manager_tags.py @@ -0,0 +1,24 @@ +import re + +from django import template + +register = template.Library() + +_HEX6 = re.compile(r"^[0-9a-fA-F]{6}$") + + +@register.filter +def hex_for_color_input(value): + """Normalize stored color to #rrggbb for HTML color inputs.""" + if value is None: + return "#ffffff" + s = str(value).strip() + if not s: + return "#ffffff" + if s.startswith("#"): + s = s[1:] + if len(s) == 3 and all(c in "0123456789abcdefABCDEF" for c in s): + return "#" + "".join((c * 2) for c in s).lower() + if len(s) == 6 and _HEX6.match(s): + return "#" + s.lower() + return "#ffffff" diff --git a/apps/tanks_manager/tests.py b/apps/tanks_manager/tests.py index 7ce503c..7ecf2ac 100644 --- a/apps/tanks_manager/tests.py +++ b/apps/tanks_manager/tests.py @@ -1,3 +1,66 @@ +from io import BytesIO + from django.test import TestCase +from PIL import Image + +from apps.tanks_manager.foreground_labels import ( + STAGE_H, + STAGE_W, + analyze_stage_foreground, + compute_foreground_label_profile, + interpolate_stage_x_pct, +) + + +class ForegroundLabelProfileTests(TestCase): + def test_interpolate_brackets(self): + samples = [[0.0, 10.0], [50.0, 60.0], [100.0, 90.0]] + self.assertAlmostEqual(interpolate_stage_x_pct(samples, 0), 10.0) + self.assertAlmostEqual(interpolate_stage_x_pct(samples, 100), 90.0) + self.assertAlmostEqual(interpolate_stage_x_pct(samples, 25), 35.0) + + def test_compute_finds_vertical_transparent_gap(self): + img = Image.new("RGBA", (400, 400), (255, 0, 0, 255)) + for y in range(400): + for x in range(160, 240): + img.putpixel((x, y), (0, 0, 0, 0)) + buf = BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + profile = compute_foreground_label_profile(buf) + self.assertGreater(len(profile), 8) + mid_x = interpolate_stage_x_pct(profile, 50.0) + self.assertGreater(mid_x, 35.0) + self.assertLess(mid_x, 65.0) + + def test_analyze_detects_tank_vertical_band(self): + img = Image.new("RGBA", (STAGE_W, STAGE_H), (10, 10, 10, 255)) + for y in range(200, 650): + for x in range(STAGE_W): + img.putpixel((x, y), (0, 0, 0, 0)) + buf = BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + profile, margins = analyze_stage_foreground(buf) + self.assertIsNotNone(margins) + top, bot = margins + self.assertEqual(top, 200) + self.assertEqual(bot, STAGE_H - 650) + self.assertGreater(len(profile), 8) -# Create your tests here. + def test_analyze_extent_covers_two_separate_alpha_zones(self): + img = Image.new("RGBA", (STAGE_W, STAGE_H), (10, 10, 10, 255)) + for y in range(100, 151): + for x in range(STAGE_W): + img.putpixel((x, y), (0, 0, 0, 0)) + for y in range(500, 601): + for x in range(STAGE_W): + img.putpixel((x, y), (0, 0, 0, 0)) + buf = BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + _profile, margins = analyze_stage_foreground(buf) + self.assertIsNotNone(margins) + top, bot = margins + self.assertEqual(top, 100) + self.assertEqual(bot, STAGE_H - 601) diff --git a/apps/tanks_manager/urls.py b/apps/tanks_manager/urls.py index c3be8d6..ac9f597 100644 --- a/apps/tanks_manager/urls.py +++ b/apps/tanks_manager/urls.py @@ -5,6 +5,9 @@ app_name = "tanks_manager" urlpatterns = [ - path("data.json", views.data_json, name="data_json"), - path("", views.edit, name="edit"), + path("", views.tanks_hub, name="hub"), + path("create/", views.create_my_tank, name="create"), + path("/delete/", views.delete_my_tank, name="delete"), + path("/edit/", views.edit, name="edit"), + path("/", views.tank_show, name="show"), ] diff --git a/apps/tanks_manager/views.py b/apps/tanks_manager/views.py index d58cbaa..67cccc9 100644 --- a/apps/tanks_manager/views.py +++ b/apps/tanks_manager/views.py @@ -1,95 +1,108 @@ -import json import os import re import time -from django.conf import settings +from datetime import datetime, timedelta, timezone as dt_timezone + from django.contrib import messages -from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied from django.core.files.base import ContentFile -from django.db import transaction -from django.http import HttpResponse -from django.shortcuts import redirect, render -from django.utils.safestring import mark_safe -from django.views.decorators.http import require_GET - -from .models import TankClone, TankLiquid, TankLog, TankSettings - -CLONE_NAME_CHOICES = ( - "Azure", - "Cyan", - "Indigo", - "Lapis", - "Midnight", - "Navy", - "Royal", - "Sapphire", - "Sky", -) - - -def _settings(): - obj, _ = TankSettings.objects.get_or_create( - pk=1, - defaults=dict(tank_top_offset=360, tank_bottom_offset=101), - ) - return obj - - -def _image_json(obj): - if obj.image_file: - base = settings.SITE_URL.rstrip("/") - return f"{base}{obj.image_file.url}" - return obj.image or "" - - -def _liquid_export(li): - row = { - "name": li.name, - "volume": li.volume, - "color": li.color, - "url": li.url, - } - img = _image_json(li) - if img: - row["image"] = img - return row - - -def _data_from_db(): - s = _settings() - return { - "clones": [ +from django.db import IntegrityError, transaction +from django.shortcuts import get_object_or_404, redirect, render +from django.templatetags.static import static +from django.utils.text import slugify +from django.views.decorators.http import require_GET, require_POST + +from .foreground_labels import analyze_stage_foreground, band_anchor_stage_x_pct +from .models import TankLiquid, TankLog, TankSite, tanks_for_user + +# Stage layout: offsets are for an 850px-tall design box (see tank_page.css aspect-ratio). +_DESIGN_STAGE_HEIGHT = 850 + + +def _stage_art_urls(site, request): + """Absolute URLs for stage layers: uploads, else bundled static defaults.""" + if site.stage_background: + bg = request.build_absolute_uri(site.stage_background.url) + else: + bg = request.build_absolute_uri( + static("tanks_manager/Alice_close_up_sheath_background.png") + ) + if site.stage_foreground: + fg = request.build_absolute_uri(site.stage_foreground.url) + else: + fg = request.build_absolute_uri( + static("tanks_manager/Alice_close_up_sheath_shot.png") + ) + return bg, fg + + +def _liquid_layer_rows(liquids, tank_top, tank_bottom, request, fg_label_profile=None): + """Bottom + height as % of design stage height.""" + fg_label_profile = fg_label_profile or [] + design = _DESIGN_STAGE_HEIGHT + tank_h = max(0, design - tank_top - tank_bottom) + offset_px = 0.0 + cumulative_vol = 0 + rows = [] + for li in liquids: + vol = int(li.volume) + h_px = (vol / 100.0) * tank_h + bottom_px = tank_bottom + offset_px + cumulative_vol += vol + if h_px <= 0: + offset_px += h_px + continue + if li.image_file: + img = request.build_absolute_uri(li.image_file.url) + elif li.image: + raw = li.image.strip() + if raw.startswith(("http://", "https://", "data:")): + img = raw + elif raw.startswith("/"): + img = request.build_absolute_uri(raw) + else: + img = request.build_absolute_uri(f"/{raw.lstrip('/')}") + else: + img = "" + bottom_pct = (bottom_px / design) * 100 + height_pct = (h_px / design) * 100 + label_fg_left_pct = None + if fg_label_profile: + y_top_lo = 100.0 - bottom_pct - height_pct + y_top_hi = 100.0 - bottom_pct + sx = band_anchor_stage_x_pct(fg_label_profile, y_top_lo, y_top_hi) + label_fg_left_pct = max(0.0, min(100.0, sx)) + + rows.append( { - "name": c.name, - "banner": c.banner, - "image": _image_json(c), - "url": c.url, + "bottom_pct": bottom_pct, + "height_pct": height_pct, + "color": li.color or "#ffffff", + "image": img, + "name": li.name, + "volume": vol, + "url": li.url.strip(), + "label_nudge": cumulative_vol < 20, + "label_fg_left_pct": label_fg_left_pct, } - for c in TankClone.objects.all() - ], - "liquids": [_liquid_export(li) for li in TankLiquid.objects.all()], - "settings": { - "tankTopOffset": s.tank_top_offset, - "tankBottomOffset": s.tank_bottom_offset, - }, - "logs": [{"date": g.date, "text": g.text} for g in TankLog.objects.all()], - } + ) + offset_px += h_px + return rows + +def _logs_for_show(logs): + out = [] + now = datetime.now(dt_timezone.utc) + for g in logs: + dt = datetime.fromtimestamp(int(g.date), tz=dt_timezone.utc) + is_new = (now - dt) < timedelta(hours=8) + out.append({"dt": dt, "text": g.text, "is_new": is_new}) + return out -def _editor_data(): - s = _settings() + +def _editor_data(site): return { - "clones": [ - { - "pk": c.pk, - "name": c.name, - "banner": c.banner, - "image": c.image, - "url": c.url, - "upload_preview_url": c.image_file.url if c.image_file else "", - } - for c in TankClone.objects.all() - ], "liquids": [ { "pk": li.pk, @@ -100,13 +113,24 @@ def _editor_data(): "image": li.image, "upload_preview_url": li.image_file.url if li.image_file else "", } - for li in TankLiquid.objects.all() + for li in TankLiquid.objects.filter(tank_site=site) ], "settings": { - "tankTopOffset": s.tank_top_offset, - "tankBottomOffset": s.tank_bottom_offset, + "tankTopOffset": site.tank_top_offset, + "tankBottomOffset": site.tank_bottom_offset, + "character_name": site.character_name, + "character_url": site.character_url, + "stage_background_url": site.stage_background.url + if site.stage_background + else "", + "stage_foreground_url": site.stage_foreground.url + if site.stage_foreground + else "", }, - "logs": [{"date": g.date, "text": g.text} for g in TankLog.objects.all()], + "logs": [ + {"date": g.date, "text": g.text} + for g in TankLog.objects.filter(tank_site=site) + ], } @@ -117,21 +141,7 @@ def _idx(post, files, letter): def _build(post, files): - clones, liquids, logs = [], [], [] - for i in _idx(post, files, "c"): - raw_pk = post.get(f"c{i}_id", "").strip() - row = { - "pk": int(raw_pk) if raw_pk.isdigit() else None, - "name": post.get(f"c{i}_name", "").strip(), - "banner": post.get(f"c{i}_banner", "").strip(), - "image": post.get(f"c{i}_image", "").strip(), - "url": post.get(f"c{i}_url", "").strip(), - "_clear_upload": f"c{i}_clearimg" in post, - } - k = f"c{i}_imgfile" - if k in files and getattr(files[k], "name", ""): - row["_upload"] = files[k] - clones.append(row) + liquids, logs = [], [] for i in _idx(post, files, "l"): raw_pk = post.get(f"l{i}_id", "").strip() row = { @@ -158,11 +168,12 @@ def _build(post, files): d = now logs.append({"date": d, "text": post.get(f"g{i}_text", "").strip()}) return { - "clones": clones, "liquids": liquids, "settings": { "tankTopOffset": int(post.get("top") or 0), "tankBottomOffset": int(post.get("bot") or 0), + "character_name": post.get("char_name", "").strip()[:200], + "character_url": post.get("char_url", "").strip()[:500], }, "logs": logs, } @@ -183,38 +194,21 @@ def _liquid_change_logs(old_vols, new_liquids): return lines -def _snapshot_image_files(model_cls): +def _snapshot_image_files(model_cls, tank_site): by_pk = {} - for obj in model_cls.objects.all(): + for obj in model_cls.objects.filter(tank_site=tank_site): if obj.image_file: with obj.image_file.open("rb") as fh: by_pk[obj.pk] = (obj.image_file.name, fh.read()) return by_pk -def _save_clone(row, snap): - pk = row["pk"] - up = row.get("_upload") - clear = row.get("_clear_upload") - obj = TankClone.objects.create( - sort_order=row["_order"], - name=row["name"], - banner=row["banner"], - image=row["image"], - url=row["url"], - ) - if up: - obj.image_file.save(up.name, up, save=True) - elif pk and snap.get(pk) and not clear: - name, raw = snap[pk] - obj.image_file.save(os.path.basename(name), ContentFile(raw), save=True) - - -def _save_liquid(row, snap): +def _save_liquid(row, snap, tank_site): pk = row["pk"] up = row.get("_upload") clear = row.get("_clear_upload") obj = TankLiquid.objects.create( + tank_site=tank_site, sort_order=row["_order"], name=row["name"], volume=row["volume"], @@ -229,69 +223,228 @@ def _save_liquid(row, snap): obj.image_file.save(os.path.basename(name), ContentFile(raw), save=True) -def _dumps(data): - return json.dumps(data, indent=1, ensure_ascii=False) + "\n" +@require_GET +def tank_show(request, slug): + site = get_object_or_404(TankSite, slug=slug) + liquids = TankLiquid.objects.filter(tank_site=site) + logs = TankLog.objects.filter(tank_site=site) + bg_url, fg_url = _stage_art_urls(site, request) + return render( + request, + "tanks_manager/tank_show.html", + { + "tank_site": site, + "stage_background_url": bg_url, + "stage_foreground_url": fg_url, + "liquid_layers": _liquid_layer_rows( + liquids, + site.tank_top_offset, + site.tank_bottom_offset, + request, + fg_label_profile=site.stage_fg_label_profile or [], + ), + "logs": _logs_for_show(logs), + }, + ) @require_GET -def data_json(request): - resp = HttpResponse(_dumps(_data_from_db()), content_type="application/json") - resp["Access-Control-Allow-Origin"] = "*" - return resp +def tanks_hub(request): + tanks_owned = [] + username_slug_hint = "" + if request.user.is_authenticated: + tanks_owned = list(tanks_for_user(request.user)) + username_slug_hint = _tank_slug_from_username(request.user) + return render( + request, + "tanks_manager/hub.html", + { + "tanks_owned": tanks_owned, + "username_slug_hint": username_slug_hint, + }, + ) + + +def _tank_slug_from_username(user): + """Preferred slug from login name (slug field rules).""" + return slugify(user.username) or f"user-{user.pk}" + + +def _slug_candidates_for_new_tank(user): + """Yield slug guesses until one is unused (globally unique).""" + base = _tank_slug_from_username(user) + yield base + for n in range(2, 500): + yield f"{base}-{n}" + + +@login_required +@require_POST +def create_my_tank(request): + redirect_to = "tanks_manager:hub" + requested = slugify((request.POST.get("slug") or "").strip())[:50] + + if requested: + if TankSite.objects.filter(slug=requested).exists(): + messages.error( + request, + "That URL slug is already taken. Pick a different one.", + ) + return redirect(redirect_to) + try: + TankSite.objects.create(owner=request.user, slug=requested) + except IntegrityError: + messages.error( + request, + "Could not create that slug — try another.", + ) + return redirect(redirect_to) + slug = requested + else: + slug = None + for cand in _slug_candidates_for_new_tank(request.user): + if TankSite.objects.filter(slug=cand).exists(): + continue + try: + TankSite.objects.create(owner=request.user, slug=cand) + slug = cand + break + except IntegrityError: + continue + if slug is None: + messages.error( + request, + "Could not allocate a URL slug. Try entering one manually.", + ) + return redirect(redirect_to) + + messages.success( + request, + "Tank page created — customize it in the editor.", + ) + return redirect("tanks_manager:edit", slug=slug) @transaction.atomic -def _persist(data): - old_vols = {li.name: li.volume for li in TankLiquid.objects.order_by("sort_order")} - clone_snap = _snapshot_image_files(TankClone) - liquid_snap = _snapshot_image_files(TankLiquid) - - s = _settings() - s.tank_top_offset = data["settings"]["tankTopOffset"] - s.tank_bottom_offset = data["settings"]["tankBottomOffset"] - s.save() - - TankClone.objects.all().delete() - for i, row in enumerate(data["clones"]): - row["_order"] = i - _save_clone(row, clone_snap) +def _persist(data, tank_site): + old_vols = { + li.name: li.volume + for li in TankLiquid.objects.filter(tank_site=tank_site).order_by("sort_order") + } + liquid_snap = _snapshot_image_files(TankLiquid, tank_site) + + tank_site.tank_top_offset = data["settings"]["tankTopOffset"] + tank_site.tank_bottom_offset = data["settings"]["tankBottomOffset"] + tank_site.character_name = data["settings"]["character_name"] + tank_site.character_url = data["settings"]["character_url"] + tank_site.save( + update_fields=[ + "tank_top_offset", + "tank_bottom_offset", + "character_name", + "character_url", + ] + ) - auto = _liquid_change_logs(old_vols, data["liquids"]) - TankLiquid.objects.all().delete() + TankLiquid.objects.filter(tank_site=tank_site).delete() for i, row in enumerate(data["liquids"]): row["_order"] = i - _save_liquid(row, liquid_snap) + _save_liquid(row, liquid_snap, tank_site) - TankLog.objects.all().delete() + auto = _liquid_change_logs(old_vols, data["liquids"]) + TankLog.objects.filter(tank_site=tank_site).delete() TankLog.objects.bulk_create( - [TankLog(date=g["date"], text=g["text"]) for g in data["logs"]] + [ + TankLog(tank_site=tank_site, date=g["date"], text=g["text"]) + for g in data["logs"] + ] ) if auto: - TankLog.objects.bulk_create([TankLog(date=d, text=t) for d, t in auto]) - - -@staff_member_required -def edit(request): - err = None - data = _editor_data() + TankLog.objects.bulk_create( + [TankLog(tank_site=tank_site, date=d, text=t) for d, t in auto] + ) + + +def _can_edit_tank(user, site): + return user.is_staff or (user.is_authenticated and user.pk == site.owner_id) + + +def _refresh_stage_foreground_analysis(site): + """Rebuild label profile + tank top/bottom offsets from overlay transparency.""" + if not site.stage_foreground: + site.stage_fg_label_profile = [] + site.save(update_fields=["stage_fg_label_profile"]) + return + try: + with site.stage_foreground.open("rb") as fh: + profile, margins = analyze_stage_foreground(fh) + except Exception: + profile, margins = [], None + site.stage_fg_label_profile = profile + update_fields = ["stage_fg_label_profile"] + if margins is not None: + top, bot = margins + site.tank_top_offset = top + site.tank_bottom_offset = bot + update_fields.extend(["tank_top_offset", "tank_bottom_offset"]) + site.save(update_fields=update_fields) + + +@login_required +@require_POST +def delete_my_tank(request, slug): + site = get_object_or_404(TankSite, slug=slug) + if not _can_edit_tank(request.user, site): + raise PermissionDenied + label = site.character_name or site.slug + site.delete() + messages.success(request, f"Deleted “{label}”.") + return redirect("tanks_manager:hub") + + +@login_required +def edit(request, slug): + site = get_object_or_404(TankSite, slug=slug) + if not _can_edit_tank(request.user, site): + raise PermissionDenied + + data = _editor_data(site) if request.method == "POST" and "save" in request.POST: try: data = _build(request.POST, request.FILES) - _persist(data) + _persist(data, site) + site = get_object_or_404(TankSite, slug=slug) + if request.POST.get("clear_stage_bg"): + if site.stage_background: + site.stage_background.delete(save=False) + site.stage_background = None + site.save(update_fields=["stage_background"]) + if request.POST.get("clear_stage_fg"): + if site.stage_foreground: + site.stage_foreground.delete(save=False) + site.stage_foreground = None + site.stage_fg_label_profile = [] + site.save(update_fields=["stage_foreground", "stage_fg_label_profile"]) + site = get_object_or_404(TankSite, slug=slug) + if f := request.FILES.get("stage_bg"): + if getattr(f, "name", ""): + site.stage_background.save(f.name, f, save=True) + if f := request.FILES.get("stage_fg"): + if getattr(f, "name", ""): + site.stage_foreground.save(f.name, f, save=True) + _refresh_stage_foreground_analysis(site) messages.success(request, "Saved.") - return redirect("tanks_manager:edit") + return redirect("tanks_manager:edit", slug=site.slug) except Exception as e: - err = str(e) + messages.error(request, str(e)) return render( request, "tanks_manager/edit.html", { + "tank_site": site, "data": data, - "err": err, "unix_now": int(time.time()), - "clone_names": CLONE_NAME_CHOICES, - "clone_names_json": mark_safe(json.dumps(CLONE_NAME_CHOICES)), }, ) diff --git a/apps/users/templates/users/edit.html b/apps/users/templates/users/edit.html index eaef1ce..ab882e1 100644 --- a/apps/users/templates/users/edit.html +++ b/apps/users/templates/users/edit.html @@ -175,12 +175,6 @@ {% block content %}

Edit your account

-{% if messages %} -{% for message in messages %} -
{{ message }}
-{% endfor %} -{% endif %} - +
+ + {% endif %} + +
+

Vixi's Tools

+
+ +
+
+ Commission Organizer Tool +
+
+

Commission Organizer

+

A comission organizer tool I made! You can track comissions, publish to your clients. Integrates + with discord webhooks too! And works with a snowsune.net account or not. Please try it and leave + feedback!

+ Try My Organizer! +
+
@@ -169,12 +178,33 @@

Quick Access

{% endif %} + {% if user.is_authenticated %} + + {% endif %} + + {% include "includes/django_messages.html" %} + {% if user.is_authenticated and not user.email_verified %} + + - - - diff --git a/templates/includes/django_messages.html b/templates/includes/django_messages.html new file mode 100644 index 0000000..bd06f1c --- /dev/null +++ b/templates/includes/django_messages.html @@ -0,0 +1,10 @@ +{% if messages %} +{# Consumed by base.html → notifications.js as toasts; kept off-screen until then #} + +{% endif %}