Add icons to dashboard
All checks were successful
Deploy to Server / deploy (push) Successful in 27s

This commit is contained in:
Joakim Hellsén 2026-07-21 06:38:09 +02:00
commit a74b550406
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 238 additions and 222 deletions

View file

@ -1015,7 +1015,7 @@ def search_view(request: HttpRequest) -> HttpResponse:
# MARK: /
def dashboard(request: HttpRequest) -> HttpResponse:
def dashboard(request: HttpRequest) -> HttpResponse: # ruff:ignore[too-many-locals, too-many-statements]
"""Dashboard view showing summary stats and latest campaigns.
Args:
@ -1038,6 +1038,27 @@ def dashboard(request: HttpRequest) -> HttpResponse:
to_attr="channels_ordered",
),
)
.prefetch_related(
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.only(
"twitch_id",
"name",
"required_minutes_watched",
"campaign_id",
).prefetch_related(
Prefetch(
"benefits",
queryset=DropBenefit.objects.only(
"twitch_id",
"name",
"image_asset_url",
"image_file",
),
),
),
),
)
.order_by("-start_at")
)
@ -1052,9 +1073,41 @@ def dashboard(request: HttpRequest) -> HttpResponse:
"owners": list(game.owners.all()),
"campaigns": [],
}
# Collect unique benefits and metadata across all time-based drops
seen_benefits: set[str] = set()
benefits_list: list[DropBenefit] = []
min_watch: int = 0
max_watch: int = 0
for drop in campaign.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
if drop.required_minutes_watched is not None:
if min_watch == 0 or drop.required_minutes_watched < min_watch:
min_watch = drop.required_minutes_watched
max_watch = max(max_watch, drop.required_minutes_watched)
for benefit in drop.benefits.all():
if benefit.twitch_id not in seen_benefits:
seen_benefits.add(benefit.twitch_id)
benefits_list.append(benefit)
watch_range: str = ""
if min_watch and max_watch:
if min_watch == max_watch:
watch_range = f"{min_watch}m watch"
else:
watch_range = f"{min_watch}m-{max_watch}m watch"
elif min_watch:
watch_range = f"{min_watch}m watch"
# Get the publisher (first owner, if any)
owners: list[Organization] = getattr(game, "owners_for_dashboard", [])
publisher: str = owners[0].name if owners else ""
twitch_campaigns_by_game[game_id]["campaigns"].append({
"campaign": campaign,
"allowed_channels": getattr(campaign, "channels_ordered", []),
"benefits": benefits_list,
"watch_range": watch_range,
"publisher": publisher,
})
active_kick_campaigns: QuerySet[KickDropCampaign] = (

View file

@ -30,6 +30,11 @@
href="{% url 'kick:campaign_feed_discord' %}" />
{% endblock extra_head %}
{% block content %}
<style>
.campaign-entry:hover {
background: color-mix(in srgb, CanvasText 5%, transparent 95%);
}
</style>
<main>
<h1>Open Drop Campaigns</h1>
<p>
@ -54,112 +59,90 @@
</header>
{% if campaigns_by_game %}
{% for game_id, game_data in campaigns_by_game.items %}
<article id="twitch-game-article-{{ game_id }}" style="margin-bottom: 2rem;">
<header style="margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem 0;">
<a href="{% url 'twitch:game_detail' game_id %}">{{ game_data.name }}</a>
</h3>
{% if game_data.owners %}
<div style="font-size: 0.9rem; color: #666;">
Organizations:
{% for org in game_data.owners %}
<a href="{% url 'twitch:organization_detail' org.twitch_id %}">{{ org.name }}</a>
{% if not forloop.last %},{% endif %}
{% endfor %}
</div>
{% endif %}
<article id="twitch-game-article-{{ game_id }}"
style="margin-bottom: 2rem;
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent 75%);
border-radius: 6px;
padding: 1rem;">
<header style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem;">
<a href="{% url 'twitch:game_detail' game_id %}" style="flex-shrink: 0; line-height: 0;">
{% picture game_data.box_art alt="Box art for "|add:game_data.name width=72 %}
</a>
<div>
<h3 style="margin: 0; font-size: 1.5rem;">
<a href="{% url 'twitch:game_detail' game_id %}" style="text-decoration: none; color: inherit;">{{ game_data.name }}</a>
</h3>
{% if game_data.owners %}
<div style="font-size: 1.1rem; color: color-mix(in srgb, CanvasText 75%, transparent 25%);">
{% for org in game_data.owners %}
<a href="{% url 'twitch:organization_detail' org.twitch_id %}" style="color: inherit;">{{ org.name }}</a>
{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</header>
<div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;">{% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 %}</div>
<div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
<div style="overflow-x: auto; padding: 0.25rem 0;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %}
<article style="display: flex;
<article class="campaign-entry" style="display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
flex-shrink: 0">
<div>
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
{% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 %}
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4>
flex-shrink: 0;
width: 450px;
padding: 0.9rem;
border-radius: 8px;">
<div style="display: flex; gap: 1rem;">
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}"
style="flex-shrink: 0; line-height: 0;">
{% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 %}
</a>
<div style="flex: 1; min-width: 0;">
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}"
style="text-decoration: none; color: inherit;">
<h4 style="margin: 0 0 0.2rem 0; font-size: 1.3rem; line-height: 1.2;">{{ campaign_data.campaign.clean_name }}</h4>
</a>
<time datetime="{{ campaign_data.campaign.end_at|date:'c' }}"
title="{{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Ends in {{ campaign_data.campaign.end_at|timeuntil }}
</time>
<time datetime="{{ campaign_data.campaign.start_at|date:'c' }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Started {{ campaign_data.campaign.start_at|timesince }} ago
</time>
<time datetime="{{ campaign_data.campaign.duration_iso }}"
title="{{ campaign_data.campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign_data.campaign.end_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Duration: {{ campaign_data.campaign.start_at|timesince:campaign_data.campaign.end_at }}
</time>
<div style="margin-top: 0.5rem; font-size: 0.8rem; ">
<strong>Channels:</strong>
<ul style="margin: 0.25rem 0 0 0;
padding-left: 1rem;
list-style-type: none">
{% if campaign_data.campaign.allow_is_enabled %}
{% if campaign_data.allowed_channels %}
{% for channel in campaign_data.allowed_channels|slice:":5" %}
<li style="margin-bottom: 0.1rem;">
<a href="https://twitch.tv/{{ channel.name }}"
title="Watch {{ channel.display_name }} on Twitch">
{{ channel.display_name }}</a><a href="{% url 'twitch:channel_detail' channel.twitch_id %}"
title="View {{ channel.display_name }} details"
style="font-family: monospace;
text-decoration: none">[i]</a>
</li>
{% endfor %}
{% else %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
title="Find streamers playing {{ campaign_data.campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
</li>
{% else %}
<li>Failed to get Twitch directory URL :(</li>
{% endif %}
{% endif %}
{% if campaign_data.allowed_channels|length > 5 %}
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign_data.allowed_channels|length|add:"-5" }} more
</li>
{% endif %}
{% else %}
{% if campaign_data.campaign.game.twitch_directory_url %}
<li>
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
title="Find streamers playing {{ campaign_data.campaign.game.display_name }} with drops enabled">
Go to a participating live channel
</a>
</li>
{% else %}
<li>Failed to get Twitch directory URL :(</li>
{% endif %}
{% endif %}
</ul>
</div>
{% if campaign_data.publisher %}
<div style="font-size: 1rem; color: color-mix(in srgb, CanvasText 75%, transparent 25%);">{{ campaign_data.publisher }}</div>
{% endif %}
<div style="font-size: 0.9rem; color: color-mix(in srgb, CanvasText 70%, transparent 30%); margin-top: 0.4rem;">
{% if campaign_data.watch_range %}{{ campaign_data.watch_range }} &middot; {% endif %}
ends in {{ campaign_data.campaign.end_at|timeuntil }}
</div>
</article>
<div style="font-size: 0.85rem; margin-top: 0.1rem;">
{% if campaign_data.allowed_channels %}
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}#allowed-channels"
style="color: color-mix(in srgb, CanvasText 60%, transparent 40%); text-decoration: none;">
{{ campaign_data.allowed_channels.0.display_name }}{% if campaign_data.allowed_channels|length > 1 %} +{{ campaign_data.allowed_channels|length|add:'-1' }} more{% endif %}
</a>
{% else %}
<a href="{{ campaign_data.campaign.game.twitch_directory_url }}"
style="color: color-mix(in srgb, CanvasText 60%, transparent 40%); text-decoration: none;">
any channel
</a>
{% endif %}
</div>
</div>
</div>
{% if campaign_data.benefits %}
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem;">
{% for benefit in campaign_data.benefits %}
<span style="display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.6rem; font-size: 1.1rem; background: color-mix(in srgb, CanvasText 8%, transparent 92%); border-radius: 6px;">
{% if benefit.image_best_url %}
<img src="{{ benefit.image_best_url }}"
alt="{{ benefit.name }}"
style="width: 30px; height: 30px; border-radius: 4px; object-fit: contain;"
loading="lazy" />
{% endif %}
{{ benefit.name }}
</span>
{% endfor %}
</div>
{% endif %}
</article>
{% endfor %}
</div>
</div>
</div>
</article>
</article>
{% endfor %}
{% else %}
<p>No active Twitch campaigns at the moment.</p>
@ -249,133 +232,113 @@
</header>
{% if kick_campaigns_by_game %}
{% for game_id, game_data in kick_campaigns_by_game.items %}
<article id="kick-game-article-{{ game_id }}" style="margin-bottom: 2rem;">
<header style="margin-bottom: 1rem;">
<h3 style="margin: 0 0 0.5rem 0;">
{% if game_data.kick_id %}
<a href="{% url 'kick:game_detail' game_data.kick_id %}">{{ game_data.name }}</a>
{% elif game_data.organizations %}
{% for org in game_data.organizations %}
<a href="{% url 'kick:organization_detail' org.kick_id %}">{{ org.name }}</a>
{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
{{ game_data.name }}
<article id="kick-game-article-{{ game_id }}"
style="margin-bottom: 2rem;
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent 75%);
border-radius: 6px;
padding: 1rem;">
<header style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem;">
{% if game_data.image %}
<img src="{{ game_data.image }}"
width="48"
height="48"
alt="Image for {{ game_data.name }}"
style="width: 72px; height: 72px; border-radius: 8px; object-fit: cover; flex-shrink: 0;" />
{% endif %}
<div>
<h3 style="margin: 0; font-size: 1.5rem;">
{% if game_data.kick_id %}
<a href="{% url 'kick:game_detail' game_data.kick_id %}" style="text-decoration: none; color: inherit;">{{ game_data.name }}</a>
{% elif game_data.organizations %}
{% for org in game_data.organizations %}
<a href="{% url 'kick:organization_detail' org.kick_id %}" style="text-decoration: none; color: inherit;">{{ org.name }}</a>
{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
{{ game_data.name }}
{% endif %}
</h3>
{% if game_data.kick_id and game_data.organizations %}
<div style="font-size: 1.1rem; color: color-mix(in srgb, CanvasText 75%, transparent 25%);">
{% for org in game_data.organizations %}
<a href="{% url 'kick:organization_detail' org.kick_id %}" style="color: inherit;">{{ org.name }}</a>
{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</h3>
{% if game_data.kick_id and game_data.organizations %}
<div style="font-size: 0.9rem; color: #666;">
Organization:
{% for org in game_data.organizations %}
<a href="{% url 'kick:organization_detail' org.kick_id %}">{{ org.name }}</a>
{% if not forloop.last %}, {% endif %}
</div>
</header>
<div style="overflow-x: auto; padding: 0.25rem 0;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %}
{% with rewards=campaign_data.rewards %}
{% with min_watch=rewards.0.required_units|default:0 max_watch=rewards.last.required_units|default:0 %}
<article class="campaign-entry" style="display: flex;
flex-direction: column;
flex-shrink: 0;
width: 450px;
padding: 0.9rem;
border-radius: 8px;">
<div style="display: flex; gap: 1rem;">
<a href="{% url 'kick:campaign_detail' campaign_data.campaign.kick_id %}"
style="flex-shrink: 0; line-height: 0;">
{% if campaign_data.campaign.image_url %}
<img src="{{ campaign_data.campaign.image_url }}"
width="120"
height="120"
alt="Image for {{ campaign_data.campaign.name }}"
style="width: 120px; height: 120px; border-radius: 8px; object-fit: cover;" />
{% endif %}
</a>
<div style="flex: 1; min-width: 0;">
<a href="{% url 'kick:campaign_detail' campaign_data.campaign.kick_id %}"
style="text-decoration: none; color: inherit;">
<h4 style="margin: 0 0 0.2rem 0; font-size: 1.3rem; line-height: 1.2;">{{ campaign_data.campaign.name }}</h4>
</a>
{% if campaign_data.campaign.organization %}
<div style="font-size: 1rem; color: color-mix(in srgb, CanvasText 75%, transparent 25%);">{{ campaign_data.campaign.organization.name }}</div>
{% endif %}
<div style="font-size: 0.9rem; color: color-mix(in srgb, CanvasText 70%, transparent 30%); margin-top: 0.4rem;">
{% if min_watch %}{% if min_watch == max_watch %}{{ min_watch }}m watch{% else %}{{ min_watch }}m{{ max_watch }}m watch{% endif %} &middot; {% endif %}
ends in {{ campaign_data.campaign.ends_at|timeuntil }}
</div>
<div style="font-size: 0.85rem; margin-top: 0.1rem;">
{% if campaign_data.channels %}
<a href="{% url 'kick:campaign_detail' campaign_data.campaign.kick_id %}#allowed-channels"
style="color: color-mix(in srgb, CanvasText 60%, transparent 40%); text-decoration: none;">
{{ campaign_data.channels.0.user.username|default:campaign_data.channels.0.slug }}{% if campaign_data.channels|length > 1 %} +{{ campaign_data.channels|length|add:'-1' }} more{% endif %}
</a>
{% else %}
<a href="{{ campaign_data.campaign.url }}"
style="color: color-mix(in srgb, CanvasText 60%, transparent 40%); text-decoration: none;">
any channel
</a>
{% endif %}
</div>
</div>
</div>
{% if rewards %}
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem;">
{% for reward in rewards %}
<span style="display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.6rem; font-size: 1.1rem; background: color-mix(in srgb, CanvasText 8%, transparent 92%); border-radius: 6px;">
{% if reward.full_image_url %}
<img src="{{ reward.full_image_url }}"
alt="{{ reward.name }}"
style="width: 30px; height: 30px; border-radius: 4px; object-fit: contain;"
loading="lazy" />
{% endif %}
{{ reward.name }}
</span>
{% endfor %}
</div>
{% endif %}
</header>
<div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;">
{% if game_data.image %}
<img src="{{ game_data.image }}"
width="200"
height="200"
alt="Image for {{ game_data.name }}"
style="width: 200px;
height: auto;
border-radius: 8px" />
{% else %}
<div style="width: 200px;
height: 200px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ddd">No Image</div>
{% endif %}
</div>
<div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;">
{% for campaign_data in game_data.campaigns %}
<article style="display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem;
flex-shrink: 0;
width: 260px">
<div>
<a href="{% url 'kick:campaign_detail' campaign_data.campaign.kick_id %}">
{% if campaign_data.campaign.image_url %}
<img src="{{ campaign_data.campaign.image_url }}"
width="120"
height="120"
alt="Image for {{ campaign_data.campaign.name }}"
style="width: 120px;
height: auto;
border-radius: 8px" />
{% endif %}
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.name }}</h4>
</a>
<time datetime="{{ campaign_data.campaign.ends_at|date:'c' }}"
title="{{ campaign_data.campaign.ends_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Ends in {{ campaign_data.campaign.ends_at|timeuntil }}
</time>
<time datetime="{{ campaign_data.campaign.starts_at|date:'c' }}"
title="{{ campaign_data.campaign.starts_at|date:'DATETIME_FORMAT' }}"
style="font-size: 0.9rem;
display: block;
text-align: left">
Started {{ campaign_data.campaign.starts_at|timesince }} ago
</time>
<div style="margin-top: 0.5rem; font-size: 0.8rem;">
<strong>Channels:</strong>
<ul style="margin: 0.25rem 0 0 0;
padding-left: 1rem;
list-style-type: none">
{% if campaign_data.channels %}
{% for channel in campaign_data.channels|slice:":5" %}
<li style="margin-bottom: 0.1rem;">
<a href="{{ channel.channel_url }}">
{% if channel.user %}
{{ channel.user.username }}
{% else %}
{{ channel.slug }}
{% endif %}
</a>
</li>
{% endfor %}
{% if campaign_data.channels|length > 5 %}
<li style="margin-bottom: 0.1rem; color: #666; font-style: italic;">
... and {{ campaign_data.channels|length|add:"-5" }} more
</li>
{% endif %}
{% else %}
<li>No specific channels listed.</li>
{% endif %}
</ul>
</div>
{% if campaign_data.rewards %}
<div style="margin-top: 0.5rem; font-size: 0.8rem;">
<strong>Rewards:</strong>
<ul style="margin: 0.25rem 0 0 0; padding-left: 1rem;">
{% for reward in campaign_data.rewards|slice:":3" %}
<li>{{ reward.name }} ({{ reward.required_units }} min)</li>
{% endfor %}
{% if campaign_data.rewards|length > 3 %}
<li style="color: #666; font-style: italic;">... and {{ campaign_data.rewards|length|add:"-3" }} more</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</article>
{% endfor %}
</div>
</div>
</div>
</article>
</article>
{% endwith %}
{% endwith %}
{% endfor %}
</div>
</div>
</article>
{% endfor %}
{% else %}
<p>No active Kick campaigns at the moment.</p>

View file

@ -121,7 +121,7 @@
</dl>
<div>
{% if campaign.channels.all %}
Participating channels:
<span id="allowed-channels">Participating channels:</span>
{% for channel in campaign.channels.all|slice:":5" %}
{% if channel.user %}
<a href="{{ channel.channel_url }}">{{ channel.user.username }}</a>

View file

@ -192,7 +192,7 @@
<!-- Allowed channels -->
{% if allowed_channels %}
<!-- If specific allowed channels are specified, list them -->
<h5>Allowed Channels</h5>
<h5 id="allowed-channels">Allowed Channels</h5>
<div>
{% for channel in allowed_channels %}
<a href="{% url 'twitch:channel_detail' channel.twitch_id %}">{{ channel.display_name }}</a>