Compare commits

...

4 Commits

Author SHA1 Message Date
AzuTear f4512eba2e Ignore local .claude config directories
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:46:03 +02:00
AzuTear 8c9425dce6 Reflect clip moderation in settings, fix leftover umlaut
- Settings: Clip-Moderation feature gate now active (admin clips exist)
- Users & Logs: fix "Fuer" -> "Für"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 07:54:55 +02:00
AzuTear 24f9a69022 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>
2026-06-18 07:53:19 +02:00
AzuTear cca297a4eb Redesign public site, add phase gating and clip submission
- Rebuild landing page and Nominations/Voting/Winners views in the
  Jayuhime brand style (purple/gold/pastel-rainbow, stars, PageHero banner)
- Gate participation by season phase (nominate/clip share the nomination
  window, vote in the voting window); hide closed/locked pages from nav
- Add login-gated clip submission flow (link-based) + voting clip links
- Make candidate admin scalable: searchable, filterable, paginated table
  with modal create/edit and confirm-delete; add delete API/store actions
- New reusable Modal and PageHero components, usePhases composable
- Segmented top nav and full-size header on admin routes
- vite: honor PORT env for preview

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 06:46:41 +02:00
37 changed files with 2243 additions and 837 deletions
+3
View File
@@ -3,3 +3,6 @@ frontend/dist/
Backend/bin/
Backend/obj/
.DS_Store
# Local Claude Code config (settings, preview launch configs)
.claude/
+13 -1
View File
@@ -67,6 +67,17 @@ public sealed record AdminNominationReviewItemDto(
string CandidateText,
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(
int Id,
int Year,
@@ -75,7 +86,8 @@ public sealed record AdminSeasonDetailResponse(
bool IsCurrent,
IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations);
IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
public sealed record UpdateSeasonRequest(
string CurrentPhase,
+8
View File
@@ -77,3 +77,11 @@ public sealed record CreateVoteRequest(
int SeasonId,
string TwitchUserId,
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<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -90,6 +91,18 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
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);
}
}
@@ -49,5 +49,22 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
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")
.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) =>
{
var session = await ResolveSessionAsync(context, db);
@@ -809,6 +873,23 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
item.CreatedAt))
.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(
season.Id,
season.Year,
@@ -817,7 +898,8 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
season.IsCurrent,
categories,
candidates,
pendingNominations));
pendingNominations,
clipSubmissions));
})
.WithName("GetAdminSeasonDetail")
.WithOpenApi();
@@ -1013,6 +1095,102 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
.WithName("UpdateAdminCandidate")
.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) =>
{
var session = await ResolveSessionAsync(context, db);
+131
View File
@@ -0,0 +1,131 @@
<svg viewBox="0 0 720 880" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Jayuhime Keyvisual (stilisierter Platzhalter)">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#efe6ff"/>
<stop offset="55%" stop-color="#f6ecff"/>
<stop offset="100%" stop-color="#fff1da"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="34%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffe39b"/>
<stop offset="55%" stop-color="#f6b938"/>
<stop offset="100%" stop-color="#d98e1d"/>
</linearGradient>
<linearGradient id="robe" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b6bff"/>
<stop offset="100%" stop-color="#5b34c9"/>
</linearGradient>
<linearGradient id="robeDark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6f4fe0"/>
<stop offset="100%" stop-color="#4a279f"/>
</linearGradient>
<linearGradient id="hair" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ece4fb"/>
</linearGradient>
<linearGradient id="rainbow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="28%" stop-color="#f5a9d6"/>
<stop offset="52%" stop-color="#fcd34d"/>
<stop offset="76%" stop-color="#86efac"/>
<stop offset="100%" stop-color="#7dd3fc"/>
</linearGradient>
</defs>
<rect width="720" height="880" fill="url(#bg)"/>
<rect width="720" height="880" fill="url(#glow)"/>
<!-- sparkles -->
<g fill="#f6b938" opacity="0.85">
<circle cx="120" cy="130" r="4"/><circle cx="610" cy="100" r="5"/><circle cx="665" cy="250" r="3"/>
<circle cx="70" cy="350" r="3"/><circle cx="650" cy="470" r="4"/><circle cx="150" cy="540" r="3"/>
</g>
<g fill="#a78bff" opacity="0.65">
<circle cx="205" cy="90" r="3"/><circle cx="545" cy="170" r="3"/><circle cx="80" cy="230" r="4"/><circle cx="605" cy="370" r="3"/>
</g>
<g fill="#ffffff">
<path d="M150 200 l6 14 14 6 -14 6 -6 14 -6 -14 -14 -6 14 -6z" opacity="0.9"/>
<path d="M585 300 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.85"/>
<path d="M120 640 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.8"/>
</g>
<!-- soft halo behind host -->
<circle cx="360" cy="300" r="210" fill="#ffffff" opacity="0.45"/>
<!-- ===== back hair (long locks framing the body) ===== -->
<path d="M286 250 C232 360 226 520 252 690 C272 650 300 648 318 596 C300 470 300 350 322 286 Z" fill="url(#hair)"/>
<path d="M434 250 C488 360 494 520 468 690 C448 650 420 648 402 596 C420 470 420 350 398 286 Z" fill="url(#hair)"/>
<!-- rainbow streaks inside the locks -->
<path d="M296 320 C268 420 266 540 286 660 C300 624 308 600 308 560 C298 470 298 384 308 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<path d="M424 320 C452 420 454 540 434 660 C420 624 412 600 412 560 C422 470 422 384 412 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<!-- ===== dress (bell skirt) ===== -->
<path d="M322 452 C300 600 250 760 214 868 C300 884 420 884 506 868 C470 760 420 600 398 452 Z" fill="url(#robe)"/>
<!-- skirt shading -->
<path d="M360 452 C352 600 340 760 332 868 L388 868 C380 760 368 600 360 452 Z" fill="#ffffff" opacity="0.12"/>
<!-- gold star accents on skirt -->
<g fill="url(#gold)" opacity="0.92">
<path d="M300 640 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
<path d="M420 600 l4 10 11 4 -11 4 -4 10 -4 -10 -11 -4 11 -4z"/>
<path d="M360 730 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
</g>
<!-- white ruffle hem -->
<path d="M214 862 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 52 0 l0 22 -340 0 z" fill="#ffffff" opacity="0.95"/>
<!-- ===== bodice ===== -->
<path d="M316 352 C322 336 398 336 404 352 L400 458 L320 458 Z" fill="url(#robeDark)"/>
<!-- star cutout on chest -->
<path d="M360 392 l8 18 19 7 -19 8 -8 18 -8 -18 -19 -8 19 -7z" fill="#f6ecff" opacity="0.85"/>
<!-- waist sash -->
<rect x="318" y="446" width="84" height="16" rx="8" fill="url(#gold)"/>
<!-- ===== lower (left) arm + puff sleeve ===== -->
<path d="M322 372 C300 420 292 470 300 512" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="318" cy="372" r="26" fill="#ffffff"/>
<!-- ===== raised (right) arm holding trophy ===== -->
<path d="M402 372 C448 350 486 300 500 250" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="402" cy="372" r="26" fill="#ffffff"/>
<circle cx="500" cy="246" r="15" fill="#fff4ec"/>
<!-- ===== neck + collar ruffle ===== -->
<rect x="350" y="300" width="20" height="40" rx="9" fill="#fff4ec"/>
<path d="M324 344 q18 -16 36 0 q18 16 36 0 l0 14 -72 0 z" fill="#ffffff"/>
<!-- ===== face ===== -->
<circle cx="360" cy="252" r="60" fill="#fff4ec"/>
<!-- cheeks -->
<circle cx="326" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<circle cx="394" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<!-- eyes -->
<ellipse cx="341" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<ellipse cx="379" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<circle cx="343" cy="251" r="2" fill="#ffffff"/>
<circle cx="381" cy="251" r="2" fill="#ffffff"/>
<!-- smile -->
<path d="M349 276 q11 9 22 0" fill="none" stroke="#caa6a0" stroke-width="3" stroke-linecap="round"/>
<!-- monocle on right eye -->
<circle cx="379" cy="254" r="17" fill="none" stroke="url(#gold)" stroke-width="3.5"/>
<path d="M379 271 q5 24 22 32" fill="none" stroke="url(#gold)" stroke-width="2" stroke-linecap="round"/>
<!-- ===== bangs over forehead ===== -->
<path d="M300 250 C300 168 330 138 360 138 C390 138 420 168 420 250 C402 222 384 214 360 214 C336 214 318 222 300 250 Z" fill="url(#hair)"/>
<path d="M360 214 C346 214 334 220 326 234 L334 250 C342 230 378 230 386 250 L394 234 C386 220 374 214 360 214 Z" fill="#ece4fb" opacity="0.6"/>
<!-- ===== star hairbuns ===== -->
<path d="M296 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<path d="M424 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<!-- ===== trophy in raised hand ===== -->
<g transform="translate(458 110)">
<path d="M22 0 h84 v28 q0 50 -42 64 q-42 -14 -42 -64 z" fill="url(#gold)"/>
<path d="M22 7 h-22 q0 36 30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<path d="M106 7 h22 q0 36 -30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<rect x="58" y="90" width="12" height="30" fill="url(#gold)"/>
<rect x="40" y="118" width="48" height="14" rx="5" fill="url(#gold)"/>
<path d="M64 16 l8 19 20 1 -15 13 5 20 -18 -11 -18 11 5 -20 -15 -13 20 -1z" fill="#ffffff" opacity="0.95"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+27 -15
View File
@@ -5,9 +5,11 @@ import { Star } from '@lucide/vue'
import Button from './ui/Button.vue'
import { useAuthStore } from '../stores/auth'
import { usePhases } from '../composables/usePhases'
const route = useRoute()
const authStore = useAuthStore()
const { infoForPhaseKey } = usePhases()
const loginOpen = ref(false)
const loginError = ref('')
const loginForm = reactive({
@@ -18,8 +20,9 @@ const loginForm = reactive({
const navItems = [
{ label: 'Home', to: '/' },
{ label: 'Nominierung', to: '/nominations' },
{ label: 'Voting', to: '/voting' },
{ label: 'Nominierung', to: '/nominations', requiresAuth: true, phase: 'nomination' },
{ label: 'Voting', to: '/voting', requiresAuth: true, phase: 'voting' },
{ label: 'Clips', to: '/clips', requiresAuth: true, phase: 'nomination' },
{ label: 'Gewinner', to: '/winners' },
{ label: 'Admin', to: '/admin' },
]
@@ -29,9 +32,15 @@ const currentLabel = computed(
)
const visibleNavItems = computed(() =>
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
navItems.filter((item) => {
if (item.to === '/admin') return authStore.isAdmin
// Teilnahme-Seiten erst nach Login zeigen
if (item.requiresAuth && !authStore.isLoggedIn) return false
// ... und nur, solange ihre Phase aktiv ist
if (item.phase && infoForPhaseKey(item.phase).state !== 'active') return false
return true
}),
)
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
async function login(role: 'viewer' | 'admin') {
loginError.value = ''
@@ -58,20 +67,18 @@ async function login(role: 'viewer' | 'admin') {
</div>
<header
class="flex flex-col rounded-[24px] border border-white/70 bg-white/72 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7"
:class="isAdminRoute ? 'mb-5 gap-3 px-4 py-3' : 'mb-10 gap-6 px-5 py-5'"
class="mb-10 flex flex-col gap-6 rounded-[24px] border border-white/70 bg-white/72 px-5 py-5 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7"
>
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between" :class="isAdminRoute ? 'gap-3' : 'gap-5'">
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
<div
class="grid place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]"
:class="isAdminRoute ? 'h-10 w-10' : 'h-12 w-12'"
class="grid h-12 w-12 place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]"
>
<Star :class="isAdminRoute ? 'h-4 w-4' : 'h-5 w-5'" />
<Star class="h-5 w-5" />
</div>
<div>
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
<span v-if="!isAdminRoute" class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
<span class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
</div>
</RouterLink>
@@ -84,7 +91,10 @@ async function login(role: 'viewer' | 'admin') {
</template>
<template v-else>
<Button variant="ghost" @click="loginOpen = !loginOpen">Einloggen</Button>
<Button @click="login('viewer')">Mit Twitch Login</Button>
<Button class="gap-2" @click="login('viewer')">
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16"><path d="M2.5 0 0 2.5v11h3.5V16h2l2.5-2.5h3l5-5V0H2.5Zm12 8L12 10.5H8.5L6 13v-2.5H2.5V2h12v6Z"/><path d="M11.5 4h1.5v3.5h-1.5V4Zm-3.5 0h1.5v3.5H8V4Z"/></svg>
Anmelden mit Twitch
</Button>
</template>
</div>
</div>
@@ -104,13 +114,15 @@ async function login(role: 'viewer' | 'admin') {
</div>
<div class="flex flex-col gap-3 border-t border-black/6 pt-3 lg:flex-row lg:items-center lg:justify-between">
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
<nav class="flex flex-wrap items-center gap-1 rounded-full border border-violet-100/80 bg-violet-50/50 p-1.5 text-sm font-medium text-slate-500">
<RouterLink
v-for="item in visibleNavItems"
:key="item.to"
:to="item.to"
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
class="rounded-full px-4 py-2 transition"
:class="route.path === item.to || route.path.startsWith(`${item.to}/`)
? 'bg-white text-violet-800 shadow-[0_4px_14px_rgba(124,92,255,0.18)] ring-1 ring-violet-100'
: 'hover:bg-white/60 hover:text-violet-700'"
>
{{ item.label }}
</RouterLink>
@@ -1,17 +1,50 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Sparkles } from '@lucide/vue'
defineProps<{
eyebrow?: string
title: string
description: string
/** Dekoratives Icon oben rechts analog zu den Frontend-Bannern */
icon?: Component
}>()
</script>
<template>
<div class="flex flex-col gap-2 border-b border-violet-100 pb-4 md:flex-row md:items-end md:justify-between">
<div>
<p v-if="eyebrow" class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">{{ eyebrow }}</p>
<h2 class="mt-1 text-2xl font-semibold text-slate-900">{{ title }}</h2>
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-9 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-11">
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<g fill="#f6b938" opacity="0.7">
<circle cx="74%" cy="24%" r="3" />
<circle cx="92%" cy="62%" r="2.5" />
<circle cx="63%" cy="78%" r="2.5" />
</g>
<g fill="#a78bff" opacity="0.6">
<circle cx="83%" cy="40%" r="2.5" />
<circle cx="69%" cy="50%" r="2" />
</g>
</svg>
<div
v-if="icon"
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
>
<div class="grid h-40 w-40 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
<component :is="icon" class="h-[4.5rem] w-[4.5rem]" />
</div>
<p class="max-w-xl text-sm leading-6 text-slate-500">{{ description }}</p>
</div>
<div class="relative max-w-2xl space-y-4">
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
{{ eyebrow ?? 'Admin' }}
</span>
<h1 class="font-[Cormorant_Garamond] text-4xl leading-[1.02] text-violet-800 sm:text-5xl">
{{ title }}
</h1>
<p class="max-w-xl text-sm leading-6 text-slate-600 sm:text-base">{{ description }}</p>
</div>
</section>
</template>
@@ -42,7 +42,7 @@ const yearStats = computed(() => [
<div class="min-w-0">
<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">
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswaehlen' }}
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswählen' }}
</h2>
</div>
</div>
@@ -54,7 +54,7 @@ const yearStats = computed(() => [
</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'">
<CheckCircle2 class="h-3.5 w-3.5" />
{{ currentSeason.isCurrent ? 'Oeffentlich sichtbar' : 'Nicht oeffentlich' }}
{{ currentSeason.isCurrent ? 'Öffentlich sichtbar' : 'Nicht öffentlich' }}
</span>
</div>
</div>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { X } from '@lucide/vue'
const props = defineProps<{
open: boolean
title?: string
subtitle?: string
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
watch(
() => props.open,
(open) => {
if (typeof document !== 'undefined') {
document.body.style.overflow = open ? 'hidden' : ''
}
},
)
onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
if (typeof document !== 'undefined') document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="emit('close')"
>
<div class="absolute inset-0 bg-violet-950/30 backdrop-blur-sm" @click="emit('close')" />
<div class="modal-panel relative z-10 w-full max-w-lg overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f3edff,#fff4e6)] px-6 py-5">
<div>
<h3 v-if="title" class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ title }}</h3>
<p v-if="subtitle" class="mt-1 text-sm text-slate-500">{{ subtitle }}</p>
</div>
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-white/70 hover:text-violet-700"
@click="emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[70vh] overflow-y-auto px-6 py-6">
<slot />
</div>
<div v-if="$slots.footer" class="flex justify-end gap-3 border-t border-violet-100 bg-violet-50/40 px-6 py-4">
<slot name="footer" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
animation: modal-fade 0.16s ease;
}
.modal-panel {
animation: modal-pop 0.18s ease;
}
@keyframes modal-fade {
from {
opacity: 0;
}
}
@keyframes modal-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Sparkles } from '@lucide/vue'
defineProps<{
eyebrow: string
title: string
description?: string
/** Großes, dekoratives Icon oben rechts */
icon?: Component
}>()
</script>
<template>
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-10 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-14">
<!-- dekorativer Schein -->
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
<!-- Sterne -->
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
<g fill="#f6b938" opacity="0.7">
<circle cx="74%" cy="24%" r="3" />
<circle cx="92%" cy="62%" r="2.5" />
<circle cx="63%" cy="78%" r="2.5" />
</g>
<g fill="#a78bff" opacity="0.6">
<circle cx="83%" cy="40%" r="2.5" />
<circle cx="69%" cy="50%" r="2" />
</g>
</svg>
<!-- großes dekoratives Icon -->
<div
v-if="icon"
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
>
<div class="grid h-44 w-44 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
<component :is="icon" class="h-20 w-20" />
</div>
</div>
<div class="relative max-w-2xl space-y-5">
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
{{ eyebrow }}
</span>
<h1 class="font-[Cormorant_Garamond] text-5xl leading-[0.95] text-violet-800 sm:text-6xl">
{{ title }}
</h1>
<p v-if="description" class="max-w-xl text-lg leading-8 text-slate-600">
{{ description }}
</p>
<slot name="actions" />
</div>
</section>
</template>
+48
View File
@@ -0,0 +1,48 @@
import { useAwardsStore } from '../stores/awards'
/** Teilnahme-Aktionen und die Phase (Timeline-key), an die sie gekoppelt sind. */
export type ActionKey = 'nominate' | 'clip' | 'vote'
export const PHASE_KEY: Record<ActionKey, string> = {
nominate: 'nomination',
clip: 'nomination', // Clips laufen im selben Fenster wie die Nominierung
vote: 'voting',
}
export type PhaseState = 'upcoming' | 'active' | 'done'
export interface PhaseInfo {
state: PhaseState
open: boolean
startLabel: string
endLabel: string
}
function formatDate(date?: string): string {
if (!date) return ''
const parsed = new Date(`${date}T00:00:00`)
return Number.isNaN(parsed.getTime())
? date
: parsed.toLocaleDateString('de-DE', { day: '2-digit', month: 'long' })
}
export function usePhases() {
const store = useAwardsStore()
function infoForPhaseKey(key?: string): PhaseInfo {
const item = store.overview.timeline.find((entry) => entry.key === key)
const state = (item?.state ?? 'upcoming') as PhaseState
return {
state,
open: state === 'active',
startLabel: formatDate(item?.startsAt),
endLabel: formatDate(item?.endsAt),
}
}
function phaseInfo(action: ActionKey): PhaseInfo {
return infoForPhaseKey(PHASE_KEY[action])
}
return { phaseInfo, infoForPhaseKey }
}
+26
View File
@@ -3,6 +3,7 @@ import type {
AdminSeasonDetailResponse,
AdminSeasonListItem,
AuthSession,
CreateClipPayload,
CreateNominationPayload,
CreateVotePayload,
LoginPayload,
@@ -40,6 +41,23 @@ async function getJson<T>(path: string): Promise<T> {
return response.json() as Promise<T>
}
async function sendDelete<TResponse>(path: string): Promise<TResponse> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}).catch(() => {
throw new Error(`API nicht erreichbar (${API_URL}). Bitte Backend starten.`)
})
if (!response.ok) {
const error = await response.text()
throw new Error(error || `API request failed for ${path}`)
}
return response.json() as Promise<TResponse>
}
async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<TResponse> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
@@ -78,6 +96,8 @@ export const api = {
sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload),
submitVote: (payload: CreateVotePayload) =>
sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload),
submitClip: (payload: CreateClipPayload) =>
sendJson<{ saved: boolean; clipId: number }>('/api/public/clips', 'POST', payload),
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload),
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
@@ -88,6 +108,12 @@ export const api = {
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload),
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, 'PUT', payload),
deleteAdminCandidate: (candidateId: number) =>
sendDelete<{ deleted: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`),
deleteAdminCategory: (categoryId: number) =>
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) =>
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`,
+23
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useAwardsStore } from './stores/awards'
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
import AdminAnalyticsView from './views/admin/AdminAnalyticsView.vue'
@@ -14,6 +15,7 @@ import AdminSeasonsView from './views/admin/AdminSeasonsView.vue'
import AdminSettingsView from './views/admin/AdminSettingsView.vue'
import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue'
import AdminVotingView from './views/admin/AdminVotingView.vue'
import ClipSubmissionView from './views/ClipSubmissionView.vue'
import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue'
@@ -45,6 +47,7 @@ const router = createRouter({
meta: {
requiresAuth: true,
keepAlive: true,
phase: 'nomination',
},
},
{
@@ -54,6 +57,17 @@ const router = createRouter({
meta: {
requiresAuth: true,
keepAlive: true,
phase: 'voting',
},
},
{
path: '/clips',
name: 'clips',
component: ClipSubmissionView,
meta: {
requiresAuth: true,
keepAlive: true,
phase: 'nomination',
},
},
{
@@ -192,6 +206,15 @@ router.beforeEach(async (to) => {
return { name: 'home' }
}
// Phasen-Gate: eine Aktion ist nur in ihrem aktiven Zeitfenster erreichbar.
if (typeof to.meta.phase === 'string') {
const awardsStore = useAwardsStore()
const phase = awardsStore.overview.timeline.find((entry) => entry.key === to.meta.phase)
if (phase?.state !== 'active') {
return { name: 'home' }
}
}
return true
})
+47 -13
View File
@@ -6,6 +6,7 @@ import type {
AdminSeasonDetailResponse,
ApproveNominationPayload,
AdminSeasonListItem,
CreateClipPayload,
CreateNominationPayload,
CreateVotePayload,
OverviewResponse,
@@ -31,9 +32,9 @@ const fallbackOverview: OverviewResponse = {
{ key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' },
],
featuredCategories: [
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die groesste Auszeichnung des Jahres.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Events, Konzerte und Showformate.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der lustigste oder emotionalste Clip.', maxNomineesPerUser: 3 },
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die VTuberin oder der VTuber, der dieses Jahr einfach alle verzaubert hat.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Das Event, das die Community zum Beben gebracht hat Konzert, Watchalong oder Mega-Stream.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der eine Clip, den du seit Monaten in jeden Chat spammst.', maxNomineesPerUser: 3 },
],
winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
@@ -41,9 +42,10 @@ const fallbackOverview: OverviewResponse = {
{ year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
],
faq: [
{ question: 'Wer kann nominieren und voten?', answer: 'Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt.' },
{ question: 'Wie werden Gewinner bestimmt?', answer: 'Aktuell rein community-basiert. Eine Mischlogik kann spaeter aktiviert werden.' },
{ question: 'Wer verwaltet Kategorien und Unterkategorien?', answer: 'Das Team pflegt diese pro Jahr im Admin-Bereich.' },
{ question: 'Wer darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt kein extra Konto, kein Papierkram.' },
{ question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf kein Jury-Geheimnis.' },
{ question: 'Kann ich meine Wahl noch ändern?', answer: 'Klar! Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen jederzeit anpassen.' },
{ question: 'Wer kuratiert die Kategorien?', answer: 'Das Jayuhime-Team stellt die Kategorien jedes Jahr frisch zusammen, passend zur Community.' },
],
}
@@ -55,23 +57,23 @@ const fallbackCategories: SeasonCategoriesResponse = {
id: 1,
name: 'VTuber des Jahres',
groupName: 'Main Awards',
description: 'Die Hauptkategorie fuer die praegendste Creator-Praesenz des Jahres.',
description: 'Die Hauptkategorie r die prägendste Creator-Präsenz des ganzen Jahres.',
maxNomineesPerUser: 3,
candidates: [
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch' },
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/HoshimiHighlight' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' },
],
},
{
id: 2,
name: 'Bestes Live Event',
groupName: 'Performance',
description: 'Konzerte, Sonderformate und grosse Community-Shows.',
description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.',
maxNomineesPerUser: 3,
candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube' },
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu3d' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube', clipUrl: 'https://www.youtube.com/watch?v=aoisakura' },
],
},
],
@@ -177,6 +179,19 @@ const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
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 = {
@@ -198,6 +213,7 @@ const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
categories: [],
candidates: [],
pendingNominations: [],
clipSubmissions: [],
}
export const useAwardsStore = defineStore('awards', {
@@ -277,6 +293,9 @@ export const useAwardsStore = defineStore('awards', {
submitVote(payload: CreateVotePayload) {
return api.submitVote(payload)
},
submitClip(payload: CreateClipPayload) {
return api.submitClip(payload)
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin()
@@ -302,6 +321,21 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdminSeasonDetail(seasonId)
return result
},
async deleteAdminCandidate(candidateId: number, seasonId: number) {
const result = await api.deleteAdminCandidate(candidateId)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async deleteAdminCategory(categoryId: number, seasonId: number) {
const result = await api.deleteAdminCategory(categoryId)
await this.loadAdminSeasonDetail(seasonId)
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) {
const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId)
+24
View File
@@ -45,6 +45,8 @@ export interface CandidateSummary {
displayName: string
channelSlug: string
platform: string
/** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */
clipUrl?: string
}
export interface PublicCategoryDetail {
@@ -157,6 +159,18 @@ export interface AdminNominationReviewItem {
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 {
id: number
year: number
@@ -166,6 +180,7 @@ export interface AdminSeasonDetailResponse {
categories: AdminCategoryItem[]
candidates: AdminCandidateItem[]
pendingNominations: AdminNominationReviewItem[]
clipSubmissions: AdminClipSubmissionItem[]
}
export interface CreateNominationPayload {
@@ -175,6 +190,15 @@ export interface CreateNominationPayload {
nominees: string[]
}
export interface CreateClipPayload {
year: number
categoryId: number | null
twitchUserId: string
clipUrl: string
title: string
creator: string
}
export interface VoteEntryPayload {
categoryId: number
candidateId: number
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { Film, Link as LinkIcon, Sparkles, Star } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const clipUrl = ref('')
const title = ref('')
const creator = ref('')
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
const clipCategory = store.categories.categories.find((c) => /clip/i.test(c.name))
selectedCategoryId.value = clipCategory?.id ?? store.categories.categories[0]?.id ?? null
})
const categoryOptions = computed(() =>
store.categories.categories.map((category) => ({ label: category.name, value: category.id })),
)
const platform = computed(() => {
const url = clipUrl.value.trim().toLowerCase()
if (!url) return null
if (url.includes('twitch.tv') || url.includes('clips.twitch.tv')) return 'Twitch'
if (url.includes('youtube.com') || url.includes('youtu.be')) return 'YouTube'
return 'unknown'
})
const urlValid = computed(() => platform.value === 'Twitch' || platform.value === 'YouTube')
async function submitClip() {
if (!urlValid.value) {
submitError.value = 'Bitte gib einen gültigen Twitch- oder YouTube-Link an.'
return
}
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
await store.submitClip({
year: store.categories.year,
categoryId: selectedCategoryId.value,
twitchUserId: authStore.session?.twitchUserId ?? '',
clipUrl: clipUrl.value.trim(),
title: title.value.trim(),
creator: creator.value.trim(),
})
submitMessage.value = 'Clip eingereicht! Das Team schaut ihn sich an. Danke fürs Teilen. 💜'
clipUrl.value = ''
title.value = ''
creator.value = ''
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Clip des Jahres"
title="Reich deinen Clip ein"
description="Der eine Moment, den die Community sehen muss? Teil den Link zu deinem Lieblings-Clip wir kümmern uns um den Rest."
:icon="Film"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Link einfügen</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Details</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Einreichen</span>
</div>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[1.1fr_0.9fr]">
<!-- Left: form -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Einreichung. 💜
</p>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Clip-Link (Twitch oder YouTube)</label>
<div class="flex items-center gap-2 rounded-2xl border bg-white px-4 py-3 transition"
:class="clipUrl && !urlValid ? 'border-rose-300' : 'border-violet-200'">
<LinkIcon class="h-4 w-4 shrink-0 text-violet-400" />
<input
v-model="clipUrl"
type="url"
class="w-full bg-transparent text-sm outline-none"
placeholder="https://clips.twitch.tv/… oder https://youtu.be/…"
/>
<span v-if="urlValid" class="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-600">{{ platform }}</span>
</div>
<p v-if="clipUrl && !urlValid" class="text-xs text-rose-500">Das sieht nicht nach einem Twitch- oder YouTube-Link aus.</p>
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Titel (optional)</label>
<input v-model="title" type="text" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm" placeholder="Worum geht's im Clip?" />
</div>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">VTuber im Clip (optional)</label>
<input v-model="creator" type="text" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm" placeholder="Name oder @handle" />
</div>
</div>
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
<Button class="w-full gap-2" :disabled="submitting || !authStore.isLoggedIn || !urlValid" @click="submitClip">
<Film class="h-4 w-4" />
{{ submitting ? 'Reicht ein ...' : 'Clip einreichen' }}
</Button>
</div>
<!-- Right: preview / rules -->
<div class="space-y-4">
<div class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">Vorschau</p>
<div class="mt-3 grid aspect-video place-items-center rounded-xl bg-white/60">
<Film class="h-10 w-10 text-violet-400" />
</div>
<p class="mt-3 truncate text-sm font-semibold text-violet-800">{{ title || 'Dein Clip-Titel' }}</p>
<p class="truncate text-xs text-slate-500">{{ creator || 'VTuber im Clip' }} · {{ platform && urlValid ? platform : 'Link einfügen' }}</p>
</div>
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Twitch-Clips oder YouTube-Links werden akzeptiert.</p>
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Mehrere Clips? Einfach nacheinander einreichen.</p>
<p class="flex items-center gap-2"><Sparkles class="h-3.5 w-3.5 text-amber-400" /> Das Team prüft jede Einreichung vor der Voting-Phase.</p>
</div>
</div>
</div>
</Card>
</div>
</template>
+562 -261
View File
@@ -1,303 +1,477 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Tag from 'primevue/tag'
import { ArrowRight, Sparkles, Star, Trophy, WandSparkles } from '@lucide/vue'
import {
ArrowRight,
Award,
BarChart3,
ChevronLeft,
ChevronRight,
Clapperboard,
Clock,
Crown,
Film,
Globe,
Heart,
Link as LinkIcon,
Megaphone,
Mic,
PartyPopper,
ShieldCheck,
Sparkles,
Star,
Trophy,
Users,
Video,
Vote,
} from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import hostVisual from '../assets/collector-editorial-reference.png'
import { usePhases, type ActionKey, type PhaseState } from '../composables/usePhases'
import heroKeyvisual from '../assets/hero-keyvisual.svg'
const store = useAwardsStore()
const authStore = useAuthStore()
const router = useRouter()
const { phaseInfo } = usePhases()
/* ----- Phasen-Zustand je Aktion (Quelle: Season-Timeline) ----- */
const phaseByAction = computed(() => ({
nominate: phaseInfo('nominate'),
vote: phaseInfo('vote'),
clip: phaseInfo('clip'),
}))
function phaseBadge(state: PhaseState) {
if (state === 'active') return { label: 'Jetzt offen', class: 'bg-emerald-50 text-emerald-600' }
if (state === 'upcoming') return { label: 'Demnächst', class: 'bg-amber-50 text-amber-600' }
return { label: 'Abgeschlossen', class: 'bg-slate-100 text-slate-500' }
}
function ctaLabel(key: ActionKey, openLabel: string) {
const info = phaseByAction.value[key]
if (info.open) return openLabel
if (info.state === 'upcoming') return `Ab ${info.startLabel}`
return 'Abgeschlossen'
}
onMounted(() => {
void store.loadHomeData()
})
const heroYear = computed(() => store.overview.year)
const isLoggedIn = computed(() => authStore.isLoggedIn)
/* ----- Login-Gate: ohne Twitch-Login kein Nominieren/Voten ----- */
async function startLogin() {
try {
await authStore.login({ twitchUserId: 'jayuhime_demo', displayName: 'Jayuhime', role: 'viewer' })
} catch {
/* Fehler werden im Header-Login-Bereich angezeigt */
}
}
function goTo(path: string) {
if (isLoggedIn.value) {
void router.push(path)
} else {
void startLogin()
}
}
/* ----- Live Countdown bis zum Ende der aktiven Phase ----- */
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | undefined
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const targetDate = computed(() => {
const active = store.overview.timeline.find((item) => item.state === 'active')
const raw = active?.endsAt ?? store.overview.showDate
const parsed = new Date(`${raw}T23:59:59`).getTime()
return Number.isNaN(parsed) ? now.value : parsed
})
const countdown = computed(() => {
const diff = Math.max(0, targetDate.value - now.value)
const totalSeconds = Math.floor(diff / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const pad = (value: number) => value.toString().padStart(2, '0')
return [
{ label: 'Tage', value: pad(days) },
{ label: 'Std', value: pad(hours) },
{ label: 'Min', value: pad(minutes) },
{ label: 'Sek', value: pad(seconds) },
]
})
/* ----- Statische Showcase-Daten ----- */
const trustItems = [
{ icon: Sparkles, title: '100% Community Powered', text: 'Deine Stimme entscheidet!' },
{ icon: ShieldCheck, title: 'Fair & Transparent', text: 'Verifiziert. Sicher. Vertrauenswürdig.' },
{ icon: Video, title: 'Live auf YouTube & Twitch', text: 'Die große Award Show.' },
{ icon: Globe, title: 'Global Celebration', text: 'Alle VTuber. Alle Fans.' },
]
const steps = [
{
icon: Sparkles,
title: 'Nominieren',
text: 'Trag deine Lieblings-Creator ein bis zu 3 Favoriten pro Kategorie.',
accent: 'from-violet-200 to-fuchsia-100 text-violet-600',
},
{
icon: Heart,
title: 'Voten',
text: 'Die Community stimmt ab. Eine Stimme pro Kategorie, mit ganz viel Herz.',
accent: 'from-rose-200 to-amber-100 text-rose-500',
},
{
icon: Trophy,
title: 'Feiern',
text: 'Die Stimmen werden gezählt und die Gewinner live in der Show gekürt!',
accent: 'from-amber-200 to-emerald-100 text-amber-600',
},
]
const categories = [
{ icon: Star, name: 'VTuber des Jahres', text: 'Die Auszeichnung des Abends für die, die alle verzaubert hat.', accent: 'bg-violet-100 text-violet-600' },
{ icon: Mic, name: 'Bestes Live Event', text: 'Konzerte, Watchalongs und Shows, die niemand vergisst.', accent: 'bg-fuchsia-100 text-fuchsia-600' },
{ icon: Video, name: 'Streamer:in des Jahres', text: 'Für die Streams, in denen man einfach hängen bleibt.', accent: 'bg-sky-100 text-sky-600' },
{ icon: Heart, name: 'Beste Collab', text: 'Wenn zwei Welten kollidieren und Magie entsteht.', accent: 'bg-rose-100 text-rose-500' },
{ icon: Clapperboard, name: 'Best Content Creator', text: 'Für kreative Köpfe, die immer wieder überraschen.', accent: 'bg-amber-100 text-amber-600' },
{ icon: Film, name: 'Clip des Jahres', text: 'Der eine Clip, den du in jeden Chat spammst.', accent: 'bg-emerald-100 text-emerald-600' },
]
const winners = [
{ name: 'Hoshimi Miyu', slug: '@HoshimiMiyu', category: 'VTuber des Jahres', year: 2025, current: false },
{ name: 'Kurainu 3D Live', slug: '@Kurainu', category: 'Best Live Event', year: 2025, current: false },
{ name: 'Shiro Ch.', slug: '@ShiroCh', category: 'Streamer:in des Jahres', year: 2025, current: true },
{ name: 'Luna & Yuki', slug: '@LunaYuki', category: 'Beste Collab', year: 2025, current: false },
{ name: 'Miyu Ch.', slug: '@MiyuCh', category: 'Best Content Creator', year: 2025, current: false },
{ name: 'Pyonkichi Kingdom', slug: '@PyonkichiKingdom', category: 'Clip des Jahres', year: 2024, current: false },
]
const winnerGradients = [
'from-violet-200 to-fuchsia-100',
'from-indigo-200 to-violet-100',
'from-sky-200 to-violet-100',
'from-rose-200 to-amber-100',
'from-violet-200 to-amber-100',
'from-fuchsia-200 to-rose-100',
]
const participation: Array<{
key: ActionKey
icon: typeof Sparkles
title: string
text: string
to: string
cta: string
accent: string
}> = [
{ key: 'nominate', icon: Sparkles, title: 'Nominieren', text: 'Trag bis zu 3 Favoriten pro Kategorie ein und bring sie auf die Bühne.', to: '/nominations', cta: 'Jetzt nominieren', accent: 'from-violet-200 to-fuchsia-100 text-violet-600' },
{ key: 'vote', icon: Heart, title: 'Voten', text: 'Eine Stimme pro Kategorie entscheide mit, wer am Ende gewinnt.', to: '/voting', cta: 'Jetzt voten', accent: 'from-rose-200 to-amber-100 text-rose-500' },
{ key: 'clip', icon: Film, title: 'Clip einreichen', text: 'Teile den Link zu deinem Lieblings-Clip fürs „Clip des Jahres".', to: '/clips', cta: 'Clip einreichen', accent: 'from-sky-200 to-violet-100 text-sky-600' },
]
const adminMetrics = [
{ label: 'Nominierungen', value: '12.341', delta: '+12,4% vs. gestern' },
{ label: 'Votes', value: '582.731', delta: '+8,7% vs. gestern' },
{ label: 'Nutzer', value: '98.452', delta: '+9,2% vs. gestern' },
{ label: 'Kategorien', value: '28', delta: 'aktiv' },
{ label: 'Reviews offen', value: '47', delta: '+14 neu' },
]
const adminTopVotes = [
{ name: 'VTuber des Jahres', votes: 186321, pct: 100 },
{ name: 'Best Streamer', votes: 142553, pct: 77 },
{ name: 'Beste Collab', votes: 112968, pct: 61 },
{ name: 'Best Content Creator', votes: 78442, pct: 42 },
{ name: 'Best Live Event', votes: 62517, pct: 34 },
]
/* ----- Gewinner-Archiv Carousel ----- */
const carousel = ref<HTMLElement | null>(null)
function scrollCarousel(direction: number) {
carousel.value?.scrollBy({ left: direction * 320, behavior: 'smooth' })
}
const winnersScopeYear = computed(() => heroYear.value - 1)
</script>
<template>
<div class="space-y-20 pb-16">
<section class="grid gap-10 lg:grid-cols-[0.82fr_1.18fr] lg:items-start">
<div class="space-y-10 pt-3">
<div class="space-y-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Die groesste Community-Auszeichnung</p>
<h1 class="max-w-[8ch] font-[Cormorant_Garamond] text-6xl leading-[0.88] text-violet-800 sm:text-7xl xl:text-[6.6rem]">
VTuber Star Awards
<div class="space-y-24 pb-20">
<!-- ============ HERO ============ -->
<section class="grid items-stretch gap-8 lg:grid-cols-[1.02fr_1.18fr]">
<div class="flex flex-col justify-center space-y-8">
<p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">
Die große Community-Auszeichnung
</p>
<div class="space-y-3">
<h1 class="font-[Cormorant_Garamond] text-6xl leading-[0.86] text-violet-800 sm:text-7xl xl:text-[6.4rem]">
VTUBER<br />STAR AWARDS
</h1>
<p class="text-2xl font-medium italic tracking-wide text-violet-500">
Presented by Jayuhime
<div class="h-1.5 w-48 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="font-[Cormorant_Garamond] text-3xl italic text-violet-500">
Presented by Jayuhime <Sparkles class="-mt-1 inline h-6 w-6 text-amber-400" />
</p>
</div>
<p class="max-w-lg text-lg leading-8 text-slate-600">
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres.
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert.
VTuber sind Künstler:innen, Entertainer und Geschichtenerzähler:innen zugleich. Hier feiern wir genau diese
Kreativität nominiere deine Favoriten, vote für die Besten und sei dabei, wenn die Stars gekürt werden!
</p>
</div>
<div class="flex flex-wrap gap-3">
<RouterLink to="/nominations">
<Button size="lg">Jetzt nominieren</Button>
</RouterLink>
<RouterLink to="/voting">
<Button variant="secondary" size="lg">Jetzt voten</Button>
</RouterLink>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<Card class="min-h-[210px] p-7">
<div class="flex items-center gap-3 text-violet-600">
<Sparkles class="h-5 w-5 text-amber-500" />
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Von der Community getragen</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Nur Twitch Login, keine Konto-Huerde, editierbare Stimmen und Nominierungen bis zur Deadline.
</p>
</Card>
<Card class="min-h-[210px] p-7">
<div class="flex items-center gap-3 text-violet-600">
<WandSparkles class="h-5 w-5 text-amber-500" />
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Team verwaltet pro Jahr</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Kategorien und Unterkategorien werden im Admin-Bereich je Jahr gepflegt und freigeschaltet.
</p>
</Card>
</div>
<!-- Aktuelle Phase Card -->
<Card class="p-7">
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Aktuelle Phase</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">
{{ store.overview.currentPhase }}
</h2>
<p class="mt-2 max-w-md text-slate-600">
Login bleibt leichtgewichtig: nur Twitch, kein separates Community-Konto.
</p>
</div>
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Sitzungsstatus</p>
<p class="mt-2 font-semibold text-violet-800">
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
</p>
<p class="mt-2 leading-7 text-slate-600">
{{ authStore.isLoggedIn ? 'Nominierung und Voting sind jetzt direkt freigeschaltet.' : 'Bitte oben im Kopfbereich einloggen, um Nominierung, Voting oder Admin zu nutzen.' }}
</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">41</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Tage</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">08</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Std</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">24</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Min</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">16</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Sek</span>
</div>
</div>
</div>
</Card>
</div>
<Card class="overflow-hidden p-0">
<div class="relative min-h-[760px] bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.82),transparent_26%),linear-gradient(160deg,rgba(224,214,255,0.72),rgba(255,240,217,0.68))]">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_75%_15%,rgba(255,255,255,0.82),transparent_25%)]" />
<div class="absolute left-10 top-10 rounded-full border border-white/60 bg-white/60 px-4 py-1 text-xs uppercase tracking-[0.3em] text-violet-600">
Presented by Jayuhime
</div>
<img
:src="hostVisual"
alt="Jayuhime Host Keyvisual"
class="absolute inset-0 h-full w-full object-cover object-center"
/>
<div class="absolute inset-y-0 right-0 flex w-full max-w-[340px] flex-col justify-between border-l border-white/40 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.38))] p-7 backdrop-blur md:p-8">
<div class="space-y-8">
<div class="rounded-[28px] border border-white/50 bg-white/30 p-5">
<div class="flex items-center gap-3 text-violet-700">
<Star class="h-5 w-5 text-amber-500" />
<span class="text-sm font-semibold uppercase tracking-[0.2em]">Jayuhime · Host of the Show</span>
</div>
<p class="mt-4 text-sm leading-7 text-slate-700">
Editoriales Hero-Panel mit klarer Host-Praesenz, aber mehr White Space und weniger competing elements.
</p>
</div>
<Tag value="Collector Editorial" severity="warn" class="self-start" />
</div>
<div class="grid gap-3">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Show Date</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">24. Jan 2026</p>
</div>
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Nur Community</p>
</div>
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Twitch</p>
</div>
</div>
</div>
</div>
</Card>
</section>
<section class="grid gap-5 lg:grid-cols-4">
<Card
v-for="item in store.overview.timeline"
:key="item.key"
class="p-7"
>
<Tag :value="item.state === 'active' ? 'live' : item.state" severity="secondary" class="mb-4" />
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ item.title }}</h3>
<p class="mt-2 text-sm text-slate-600">{{ item.startsAt }} - {{ item.endsAt }}</p>
</Card>
</section>
<section class="grid gap-5 lg:grid-cols-3">
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<Trophy class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">How it works</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Nominate, vote, celebrate.</h2>
<p class="mt-3 text-slate-600">
Die Plattform trennt bewusst zwischen showhafter Startseite und ruhigen Produktflows. So bleibt der Einstieg emotional, waehrend die Interaktion klar bleibt.
</p>
</Card>
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<Sparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Rules</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Moderater Abuse-Schutz</h2>
<p class="mt-3 text-slate-600">
Rate limits, serverseitige Pruefung und Risikoflags laufen im Hintergrund. Die User-Huerde bleibt niedrig, der operative Blick landet im Admin.
</p>
</Card>
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<WandSparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Jahresbasiertes Management</h2>
<p class="mt-3 text-slate-600">
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als kuratierte Award-Jahre gedacht, nicht als harte statische App-Texte.
</p>
</Card>
</section>
<section class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Featured Kategorien</p>
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">Team-gesteuerte Awards fuer {{ heroYear }}</h2>
</div>
<span class="text-sm text-slate-500">
API-Modus:
<strong class="text-violet-700">{{ store.apiMode }}</strong>
<div class="flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Aktuelle Phase</p>
<span class="inline-flex items-center gap-2 rounded-full bg-rose-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-rose-500">
<span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-rose-500" /> Live
</span>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Card
v-for="category in store.overview.featuredCategories"
:key="category.id"
class="p-6"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category.groupName }}</p>
<h3 class="mt-3 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ category.name }}</h3>
<p class="mt-3 text-slate-600">{{ category.description }}</p>
<div class="mt-6 flex items-center justify-between text-sm text-slate-500">
<span>Max. {{ category.maxNomineesPerUser }} Nominierungen</span>
<ArrowRight class="h-4 w-4 text-amber-500" />
</div>
</Card>
</div>
</section>
<section class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierung</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Bis zu drei Favoriten, direkt validiert</h2>
<ul class="mt-5 space-y-3 text-slate-600">
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</li>
<li>Regeln werden direkt im Formular sichtbar gemacht.</li>
<li>Freitext-Ideen und Alias-Faelle gehen spaeter in die Review-Liste.</li>
</ul>
<div class="mt-6">
<RouterLink to="/nominations">
<Button>Zur Nominierungsansicht</Button>
</RouterLink>
</div>
</Card>
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Ein Kandidat pro Kategorie, bis zur Deadline editierbar</h2>
<ul class="mt-5 space-y-3 text-slate-600">
<li>Nur eine Stimme pro Kategorie.</li>
<li>Videos, Clips und spaetere Detail-Previews koennen direkt im Flow eingebettet werden.</li>
<li>Die Ballot-Logik lebt im Backend ueber VoteBallot und VoteEntry.</li>
</ul>
<div class="mt-6">
<RouterLink to="/voting">
<Button variant="secondary">Zur Voting-Ansicht</Button>
</RouterLink>
</div>
</Card>
</section>
<section class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinner Archiv</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Vergangene Jahre sichtbar machen</h2>
<p class="mt-4 text-slate-600">
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ store.overview.currentPhase }}</h2>
<p class="mt-2 text-sm text-slate-600">
Die Nominierungsphase ist abgeschlossen. Jetzt liegt es an dir: Stimme für deine Favoriten!
</p>
<div class="mt-6 space-y-3">
<div class="mt-6 grid grid-cols-4 gap-3">
<div
v-for="entry in store.overview.winnersPreview"
:key="`${entry.year}-${entry.category}`"
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
v-for="unit in countdown"
:key="unit.label"
class="rounded-2xl border border-violet-100 bg-violet-50/70 py-4 text-center"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
<strong class="block font-[Cormorant_Garamond] text-3xl text-violet-800">{{ unit.value }}</strong>
<span class="text-[10px] uppercase tracking-[0.2em] text-slate-500">{{ unit.label }}</span>
</div>
<Tag :value="entry.year.toString()" severity="info" />
</div>
<div class="mt-6 flex flex-wrap gap-3">
<Button
size="lg"
class="gap-2"
:disabled="!phaseByAction.nominate.open"
@click="goTo('/nominations')"
>
<Sparkles class="h-4 w-4" /> {{ ctaLabel('nominate', 'Jetzt nominieren') }}
</Button>
<Button
variant="secondary"
size="lg"
class="gap-2"
:disabled="!phaseByAction.vote.open"
@click="goTo('/voting')"
>
<Star class="h-4 w-4" /> {{ ctaLabel('vote', 'Jetzt voten') }}
</Button>
</div>
</Card>
</div>
<!-- Hero Keyvisual -->
<Card class="relative overflow-hidden p-0">
<div class="relative min-h-[640px]">
<img :src="heroKeyvisual" alt="VTuber Star Awards Keyvisual (Platzhalter)" class="absolute inset-0 h-full w-full object-cover object-top" />
<div class="absolute inset-0 bg-[radial-gradient(circle_at_70%_18%,rgba(255,255,255,0.55),transparent_45%)]" />
<!-- Host Badge -->
<div class="absolute bottom-7 right-7 w-[230px] rounded-[24px] border border-white/70 bg-white/80 p-5 shadow-lg backdrop-blur">
<p class="text-[10px] font-semibold uppercase tracking-[0.3em] text-slate-400">Host</p>
<p class="mt-1 flex items-center gap-2 font-[Cormorant_Garamond] text-3xl text-violet-800">
Jayuhime <Sparkles class="h-5 w-5 text-amber-400" />
</p>
<p class="text-sm text-slate-500">VTuber &amp; Award Host</p>
<div class="mt-3 flex items-center gap-2 text-violet-500">
<span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<Video class="h-4 w-4" />
</span>
<span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<PartyPopper class="h-4 w-4" />
</span>
<span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<svg class="h-3.5 w-3.5" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg>
</span>
</div>
</div>
</div>
</Card>
</section>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2>
<Accordion value="0" class="mt-6">
<!-- ============ TRUST BAR ============ -->
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card v-for="item in trustItems" :key="item.title" class="flex items-center gap-4 p-5">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#efe6ff,#fff1da)] text-violet-600">
<component :is="item.icon" class="h-5 w-5" />
</span>
<div>
<p class="text-sm font-semibold uppercase tracking-[0.15em] text-violet-700">{{ item.title }}</p>
<p class="text-sm text-slate-500">{{ item.text }}</p>
</div>
</Card>
</section>
<!-- ============ SO FUNKTIONIERT'S ============ -->
<section class="space-y-10">
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">So funktioniert's</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">In drei Schritten zur großen Bühne</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<div v-for="(step, index) in steps" :key="step.title" class="relative text-center">
<div :class="['mx-auto grid h-20 w-20 place-items-center rounded-3xl bg-gradient-to-br shadow-[0_18px_40px_rgba(124,92,255,0.18)]', step.accent]">
<component :is="step.icon" class="h-8 w-8" />
</div>
<span class="mt-5 inline-flex h-8 w-8 items-center justify-center rounded-full border border-violet-200 bg-white text-sm font-semibold text-violet-600">
{{ index + 1 }}
</span>
<h3 class="mt-3 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ step.title }}</h3>
<p class="mx-auto mt-2 max-w-xs text-sm leading-7 text-slate-600">{{ step.text }}</p>
</div>
</div>
</section>
<!-- ============ BELIEBTE KATEGORIEN ============ -->
<section class="space-y-8">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Beliebte Kategorien</h2>
<RouterLink to="/voting" class="inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Alle Kategorien ansehen <ArrowRight class="h-4 w-4" />
</RouterLink>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card v-for="category in categories" :key="category.name" class="group p-6 transition hover:-translate-y-1 hover:shadow-[0_30px_70px_rgba(124,92,255,0.18)]">
<span :class="['grid h-14 w-14 place-items-center rounded-2xl', category.accent]">
<component :is="category.icon" class="h-6 w-6" />
</span>
<h3 class="mt-5 text-base font-semibold text-violet-800">{{ category.name }}</h3>
<p class="mt-2 text-xs leading-6 text-slate-500">{{ category.text }}</p>
<RouterLink to="/voting" class="mt-4 inline-flex items-center gap-1 text-xs font-semibold text-amber-500 group-hover:gap-2">
Top 10 ansehen <ArrowRight class="h-3.5 w-3.5" />
</RouterLink>
</Card>
</div>
</section>
<!-- ============ GEWINNER ARCHIV ============ -->
<section class="space-y-8">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="flex items-center gap-3">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Gewinner Archiv</h2>
</div>
<div class="flex items-center gap-3">
<RouterLink to="/winners" class="inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Alle Jahre ansehen <ArrowRight class="h-4 w-4" />
</RouterLink>
<div class="flex gap-2">
<button class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 bg-white text-violet-600 transition hover:bg-violet-50" @click="scrollCarousel(-1)">
<ChevronLeft class="h-4 w-4" />
</button>
<button class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 bg-white text-violet-600 transition hover:bg-violet-50" @click="scrollCarousel(1)">
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div ref="carousel" class="flex gap-4 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Card
v-for="(winner, index) in winners"
:key="winner.slug"
class="w-[230px] shrink-0 overflow-hidden p-0"
>
<div :class="['relative h-[230px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<div class="absolute inset-0 grid place-items-center text-white/70">
<Star class="h-12 w-12" />
</div>
<span
v-if="winner.current"
class="absolute left-3 top-3 rounded-full bg-violet-600 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-white"
>
{{ winner.year }}
</span>
<span
v-else
class="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-[linear-gradient(135deg,#ffd97a,#f6b938)] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-950"
>
<Crown class="h-3 w-3" /> Winner
</span>
</div>
<div class="p-4">
<p class="text-[10px] uppercase tracking-[0.2em] text-violet-400">{{ winner.category }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ winner.name }}</p>
<p class="text-sm text-slate-500">{{ winner.slug }}</p>
</div>
</Card>
</div>
<p class="text-xs text-slate-400">Archiv-Stand: Gewinner aus {{ winnersScopeYear }} · Bilder sind Platzhalter.</p>
</section>
<!-- ============ MITMACHEN (Teaser-CTAs) ============ -->
<section class="space-y-8">
<div class="text-center">
<p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">Mach mit</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Werde Teil der Show</h2>
</div>
<div class="grid gap-5 md:grid-cols-3">
<Card
v-for="action in participation"
:key="action.to"
class="flex flex-col p-7"
:class="phaseByAction[action.key].open ? 'ring-1 ring-violet-200/70' : 'opacity-95'"
>
<div class="flex items-start justify-between">
<span :class="['grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br', action.accent]">
<component :is="action.icon" class="h-6 w-6" />
</span>
<span :class="['rounded-full px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.15em]', phaseBadge(phaseByAction[action.key].state).class]">
{{ phaseBadge(phaseByAction[action.key].state).label }}
</span>
</div>
<h3 class="mt-5 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ action.title }}</h3>
<p class="mt-2 flex-1 text-sm leading-7 text-slate-600">{{ action.text }}</p>
<!-- Phase offen -->
<template v-if="phaseByAction[action.key].open">
<Button v-if="isLoggedIn" class="mt-6 w-full gap-2" @click="goTo(action.to)">
{{ action.cta }} <ArrowRight class="h-4 w-4" />
</Button>
<Button v-else variant="ghost" class="mt-6 w-full gap-2 border border-violet-200" @click="startLogin">
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16"><path d="M2.5 0 0 2.5v11h3.5V16h2l2.5-2.5h3l5-5V0H2.5Zm12 8L12 10.5H8.5L6 13v-2.5H2.5V2h12v6Z" /><path d="M11.5 4h1.5v3.5h-1.5V4Zm-3.5 0h1.5v3.5H8V4Z" /></svg>
Mit Twitch anmelden
</Button>
</template>
<!-- Phase noch nicht offen / abgeschlossen -->
<div v-else class="mt-6 flex items-center justify-center gap-2 rounded-xl bg-slate-50 px-4 py-3 text-sm font-semibold text-slate-400">
<Clock class="h-4 w-4" />
{{ ctaLabel(action.key, action.cta) }}
</div>
</Card>
</div>
</section>
<!-- ============ FAQ / COMMUNITY / SHARE ============ -->
<section class="grid gap-6 lg:grid-cols-3">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">FAQ Häufige Fragen</p>
<Accordion value="0" class="mt-5">
<AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)">
<AccordionHeader>{{ item.question }}</AccordionHeader>
<AccordionContent>
@@ -305,6 +479,133 @@ const heroYear = computed(() => store.overview.year)
</AccordionContent>
</AccordionPanel>
</Accordion>
<RouterLink to="/winners" class="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Alle Fragen ansehen <ArrowRight class="h-4 w-4" />
</RouterLink>
</Card>
<Card class="flex flex-col p-7">
<div class="flex items-center gap-2 text-violet-600">
<Megaphone class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em]">Community &amp; Updates</p>
</div>
<p class="mt-4 text-sm leading-7 text-slate-600">
Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!
</p>
<div class="mt-5 flex gap-3 text-violet-600">
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50"><Video class="h-4 w-4" /></span>
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50"><PartyPopper class="h-4 w-4" /></span>
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50">
<svg class="h-4 w-4" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg>
</span>
<span class="grid h-10 w-10 place-items-center rounded-full bg-violet-50">
<svg class="h-4 w-4" viewBox="0 0 20 19"><use href="/icons.svg#discord-icon" /></svg>
</span>
</div>
<button class="mt-auto flex w-full items-center justify-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold text-violet-700">
Newsletter abonnieren
</button>
</Card>
<Card class="flex flex-col p-7">
<p class="font-[Cormorant_Garamond] text-3xl text-violet-800">Teile die Awards</p>
<p class="mt-2 text-sm leading-7 text-slate-600">
Supporte deine Favoriten und teile die Awards mit deinen Freunden!
</p>
<div class="mt-5 space-y-3">
<button class="flex w-full items-center gap-3 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white">
<svg class="h-4 w-4 fill-white" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg> Auf X teilen
</button>
<button class="flex w-full items-center gap-3 rounded-2xl bg-violet-600 px-4 py-3 text-sm font-semibold text-white">
<svg class="h-4 w-4 fill-white" viewBox="0 0 20 19"><use href="/icons.svg#discord-icon" /></svg> Auf Discord teilen
</button>
<button class="flex w-full items-center gap-3 rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold text-violet-700">
<LinkIcon class="h-4 w-4" /> Link kopieren
</button>
</div>
</Card>
</section>
<!-- ============ ADMIN DASHBOARD VORSCHAU ============ -->
<section>
<Card class="overflow-hidden p-0">
<div class="flex flex-col gap-2 border-b border-violet-100 px-7 py-5 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Für das Team: Admin Dashboard (Vorschau)</p>
<ShieldCheck class="h-4 w-4 text-amber-500" />
</div>
<RouterLink to="/admin" class="inline-flex items-center gap-2 text-sm font-semibold text-violet-600 hover:text-violet-800">
Zum vollständigen Admin Panel <ArrowRight class="h-4 w-4" />
</RouterLink>
</div>
<div class="grid gap-0 lg:grid-cols-[200px_1fr]">
<!-- Sidebar -->
<aside class="hidden flex-col gap-1 border-r border-violet-100 bg-violet-50/40 p-5 text-sm text-slate-600 lg:flex">
<p class="mb-2 flex items-center gap-2 font-semibold text-violet-800"><Star class="h-4 w-4 text-amber-500" /> VSA Admin</p>
<span class="rounded-xl bg-violet-100 px-3 py-2 font-semibold text-violet-800">Dashboard</span>
<span class="px-3 py-2">Nominierungen</span>
<span class="px-3 py-2">Voting</span>
<span class="px-3 py-2">Kategorien</span>
<span class="px-3 py-2">Clips</span>
<span class="px-3 py-2">Reviews</span>
<span class="px-3 py-2">User &amp; Logs</span>
<span class="px-3 py-2">Analytics</span>
<span class="px-3 py-2">Einstellungen</span>
</aside>
<!-- Content -->
<div class="space-y-6 p-7">
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-5">
<div v-for="metric in adminMetrics" :key="metric.label" class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
<p class="text-[11px] uppercase tracking-[0.15em] text-slate-400">{{ metric.label }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ metric.value }}</p>
<p class="text-[11px] font-semibold text-emerald-500">{{ metric.delta }}</p>
</div>
</div>
<div class="grid gap-5 lg:grid-cols-2">
<div class="rounded-2xl border border-violet-100 bg-white p-5">
<div class="flex items-center gap-2 text-violet-600">
<BarChart3 class="h-4 w-4 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.2em]">Top Kategorien (nach Votes)</p>
</div>
<div class="mt-4 space-y-3">
<div v-for="item in adminTopVotes" :key="item.name">
<div class="flex items-center justify-between text-xs text-slate-600">
<span>{{ item.name }}</span>
<span class="font-semibold text-violet-700">{{ item.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-1 h-2 rounded-full bg-violet-50">
<div class="h-2 rounded-full bg-[linear-gradient(90deg,#a78bff,#7c5cff)]" :style="{ width: `${item.pct}%` }" />
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-violet-100 bg-white p-5">
<div class="flex items-center gap-2 text-violet-600">
<Vote class="h-4 w-4 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.2em]">Letzte Aktivitäten</p>
</div>
<ul class="mt-4 space-y-3 text-sm text-slate-600">
<li class="flex items-center justify-between">
<span class="flex items-center gap-2"><Sparkles class="h-4 w-4 text-violet-400" /> Neue Nominierung in Best New VTuber</span>
<span class="text-xs text-slate-400">vor 2 Min.</span>
</li>
<li class="flex items-center justify-between">
<span class="flex items-center gap-2"><Users class="h-4 w-4 text-violet-400" /> 24 Votes in VTuber des Jahres</span>
<span class="text-xs text-slate-400">vor 5 Min.</span>
</li>
<li class="flex items-center justify-between">
<span class="flex items-center gap-2"><Award class="h-4 w-4 text-violet-400" /> Clip-Einreichung in Beste Collab</span>
<span class="text-xs text-slate-400">vor 18 Min.</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</Card>
</section>
</div>
+107 -52
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { Plus, Sparkles, Star, Trophy, X } from '@lucide/vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
const store = useAwardsStore()
const authStore = useAuthStore()
@@ -31,6 +33,12 @@ const selectedCategory = computed(() =>
store.categories.categories.find((category) => category.id === selectedCategoryId.value),
)
const slotsLeft = computed(() => 3 - nominees.value.length)
function slugFor(name: string) {
return `@${name.toLowerCase().replace(/\s+/g, '')}`
}
function addNominee() {
const value = nomineeName.value.trim()
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
@@ -57,9 +65,9 @@ async function submitNomination() {
nominees: nominees.value,
})
submitMessage.value = `${response.saved} Nominierungen fuer ${response.category} gespeichert.`
submitMessage.value = `Stark! ${response.saved} Nominierung(en) für ${response.category} sind eingetragen.`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht gespeichert werden.'
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
@@ -67,34 +75,33 @@ async function submitNomination() {
</script>
<template>
<div class="space-y-10 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierungs-Flow</p>
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Kategorien waehlen, Regeln live pruefen</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Nur Twitch Login, kein separates Konto. Das Team pflegt Kategorien pro Jahr, waehrend die UI sofort Limits, Dubletten und editierbare Entwuerfe abbildet.
</p>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Schritt 1 · Nominieren"
title="Wen feiern wir dieses Jahr?"
:description="`Trag deine Lieblings-Creator ein und bring sie auf die große Bühne. Pro Kategorie hast du ${ 3 } Plätze nutz sie für die VTuber, die dein Jahr gemacht haben.`"
:icon="Star"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Kategorie</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Favoriten eintragen</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Abschicken</span>
</div>
<div class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Regeln</p>
<ul class="mt-5 space-y-4 text-slate-600">
<li>Pro Kategorie nur eine Nominierung derselben Person.</li>
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li>
<li>Freitext-Ideen landen spaeter in der Review-Liste.</li>
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
</ul>
</Card>
<Card class="p-7">
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<!-- Left: category + add -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Nominierung. 💜
</p>
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">In welcher Kategorie?</label>
<Select
v-model="selectedCategoryId"
:options="categories"
@@ -102,53 +109,101 @@ async function submitNomination() {
option-value="value"
class="w-full"
/>
<div v-if="selectedCategory" class="rounded-[28px] bg-violet-50/70 p-6">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ selectedCategory.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-slate-600">{{ selectedCategory.description }}</p>
</div>
<div class="space-y-3 rounded-[28px] border border-violet-100 bg-white/70 p-5">
<label class="text-sm font-semibold text-slate-600">Neuen Namen hinzufuegen</label>
<div v-if="selectedCategory" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ selectedCategory.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-600">{{ selectedCategory.description }}</p>
</div>
<div class="space-y-3 rounded-2xl border border-violet-100 bg-white/70 p-5">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Wen möchtest du nominieren?</label>
<input
v-model="nomineeName"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
placeholder="z. B. Shiro Ch."
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm"
placeholder="Name oder @handle, z. B. Shiro Ch."
@keyup.enter="addNominee"
/>
<Button @click="addNominee">Nominierung hinzufuegen</Button>
<Button class="w-full gap-2" :disabled="slotsLeft <= 0" @click="addNominee">
<Plus class="h-4 w-4" /> {{ slotsLeft > 0 ? 'Zur Liste hinzufügen' : 'Alle Plätze belegt' }}
</Button>
</div>
</div>
<!-- Right: draft -->
<div class="space-y-4">
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3>
<div class="flex items-center justify-between">
<div>
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Deine Favoriten</h3>
<p class="text-sm text-slate-500">
{{ slotsLeft > 0 ? `Noch ${slotsLeft} Platz${slotsLeft === 1 ? '' : 'e'} frei` : 'Volle Liste richtig so!' }}
</p>
</div>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">{{ nominees.length }}/3</span>
</div>
<transition-group name="list" tag="div" class="space-y-2">
<div
v-for="name in nominees"
v-for="(name, index) in nominees"
:key="name"
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5"
class="flex items-center justify-between rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
>
<div class="flex items-center gap-3">
<span class="grid h-10 w-10 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ name.charAt(0) }}
</span>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</p>
<p class="text-xs text-slate-500">{{ slugFor(name) }}</p>
</div>
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
</div>
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600">
Live-Status: {{ nominees.length }}/3 Slots belegt.
</p>
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-violet-400">#{{ index + 1 }}</span>
<button class="grid h-8 w-8 place-items-center rounded-full text-rose-400 transition hover:bg-rose-50" @click="removeNominee(name)">
<X class="h-4 w-4" />
</button>
</div>
</div>
</transition-group>
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">
{{ submitMessage }}
</p>
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">
{{ submitError }}
</p>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination">
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }}
<div v-if="nominees.length === 0" class="rounded-2xl border border-dashed border-violet-200 bg-violet-50/40 px-5 py-8 text-center">
<Sparkles class="mx-auto h-6 w-6 text-amber-400" />
<p class="mt-2 text-sm text-slate-500">Noch leer wer hat dieses Jahr deinen Bildschirm zum Leuchten gebracht?</p>
</div>
<div class="rounded-2xl bg-violet-50/50 px-5 py-4 text-xs leading-6 text-slate-500">
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Bis zu 3 Favoriten pro Kategorie keine Person doppelt.</p>
<p class="flex items-center gap-2"><Star class="h-3.5 w-3.5 text-amber-400" /> Alles bleibt bis zum Ende der Phase änderbar.</p>
</div>
<p v-if="submitMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">{{ submitError }}</p>
<Button
class="w-full gap-2"
:disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0"
@click="submitNomination"
>
<Trophy class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Nominierungen abschicken' }}
</Button>
</div>
</div>
</Card>
</div>
</div>
</template>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.25s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+93 -41
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import RadioButton from 'primevue/radiobutton'
import { Check, PlayCircle, Sparkles, Star, Vote } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
@@ -32,6 +33,10 @@ const category = computed(() =>
store.categories.categories.find((item) => item.id === selectedCategoryId.value) ?? store.categories.categories[0],
)
const selectedCandidate = computed(() =>
category.value?.candidates.find((candidate) => candidate.id === selectedCandidateId.value),
)
async function submitVote() {
if (!category.value || !selectedCandidateId.value) return
@@ -51,9 +56,9 @@ async function submitVote() {
],
})
submitMessage.value = `Ballot #${response.ballotId} mit ${response.entries} Eintrag gespeichert.`
submitMessage.value = `Stimme gezählt! (Ballot #${response.ballotId}) Danke fürs Mitmachen. 💜`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Vote konnte nicht gespeichert werden.'
submitError.value = error instanceof Error ? error.message : 'Ups das hat nicht geklappt. Versuch es gleich nochmal.'
} finally {
submitting.value = false
}
@@ -61,24 +66,33 @@ async function submitVote() {
</script>
<template>
<div class="space-y-10 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Ein ruhiger, schneller Community-Voting-Flow</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Der V2-Flow priorisiert geringe Reibung: Twitch Login, ein Kandidat pro Kategorie, spaeter editierbar bis zur Deadline und klarer Review-Screen.
</p>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Schritt 2 · Voten"
title="Jetzt zählt deine Stimme"
description="Eine Stimme pro Kategorie wähl mit Herz. Deine Wahl bleibt bis zum Ende der Voting-Phase jederzeit änderbar, also kein Stress."
:icon="Vote"
/>
<Card class="overflow-hidden p-0">
<!-- Stepper -->
<div class="flex items-center gap-4 border-b border-violet-100 px-7 py-5 text-xs font-semibold text-slate-400 sm:px-9">
<span class="text-violet-600">1 · Kategorie</span>
<span class="h-px flex-1 bg-slate-200" />
<span class="text-violet-600">2 · Favorit wählen</span>
<span class="h-px flex-1 bg-slate-200" />
<span>3 · Stimme abgeben</span>
</div>
<Card class="p-7">
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<!-- Left -->
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
<p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Logg dich kurz oben mit Twitch ein dann zählt deine Stimme. 💜
</p>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
<div class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Für welche Kategorie stimmst du?</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
@@ -88,50 +102,88 @@ async function submitVote() {
/>
</div>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category?.groupName }}</p>
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">{{ category?.name }}</h2>
<p class="text-slate-600">{{ category?.description }}</p>
<div class="rounded-[28px] bg-violet-50/70 p-6 text-sm leading-7 text-slate-600">
Nur eine Stimme pro Kategorie. Videos/Clips koennen spaeter direkt auf Karten oder Detailmodals referenziert werden.
<div v-if="category" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ category.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ category.name }}</h2>
<p class="mt-2 text-sm leading-7 text-slate-600">{{ category.description }}</p>
</div>
<p class="flex items-start gap-2 rounded-2xl border border-violet-100 bg-white/70 px-5 py-4 text-sm text-slate-600">
<Star class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
Nur eine Stimme zählt pro Kategorie aber du darfst sie jederzeit umentscheiden.
</p>
<p class="flex items-start gap-2 rounded-2xl border border-violet-100 bg-white/70 px-5 py-4 text-sm text-slate-600">
<PlayCircle class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" />
Schau dir die eingereichten Clips an, bevor du wählst so siehst du, was jede:n in dieser Kategorie besonders macht.
</p>
</div>
<!-- Right: candidate grid -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Die Nominierten</h3>
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-600">
{{ category?.candidates.length ?? 0 }} im Rennen
</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<label
v-for="candidate in category?.candidates ?? []"
:key="candidate.id"
class="flex cursor-pointer items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5 transition hover:border-violet-300 hover:bg-white"
:class="[
'group flex cursor-pointer items-center gap-3 rounded-2xl border px-4 py-3 transition',
selectedCandidateId === candidate.id
? 'border-violet-400 bg-violet-50 ring-2 ring-violet-200'
: 'border-violet-100 bg-white hover:border-violet-300 hover:bg-violet-50/40',
]"
>
<div>
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
<input v-model="selectedCandidateId" type="radio" :value="candidate.id" class="sr-only" />
<span class="grid h-11 w-11 shrink-0 place-items-center self-start rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<div class="min-w-0 flex-1">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
<a
v-if="candidate.clipUrl"
:href="candidate.clipUrl"
target="_blank"
rel="noopener"
class="mt-1.5 inline-flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800"
@click.stop
>
<PlayCircle class="h-3.5 w-3.5" /> Clip ansehen
</a>
</div>
<RadioButton
v-model="selectedCandidateId"
:input-id="`candidate-${candidate.id}`"
:name="category?.name"
:value="candidate.id"
/>
<span
:class="[
'grid h-6 w-6 shrink-0 place-items-center self-start rounded-full border transition',
selectedCandidateId === candidate.id ? 'border-violet-500 bg-violet-500 text-white' : 'border-violet-200 text-transparent',
]"
>
<Check class="h-3.5 w-3.5" />
</span>
</label>
</div>
</div>
<div class="mt-7 flex flex-wrap items-center justify-between gap-4 rounded-[28px] bg-violet-50/60 px-6 py-5">
<div class="space-y-2">
<p class="text-sm text-slate-600">
Auswahl:
<strong class="text-violet-700">
{{ category?.candidates.find((candidate) => candidate.id === selectedCandidateId)?.displayName ?? 'Noch keine Stimme abgegeben' }}
</strong>
<div class="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-[linear-gradient(135deg,#f6ecff,#fff2dd)] px-6 py-5">
<div class="space-y-1">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-400">Deine Wahl</p>
<p class="font-[Cormorant_Garamond] text-2xl text-violet-800">
{{ selectedCandidate?.displayName ?? 'Noch nichts gewählt' }}
</p>
<p v-if="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
</div>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }}
<Button class="gap-2" :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
<Sparkles class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Stimme abgeben' }}
</Button>
</div>
</div>
</div>
</Card>
</div>
</template>
+47 -20
View File
@@ -1,14 +1,24 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Crown, Star, Trophy } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards'
const store = useAwardsStore()
const years = [2025, 2024, 2023, 2022]
const activeYear = ref(2025)
const winnerGradients = [
'from-violet-200 to-fuchsia-100',
'from-indigo-200 to-violet-100',
'from-sky-200 to-violet-100',
'from-rose-200 to-amber-100',
'from-violet-200 to-amber-100',
'from-fuchsia-200 to-rose-100',
]
onMounted(async () => {
await store.loadHomeData()
await store.loadArchive(activeYear.value)
@@ -21,37 +31,54 @@ async function selectYear(year: number) {
</script>
<template>
<div class="space-y-10 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p>
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Jahre, Gewinner und Show-Historie</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
</p>
</div>
<div class="space-y-12 pb-16">
<PageHero
eyebrow="Gewinner Archiv"
title="Jahre, Gewinner und Show-Historie"
description="Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien, Gewinner und Banner bleiben pro Jahr nachvollziehbar."
:icon="Trophy"
/>
<div class="flex flex-wrap gap-3">
<Button
<!-- Year tabs -->
<div class="flex flex-wrap gap-2">
<button
v-for="year in years"
:key="year"
:variant="activeYear === year ? 'default' : 'ghost'"
:class="[
'rounded-full px-5 py-2 text-sm font-semibold transition',
activeYear === year
? 'bg-violet-600 text-white shadow-lg shadow-violet-500/20'
: 'border border-violet-200 bg-white text-violet-600 hover:bg-violet-50',
]"
@click="selectYear(year)"
>
{{ year }}
</Button>
</button>
</div>
<div class="grid gap-5 lg:grid-cols-3">
<!-- Winner grid -->
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<Card
v-for="item in store.archive.items"
v-for="(item, index) in store.archive.items"
:key="`${store.archive.year}-${item.category}`"
class="p-7"
class="overflow-hidden p-0"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ store.archive.year }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ item.category }}</h2>
<p class="mt-4 text-lg font-semibold text-slate-800">{{ item.winnerName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ item.winnerSlug }}</p>
<div :class="['relative h-[220px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<div class="absolute inset-0 grid place-items-center text-white/70">
<Star class="h-12 w-12" />
</div>
<span class="absolute left-3 top-3 inline-flex items-center gap-1 rounded-full bg-[linear-gradient(135deg,#ffd97a,#f6b938)] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-amber-950">
<Crown class="h-3 w-3" /> Winner {{ store.archive.year }}
</span>
</div>
<div class="p-5">
<p class="text-[10px] uppercase tracking-[0.2em] text-violet-400">{{ item.category }}</p>
<p class="mt-1 font-[Cormorant_Garamond] text-2xl text-violet-800">{{ item.winnerName }}</p>
<p class="text-sm text-slate-500">{{ item.winnerSlug }}</p>
</div>
</Card>
</div>
<p class="text-xs text-slate-400">Bilder sind Platzhalter echte Banner werden pro Jahr im Admin gepflegt.</p>
</div>
</template>
@@ -37,7 +37,7 @@ const insights = computed(() => {
{
label: 'Votes pro Kandidat',
value: votesPerCandidate,
note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.',
note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.',
},
{
label: 'Leere Kategorien',
@@ -58,7 +58,7 @@ const insights = computed(() => {
<AdminPageHeader
eyebrow="Analytics"
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"
/>
+239 -242
View File
@@ -1,184 +1,166 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select'
import { Search, Sparkles, Tags, UserPlus, Users } from '@lucide/vue'
import { ChevronLeft, ChevronRight, Pencil, Search, Trash2, TriangleAlert, UserPlus, Users, X } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
const store = useAwardsStore()
const candidateSaving = ref<number | 'new' | null>(null)
const saving = ref(false)
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const newCandidateForm = reactive({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
})
const candidateForms = reactive<Record<number, {
categoryId: number
displayName: string
channelSlug: string
platform: string
}>>({})
const seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const candidateFilter = ref('')
const categoryFilter = ref<number | null>(null)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
)
const categoryFilterOptions = computed(() => [
{ label: 'Alle Kategorien', value: null },
...categoryOptions.value,
])
const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
)
const platformSummary = computed(() => {
const platforms = new Set(seasonDetail.value.candidates.map((candidate) => candidate.platform).filter(Boolean))
return platforms.size
})
const candidateStats = computed(() => [
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'im Jahr gepflegt' },
{ label: 'Kategorien', value: seasonDetail.value.categories.length, note: 'als Ziel verfuegbar' },
{ label: 'Plattformen', value: platformSummary.value, note: 'in der Kandidatenbasis' },
])
const filteredCandidates = computed(() => {
const query = candidateFilter.value.trim().toLowerCase()
const candidates = categoryFilter.value
? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === categoryFilter.value)
: seasonDetail.value.candidates
if (!query) return candidates
return candidates.filter((candidate) =>
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
/* ---------- Filter + Suche + Pagination ---------- */
const search = ref('')
const categoryFilter = ref<number | null>(null)
const page = ref(1)
const pageSize = 10
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
)
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.name}`])),
)
const filteredCandidates = computed(() => {
const query = search.value.trim().toLowerCase()
let list = seasonDetail.value.candidates
if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
if (query) {
list = list.filter((c) =>
[c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
.join(' ')
.toLowerCase()
.includes(query),
)
}
return list
})
const hasFilters = computed(() => candidateFilter.value.trim().length > 0 || categoryFilter.value !== null)
const canCreateCandidate = computed(() =>
Boolean(selectedSeasonId.value && newCandidateForm.categoryId && newCandidateForm.displayName.trim() && newCandidateForm.channelSlug.trim()),
)
watch(
seasonDetail,
(detail) => {
for (const candidate of detail.candidates) {
candidateForms[candidate.id] = {
categoryId: candidate.categoryId,
displayName: candidate.displayName,
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
const pagedCandidates = computed(() => {
const start = (page.value - 1) * pageSize
return filteredCandidates.value.slice(start, start + pageSize)
})
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
},
{ immediate: true },
)
async function saveCandidate(candidateId: number) {
if (!selectedSeasonId.value) return
candidateSaving.value = candidateId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
adminMessage.value = 'Kandidat gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
} finally {
candidateSaving.value = null
}
}
async function createCandidate() {
if (!canCreateCandidate.value || !selectedSeasonId.value) return
candidateSaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
adminMessage.value = 'Kandidat angelegt.'
newCandidateForm.displayName = ''
newCandidateForm.channelSlug = ''
newCandidateForm.platform = 'Twitch'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
} finally {
candidateSaving.value = null
}
}
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
function clearFilters() {
candidateFilter.value = ''
search.value = ''
categoryFilter.value = null
}
/* ---------- Modal: anlegen / bearbeiten ---------- */
const modalOpen = ref(false)
const editingId = ref<number | 'new' | null>(null)
const form = reactive({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
const canSave = computed(() =>
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim()),
)
function openCreate() {
adminMessage.value = ''
adminError.value = ''
editingId.value = 'new'
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
form.displayName = ''
form.channelSlug = ''
form.platform = 'Twitch'
modalOpen.value = true
}
function openEdit(candidate: AdminCandidateItem) {
adminMessage.value = ''
adminError.value = ''
editingId.value = candidate.id
form.categoryId = candidate.categoryId
form.displayName = candidate.displayName
form.channelSlug = candidate.channelSlug
form.platform = candidate.platform
modalOpen.value = true
}
async function saveModal() {
if (!canSave.value || !selectedSeasonId.value) return
saving.value = true
adminError.value = ''
try {
if (editingId.value === 'new') {
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde angelegt.`
} else if (typeof editingId.value === 'number') {
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
adminMessage.value = `${form.displayName}" wurde gespeichert.`
}
modalOpen.value = false
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
} finally {
saving.value = false
}
}
/* ---------- Löschen mit Bestätigung ---------- */
const candidateToDelete = ref<AdminCandidateItem | null>(null)
async function confirmDelete() {
if (!candidateToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `${candidateToDelete.value.displayName}" wurde gelöscht.`
candidateToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kandidaten"
title="Kandidatenbasis pflegen"
description="Schneller finden, sauber pruefen, gezielt bearbeiten: die Kandidaten sind jetzt nach Jahr, Kategorie und Handle besser steuerbar."
title="Kandidaten verwalten"
description="Suchen, filtern, anlegen, bearbeiten und löschen auch bei vielen Nominierten bleibt die Liste übersichtlich."
:icon="Users"
/>
<AdminSeasonToolbar />
<div class="grid gap-4 md:grid-cols-3">
<Card
v-for="stat in candidateStats"
:key="stat.label"
class="p-5"
>
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-500">{{ stat.label }}</p>
<strong class="mt-2 block text-3xl text-violet-800">{{ stat.value }}</strong>
<p class="mt-1 text-sm text-slate-500">{{ stat.note }}</p>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-white/70 p-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kandidatenbereich</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Suchen, pruefen, aktualisieren</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Filtere nach Kategorie oder Handle und bearbeite nur den Kandidaten, der wirklich geaendert werden muss.
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
<strong class="text-violet-800">{{ filteredCandidates.length }}</strong> von {{ seasonDetail.candidates.length }} sichtbar
</div>
</div>
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px_auto]">
<label class="relative block">
<!-- Toolbar -->
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
<label class="relative block flex-1">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="candidateFilter"
v-model="search"
type="text"
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Name, Handle, Plattform oder Kategorie suchen"
class="h-11 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="Name, Handle oder Plattform suchen"
/>
</label>
<Select
@@ -186,130 +168,145 @@ function clearFilters() {
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full"
class="w-full lg:w-72"
/>
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button>
</div>
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="clearFilters">
<X class="h-4 w-4" /> Filter
</Button>
<Button class="gap-2" @click="openCreate">
<UserPlus class="h-4 w-4" /> Kandidat anlegen
</Button>
</div>
<div class="p-5">
<div class="grid gap-4">
<article
v-for="candidate in filteredCandidates"
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<!-- Tabellenkopf (Desktop) -->
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
<span>Kandidat</span>
<span>Kategorie</span>
<span>Plattform</span>
<span class="text-right">Aktionen</span>
</div>
<!-- Zeilen -->
<div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]"
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-700">
{{ candidate.platform }}
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
{{ candidate.displayName.charAt(0) }}
</span>
<span class="rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-violet-700">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
</div>
</div>
<div class="min-w-0">
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
</span>
</div>
<h3 class="mt-3 truncate font-[Cormorant_Garamond] text-4xl text-violet-800">{{ candidate.displayName }}</h3>
<p class="mt-1 text-sm font-semibold text-slate-500">{{ candidate.channelSlug }}</p>
</div>
<Button :disabled="candidateSaving === candidate.id" size="sm" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Speichern' }}
</Button>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="candidateForms[candidate.id].displayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Anzeigename" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="candidateForms[candidate.id].platform" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube, ..." />
</label>
</div>
</article>
<p v-if="filteredCandidates.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Kandidaten passen zum aktuellen Filter.
</p>
</div>
</div>
</Card>
<aside class="space-y-4 xl:sticky xl:top-6 xl:self-start">
<Card class="p-6">
<div class="flex items-start gap-4">
<div class="rounded-2xl bg-violet-100 p-3 text-violet-700">
<UserPlus class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neu</p>
<h2 class="mt-1 font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidat anlegen</h2>
<p class="mt-1 text-sm leading-6 text-slate-500">Erstelle bekannte Kandidaten direkt fuer die richtige Kategorie.</p>
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
</div>
<div class="flex gap-2 lg:justify-end">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
title="Bearbeiten"
@click="openEdit(candidate)"
>
<Pencil class="h-4 w-4" />
</button>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Löschen"
@click="candidateToDelete = candidate"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<div class="mt-6 space-y-4">
<label class="space-y-2">
<div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
<p class="text-sm text-slate-500">
{{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p>
<Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
</div>
</div>
<!-- Pagination -->
<div v-if="filteredCandidates.length > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
<div class="flex items-center gap-2">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page <= 1"
@click="page--"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="page++"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</Card>
<!-- Modal: anlegen / bearbeiten -->
<Modal :open="modalOpen" :title="modalTitle" subtitle="Anzeigename und Handle sind Pflicht." @close="modalOpen = false">
<div class="space-y-4">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
<Select
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<Select v-model="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
</label>
<label class="space-y-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="newCandidateForm.displayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. Jayuhime" />
<input v-model="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" />
</label>
<label class="space-y-2">
<div class="grid gap-4 sm:grid-cols-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
<input v-model="newCandidateForm.channelSlug" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
<input v-model="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" />
</label>
<label class="space-y-2">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<input v-model="newCandidateForm.platform" type="text" class="h-12 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
<input v-model="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch, YouTube …" />
</label>
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</div>
</div>
<template #footer>
<Button variant="ghost" @click="modalOpen = false">Abbrechen</Button>
<Button :disabled="saving || !canSave" @click="saveModal">{{ saving ? 'Speichert ' : 'Speichern' }}</Button>
</template>
</Modal>
<!-- Modal: löschen bestätigen -->
<Modal :open="!!candidateToDelete" title="Kandidat löschen?" @close="candidateToDelete = 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">{{ candidateToDelete?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="candidateToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</div>
<p v-if="adminMessage" class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ adminMessage }}
</p>
<p v-if="adminError" class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ adminError }}
</p>
</Card>
<Card class="p-5">
<div class="flex items-center gap-3">
<Sparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ablauf</p>
</div>
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<p class="flex gap-3"><Users class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Erst Kandidaten suchen, damit du keine Duplikate anlegst.</p>
<p class="flex gap-3"><Tags class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" /> Kategorie-Chip pruefen, dann nur die noetigen Felder anpassen.</p>
</div>
</Card>
</aside>
</div>
</template>
</Modal>
</div>
</template>
@@ -1,12 +1,14 @@
<script setup lang="ts">
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 AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminCategoryItem } from '../../types/awards'
const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
@@ -71,7 +73,7 @@ const statusFilters = computed(() => [
{ 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: '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(
@@ -150,6 +152,24 @@ function slugify(value: string) {
function fillNewSlug() {
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>
<template>
@@ -157,7 +177,7 @@ function fillNewSlug() {
<AdminPageHeader
eyebrow="Kategorien"
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"
/>
@@ -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" />
</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)">
{{ saving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }}
{{ saving === selectedCategory.id ? 'Speichert ' : 'Kategorie speichern' }}
</Button>
</div>
</Card>
@@ -305,5 +328,23 @@ function fillNewSlug() {
</Card>
</div>
</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>
</template>
+106 -66
View File
@@ -1,57 +1,70 @@
<script setup lang="ts">
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 AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore()
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 clipCategories = computed(() =>
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')),
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
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()
return seasonDetail.value.candidates
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId))
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search))
if (!search) return seasonDetail.value.clipSubmissions
return seasonDetail.value.clipSubmissions.filter((clip) =>
[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(() => [
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags },
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users },
{ label: 'Offene Reviews', value: clipReviewCount.value, icon: Film },
{ label: 'Einreichungen', value: seasonDetail.value.clipSubmissions.length, icon: Film },
{ label: 'Offen', value: seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ 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>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Clips"
title="Clip-Kategorien im Blick behalten"
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an."
title="Clip-Einreichungen moderieren"
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"
/>
@@ -71,50 +84,77 @@ const stats = computed(() => [
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2>
<div class="mt-5 space-y-3">
<div
v-for="item in clipReadiness"
:key="item.label"
class="rounded-2xl border p-4"
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'"
>
<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">
<div class="border-b border-violet-100 p-5">
<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" />
<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" />
<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="Titel, Creator, Plattform oder User suchen …"
/>
</label>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<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 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">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
<Film class="h-5 w-5" />
</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>
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an.
<div class="min-w-0 flex-1">
<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>
</div>
</div>
</Card>
</section>
<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>
</template>
+13 -13
View File
@@ -23,7 +23,7 @@ const metricToneMap = {
icon: BarChart3,
trend: 8.7,
sparkline: [54, 57, 63, 66, 72, 76, 81],
context: 'Voting-Aktivitaet stabil positiv',
context: 'Voting-Aktivität stabil positiv',
},
Kategorien: {
icon: Tags,
@@ -67,7 +67,7 @@ const yearTotals = computed(() => [
{
label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length,
note: 'fuer Voting und Archiv gepflegt',
note: 'für Voting und Archiv gepflegt',
icon: Users,
},
{
@@ -99,10 +99,10 @@ const priorityActions = computed(() => [
tone: 'violet',
},
{
label: 'Risiko pruefen',
label: 'Risiko prüfen',
value: store.admin.riskFlags.length,
to: '/admin/risk',
hint: 'Auffaellige Muster brauchen Sichtung',
hint: 'Auffällige Muster brauchen Sichtung',
icon: ShieldAlert,
tone: 'rose',
},
@@ -118,7 +118,7 @@ const priorityActions = computed(() => [
label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length,
to: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell pruefen',
hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users,
tone: 'emerald',
},
@@ -137,7 +137,7 @@ const operationChecks = computed(() => {
value: categoriesWithoutCandidates.length,
to: '/admin/categories',
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',
@@ -151,7 +151,7 @@ const operationChecks = computed(() => {
value: store.admin.riskFlags.length,
to: '/admin/risk',
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
eyebrow="Dashboard"
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"
/>
@@ -174,11 +174,11 @@ const operationChecks = computed(() => {
<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>
<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>
</div>
<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>
@@ -354,10 +354,10 @@ const operationChecks = computed(() => {
<Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<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>
</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 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>
</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">
Noch keine aktuellen Audit-Aktivitaeten vorhanden.
Noch keine aktuellen Audit-Aktivitäten vorhanden.
</p>
</div>
</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: '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: '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',
items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Faelle 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: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.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}` },
],
},
@@ -119,7 +119,7 @@ onMounted(async () => {
<Card class="p-3">
<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-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
v-for="item in seasonSummary"
@@ -150,7 +150,7 @@ const statusFilters = computed(() => [
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"
>
erst Kandidatenbasis klaeren
erst Kandidatenbasis klären
</span>
</div>
</div>
@@ -100,9 +100,9 @@ async function approveNomination(nominationId: number) {
try {
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) {
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 {
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>
<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">
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>
</div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
@@ -229,10 +229,10 @@ function setPlatform(platform: string) {
</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">
Keine offenen Review-Faelle im aktuell gewaehlten Award-Jahr.
Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
</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">
Keine Review-Faelle passen zum aktuellen Filter.
Keine Review-Fälle passen zum aktuellen Filter.
</p>
</div>
@@ -251,7 +251,7 @@ function setPlatform(platform: string) {
</div>
<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">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
@@ -294,7 +294,7 @@ function setPlatform(platform: string) {
</button>
</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">
Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
</p>
</div>
@@ -305,7 +305,7 @@ function setPlatform(platform: string) {
</Button>
<Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button>
</div>
</div>
+10 -10
View File
@@ -69,8 +69,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<div class="space-y-6">
<AdminPageHeader
eyebrow="Risiko & Audit"
title="Auffaellige 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."
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 ausgeführte Eingriffe deutlich schneller."
:icon="ShieldAlert"
/>
@@ -78,8 +78,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikopruefung</h2>
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikoprüfung</h2>
<p class="mt-2 text-sm text-slate-500">Auffällige Login-, Nominierungs- und Voting-Muster für die manuelle Sichtung.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
@@ -110,7 +110,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
/>
</label>
<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 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>
<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>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Eintraege
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Einträge
</span>
</div>
@@ -182,7 +182,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
v-model="auditFilter"
type="text"
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>
@@ -204,10 +204,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
</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">
Noch keine Audit-Eintraege vorhanden.
Noch keine Audit-Einträge vorhanden.
</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">
Keine Audit-Eintraege passen zum aktuellen Filter.
Keine Audit-Einträge passen zum aktuellen Filter.
</p>
</div>
</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>
<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">
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>
</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'">
{{ seasonForm.isCurrent ? 'Oeffentlich aktiv' : 'Intern vorbereitet' }}
{{ seasonForm.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div>
</div>
</div>
@@ -195,16 +195,16 @@ async function createCategory() {
placeholder="z.B. Community Voting, Nominierung, Archiviert"
/>
<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>
</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">
<input v-model="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<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">
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>
</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">
<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>
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ 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>
<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">
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>
</div>
</div>
@@ -268,7 +268,7 @@ async function createCategory() {
<label class="space-y-2">
<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>
<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">
<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>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ 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>
<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">
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>
</div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]">
@@ -29,7 +29,7 @@ const checks = computed(() => [
{
label: 'Public-Jahr gesetzt',
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,
},
{
@@ -57,8 +57,8 @@ const featureGates = computed(() => [
},
{
label: 'Clip-Moderation',
state: false,
note: 'Admin-API fuer ClipSubmissions fehlt noch und sollte spaeter ergaenzt werden.',
state: true,
note: 'Clip-Einreichungen laufen in den Clips-Bereich und können dort moderiert 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">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span>
<span class="block font-semibold text-slate-900">Dieses Award-Jahr oeffentlich 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="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 für Community, Voting und später Archiv.</span>
</span>
</label>
</div>
@@ -40,7 +40,7 @@ const adminCounts = computed(() => {
return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
})
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: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
])
@@ -51,7 +51,7 @@ const logStats = computed(() => [
<AdminPageHeader
eyebrow="User & Logs"
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. Für Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
:icon="UserCog"
/>
@@ -73,14 +73,14 @@ const logStats = computed(() => [
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6">
<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 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="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span>
</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">
Noch keine Admin-Aktivitaet vorhanden.
Noch keine Admin-Aktivität vorhanden.
</p>
</div>
</Card>
@@ -100,7 +100,7 @@ const logStats = computed(() => [
<span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span>
</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>
</Card>
</section>
@@ -112,7 +112,7 @@ const logStats = computed(() => [
<ShieldAlert class="h-5 w-5" />
</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>
</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>
</div>
<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>
</div>
</Card>
+4 -4
View File
@@ -57,7 +57,7 @@ const votingChecklist = computed(() => [
<AdminPageHeader
eyebrow="Voting"
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"
/>
@@ -97,7 +97,7 @@ const votingChecklist = computed(() => [
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div>
<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>
<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>
</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'">
{{ category.ready ? 'bereit' : 'pruefen' }}
{{ category.ready ? 'bereit' : 'prüfen' }}
</span>
</div>
</div>
@@ -142,7 +142,7 @@ const votingChecklist = computed(() => [
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</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'">
{{ item.done ? 'ok' : 'pruefen' }}
{{ item.done ? 'ok' : 'prüfen' }}
</span>
</div>
</RouterLink>
+3
View File
@@ -5,4 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.PORT) || 5173,
},
})