Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions apps/bot_manager/templates/bot_manager/add_subscription.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,6 @@ <h1>Add New Subscription</h1>

<a href="javascript:history.back()">← Back</a>

{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}

<form method="post">
{% csrf_token %}

Expand Down
6 changes: 0 additions & 6 deletions apps/bot_manager/templates/bot_manager/edit_subscription.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,6 @@ <h1>Edit Subscription</h1>

<a href="javascript:history.back()">← Back</a>

{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}

<form method="post">
{% csrf_token %}

Expand Down
6 changes: 0 additions & 6 deletions apps/bot_manager/templates/bot_manager/guild_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,6 @@ <h2>Recent Logs</h2>
</div>
{% endif %}

{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% endif %}

<h2>Guild Info</h2>
<table>
<tr>
Expand Down
21 changes: 21 additions & 0 deletions apps/custompages/migrations/0002_alter_custompage_path.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
29 changes: 27 additions & 2 deletions apps/notifications/static/notifications/js/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 = {
Expand Down Expand Up @@ -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';
}

Expand Down
29 changes: 22 additions & 7 deletions apps/tanks_manager/admin.py
Original file line number Diff line number Diff line change
@@ -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"]
190 changes: 190 additions & 0 deletions apps/tanks_manager/foreground_labels.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading