Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season

Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-28 23:32:21 +02:00
parent 4b2c5fa15d
commit b53c7fb736
178 changed files with 28933 additions and 1908 deletions
@@ -23,6 +23,9 @@ const {
winnerSelections,
clearWinner,
saveWinner,
workflowRuleSummary,
selectedWinnerIdentitySummary,
selectedWinnerClipPreview,
winnerRuleNoticeFor,
} = useAdminWinnersManager()
@@ -101,6 +104,25 @@ watchAdminToast(adminMessage, adminError)
</button>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<span
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
:class="workflowRuleSummary.maxWinnerPlacements?.enabled ? 'border-violet-200 bg-violet-50 text-violet-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
>
{{ workflowRuleSummary.maxWinnerPlacements?.enabled
? `Gewinnerlimit aktiv: ${workflowRuleSummary.maxWinnerPlacements.limit}x pro Person`
: 'Gewinnerlimit aus' }}
</span>
<span
class="rounded-full border px-3 py-1.5 text-xs font-semibold"
:class="workflowRuleSummary.winnerRequiresClip?.enabled ? 'border-amber-200 bg-amber-50 text-amber-800' : 'border-slate-200 bg-slate-50 text-slate-600'"
>
{{ workflowRuleSummary.winnerRequiresClip?.enabled
? `Clip-Pflicht ${workflowRuleSummary.winnerRequiresClip.mode === 'warn' ? 'als Warnung' : 'als Blocker'}`
: 'Clip-Pflicht aus' }}
</span>
</div>
</div>
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
@@ -155,6 +177,69 @@ watchAdminToast(adminMessage, adminError)
{{ winnerRuleNoticeFor(row.category.id)?.mode === 'block' ? 'Blockiert' : 'Warnung' }}:
{{ winnerRuleNoticeFor(row.category.id)?.message }}
</p>
<div
v-if="selectedWinnerIdentitySummary(row.category.id)"
class="grid gap-2 sm:grid-cols-4"
>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Kandidaturen</p>
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.appearances }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Gewinner</p>
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.winnerPlacements }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Viewer-Nominierungen</p>
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.nominationTally }}</strong>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/40 px-3 py-2">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Clip</p>
<strong class="mt-1 block text-sm text-violet-900">{{ selectedWinnerIdentitySummary(row.category.id)?.hasClip ? 'vorhanden' : 'fehlt' }}</strong>
</div>
</div>
<div
v-if="selectedWinnerClipPreview(row.category.id)"
class="rounded-[22px] border border-violet-100 bg-violet-50/40 p-3"
>
<div class="mb-2 flex items-center justify-between gap-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">Winner Preview</p>
<span class="rounded-full border border-violet-100 bg-white px-2.5 py-1 text-[10px] font-semibold text-violet-700">
{{ selectedWinnerClipPreview(row.category.id)?.clipPlatform }}
</span>
</div>
<iframe
v-if="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl"
:src="selectedWinnerClipPreview(row.category.id)?.clipEmbedUrl ?? undefined"
:title="selectedWinnerClipPreview(row.category.id)?.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
class="w-full rounded-2xl border-0 bg-slate-950"
style="aspect-ratio:16/9;"
/>
<a
v-else-if="selectedWinnerClipPreview(row.category.id)?.clipUrl"
:href="selectedWinnerClipPreview(row.category.id)?.clipUrl ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-violet-100 bg-white px-4 text-center text-sm font-semibold text-violet-700 hover:bg-violet-100/60"
>
{{ selectedWinnerClipPreview(row.category.id)?.clipTitle }}
</a>
<div
v-else
class="flex aspect-[16/9] items-center justify-center rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 text-center text-sm font-semibold text-amber-700"
>
Noch kein Clip-Link gepflegt.
</div>
</div>
</div>
<div class="flex flex-wrap justify-end gap-2">