Align backend with frontend, make clips end-to-end, polish admin

Backend
- Add ClipSubmission entity + table (runtime bootstrapper) and
  POST /api/public/clips (server-derives platform from the link)
- Surface clip submissions in the admin season detail
- Add DELETE candidate/category/clip endpoints with audit entries

Frontend
- Clips admin: real moderation view (list, open link, delete) instead
  of placeholder; wired clipSubmissions through types/api/store
- Categories admin: add delete with confirm modal (matches Candidates)
- Voting ranking bar uses the unified purple gradient
- Fix ASCII transliterations to proper German umlauts across admin
- Remove orphan AdminView 2.vue

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-18 07:53:19 +02:00
parent cca297a4eb
commit 24f9a69022
22 changed files with 504 additions and 145 deletions
+13 -1
View File
@@ -67,6 +67,17 @@ public sealed record AdminNominationReviewItemDto(
string CandidateText, string CandidateText,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
public sealed record AdminClipSubmissionItemDto(
int Id,
int? CategoryId,
string SubmittedByTwitchId,
string ClipUrl,
string Title,
string Creator,
string Platform,
string Status,
DateTimeOffset CreatedAt);
public sealed record AdminSeasonDetailResponse( public sealed record AdminSeasonDetailResponse(
int Id, int Id,
int Year, int Year,
@@ -75,7 +86,8 @@ public sealed record AdminSeasonDetailResponse(
bool IsCurrent, bool IsCurrent,
IEnumerable<AdminCategoryItemDto> Categories, IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates, IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations); IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
public sealed record UpdateSeasonRequest( public sealed record UpdateSeasonRequest(
string CurrentPhase, string CurrentPhase,
+8
View File
@@ -77,3 +77,11 @@ public sealed record CreateVoteRequest(
int SeasonId, int SeasonId,
string TwitchUserId, string TwitchUserId,
VoteEntryRequest[] Entries); VoteEntryRequest[] Entries);
public sealed record CreateClipRequest(
int Year,
int? CategoryId,
string TwitchUserId,
string ClipUrl,
string Title,
string Creator);
+13
View File
@@ -15,6 +15,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<UserSession> UserSessions => Set<UserSession>(); public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>(); public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>(); public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -90,6 +91,18 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Summary).HasMaxLength(240); entity.Property(item => item.Summary).HasMaxLength(240);
}); });
modelBuilder.Entity<ClipSubmission>(entity =>
{
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.ClipUrl).HasMaxLength(500);
entity.Property(item => item.Title).HasMaxLength(200);
entity.Property(item => item.Creator).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.Status });
});
SeedData.Apply(modelBuilder); SeedData.Apply(modelBuilder);
} }
} }
@@ -49,5 +49,22 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt" CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt" DESC); ON "AdminAuditEntries" ("CreatedAt" DESC);
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"CategoryId" integer NULL,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"ClipUrl" character varying(500) NOT NULL,
"Title" character varying(200) NOT NULL,
"Creator" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
"Status" character varying(20) NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status");
"""); """);
} }
+16
View File
@@ -0,0 +1,16 @@
namespace Backend.Domain;
public sealed class ClipSubmission
{
public int Id { get; set; }
public int SeasonId { get; set; }
public int? CategoryId { get; set; }
public string SubmittedByTwitchId { get; set; } = string.Empty;
public string ClipUrl { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public string Status { get; set; } = "pending";
public string CreatedFromIp { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}
+179 -1
View File
@@ -621,6 +621,70 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
.WithName("CreateVote") .WithName("CreateVote")
.WithOpenApi(); .WithOpenApi();
app.MapPost("/api/public/clips", async (HttpContext context, CreateClipRequest request, AwardsDbContext db) =>
{
var clipUrl = request.ClipUrl?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(clipUrl))
{
return Results.BadRequest(new { message = "A clip link is required." });
}
var loweredUrl = clipUrl.ToLowerInvariant();
var platform = loweredUrl.Contains("twitch.tv")
? "Twitch"
: loweredUrl.Contains("youtube.com") || loweredUrl.Contains("youtu.be")
? "YouTube"
: "Other";
if (platform == "Other")
{
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
if (season is null)
{
return Results.BadRequest(new { message = "The selected season does not exist." });
}
if (request.CategoryId is int categoryId)
{
var categoryExists = await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
if (!categoryExists)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
}
var session = await ResolveSessionAsync(context, db);
var submitterId = session?.TwitchUserId ?? request.TwitchUserId;
if (string.IsNullOrWhiteSpace(submitterId))
{
return Results.BadRequest(new { message = "A logged in user is required to submit clips." });
}
var clip = new ClipSubmission
{
SeasonId = season.Id,
CategoryId = request.CategoryId,
SubmittedByTwitchId = submitterId,
ClipUrl = clipUrl,
Title = request.Title?.Trim() ?? string.Empty,
Creator = request.Creator?.Trim() ?? string.Empty,
Platform = platform,
Status = "pending",
CreatedFromIp = ReadClientIp(context),
CreatedAt = DateTimeOffset.UtcNow,
};
db.ClipSubmissions.Add(clip);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, clipId = clip.Id });
})
.WithName("CreateClip")
.WithOpenApi();
app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) => app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) =>
{ {
var session = await ResolveSessionAsync(context, db); var session = await ResolveSessionAsync(context, db);
@@ -809,6 +873,23 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
item.CreatedAt)) item.CreatedAt))
.ToArrayAsync(); .ToArrayAsync();
var clipSubmissions = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderByDescending(item => item.CreatedAt)
.Take(100)
.Select(item => new AdminClipSubmissionItemDto(
item.Id,
item.CategoryId,
item.SubmittedByTwitchId,
item.ClipUrl,
item.Title,
item.Creator,
item.Platform,
item.Status,
item.CreatedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse( return Results.Ok(new AdminSeasonDetailResponse(
season.Id, season.Id,
season.Year, season.Year,
@@ -817,7 +898,8 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
season.IsCurrent, season.IsCurrent,
categories, categories,
candidates, candidates,
pendingNominations)); pendingNominations,
clipSubmissions));
}) })
.WithName("GetAdminSeasonDetail") .WithName("GetAdminSeasonDetail")
.WithOpenApi(); .WithOpenApi();
@@ -1013,6 +1095,102 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
.WithName("UpdateAdminCandidate") .WithName("UpdateAdminCandidate")
.WithOpenApi(); .WithOpenApi();
app.MapDelete("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
if (candidate is null)
{
return Results.NotFound();
}
db.Candidates.Remove(candidate);
AddAuditEntry(
db,
session.TwitchUserId,
"candidate.delete",
"candidate",
candidate.Id.ToString(),
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
new { candidate.CategoryId, candidate.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, candidateId });
})
.WithName("DeleteAdminCandidate")
.WithOpenApi();
app.MapDelete("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
if (category is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
if (candidates.Length > 0)
{
db.Candidates.RemoveRange(candidates);
}
db.Categories.Remove(category);
AddAuditEntry(
db,
session.TwitchUserId,
"category.delete",
"category",
category.Id.ToString(),
$"Kategorie {category.Name} wurde gelöscht.",
new { removedCandidates = candidates.Length });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, categoryId });
})
.WithName("DeleteAdminCategory")
.WithOpenApi();
app.MapDelete("/api/admin/clips/{clipId:int}", async (HttpContext context, int clipId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
if (clip is null)
{
return Results.NotFound();
}
db.ClipSubmissions.Remove(clip);
AddAuditEntry(
db,
session.TwitchUserId,
"clip.delete",
"clip",
clip.Id.ToString(),
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
new { clip.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, clipId });
})
.WithName("DeleteAdminClip")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) => app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) =>
{ {
var session = await ResolveSessionAsync(context, db); var session = await ResolveSessionAsync(context, db);
@@ -42,7 +42,7 @@ const yearStats = computed(() => [
<div class="min-w-0"> <div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktuelles Award-Jahr</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktuelles Award-Jahr</p>
<h2 class="truncate text-xl font-semibold text-slate-900"> <h2 class="truncate text-xl font-semibold text-slate-900">
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswaehlen' }} {{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswählen' }}
</h2> </h2>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@ const yearStats = computed(() => [
</span> </span>
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold" :class="currentSeason.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'"> <span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold" :class="currentSeason.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
<CheckCircle2 class="h-3.5 w-3.5" /> <CheckCircle2 class="h-3.5 w-3.5" />
{{ currentSeason.isCurrent ? 'Oeffentlich sichtbar' : 'Nicht oeffentlich' }} {{ currentSeason.isCurrent ? 'Öffentlich sichtbar' : 'Nicht öffentlich' }}
</span> </span>
</div> </div>
</div> </div>
+2
View File
@@ -112,6 +112,8 @@ export const api = {
sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`), sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`),
deleteAdminCategory: (categoryId: number) => deleteAdminCategory: (categoryId: number) =>
sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`), sendDelete<{ deleted: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`),
deleteAdminClip: (clipId: number) =>
sendDelete<{ deleted: boolean; clipId: number }>(`/api/admin/clips/${clipId}`),
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) => approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>( sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`, `/api/admin/nominations/${nominationId}/approve`,
+19
View File
@@ -179,6 +179,19 @@ const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
createdAt: '2026-06-17T08:00:00Z', createdAt: '2026-06-17T08:00:00Z',
}, },
], ],
clipSubmissions: [
{
id: 1,
categoryId: 1,
submittedByTwitchId: 'demo_user',
clipUrl: 'https://clips.twitch.tv/DemoClip',
title: 'Epischer Clutch im Finale',
creator: 'Hoshimi Miyu',
platform: 'Twitch',
status: 'pending',
createdAt: '2026-06-17T09:10:00Z',
},
],
} }
const emptyAdmin: AdminDashboardResponse = { const emptyAdmin: AdminDashboardResponse = {
@@ -200,6 +213,7 @@ const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
categories: [], categories: [],
candidates: [], candidates: [],
pendingNominations: [], pendingNominations: [],
clipSubmissions: [],
} }
export const useAwardsStore = defineStore('awards', { export const useAwardsStore = defineStore('awards', {
@@ -317,6 +331,11 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdminSeasonDetail(seasonId) await this.loadAdminSeasonDetail(seasonId)
return result return result
}, },
async deleteAdminClip(clipId: number, seasonId: number) {
const result = await api.deleteAdminClip(clipId)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) { async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload) const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId) await this.loadAdminSeasonDetail(seasonId)
+13
View File
@@ -159,6 +159,18 @@ export interface AdminNominationReviewItem {
createdAt: string createdAt: string
} }
export interface AdminClipSubmissionItem {
id: number
categoryId: number | null
submittedByTwitchId: string
clipUrl: string
title: string
creator: string
platform: string
status: string
createdAt: string
}
export interface AdminSeasonDetailResponse { export interface AdminSeasonDetailResponse {
id: number id: number
year: number year: number
@@ -168,6 +180,7 @@ export interface AdminSeasonDetailResponse {
categories: AdminCategoryItem[] categories: AdminCategoryItem[]
candidates: AdminCandidateItem[] candidates: AdminCandidateItem[]
pendingNominations: AdminNominationReviewItem[] pendingNominations: AdminNominationReviewItem[]
clipSubmissions: AdminClipSubmissionItem[]
} }
export interface CreateNominationPayload { export interface CreateNominationPayload {
@@ -37,7 +37,7 @@ const insights = computed(() => {
{ {
label: 'Votes pro Kandidat', label: 'Votes pro Kandidat',
value: votesPerCandidate, value: votesPerCandidate,
note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.', note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.',
}, },
{ {
label: 'Leere Kategorien', label: 'Leere Kategorien',
@@ -58,7 +58,7 @@ const insights = computed(() => {
<AdminPageHeader <AdminPageHeader
eyebrow="Analytics" eyebrow="Analytics"
title="Zahlen, die Entscheidungen helfen" title="Zahlen, die Entscheidungen helfen"
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Luecken oder Backlog entstehen." description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Lücken oder Backlog entstehen."
:icon="BarChart3" :icon="BarChart3"
/> />
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Layers3, PlusCircle, Search, Tags } from '@lucide/vue' import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue' import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminCategoryItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
@@ -71,7 +73,7 @@ const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length }, { key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length }, { key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length }, { key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
{ key: 'thin' as const, label: 'Duenn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length }, { key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
]) ])
watch( watch(
@@ -150,6 +152,24 @@ function slugify(value: string) {
function fillNewSlug() { function fillNewSlug() {
newCategoryForm.slug = slugify(newCategoryForm.name) newCategoryForm.slug = slugify(newCategoryForm.name)
} }
const categoryToDelete = ref<AdminCategoryItem | null>(null)
const deleting = ref(false)
async function confirmDeleteCategory() {
if (!categoryToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
categoryToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
</script> </script>
<template> <template>
@@ -157,7 +177,7 @@ function fillNewSlug() {
<AdminPageHeader <AdminPageHeader
eyebrow="Kategorien" eyebrow="Kategorien"
title="Award-Struktur pflegen" title="Award-Struktur pflegen"
description="Eine kompakte Arbeitsansicht fuer viele Kategorien: links filtern und auswaehlen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten." description="Eine kompakte Arbeitsansicht für viele Kategorien: links filtern und auswählen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
:icon="Tags" :icon="Tags"
/> />
@@ -264,9 +284,12 @@ function fillNewSlug() {
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" /> <textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label> </label>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex items-center justify-between gap-3">
<Button variant="ghost" class="gap-2 border border-rose-200 text-rose-600 hover:bg-rose-50" @click="categoryToDelete = selectedCategory">
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)"> <Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
{{ saving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }} {{ saving === selectedCategory.id ? 'Speichert ' : 'Kategorie speichern' }}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -305,5 +328,23 @@ function fillNewSlug() {
</Card> </Card>
</div> </div>
</section> </section>
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden aus diesem
Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</div> </div>
</template> </template>
+113 -73
View File
@@ -1,57 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ExternalLink, Film, Search, Tags, Users } from '@lucide/vue' import { ExternalLink, Film, PlayCircle, Search, Tags, Trash2, TriangleAlert, Users } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const query = ref('') const query = ref('')
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const clipCategories = computed(() => const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')), const categoryName = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])),
) )
const clipCategoryIds = computed(() => new Set(clipCategories.value.map((category) => category.id)))
const clipCandidates = computed(() => { const clips = computed(() => {
const search = query.value.trim().toLowerCase() const search = query.value.trim().toLowerCase()
return seasonDetail.value.candidates if (!search) return seasonDetail.value.clipSubmissions
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId)) return seasonDetail.value.clipSubmissions.filter((clip) =>
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search)) [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search),
)
}) })
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
const clipReviewCount = computed(() => seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length)
const clipReadiness = computed(() => [
{
label: 'Clip-Kategorie existiert',
done: clipCategories.value.length > 0,
note: clipCategories.value.length > 0 ? `${clipCategories.value.length} Clip-Kategorien gefunden.` : 'Lege mindestens eine Clip-Kategorie an.',
},
{
label: 'Kandidaten vorhanden',
done: clipCandidates.value.length > 0,
note: clipCandidates.value.length > 0 ? `${clipCandidates.value.length} Clip-Kandidaten gepflegt.` : 'Noch keine Clip-Kandidaten vorhanden.',
},
{
label: 'Review-Restbestand',
done: clipReviewCount.value === 0,
note: clipReviewCount.value === 0 ? 'Keine offenen Clip-Reviews.' : `${clipReviewCount.value} Clip-Reviews offen.`,
},
])
const stats = computed(() => [ const stats = computed(() => [
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags }, { label: 'Einreichungen', value: seasonDetail.value.clipSubmissions.length, icon: Film },
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users }, { label: 'Offen', value: seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Offene Reviews', value: clipReviewCount.value, icon: Film }, { label: 'Clip-Kategorien', value: seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length, icon: Tags },
]) ])
function platformClass(platform: string) {
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
async function confirmDelete() {
if (!clipToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
clipToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Clips" eyebrow="Clips"
title="Clip-Kategorien im Blick behalten" title="Clip-Einreichungen moderieren"
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an." description="Alle von der Community eingereichten Clips laufen hier auf. Sieh sie dir an, prüfe die Links und entferne Spam oder Duplikate."
:icon="Film" :icon="Film"
/> />
@@ -71,50 +84,77 @@ const stats = computed(() => [
</Card> </Card>
</section> </section>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]"> <Card class="overflow-hidden">
<Card class="p-6"> <div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p> <label class="relative block">
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2> <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<div class="mt-5 space-y-3"> <input
<div v-model="query"
v-for="item in clipReadiness" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
:key="item.label" placeholder="Titel, Creator, Plattform oder User suchen …"
class="rounded-2xl border p-4" />
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'" </label>
> </div>
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ item.note }}</p>
</div>
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
<p class="font-semibold text-amber-800">Naechster Backend-Ausbau</p>
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
</div>
</div>
</Card>
<Card class="overflow-hidden"> <p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<div class="border-b border-violet-100 p-5"> <p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" /> <div class="divide-y divide-violet-50">
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Clip-Kandidat, Handle oder Plattform suchen" /> <div v-for="clip in clips" :key="clip.id" class="flex flex-col gap-3 px-5 py-4 lg:flex-row lg:items-center lg:gap-4">
</label> <div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
</div> <Film class="h-5 w-5" />
<div class="divide-y divide-violet-50">
<div v-for="candidate in clipCandidates" :key="candidate.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ candidate.displayName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }} · {{ candidateCategory[candidate.categoryId] }}</p>
</div>
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
<ExternalLink class="h-3.5 w-3.5" />
Clip-Link spaeter
</span>
</div> </div>
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500"> <div class="min-w-0 flex-1">
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an. <p class="truncate font-semibold text-slate-900">{{ clip.title || 'Ohne Titel' }}</p>
<p class="truncate text-sm text-slate-500">
{{ clip.creator || 'Unbekannt' }}
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }}
</p>
</div>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<a
:href="clip.clipUrl"
target="_blank"
rel="noopener"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
>
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
</a>
<button
class="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
<Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500">
{{ seasonDetail.clipSubmissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p> </p>
</div> </div>
</Card> </div>
</section> </Card>
<Modal :open="!!clipToDelete" title="Clip entfernen?" @close="clipToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
Die Einreichung <strong class="text-slate-800">{{ clipToDelete?.title || clipToDelete?.clipUrl }}</strong>" wird entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="clipToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Entfernt …' : 'Entfernen' }}
</Button>
</template>
</Modal>
</div> </div>
</template> </template>
+13 -13
View File
@@ -23,7 +23,7 @@ const metricToneMap = {
icon: BarChart3, icon: BarChart3,
trend: 8.7, trend: 8.7,
sparkline: [54, 57, 63, 66, 72, 76, 81], sparkline: [54, 57, 63, 66, 72, 76, 81],
context: 'Voting-Aktivitaet stabil positiv', context: 'Voting-Aktivität stabil positiv',
}, },
Kategorien: { Kategorien: {
icon: Tags, icon: Tags,
@@ -67,7 +67,7 @@ const yearTotals = computed(() => [
{ {
label: 'Kandidaten', label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length, value: store.adminSeasonDetail.candidates.length,
note: 'fuer Voting und Archiv gepflegt', note: 'für Voting und Archiv gepflegt',
icon: Users, icon: Users,
}, },
{ {
@@ -99,10 +99,10 @@ const priorityActions = computed(() => [
tone: 'violet', tone: 'violet',
}, },
{ {
label: 'Risiko pruefen', label: 'Risiko prüfen',
value: store.admin.riskFlags.length, value: store.admin.riskFlags.length,
to: '/admin/risk', to: '/admin/risk',
hint: 'Auffaellige Muster brauchen Sichtung', hint: 'Auffällige Muster brauchen Sichtung',
icon: ShieldAlert, icon: ShieldAlert,
tone: 'rose', tone: 'rose',
}, },
@@ -118,7 +118,7 @@ const priorityActions = computed(() => [
label: 'Kandidatenbasis', label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length, value: store.adminSeasonDetail.candidates.length,
to: '/admin/candidates', to: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell pruefen', hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users, icon: Users,
tone: 'emerald', tone: 'emerald',
}, },
@@ -137,7 +137,7 @@ const operationChecks = computed(() => {
value: categoriesWithoutCandidates.length, value: categoriesWithoutCandidates.length,
to: '/admin/categories', to: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn', state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt pruefen.', note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
}, },
{ {
label: 'Review-Backlog verteilt', label: 'Review-Backlog verteilt',
@@ -151,7 +151,7 @@ const operationChecks = computed(() => {
value: store.admin.riskFlags.length, value: store.admin.riskFlags.length,
to: '/admin/risk', to: '/admin/risk',
state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger', state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger',
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst pruefen.', note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
}, },
] ]
}) })
@@ -162,7 +162,7 @@ const operationChecks = computed(() => {
<AdminPageHeader <AdminPageHeader
eyebrow="Dashboard" eyebrow="Dashboard"
title="Was braucht gerade Aufmerksamkeit?" title="Was braucht gerade Aufmerksamkeit?"
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebuendelt, damit du schneller entscheiden kannst, was als Naechstes drankommt." description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebündelt, damit du schneller entscheiden kannst, was als Nächstes drankommt."
:icon="LayoutDashboard" :icon="LayoutDashboard"
/> />
@@ -174,11 +174,11 @@ const operationChecks = computed(() => {
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p> <p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600"> <p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
Voting und Nominierungen ziehen an, waehrend der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert. Voting und Nominierungen ziehen an, während der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert.
</p> </p>
</div> </div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700"> <div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
+9.8% Gesamtaktivitaet +9.8% Gesamtaktivität
</div> </div>
</div> </div>
</div> </div>
@@ -354,10 +354,10 @@ const operationChecks = computed(() => {
<Card class="p-7"> <Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitaeten</p> <p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2>
</div> </div>
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert fuer den schnellen Blick.</p> <p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
</div> </div>
<div class="mt-6 grid gap-4 md:grid-cols-3"> <div class="mt-6 grid gap-4 md:grid-cols-3">
@@ -370,7 +370,7 @@ const operationChecks = computed(() => {
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p> <p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
</div> </div>
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine aktuellen Audit-Aktivitaeten vorhanden. Noch keine aktuellen Audit-Aktivitäten vorhanden.
</p> </p>
</div> </div>
</Card> </Card>
+4 -4
View File
@@ -39,14 +39,14 @@ const navGroups = [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` }, { label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` }, { label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` }, { label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien pruefen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` }, { label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien prüfen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` },
], ],
}, },
{ {
label: 'Kontrolle', label: 'Kontrolle',
items: [ items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Faelle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` }, { label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags pruefen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` }, { label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags prüfen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
{ label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` }, { label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
], ],
}, },
@@ -119,7 +119,7 @@ onMounted(async () => {
<Card class="p-3"> <Card class="p-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p> <p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p>
<p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p> <p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswaehlen.' }}</p> <p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswählen.' }}</p>
<div class="mt-3 grid grid-cols-3 gap-1.5"> <div class="mt-3 grid grid-cols-3 gap-1.5">
<div <div
v-for="item in seasonSummary" v-for="item in seasonSummary"
@@ -150,7 +150,7 @@ const statusFilters = computed(() => [
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)" v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700" class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
> >
erst Kandidatenbasis klaeren erst Kandidatenbasis klären
</span> </span>
</div> </div>
</div> </div>
@@ -100,9 +100,9 @@ async function approveNomination(nominationId: number) {
try { try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId]) await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.' adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
} catch (error) { } catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.' adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
} finally { } finally {
reviewSaving.value = null reviewSaving.value = null
} }
@@ -149,7 +149,7 @@ function setPlatform(platform: string) {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Kompakte Liste fuer viele Freitext-Faelle. Waehle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird. Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
</p> </p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]"> <div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
@@ -229,10 +229,10 @@ function setPlatform(platform: string) {
</button> </button>
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Review-Faelle im aktuell gewaehlten Award-Jahr. Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
</p> </p>
<p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Faelle passen zum aktuellen Filter. Keine Review-Fälle passen zum aktuellen Filter.
</p> </p>
</div> </div>
@@ -251,7 +251,7 @@ function setPlatform(platform: string) {
</div> </div>
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4"> <div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat uebernehmen</p> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
<div class="mt-4 grid gap-4 md:grid-cols-3"> <div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="space-y-2"> <label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
@@ -294,7 +294,7 @@ function setPlatform(platform: string) {
</button> </button>
</div> </div>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
</p> </p>
</div> </div>
@@ -305,7 +305,7 @@ function setPlatform(platform: string) {
</Button> </Button>
<Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)"> <Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" /> <CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }} {{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button> </Button>
</div> </div>
</div> </div>
+10 -10
View File
@@ -69,8 +69,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Risiko & Audit" eyebrow="Risiko & Audit"
title="Auffaellige Muster und Admin-Aktionen verfolgen" title="Auffällige Muster und Admin-Aktionen verfolgen"
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgefuehrte Eingriffe deutlich schneller." description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgeführte Eingriffe deutlich schneller."
:icon="ShieldAlert" :icon="ShieldAlert"
/> />
@@ -78,8 +78,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<Card class="p-7"> <Card class="p-7">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikopruefung</h2> <h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikoprüfung</h2>
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p> <p class="mt-2 text-sm text-slate-500">Auffällige Login-, Nominierungs- und Voting-Muster für die manuelle Sichtung.</p>
</div> </div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500"> <span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen {{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
@@ -110,7 +110,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
/> />
</label> </label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600"> <div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprueften Fall. Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprüften Fall.
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
@@ -170,10 +170,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2> <h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2>
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p> <p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen für Kategorie-, Kandidaten- und Review-Änderungen.</p>
</div> </div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500"> <span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Eintraege {{ filteredAuditEntries.length }} / {{ auditEntries.length }} Einträge
</span> </span>
</div> </div>
@@ -182,7 +182,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
v-model="auditFilter" v-model="auditFilter"
type="text" type="text"
class="w-full rounded-2xl border border-violet-200 px-4 py-3" class="w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Audit-Eintraege nach Aktion, Admin oder Objekt filtern" placeholder="Audit-Einträge nach Aktion, Admin oder Objekt filtern"
/> />
</div> </div>
@@ -204,10 +204,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
</div> </div>
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine Audit-Eintraege vorhanden. Noch keine Audit-Einträge vorhanden.
</p> </p>
<p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Audit-Eintraege passen zum aktuellen Filter. Keine Audit-Einträge passen zum aktuellen Filter.
</p> </p>
</div> </div>
</Card> </Card>
+10 -10
View File
@@ -176,11 +176,11 @@ async function createCategory() {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Award-Jahr steuern</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Award-Jahr steuern</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500"> <p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr oeffentlich fuer Community, Voting und Archiv sichtbar ist. Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr öffentlich für Community, Voting und Archiv sichtbar ist.
</p> </p>
</div> </div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'"> <div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
{{ seasonForm.isCurrent ? 'Oeffentlich aktiv' : 'Intern vorbereitet' }} {{ seasonForm.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div> </div>
</div> </div>
</div> </div>
@@ -195,16 +195,16 @@ async function createCategory() {
placeholder="z.B. Community Voting, Nominierung, Archiviert" placeholder="z.B. Community Voting, Nominierung, Archiviert"
/> />
<span class="block text-xs leading-5 text-slate-500"> <span class="block text-xs leading-5 text-slate-500">
Diese Phase wird als Orientierung fuer Team und spaeter auch fuer Public-Kommunikation genutzt. Diese Phase wird als Orientierung für Team und später auch für Public-Kommunikation genutzt.
</span> </span>
</label> </label>
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50"> <label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" /> <input v-model="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span> <span>
<span class="block font-semibold text-slate-800">Dieses Award-Jahr oeffentlich schalten</span> <span class="block font-semibold text-slate-800">Dieses Award-Jahr öffentlich schalten</span>
<span class="mt-1 block text-sm leading-6 text-slate-500"> <span class="mt-1 block text-sm leading-6 text-slate-500">
Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig oeffentlich sein. Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig öffentlich sein.
</span> </span>
</span> </span>
</label> </label>
@@ -226,7 +226,7 @@ async function createCategory() {
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-6 text-slate-500"> <p class="text-sm leading-6 text-slate-500">
Speichert Phase und Public-Status fuer <strong class="text-slate-700">{{ seasonDetail.name }}</strong>. Speichert Phase und Public-Status für <strong class="text-slate-700">{{ seasonDetail.name }}</strong>.
</p> </p>
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason"> <Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }} {{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }}
@@ -248,7 +248,7 @@ async function createCategory() {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie planen</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie planen</h2>
<p class="mt-2 text-sm leading-6 text-slate-500"> <p class="mt-2 text-sm leading-6 text-slate-500">
Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen spaeter URL, Anzeige und Reihenfolge im Voting. Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen später URL, Anzeige und Reihenfolge im Voting.
</p> </p>
</div> </div>
</div> </div>
@@ -268,7 +268,7 @@ async function createCategory() {
<label class="space-y-2"> <label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklaeren, wofuer diese Kategorie steht." /> <textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklären, wofür diese Kategorie steht." />
</label> </label>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
@@ -288,7 +288,7 @@ async function createCategory() {
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-6 text-slate-500"> <p class="text-sm leading-6 text-slate-500">
Neue Kategorien sind sofort Teil des gewaehlten Award-Jahres und koennen danach unten weiter bearbeitet werden. Neue Kategorien sind sofort Teil des gewählten Award-Jahres und können danach unten weiter bearbeitet werden.
</p> </p>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory"> <Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }} {{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
@@ -305,7 +305,7 @@ async function createCategory() {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien dieses Jahres</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien dieses Jahres</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Pruefe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten. Prüfe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten.
</p> </p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]"> <div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]">
@@ -29,7 +29,7 @@ const checks = computed(() => [
{ {
label: 'Public-Jahr gesetzt', label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent, value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist oeffentlich markiert.' : 'Dieses Jahr ist aktuell intern.', note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist öffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
icon: CheckCircle2, icon: CheckCircle2,
}, },
{ {
@@ -58,7 +58,7 @@ const featureGates = computed(() => [
{ {
label: 'Clip-Moderation', label: 'Clip-Moderation',
state: false, state: false,
note: 'Admin-API fuer ClipSubmissions fehlt noch und sollte spaeter ergaenzt werden.', note: 'Admin-API für ClipSubmissions fehlt noch und sollte später ergänzt werden.',
}, },
]) ])
@@ -117,8 +117,8 @@ async function saveSettings() {
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50"> <label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" /> <input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span> <span>
<span class="block font-semibold text-slate-900">Dieses Award-Jahr oeffentlich markieren</span> <span class="block font-semibold text-slate-900">Dieses Award-Jahr öffentlich markieren</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext fuer Community, Voting und spaeter Archiv.</span> <span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext für Community, Voting und später Archiv.</span>
</span> </span>
</label> </label>
</div> </div>
@@ -40,7 +40,7 @@ const adminCounts = computed(() => {
return [...counts.entries()].map(([admin, count]) => ({ admin, count })) return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
}) })
const logStats = computed(() => [ const logStats = computed(() => [
{ label: 'Audit-Eintraege', value: auditEntries.value.length }, { label: 'Audit-Einträge', value: auditEntries.value.length },
{ label: 'Admins aktiv', value: adminCounts.value.length }, { label: 'Admins aktiv', value: adminCounts.value.length },
{ label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size }, { label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
]) ])
@@ -51,7 +51,7 @@ const logStats = computed(() => [
<AdminPageHeader <AdminPageHeader
eyebrow="User & Logs" eyebrow="User & Logs"
title="User-Spuren und Admin-Aktionen" title="User-Spuren und Admin-Aktionen"
description="Eine kompakte Kontrollansicht fuer Audit-Eintraege, auffaellige User und Admin-Aktivitaet. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich." description="Eine kompakte Kontrollansicht für Audit-Einträge, auffällige User und Admin-Aktivität. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
:icon="UserCog" :icon="UserCog"
/> />
@@ -73,14 +73,14 @@ const logStats = computed(() => [
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]"> <section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6"> <Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivitaet</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivität</h2>
<div class="mt-5 space-y-3"> <div class="mt-5 space-y-3">
<div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3"> <div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3">
<span class="font-semibold text-slate-900">{{ item.admin }}</span> <span class="font-semibold text-slate-900">{{ item.admin }}</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span> <span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span>
</div> </div>
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500"> <p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500">
Noch keine Admin-Aktivitaet vorhanden. Noch keine Admin-Aktivität vorhanden.
</p> </p>
</div> </div>
</Card> </Card>
@@ -100,7 +100,7 @@ const logStats = computed(() => [
<span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span> <span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span>
</div> </div>
</div> </div>
<p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Eintraege gefunden.</p> <p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Einträge gefunden.</p>
</div> </div>
</Card> </Card>
</section> </section>
@@ -112,7 +112,7 @@ const logStats = computed(() => [
<ShieldAlert class="h-5 w-5" /> <ShieldAlert class="h-5 w-5" />
</div> </div>
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffaellige User</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffällige User</p>
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2> <h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2>
</div> </div>
</div> </div>
@@ -127,7 +127,7 @@ const logStats = computed(() => [
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span> <span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
</div> </div>
<p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500"> <p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine auffaelligen User fuer den aktuellen Filter. Keine auffälligen User für den aktuellen Filter.
</p> </p>
</div> </div>
</Card> </Card>
+4 -4
View File
@@ -57,7 +57,7 @@ const votingChecklist = computed(() => [
<AdminPageHeader <AdminPageHeader
eyebrow="Voting" eyebrow="Voting"
title="Voting-Status und Rankings" title="Voting-Status und Rankings"
description="Pruefe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivitaet erzeugen." description="Prüfe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivität erzeugen."
:icon="Vote" :icon="Vote"
/> />
@@ -97,7 +97,7 @@ const votingChecklist = computed(() => [
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong> <strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div> </div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50"> <div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" /> <div class="h-full rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
</div> </div>
</div> </div>
<p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500"> <p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500">
@@ -118,7 +118,7 @@ const votingChecklist = computed(() => [
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</p> <p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</p>
</div> </div>
<span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'"> <span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'">
{{ category.ready ? 'bereit' : 'pruefen' }} {{ category.ready ? 'bereit' : 'prüfen' }}
</span> </span>
</div> </div>
</div> </div>
@@ -142,7 +142,7 @@ const votingChecklist = computed(() => [
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p> <p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div> </div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"> <span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'">
{{ item.done ? 'ok' : 'pruefen' }} {{ item.done ? 'ok' : 'prüfen' }}
</span> </span>
</div> </div>
</RouterLink> </RouterLink>