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:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user