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/bin/
Backend/obj/ Backend/obj/
.DS_Store .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, string CandidateText,
DateTimeOffset CreatedAt); DateTimeOffset CreatedAt);
public sealed record AdminClipSubmissionItemDto(
int Id,
int? CategoryId,
string SubmittedByTwitchId,
string ClipUrl,
string Title,
string Creator,
string Platform,
string Status,
DateTimeOffset CreatedAt);
public sealed record AdminSeasonDetailResponse( public sealed record AdminSeasonDetailResponse(
int Id, int Id,
int Year, int Year,
@@ -75,7 +86,8 @@ public sealed record AdminSeasonDetailResponse(
bool IsCurrent, bool IsCurrent,
IEnumerable<AdminCategoryItemDto> Categories, IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates, IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations); IEnumerable<AdminNominationReviewItemDto> PendingNominations,
IEnumerable<AdminClipSubmissionItemDto> ClipSubmissions);
public sealed record UpdateSeasonRequest( public sealed record UpdateSeasonRequest(
string CurrentPhase, string CurrentPhase,
+8
View File
@@ -77,3 +77,11 @@ public sealed record CreateVoteRequest(
int SeasonId, int SeasonId,
string TwitchUserId, string TwitchUserId,
VoteEntryRequest[] Entries); VoteEntryRequest[] Entries);
public sealed record CreateClipRequest(
int Year,
int? CategoryId,
string TwitchUserId,
string ClipUrl,
string Title,
string Creator);
+13
View File
@@ -15,6 +15,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
public DbSet<UserSession> UserSessions => Set<UserSession>(); public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>(); public DbSet<RiskFlag> RiskFlags => Set<RiskFlag>();
public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>(); public DbSet<AdminAuditEntry> AdminAuditEntries => Set<AdminAuditEntry>();
public DbSet<ClipSubmission> ClipSubmissions => Set<ClipSubmission>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -90,6 +91,18 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
entity.Property(item => item.Summary).HasMaxLength(240); entity.Property(item => item.Summary).HasMaxLength(240);
}); });
modelBuilder.Entity<ClipSubmission>(entity =>
{
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.ClipUrl).HasMaxLength(500);
entity.Property(item => item.Title).HasMaxLength(200);
entity.Property(item => item.Creator).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
entity.Property(item => item.Status).HasMaxLength(20);
entity.Property(item => item.CreatedFromIp).HasMaxLength(80);
entity.HasIndex(item => new { item.SeasonId, item.Status });
});
SeedData.Apply(modelBuilder); SeedData.Apply(modelBuilder);
} }
} }
@@ -49,5 +49,22 @@ public static class OperationalTablesBootstrapper
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt" CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
ON "AdminAuditEntries" ("CreatedAt" DESC); ON "AdminAuditEntries" ("CreatedAt" DESC);
CREATE TABLE IF NOT EXISTS "ClipSubmissions" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL,
"CategoryId" integer NULL,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"ClipUrl" character varying(500) NOT NULL,
"Title" character varying(200) NOT NULL,
"Creator" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
"Status" character varying(20) NOT NULL,
"CreatedFromIp" character varying(80) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS "IX_ClipSubmissions_SeasonId_Status"
ON "ClipSubmissions" ("SeasonId", "Status");
"""); """);
} }
+16
View File
@@ -0,0 +1,16 @@
namespace Backend.Domain;
public sealed class ClipSubmission
{
public int Id { get; set; }
public int SeasonId { get; set; }
public int? CategoryId { get; set; }
public string SubmittedByTwitchId { get; set; } = string.Empty;
public string ClipUrl { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public string Status { get; set; } = "pending";
public string CreatedFromIp { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
}
+179 -1
View File
@@ -621,6 +621,70 @@ app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest r
.WithName("CreateVote") .WithName("CreateVote")
.WithOpenApi(); .WithOpenApi();
app.MapPost("/api/public/clips", async (HttpContext context, CreateClipRequest request, AwardsDbContext db) =>
{
var clipUrl = request.ClipUrl?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(clipUrl))
{
return Results.BadRequest(new { message = "A clip link is required." });
}
var loweredUrl = clipUrl.ToLowerInvariant();
var platform = loweredUrl.Contains("twitch.tv")
? "Twitch"
: loweredUrl.Contains("youtube.com") || loweredUrl.Contains("youtu.be")
? "YouTube"
: "Other";
if (platform == "Other")
{
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
if (season is null)
{
return Results.BadRequest(new { message = "The selected season does not exist." });
}
if (request.CategoryId is int categoryId)
{
var categoryExists = await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
if (!categoryExists)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
}
var session = await ResolveSessionAsync(context, db);
var submitterId = session?.TwitchUserId ?? request.TwitchUserId;
if (string.IsNullOrWhiteSpace(submitterId))
{
return Results.BadRequest(new { message = "A logged in user is required to submit clips." });
}
var clip = new ClipSubmission
{
SeasonId = season.Id,
CategoryId = request.CategoryId,
SubmittedByTwitchId = submitterId,
ClipUrl = clipUrl,
Title = request.Title?.Trim() ?? string.Empty,
Creator = request.Creator?.Trim() ?? string.Empty,
Platform = platform,
Status = "pending",
CreatedFromIp = ReadClientIp(context),
CreatedAt = DateTimeOffset.UtcNow,
};
db.ClipSubmissions.Add(clip);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, clipId = clip.Id });
})
.WithName("CreateClip")
.WithOpenApi();
app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) => app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) =>
{ {
var session = await ResolveSessionAsync(context, db); var session = await ResolveSessionAsync(context, db);
@@ -809,6 +873,23 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
item.CreatedAt)) item.CreatedAt))
.ToArrayAsync(); .ToArrayAsync();
var clipSubmissions = await db.ClipSubmissions
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderByDescending(item => item.CreatedAt)
.Take(100)
.Select(item => new AdminClipSubmissionItemDto(
item.Id,
item.CategoryId,
item.SubmittedByTwitchId,
item.ClipUrl,
item.Title,
item.Creator,
item.Platform,
item.Status,
item.CreatedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse( return Results.Ok(new AdminSeasonDetailResponse(
season.Id, season.Id,
season.Year, season.Year,
@@ -817,7 +898,8 @@ app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int
season.IsCurrent, season.IsCurrent,
categories, categories,
candidates, candidates,
pendingNominations)); pendingNominations,
clipSubmissions));
}) })
.WithName("GetAdminSeasonDetail") .WithName("GetAdminSeasonDetail")
.WithOpenApi(); .WithOpenApi();
@@ -1013,6 +1095,102 @@ app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context
.WithName("UpdateAdminCandidate") .WithName("UpdateAdminCandidate")
.WithOpenApi(); .WithOpenApi();
app.MapDelete("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
if (candidate is null)
{
return Results.NotFound();
}
db.Candidates.Remove(candidate);
AddAuditEntry(
db,
session.TwitchUserId,
"candidate.delete",
"candidate",
candidate.Id.ToString(),
$"Kandidat {candidate.DisplayName} wurde gelöscht.",
new { candidate.CategoryId, candidate.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, candidateId });
})
.WithName("DeleteAdminCandidate")
.WithOpenApi();
app.MapDelete("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
if (category is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates.Where(item => item.CategoryId == categoryId).ToArrayAsync();
if (candidates.Length > 0)
{
db.Candidates.RemoveRange(candidates);
}
db.Categories.Remove(category);
AddAuditEntry(
db,
session.TwitchUserId,
"category.delete",
"category",
category.Id.ToString(),
$"Kategorie {category.Name} wurde gelöscht.",
new { removedCandidates = candidates.Length });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, categoryId });
})
.WithName("DeleteAdminCategory")
.WithOpenApi();
app.MapDelete("/api/admin/clips/{clipId:int}", async (HttpContext context, int clipId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var clip = await db.ClipSubmissions.FirstOrDefaultAsync(item => item.Id == clipId);
if (clip is null)
{
return Results.NotFound();
}
db.ClipSubmissions.Remove(clip);
AddAuditEntry(
db,
session.TwitchUserId,
"clip.delete",
"clip",
clip.Id.ToString(),
$"Clip-Einreichung von {clip.SubmittedByTwitchId} wurde entfernt.",
new { clip.Platform });
await db.SaveChangesAsync();
return Results.Ok(new { deleted = true, clipId });
})
.WithName("DeleteAdminClip")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) => app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) =>
{ {
var session = await ResolveSessionAsync(context, db); var session = await ResolveSessionAsync(context, db);
+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 Button from './ui/Button.vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { usePhases } from '../composables/usePhases'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const { infoForPhaseKey } = usePhases()
const loginOpen = ref(false) const loginOpen = ref(false)
const loginError = ref('') const loginError = ref('')
const loginForm = reactive({ const loginForm = reactive({
@@ -18,8 +20,9 @@ const loginForm = reactive({
const navItems = [ const navItems = [
{ label: 'Home', to: '/' }, { label: 'Home', to: '/' },
{ label: 'Nominierung', to: '/nominations' }, { label: 'Nominierung', to: '/nominations', requiresAuth: true, phase: 'nomination' },
{ label: 'Voting', to: '/voting' }, { label: 'Voting', to: '/voting', requiresAuth: true, phase: 'voting' },
{ label: 'Clips', to: '/clips', requiresAuth: true, phase: 'nomination' },
{ label: 'Gewinner', to: '/winners' }, { label: 'Gewinner', to: '/winners' },
{ label: 'Admin', to: '/admin' }, { label: 'Admin', to: '/admin' },
] ]
@@ -29,9 +32,15 @@ const currentLabel = computed(
) )
const visibleNavItems = 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') { async function login(role: 'viewer' | 'admin') {
loginError.value = '' loginError.value = ''
@@ -58,20 +67,18 @@ async function login(role: 'viewer' | 'admin') {
</div> </div>
<header <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="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"
:class="isAdminRoute ? 'mb-5 gap-3 px-4 py-3' : 'mb-10 gap-6 px-5 py-5'"
> >
<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"> <RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
<div <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="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)]"
:class="isAdminRoute ? 'h-10 w-10' : 'h-12 w-12'"
> >
<Star :class="isAdminRoute ? 'h-4 w-4' : 'h-5 w-5'" /> <Star class="h-5 w-5" />
</div> </div>
<div> <div>
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong> <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> </div>
</RouterLink> </RouterLink>
@@ -84,7 +91,10 @@ async function login(role: 'viewer' | 'admin') {
</template> </template>
<template v-else> <template v-else>
<Button variant="ghost" @click="loginOpen = !loginOpen">Einloggen</Button> <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> </template>
</div> </div>
</div> </div>
@@ -104,13 +114,15 @@ async function login(role: 'viewer' | 'admin') {
</div> </div>
<div class="flex flex-col gap-3 border-t border-black/6 pt-3 lg:flex-row lg:items-center lg:justify-between"> <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 <RouterLink
v-for="item in visibleNavItems" v-for="item in visibleNavItems"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700" class="rounded-full px-4 py-2 transition"
:class="route.path === item.to || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''" :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 }} {{ item.label }}
</RouterLink> </RouterLink>
@@ -1,17 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Component } from 'vue'
import { Sparkles } from '@lucide/vue'
defineProps<{ defineProps<{
eyebrow?: string eyebrow?: string
title: string title: string
description: string description: string
/** Dekoratives Icon oben rechts analog zu den Frontend-Bannern */
icon?: Component
}>() }>()
</script> </script>
<template> <template>
<div class="flex flex-col gap-2 border-b border-violet-100 pb-4 md:flex-row md:items-end md:justify-between"> <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> <div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
<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> <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>
</div> </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> </template>
@@ -42,7 +42,7 @@ const yearStats = computed(() => [
<div class="min-w-0"> <div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktuelles Award-Jahr</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aktuelles Award-Jahr</p>
<h2 class="truncate text-xl font-semibold text-slate-900"> <h2 class="truncate text-xl font-semibold text-slate-900">
{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswaehlen' }} {{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.name || 'Bitte Jahr auswählen' }}
</h2> </h2>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@ const yearStats = computed(() => [
</span> </span>
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold" :class="currentSeason.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'"> <span class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold" :class="currentSeason.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
<CheckCircle2 class="h-3.5 w-3.5" /> <CheckCircle2 class="h-3.5 w-3.5" />
{{ currentSeason.isCurrent ? 'Oeffentlich sichtbar' : 'Nicht oeffentlich' }} {{ currentSeason.isCurrent ? 'Öffentlich sichtbar' : 'Nicht öffentlich' }}
</span> </span>
</div> </div>
</div> </div>
+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, AdminSeasonDetailResponse,
AdminSeasonListItem, AdminSeasonListItem,
AuthSession, AuthSession,
CreateClipPayload,
CreateNominationPayload, CreateNominationPayload,
CreateVotePayload, CreateVotePayload,
LoginPayload, LoginPayload,
@@ -40,6 +41,23 @@ async function getJson<T>(path: string): Promise<T> {
return response.json() as 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> { async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<TResponse> {
const token = getAuthToken() const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, { const response = await fetch(`${API_URL}${path}`, {
@@ -78,6 +96,8 @@ export const api = {
sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload), sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload),
submitVote: (payload: CreateVotePayload) => submitVote: (payload: CreateVotePayload) =>
sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload), 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) => updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload), sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload),
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) => createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
@@ -88,6 +108,12 @@ export const api = {
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload), sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload),
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) => updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, 'PUT', payload), 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) => approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>( sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`, `/api/admin/nominations/${nominationId}/approve`,
+23
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { useAwardsStore } from './stores/awards'
import AdminCandidatesView from './views/admin/AdminCandidatesView.vue' import AdminCandidatesView from './views/admin/AdminCandidatesView.vue'
import AdminAnalyticsView from './views/admin/AdminAnalyticsView.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 AdminSettingsView from './views/admin/AdminSettingsView.vue'
import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue' import AdminUsersLogsView from './views/admin/AdminUsersLogsView.vue'
import AdminVotingView from './views/admin/AdminVotingView.vue' import AdminVotingView from './views/admin/AdminVotingView.vue'
import ClipSubmissionView from './views/ClipSubmissionView.vue'
import HomeView from './views/HomeView.vue' import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue' import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue' import VotingView from './views/VotingView.vue'
@@ -45,6 +47,7 @@ const router = createRouter({
meta: { meta: {
requiresAuth: true, requiresAuth: true,
keepAlive: true, keepAlive: true,
phase: 'nomination',
}, },
}, },
{ {
@@ -54,6 +57,17 @@ const router = createRouter({
meta: { meta: {
requiresAuth: true, requiresAuth: true,
keepAlive: 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' } 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 return true
}) })
+47 -13
View File
@@ -6,6 +6,7 @@ import type {
AdminSeasonDetailResponse, AdminSeasonDetailResponse,
ApproveNominationPayload, ApproveNominationPayload,
AdminSeasonListItem, AdminSeasonListItem,
CreateClipPayload,
CreateNominationPayload, CreateNominationPayload,
CreateVotePayload, CreateVotePayload,
OverviewResponse, OverviewResponse,
@@ -31,9 +32,9 @@ const fallbackOverview: OverviewResponse = {
{ key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' }, { key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' },
], ],
featuredCategories: [ featuredCategories: [
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die groesste Auszeichnung des Jahres.', 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: 'Events, Konzerte und Showformate.', 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 lustigste oder emotionalste Clip.', 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: [ winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' }, { 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' }, { year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
], ],
faq: [ faq: [
{ question: 'Wer kann nominieren und voten?', answer: 'Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt.' }, { question: 'Wer darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt kein extra Konto, kein Papierkram.' },
{ question: 'Wie werden Gewinner bestimmt?', answer: 'Aktuell rein community-basiert. Eine Mischlogik kann spaeter aktiviert werden.' }, { question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf kein Jury-Geheimnis.' },
{ question: 'Wer verwaltet Kategorien und Unterkategorien?', answer: 'Das Team pflegt diese pro Jahr im Admin-Bereich.' }, { 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, id: 1,
name: 'VTuber des Jahres', name: 'VTuber des Jahres',
groupName: 'Main Awards', 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, maxNomineesPerUser: 3,
candidates: [ candidates: [
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', 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' }, { 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' }, { id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' },
], ],
}, },
{ {
id: 2, id: 2,
name: 'Bestes Live Event', name: 'Bestes Live Event',
groupName: 'Performance', groupName: 'Performance',
description: 'Konzerte, Sonderformate und grosse Community-Shows.', description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.',
maxNomineesPerUser: 3, maxNomineesPerUser: 3,
candidates: [ candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' }, { 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' }, { 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', createdAt: '2026-06-17T08:00:00Z',
}, },
], ],
clipSubmissions: [
{
id: 1,
categoryId: 1,
submittedByTwitchId: 'demo_user',
clipUrl: 'https://clips.twitch.tv/DemoClip',
title: 'Epischer Clutch im Finale',
creator: 'Hoshimi Miyu',
platform: 'Twitch',
status: 'pending',
createdAt: '2026-06-17T09:10:00Z',
},
],
} }
const emptyAdmin: AdminDashboardResponse = { const emptyAdmin: AdminDashboardResponse = {
@@ -198,6 +213,7 @@ const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
categories: [], categories: [],
candidates: [], candidates: [],
pendingNominations: [], pendingNominations: [],
clipSubmissions: [],
} }
export const useAwardsStore = defineStore('awards', { export const useAwardsStore = defineStore('awards', {
@@ -277,6 +293,9 @@ export const useAwardsStore = defineStore('awards', {
submitVote(payload: CreateVotePayload) { submitVote(payload: CreateVotePayload) {
return api.submitVote(payload) return api.submitVote(payload)
}, },
submitClip(payload: CreateClipPayload) {
return api.submitClip(payload)
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) { async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload) const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin() await this.loadAdmin()
@@ -302,6 +321,21 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdminSeasonDetail(seasonId) await this.loadAdminSeasonDetail(seasonId)
return result 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) { async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload) const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId) await this.loadAdminSeasonDetail(seasonId)
+24
View File
@@ -45,6 +45,8 @@ export interface CandidateSummary {
displayName: string displayName: string
channelSlug: string channelSlug: string
platform: string platform: string
/** Repräsentativer Clip/Video-Link, damit Votende vor der Wahl reinschauen können. */
clipUrl?: string
} }
export interface PublicCategoryDetail { export interface PublicCategoryDetail {
@@ -157,6 +159,18 @@ export interface AdminNominationReviewItem {
createdAt: string createdAt: string
} }
export interface AdminClipSubmissionItem {
id: number
categoryId: number | null
submittedByTwitchId: string
clipUrl: string
title: string
creator: string
platform: string
status: string
createdAt: string
}
export interface AdminSeasonDetailResponse { export interface AdminSeasonDetailResponse {
id: number id: number
year: number year: number
@@ -166,6 +180,7 @@ export interface AdminSeasonDetailResponse {
categories: AdminCategoryItem[] categories: AdminCategoryItem[]
candidates: AdminCandidateItem[] candidates: AdminCandidateItem[]
pendingNominations: AdminNominationReviewItem[] pendingNominations: AdminNominationReviewItem[]
clipSubmissions: AdminClipSubmissionItem[]
} }
export interface CreateNominationPayload { export interface CreateNominationPayload {
@@ -175,6 +190,15 @@ export interface CreateNominationPayload {
nominees: string[] nominees: string[]
} }
export interface CreateClipPayload {
year: number
categoryId: number | null
twitchUserId: string
clipUrl: string
title: string
creator: string
}
export interface VoteEntryPayload { export interface VoteEntryPayload {
categoryId: number categoryId: number
candidateId: 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>
+549 -248
View File
@@ -1,303 +1,477 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink, useRouter } from 'vue-router'
import Accordion from 'primevue/accordion' import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent' import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader' import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel' import AccordionPanel from 'primevue/accordionpanel'
import Tag from 'primevue/tag' import {
import { ArrowRight, Sparkles, Star, Trophy, WandSparkles } from '@lucide/vue' 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 Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth' 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 store = useAwardsStore()
const authStore = useAuthStore() 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(() => { onMounted(() => {
void store.loadHomeData() void store.loadHomeData()
}) })
const heroYear = computed(() => store.overview.year) 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> </script>
<template> <template>
<div class="space-y-20 pb-16"> <div class="space-y-24 pb-20">
<section class="grid gap-10 lg:grid-cols-[0.82fr_1.18fr] lg:items-start"> <!-- ============ HERO ============ -->
<div class="space-y-10 pt-3"> <section class="grid items-stretch gap-8 lg:grid-cols-[1.02fr_1.18fr]">
<div class="space-y-6"> <div class="flex flex-col justify-center space-y-8">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Die groesste Community-Auszeichnung</p> <p class="text-xs font-semibold uppercase tracking-[0.4em] text-amber-500">
<h1 class="max-w-[8ch] font-[Cormorant_Garamond] text-6xl leading-[0.88] text-violet-800 sm:text-7xl xl:text-[6.6rem]"> Die große Community-Auszeichnung
VTuber Star Awards </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> </h1>
<p class="text-2xl font-medium italic tracking-wide text-violet-500"> <div class="h-1.5 w-48 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
Presented by Jayuhime <p class="font-[Cormorant_Garamond] text-3xl italic text-violet-500">
</p> Presented by Jayuhime <Sparkles class="-mt-1 inline h-6 w-6 text-amber-400" />
<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.
</p> </p>
</div> </div>
<p class="max-w-lg text-lg leading-8 text-slate-600">
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 class="flex flex-wrap gap-3"> <!-- Aktuelle Phase Card -->
<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>
<Card class="p-7"> <Card class="p-7">
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between"> <div class="flex items-center justify-between">
<div> <p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Aktuelle Phase</p>
<p class="text-xs font-semibold uppercase tracking-[0.25em] 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">
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800"> <span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-rose-500" /> Live
{{ store.overview.currentPhase }} </span>
</h2> </div>
<p class="mt-2 max-w-md text-slate-600"> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ store.overview.currentPhase }}</h2>
Login bleibt leichtgewichtig: nur Twitch, kein separates Community-Konto. <p class="mt-2 text-sm text-slate-600">
</p> Die Nominierungsphase ist abgeschlossen. Jetzt liegt es an dir: Stimme für deine Favoriten!
</div> </p>
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700"> <div class="mt-6 grid grid-cols-4 gap-3">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Sitzungsstatus</p> <div
<p class="mt-2 font-semibold text-violet-800"> v-for="unit in countdown"
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }} :key="unit.label"
</p> class="rounded-2xl border border-violet-100 bg-violet-50/70 py-4 text-center"
<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.' }} <strong class="block font-[Cormorant_Garamond] text-3xl text-violet-800">{{ unit.value }}</strong>
</p> <span class="text-[10px] uppercase tracking-[0.2em] text-slate-500">{{ unit.label }}</span>
</div> </div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div class="mt-6 flex flex-wrap gap-3">
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> <Button
<strong class="block text-2xl text-violet-800">41</strong> size="lg"
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Tage</span> class="gap-2"
</div> :disabled="!phaseByAction.nominate.open"
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> @click="goTo('/nominations')"
<strong class="block text-2xl text-violet-800">08</strong> >
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Std</span> <Sparkles class="h-4 w-4" /> {{ ctaLabel('nominate', 'Jetzt nominieren') }}
</div> </Button>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> <Button
<strong class="block text-2xl text-violet-800">24</strong> variant="secondary"
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Min</span> size="lg"
</div> class="gap-2"
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center"> :disabled="!phaseByAction.vote.open"
<strong class="block text-2xl text-violet-800">16</strong> @click="goTo('/voting')"
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Sek</span> >
</div> <Star class="h-4 w-4" /> {{ ctaLabel('vote', 'Jetzt voten') }}
</div> </Button>
</div> </div>
</Card> </Card>
</div> </div>
<Card class="overflow-hidden p-0"> <!-- Hero Keyvisual -->
<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))]"> <Card class="relative overflow-hidden p-0">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_75%_15%,rgba(255,255,255,0.82),transparent_25%)]" /> <div class="relative min-h-[640px]">
<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"> <img :src="heroKeyvisual" alt="VTuber Star Awards Keyvisual (Platzhalter)" class="absolute inset-0 h-full w-full object-cover object-top" />
Presented by Jayuhime <div class="absolute inset-0 bg-[radial-gradient(circle_at_70%_18%,rgba(255,255,255,0.55),transparent_45%)]" />
</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" /> <!-- Host Badge -->
</div> <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>
<div class="grid gap-3"> <p class="mt-1 flex items-center gap-2 font-[Cormorant_Garamond] text-3xl text-violet-800">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5"> Jayuhime <Sparkles class="h-5 w-5 text-amber-400" />
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Show Date</p> </p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">24. Jan 2026</p> <p class="text-sm text-slate-500">VTuber &amp; Award Host</p>
</div> <div class="mt-3 flex items-center gap-2 text-violet-500">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5"> <span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p> <Video class="h-4 w-4" />
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Nur Community</p> </span>
</div> <span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5"> <PartyPopper class="h-4 w-4" />
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p> </span>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Twitch</p> <span class="grid h-8 w-8 place-items-center rounded-full bg-violet-50">
</div> <svg class="h-3.5 w-3.5" viewBox="0 0 19 19"><use href="/icons.svg#x-icon" /></svg>
</span>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
</section> </section>
<section class="grid gap-5 lg:grid-cols-4"> <!-- ============ TRUST BAR ============ -->
<Card <section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
v-for="item in store.overview.timeline" <Card v-for="item in trustItems" :key="item.title" class="flex items-center gap-4 p-5">
:key="item.key" <span class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#efe6ff,#fff1da)] text-violet-600">
class="p-7" <component :is="item.icon" class="h-5 w-5" />
>
<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>
</span> </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>
<div class="grid gap-4 lg:grid-cols-3"> <div ref="carousel" class="flex gap-4 overflow-x-auto pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Card <Card
v-for="category in store.overview.featuredCategories" v-for="(winner, index) in winners"
:key="category.id" :key="winner.slug"
class="p-6" class="w-[230px] shrink-0 overflow-hidden p-0"
> >
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category.groupName }}</p> <div :class="['relative h-[230px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<h3 class="mt-3 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ category.name }}</h3> <div class="absolute inset-0 grid place-items-center text-white/70">
<p class="mt-3 text-slate-600">{{ category.description }}</p> <Star class="h-12 w-12" />
<div class="mt-6 flex items-center justify-between text-sm text-slate-500"> </div>
<span>Max. {{ category.maxNomineesPerUser }} Nominierungen</span> <span
<ArrowRight class="h-4 w-4 text-amber-500" /> 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> </div>
</Card> </Card>
</div> </div>
</section> </section>
<section class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <!-- ============ FAQ / COMMUNITY / SHARE ============ -->
<section class="grid gap-6 lg:grid-cols-3">
<Card class="p-7"> <Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierung</p> <p class="text-xs font-semibold uppercase tracking-[0.3em] text-amber-500">FAQ Häufige Fragen</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Bis zu drei Favoriten, direkt validiert</h2> <Accordion value="0" class="mt-5">
<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.
</p>
<div class="mt-6 space-y-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"
>
<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>
</div>
<Tag :value="entry.year.toString()" severity="info" />
</div>
</div>
</div>
</Card>
<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">
<AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)"> <AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)">
<AccordionHeader>{{ item.question }}</AccordionHeader> <AccordionHeader>{{ item.question }}</AccordionHeader>
<AccordionContent> <AccordionContent>
@@ -305,6 +479,133 @@ const heroYear = computed(() => store.overview.year)
</AccordionContent> </AccordionContent>
</AccordionPanel> </AccordionPanel>
</Accordion> </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> </Card>
</section> </section>
</div> </div>
+124 -69
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select' import Select from 'primevue/select'
import { Plus, Sparkles, Star, Trophy, X } from '@lucide/vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import Button from '../components/ui/Button.vue' import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
const store = useAwardsStore() const store = useAwardsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -31,6 +33,12 @@ const selectedCategory = computed(() =>
store.categories.categories.find((category) => category.id === selectedCategoryId.value), 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() { function addNominee() {
const value = nomineeName.value.trim() const value = nomineeName.value.trim()
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
@@ -57,9 +65,9 @@ async function submitNomination() {
nominees: nominees.value, 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) { } 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 { } finally {
submitting.value = false submitting.value = false
} }
@@ -67,34 +75,33 @@ async function submitNomination() {
</script> </script>
<template> <template>
<div class="space-y-10 pb-14"> <div class="space-y-12 pb-16">
<div class="space-y-4"> <PageHero
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierungs-Flow</p> eyebrow="Schritt 1 · Nominieren"
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Kategorien waehlen, Regeln live pruefen</h1> title="Wen feiern wir dieses Jahr?"
<p class="max-w-3xl text-lg leading-8 text-slate-600"> :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.`"
Nur Twitch Login, kein separates Konto. Das Team pflegt Kategorien pro Jahr, waehrend die UI sofort Limits, Dubletten und editierbare Entwuerfe abbildet. :icon="Star"
</p> />
</div>
<div class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]"> <Card class="overflow-hidden p-0">
<Card class="p-7"> <!-- Stepper -->
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Regeln</p> <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">
<ul class="mt-5 space-y-4 text-slate-600"> <span class="text-violet-600">1 · Kategorie</span>
<li>Pro Kategorie nur eine Nominierung derselben Person.</li> <span class="h-px flex-1 bg-slate-200" />
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li> <span class="text-violet-600">2 · Favoriten eintragen</span>
<li>Freitext-Ideen landen spaeter in der Review-Liste.</li> <span class="h-px flex-1 bg-slate-200" />
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li> <span>3 · Abschicken</span>
</ul> </div>
</Card>
<Card class="p-7"> <div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]"> <!-- Left: category + add -->
<div class="space-y-5"> <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"> <p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann. Logg dich kurz oben mit Twitch ein dann zählt deine Nominierung. 💜
</p> </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 <Select
v-model="selectedCategoryId" v-model="selectedCategoryId"
:options="categories" :options="categories"
@@ -102,53 +109,101 @@ async function submitNomination() {
option-value="value" option-value="value"
class="w-full" 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>
<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."
/>
<Button @click="addNominee">Nominierung hinzufuegen</Button>
</div>
</div> </div>
<div class="space-y-4"> <div v-if="selectedCategory" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3> <div class="absolute right-3 top-3 h-1 w-24 rounded-full bg-[linear-gradient(90deg,#c4b5fd,#f5d0fe,#fecdd3,#fde68a,#bbf7d0,#bae6fd)]" />
<div <p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ selectedCategory.groupName }}</p>
v-for="name in nominees" <h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ selectedCategory.name }}</h2>
:key="name" <p class="mt-2 text-sm leading-7 text-slate-600">{{ selectedCategory.description }}</p>
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5" </div>
>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</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>
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700"> <div class="space-y-3 rounded-2xl border border-violet-100 bg-white/70 p-5">
{{ submitMessage }} <label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Wen möchtest du nominieren?</label>
</p> <input
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700"> v-model="nomineeName"
{{ submitError }} type="text"
</p> class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm"
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination"> placeholder="Name oder @handle, z. B. Shiro Ch."
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }} @keyup.enter="addNominee"
/>
<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> </Button>
</div> </div>
</div> </div>
</Card>
</div> <!-- Right: draft -->
<div class="space-y-4">
<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, index) in nominees"
:key="name"
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-xs text-slate-500">{{ slugFor(name) }}</p>
</div>
</div>
<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>
<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> </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>
+108 -56
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select' 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 Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue' import Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth' 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], 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() { async function submitVote() {
if (!category.value || !selectedCandidateId.value) return 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) { } 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 { } finally {
submitting.value = false submitting.value = false
} }
@@ -61,24 +66,33 @@ async function submitVote() {
</script> </script>
<template> <template>
<div class="space-y-10 pb-14"> <div class="space-y-12 pb-16">
<div class="space-y-4"> <PageHero
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p> eyebrow="Schritt 2 · Voten"
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Ein ruhiger, schneller Community-Voting-Flow</h1> title="Jetzt zählt deine Stimme"
<p class="max-w-3xl text-lg leading-8 text-slate-600"> description="Eine Stimme pro Kategorie wähl mit Herz. Deine Wahl bleibt bis zum Ende der Voting-Phase jederzeit änderbar, also kein Stress."
Der V2-Flow priorisiert geringe Reibung: Twitch Login, ein Kandidat pro Kategorie, spaeter editierbar bis zur Deadline und klarer Review-Screen. :icon="Vote"
</p> />
</div>
<Card class="p-7"> <Card class="overflow-hidden p-0">
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]"> <!-- 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>
<div class="grid gap-8 p-7 sm:p-9 lg:grid-cols-[0.82fr_1.18fr]">
<!-- Left -->
<div class="space-y-5"> <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"> <p v-if="!authStore.isLoggedIn" class="rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-800">
Bitte zuerst ueber den Kopfbereich mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann. Logg dich kurz oben mit Twitch ein dann zählt deine Stimme. 💜
</p> </p>
<div class="space-y-3"> <div class="space-y-2">
<label class="text-sm font-semibold text-slate-600">Kategorie</label> <label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">Für welche Kategorie stimmst du?</label>
<Select <Select
v-model="selectedCategoryId" v-model="selectedCategoryId"
:options="categoryOptions" :options="categoryOptions"
@@ -88,49 +102,87 @@ async function submitVote() {
/> />
</div> </div>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category?.groupName }}</p> <div v-if="category" class="relative overflow-hidden rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#f6ecff_55%,#fff2dd)] p-6">
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">{{ category?.name }}</h2> <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-slate-600">{{ category?.description }}</p> <p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ category.groupName }}</p>
<div class="rounded-[28px] bg-violet-50/70 p-6 text-sm leading-7 text-slate-600"> <h2 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ category.name }}</h2>
Nur eine Stimme pro Kategorie. Videos/Clips koennen spaeter direkt auf Karten oder Detailmodals referenziert werden. <p class="mt-2 text-sm leading-7 text-slate-600">{{ category.description }}</p>
</div> </div>
</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" />
<div class="space-y-4"> Nur eine Stimme zählt pro Kategorie aber du darfst sie jederzeit umentscheiden.
<label </p>
v-for="candidate in category?.candidates ?? []" <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">
:key="candidate.id" <PlayCircle class="mt-0.5 h-4 w-4 shrink-0 text-violet-500" />
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" Schau dir die eingereichten Clips an, bevor du wählst so siehst du, was jede:n in dieser Kategorie besonders macht.
>
<div>
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
</div>
<RadioButton
v-model="selectedCandidateId"
:input-id="`candidate-${candidate.id}`"
:name="category?.name"
:value="candidate.id"
/>
</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>
</p> </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> </div>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }} <!-- Right: candidate grid -->
</Button> <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="[
'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',
]"
>
<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>
<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 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 class="gap-2" :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
<Sparkles class="h-4 w-4" />
{{ submitting ? 'Speichert ...' : 'Stimme abgeben' }}
</Button>
</div>
</div>
</div> </div>
</Card> </Card>
</div> </div>
+47 -20
View File
@@ -1,14 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' 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 Card from '../components/ui/Card.vue'
import PageHero from '../components/ui/PageHero.vue'
import { useAwardsStore } from '../stores/awards' import { useAwardsStore } from '../stores/awards'
const store = useAwardsStore() const store = useAwardsStore()
const years = [2025, 2024, 2023, 2022] const years = [2025, 2024, 2023, 2022]
const activeYear = ref(2025) 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 () => { onMounted(async () => {
await store.loadHomeData() await store.loadHomeData()
await store.loadArchive(activeYear.value) await store.loadArchive(activeYear.value)
@@ -21,37 +31,54 @@ async function selectYear(year: number) {
</script> </script>
<template> <template>
<div class="space-y-10 pb-14"> <div class="space-y-12 pb-16">
<div class="space-y-4"> <PageHero
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p> eyebrow="Gewinner Archiv"
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Jahre, Gewinner und Show-Historie</h1> title="Jahre, Gewinner und Show-Historie"
<p class="max-w-3xl text-lg leading-8 text-slate-600"> description="Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien, Gewinner und Banner bleiben pro Jahr nachvollziehbar."
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar. :icon="Trophy"
</p> />
</div>
<div class="flex flex-wrap gap-3"> <!-- Year tabs -->
<Button <div class="flex flex-wrap gap-2">
<button
v-for="year in years" v-for="year in years"
:key="year" :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)" @click="selectYear(year)"
> >
{{ year }} {{ year }}
</Button> </button>
</div> </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 <Card
v-for="item in store.archive.items" v-for="(item, index) in store.archive.items"
:key="`${store.archive.year}-${item.category}`" :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> <div :class="['relative h-[220px] bg-gradient-to-br', winnerGradients[index % winnerGradients.length]]">
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ item.category }}</h2> <div class="absolute inset-0 grid place-items-center text-white/70">
<p class="mt-4 text-lg font-semibold text-slate-800">{{ item.winnerName }}</p> <Star class="h-12 w-12" />
<p class="mt-1 text-sm text-slate-500">{{ item.winnerSlug }}</p> </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> </Card>
</div> </div>
<p class="text-xs text-slate-400">Bilder sind Platzhalter echte Banner werden pro Jahr im Admin gepflegt.</p>
</div> </div>
</template> </template>
@@ -37,7 +37,7 @@ const insights = computed(() => {
{ {
label: 'Votes pro Kandidat', label: 'Votes pro Kandidat',
value: votesPerCandidate, value: votesPerCandidate,
note: 'Hilft einzuschaetzen, ob die Kandidatenbasis breit genug ist.', note: 'Hilft einzuschätzen, ob die Kandidatenbasis breit genug ist.',
}, },
{ {
label: 'Leere Kategorien', label: 'Leere Kategorien',
@@ -58,7 +58,7 @@ const insights = computed(() => {
<AdminPageHeader <AdminPageHeader
eyebrow="Analytics" eyebrow="Analytics"
title="Zahlen, die Entscheidungen helfen" title="Zahlen, die Entscheidungen helfen"
description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Luecken oder Backlog entstehen." description="Verdichte Voting-, Kategorie- und Review-Daten in eine Admin-Ansicht, damit das Team sofort erkennt, wo Reichweite, Lücken oder Backlog entstehen."
:icon="BarChart3" :icon="BarChart3"
/> />
+261 -264
View File
@@ -1,315 +1,312 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import Select from 'primevue/select' 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 AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue' import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminCandidateItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const candidateSaving = ref<number | 'new' | null>(null) const saving = ref(false)
const deleting = ref(false)
const adminMessage = ref('') const adminMessage = ref('')
const adminError = 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 seasonDetail = computed(() => store.adminSeasonDetail)
const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
const candidateFilter = ref('')
/* ---------- Filter + Suche + Pagination ---------- */
const search = ref('')
const categoryFilter = ref<number | null>(null) const categoryFilter = ref<number | null>(null)
const page = ref(1)
const pageSize = 10
const categoryOptions = computed(() => const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({ seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
) )
const categoryFilterOptions = computed(() => [ const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
{ label: 'Alle Kategorien', value: null },
...categoryOptions.value,
])
const categoryLabelMap = computed(() => const categoryLabelMap = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])), Object.fromEntries(seasonDetail.value.categories.map((c) => [c.id, `${c.groupName} · ${c.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 filteredCandidates = computed(() => {
const query = candidateFilter.value.trim().toLowerCase() const query = search.value.trim().toLowerCase()
const candidates = categoryFilter.value let list = seasonDetail.value.candidates
? seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === categoryFilter.value) if (categoryFilter.value) list = list.filter((c) => c.categoryId === categoryFilter.value)
: seasonDetail.value.candidates if (query) {
list = list.filter((c) =>
if (!query) return candidates [c.displayName, c.channelSlug, c.platform, categoryLabelMap.value[c.categoryId] ?? '']
return candidates.filter((candidate) => .join(' ')
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? ''] .toLowerCase()
.join(' ') .includes(query),
.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( const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
seasonDetail, const pagedCandidates = computed(() => {
(detail) => { const start = (page.value - 1) * pageSize
for (const candidate of detail.candidates) { return filteredCandidates.value.slice(start, start + pageSize)
candidateForms[candidate.id] = { })
categoryId: candidate.categoryId, const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
displayName: candidate.displayName, const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0 watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
}, page.value = 1
{ immediate: true }, })
) watch(totalPages, (max) => {
if (page.value > max) page.value = max
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
}
}
function clearFilters() { function clearFilters() {
candidateFilter.value = '' search.value = ''
categoryFilter.value = null 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> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Kandidaten" eyebrow="Kandidaten"
title="Kandidatenbasis pflegen" title="Kandidaten verwalten"
description="Schneller finden, sauber pruefen, gezielt bearbeiten: die Kandidaten sind jetzt nach Jahr, Kategorie und Handle besser steuerbar." description="Suchen, filtern, anlegen, bearbeiten und löschen auch bei vielen Nominierten bleibt die Liste übersichtlich."
:icon="Users"
/> />
<AdminSeasonToolbar /> <AdminSeasonToolbar />
<div class="grid gap-4 md:grid-cols-3"> <Card class="overflow-hidden">
<Card <!-- Toolbar -->
v-for="stat in candidateStats" <div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
:key="stat.label" <label class="relative block flex-1">
class="p-5" <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
> <input
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-500">{{ stat.label }}</p> v-model="search"
<strong class="mt-2 block text-3xl text-violet-800">{{ stat.value }}</strong> type="text"
<p class="mt-1 text-sm text-slate-500">{{ stat.note }}</p> 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"
</Card> placeholder="Name, Handle oder Plattform suchen …"
</div> />
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full lg:w-72"
/>
<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="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]"> <p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<Card class="overflow-hidden"> <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="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"> <!-- Tabellenkopf (Desktop) -->
<div> <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">
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kandidatenbereich</p> <span>Kandidat</span>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Suchen, pruefen, aktualisieren</h2> <span>Kategorie</span>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <span>Plattform</span>
Filtere nach Kategorie oder Handle und bearbeite nur den Kandidaten, der wirklich geaendert werden muss. <span class="text-right">Aktionen</span>
</p> </div>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600"> <!-- Zeilen -->
<strong class="text-violet-800">{{ filteredCandidates.length }}</strong> von {{ seasonDetail.candidates.length }} sichtbar <div class="divide-y divide-violet-50">
<div
v-for="candidate in pagedCandidates"
:key="candidate.id"
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 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>
<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> </div>
<div class="min-w-0">
<div class="mt-5 grid gap-3 lg:grid-cols-[minmax(0,1fr)_280px_auto]"> <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">
<label class="relative block"> {{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" /> </span>
<input
v-model="candidateFilter"
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"
/>
</label>
<Select
v-model="categoryFilter"
:options="categoryFilterOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<Button v-if="hasFilters" variant="ghost" @click="clearFilters">Filter loeschen</Button>
</div> </div>
</div> <div>
<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 class="p-5"> </div>
<div class="grid gap-4"> <div class="flex gap-2 lg:justify-end">
<article <button
v-for="candidate in filteredCandidates" class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
:key="candidate.id" title="Bearbeiten"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)]" @click="openEdit(candidate)"
> >
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <Pencil class="h-4 w-4" />
<div class="min-w-0"> </button>
<div class="flex flex-wrap items-center gap-2"> <button
<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"> class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
{{ candidate.platform }} title="Löschen"
</span> @click="candidateToDelete = candidate"
<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"> >
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }} <Trash2 class="h-4 w-4" />
</span> </button>
</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>
</div> </div>
</Card>
<aside class="space-y-4 xl:sticky xl:top-6 xl:self-start"> <div v-if="filteredCandidates.length === 0" class="px-6 py-12 text-center">
<Card class="p-6"> <p class="text-sm text-slate-500">
<div class="flex items-start gap-4"> {{ seasonDetail.candidates.length === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
<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>
</div>
</div>
<div class="mt-6 space-y-4">
<label class="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"
/>
</label>
<label class="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" />
</label>
<label class="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" />
</label>
<label class="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" />
</label>
<Button class="w-full" :disabled="candidateSaving === 'new' || !canCreateCandidate" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</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>
<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"> <Button class="mt-4 gap-2" @click="openCreate"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
{{ adminError }} </div>
</p> </div>
</Card>
<Card class="p-5"> <!-- Pagination -->
<div class="flex items-center gap-3"> <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">
<Sparkles class="h-5 w-5 text-amber-500" /> <span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCandidates.length }}</span>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Ablauf</p> <div class="flex items-center gap-2">
</div> <button
<div class="mt-4 space-y-3 text-sm leading-6 text-slate-600"> 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"
<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> :disabled="page <= 1"
<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> @click="page--"
</div> >
</Card> <ChevronLeft class="h-4 w-4" />
</aside> </button>
</div> <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="form.categoryId" :options="categoryOptions" option-label="label" option-value="value" class="w-full" />
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<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>
<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="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="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<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>
</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>
</template>
</Modal>
</div> </div>
</template> </template>
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Layers3, PlusCircle, Search, Tags } from '@lucide/vue' import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue' import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminCategoryItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const selectedSeasonId = computed(() => store.adminSelectedSeasonId) const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
@@ -71,7 +73,7 @@ const statusFilters = computed(() => [
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length }, { key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length }, { key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length }, { key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
{ key: 'thin' as const, label: 'Duenn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length }, { key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
]) ])
watch( watch(
@@ -150,6 +152,24 @@ function slugify(value: string) {
function fillNewSlug() { function fillNewSlug() {
newCategoryForm.slug = slugify(newCategoryForm.name) newCategoryForm.slug = slugify(newCategoryForm.name)
} }
const categoryToDelete = ref<AdminCategoryItem | null>(null)
const deleting = ref(false)
async function confirmDeleteCategory() {
if (!categoryToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
categoryToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
</script> </script>
<template> <template>
@@ -157,7 +177,7 @@ function fillNewSlug() {
<AdminPageHeader <AdminPageHeader
eyebrow="Kategorien" eyebrow="Kategorien"
title="Award-Struktur pflegen" title="Award-Struktur pflegen"
description="Eine kompakte Arbeitsansicht fuer viele Kategorien: links filtern und auswaehlen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten." description="Eine kompakte Arbeitsansicht für viele Kategorien: links filtern und auswählen, rechts gezielt Gruppe, Slug, Limit und Beschreibung bearbeiten."
:icon="Tags" :icon="Tags"
/> />
@@ -264,9 +284,12 @@ function fillNewSlug() {
<textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" /> <textarea v-model="editForms[selectedCategory.id].description" class="min-h-24 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
</label> </label>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex items-center justify-between gap-3">
<Button variant="ghost" class="gap-2 border border-rose-200 text-rose-600 hover:bg-rose-50" @click="categoryToDelete = selectedCategory">
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)"> <Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
{{ saving === selectedCategory.id ? 'Speichert ...' : 'Kategorie speichern' }} {{ saving === selectedCategory.id ? 'Speichert ' : 'Kategorie speichern' }}
</Button> </Button>
</div> </div>
</Card> </Card>
@@ -305,5 +328,23 @@ function fillNewSlug() {
</Card> </Card>
</div> </div>
</section> </section>
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden aus diesem
Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</div> </div>
</template> </template>
+113 -73
View File
@@ -1,57 +1,70 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ExternalLink, Film, Search, Tags, Users } from '@lucide/vue' import { ExternalLink, Film, PlayCircle, Search, Tags, Trash2, TriangleAlert, Users } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue' import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue' import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue' import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { AdminClipSubmissionItem } from '../../types/awards'
const store = useAwardsStore() const store = useAwardsStore()
const query = ref('') const query = ref('')
const deleting = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
const seasonDetail = computed(() => store.adminSeasonDetail) const seasonDetail = computed(() => store.adminSeasonDetail)
const clipCategories = computed(() => const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')), const categoryName = computed(() =>
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])),
) )
const clipCategoryIds = computed(() => new Set(clipCategories.value.map((category) => category.id)))
const clipCandidates = computed(() => { const clips = computed(() => {
const search = query.value.trim().toLowerCase() const search = query.value.trim().toLowerCase()
return seasonDetail.value.candidates if (!search) return seasonDetail.value.clipSubmissions
.filter((candidate) => clipCategoryIds.value.has(candidate.categoryId)) return seasonDetail.value.clipSubmissions.filter((clip) =>
.filter((candidate) => !search || [candidate.displayName, candidate.channelSlug, candidate.platform].join(' ').toLowerCase().includes(search)) [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search),
)
}) })
const candidateCategory = computed(() => Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, category.name])))
const clipReviewCount = computed(() => seasonDetail.value.pendingNominations.filter((nomination) => clipCategoryIds.value.has(nomination.categoryId)).length)
const clipReadiness = computed(() => [
{
label: 'Clip-Kategorie existiert',
done: clipCategories.value.length > 0,
note: clipCategories.value.length > 0 ? `${clipCategories.value.length} Clip-Kategorien gefunden.` : 'Lege mindestens eine Clip-Kategorie an.',
},
{
label: 'Kandidaten vorhanden',
done: clipCandidates.value.length > 0,
note: clipCandidates.value.length > 0 ? `${clipCandidates.value.length} Clip-Kandidaten gepflegt.` : 'Noch keine Clip-Kandidaten vorhanden.',
},
{
label: 'Review-Restbestand',
done: clipReviewCount.value === 0,
note: clipReviewCount.value === 0 ? 'Keine offenen Clip-Reviews.' : `${clipReviewCount.value} Clip-Reviews offen.`,
},
])
const stats = computed(() => [ const stats = computed(() => [
{ label: 'Clip-Kategorien', value: clipCategories.value.length, icon: Tags }, { label: 'Einreichungen', value: seasonDetail.value.clipSubmissions.length, icon: Film },
{ label: 'Clip-Kandidaten', value: clipCandidates.value.length, icon: Users }, { label: 'Offen', value: seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
{ label: 'Offene Reviews', value: clipReviewCount.value, icon: Film }, { label: 'Clip-Kategorien', value: seasonDetail.value.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length, icon: Tags },
]) ])
function platformClass(platform: string) {
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
return 'border-slate-200 bg-slate-50 text-slate-600'
}
async function confirmDelete() {
if (!clipToDelete.value || !selectedSeasonId.value) return
deleting.value = true
adminError.value = ''
try {
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
clipToDelete.value = null
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
} finally {
deleting.value = false
}
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Clips" eyebrow="Clips"
title="Clip-Kategorien im Blick behalten" title="Clip-Einreichungen moderieren"
description="Bis ein eigener Clip-Review-Endpunkt existiert, zeigt diese Seite die operativen Clip-Kategorien, Kandidaten und offenen Review-Faelle kompakt an." description="Alle von der Community eingereichten Clips laufen hier auf. Sieh sie dir an, prüfe die Links und entferne Spam oder Duplikate."
:icon="Film" :icon="Film"
/> />
@@ -71,50 +84,77 @@ const stats = computed(() => [
</Card> </Card>
</section> </section>
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]"> <Card class="overflow-hidden">
<Card class="p-6"> <div class="border-b border-violet-100 p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Workflow</p> <label class="relative block">
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was hier geprueft wird</h2> <Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<div class="mt-5 space-y-3"> <input
<div v-model="query"
v-for="item in clipReadiness" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
:key="item.label" placeholder="Titel, Creator, Plattform oder User suchen …"
class="rounded-2xl border p-4" />
:class="item.done ? 'border-emerald-100 bg-emerald-50/40' : 'border-amber-100 bg-amber-50/60'" </label>
> </div>
<p class="font-semibold text-slate-900">{{ item.label }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">{{ item.note }}</p>
</div>
<div class="rounded-2xl border border-amber-100 bg-amber-50/70 p-4">
<p class="font-semibold text-amber-800">Naechster Backend-Ausbau</p>
<p class="mt-1 text-sm leading-6 text-amber-700">Fuer echte Clip-Moderation brauchen wir spaeter eine ClipSubmission-Admin-API mit Status, Duplikaten und Entscheidung.</p>
</div>
</div>
</Card>
<Card class="overflow-hidden"> <p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<div class="border-b border-violet-100 p-5"> <p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" /> <div class="divide-y divide-violet-50">
<input v-model="query" class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Clip-Kandidat, Handle oder Plattform suchen" /> <div v-for="clip in clips" :key="clip.id" class="flex flex-col gap-3 px-5 py-4 lg:flex-row lg:items-center lg:gap-4">
</label> <div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
</div> <Film class="h-5 w-5" />
<div class="divide-y divide-violet-50">
<div v-for="candidate in clipCandidates" :key="candidate.id" class="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div class="min-w-0">
<p class="font-semibold text-slate-900">{{ candidate.displayName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }} · {{ candidateCategory[candidate.categoryId] }}</p>
</div>
<span class="inline-flex items-center gap-2 rounded-full border border-violet-100 bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
<ExternalLink class="h-3.5 w-3.5" />
Clip-Link spaeter
</span>
</div> </div>
<p v-if="clipCandidates.length === 0" class="px-5 py-10 text-center text-sm text-slate-500"> <div class="min-w-0 flex-1">
Keine Clip-Kandidaten gefunden. Lege zuerst eine Clip-Kategorie und passende Kandidaten an. <p class="truncate font-semibold text-slate-900">{{ clip.title || 'Ohne Titel' }}</p>
<p class="truncate text-sm text-slate-500">
{{ clip.creator || 'Unbekannt' }}
<span v-if="clip.categoryId"> · {{ categoryName[clip.categoryId] }}</span>
· von {{ clip.submittedByTwitchId }}
</p>
</div>
<span :class="['shrink-0 rounded-full border px-3 py-1 text-xs font-semibold', platformClass(clip.platform)]">{{ clip.platform }}</span>
<a
:href="clip.clipUrl"
target="_blank"
rel="noopener"
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-violet-200 px-3 py-1.5 text-xs font-semibold text-violet-700 transition hover:bg-violet-50"
>
<ExternalLink class="h-3.5 w-3.5" /> Clip öffnen
</a>
<button
class="grid h-9 w-9 shrink-0 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
title="Einreichung entfernen"
@click="clipToDelete = clip"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
<div v-if="clips.length === 0" class="px-5 py-12 text-center">
<Users class="mx-auto h-6 w-6 text-violet-300" />
<p class="mt-2 text-sm text-slate-500">
{{ seasonDetail.clipSubmissions.length === 0 ? 'Noch keine Clip-Einreichungen in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
</p> </p>
</div> </div>
</Card> </div>
</section> </Card>
<Modal :open="!!clipToDelete" title="Clip entfernen?" @close="clipToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
Die Einreichung <strong class="text-slate-800">{{ clipToDelete?.title || clipToDelete?.clipUrl }}</strong>" wird entfernt.
Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="clipToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDelete">
{{ deleting ? 'Entfernt …' : 'Entfernen' }}
</Button>
</template>
</Modal>
</div> </div>
</template> </template>
+13 -13
View File
@@ -23,7 +23,7 @@ const metricToneMap = {
icon: BarChart3, icon: BarChart3,
trend: 8.7, trend: 8.7,
sparkline: [54, 57, 63, 66, 72, 76, 81], sparkline: [54, 57, 63, 66, 72, 76, 81],
context: 'Voting-Aktivitaet stabil positiv', context: 'Voting-Aktivität stabil positiv',
}, },
Kategorien: { Kategorien: {
icon: Tags, icon: Tags,
@@ -67,7 +67,7 @@ const yearTotals = computed(() => [
{ {
label: 'Kandidaten', label: 'Kandidaten',
value: store.adminSeasonDetail.candidates.length, value: store.adminSeasonDetail.candidates.length,
note: 'fuer Voting und Archiv gepflegt', note: 'für Voting und Archiv gepflegt',
icon: Users, icon: Users,
}, },
{ {
@@ -99,10 +99,10 @@ const priorityActions = computed(() => [
tone: 'violet', tone: 'violet',
}, },
{ {
label: 'Risiko pruefen', label: 'Risiko prüfen',
value: store.admin.riskFlags.length, value: store.admin.riskFlags.length,
to: '/admin/risk', to: '/admin/risk',
hint: 'Auffaellige Muster brauchen Sichtung', hint: 'Auffällige Muster brauchen Sichtung',
icon: ShieldAlert, icon: ShieldAlert,
tone: 'rose', tone: 'rose',
}, },
@@ -118,7 +118,7 @@ const priorityActions = computed(() => [
label: 'Kandidatenbasis', label: 'Kandidatenbasis',
value: store.adminSeasonDetail.candidates.length, value: store.adminSeasonDetail.candidates.length,
to: '/admin/candidates', to: '/admin/candidates',
hint: 'Kandidaten und Plattformen schnell pruefen', hint: 'Kandidaten und Plattformen schnell prüfen',
icon: Users, icon: Users,
tone: 'emerald', tone: 'emerald',
}, },
@@ -137,7 +137,7 @@ const operationChecks = computed(() => {
value: categoriesWithoutCandidates.length, value: categoriesWithoutCandidates.length,
to: '/admin/categories', to: '/admin/categories',
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn', state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt pruefen.', note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
}, },
{ {
label: 'Review-Backlog verteilt', label: 'Review-Backlog verteilt',
@@ -151,7 +151,7 @@ const operationChecks = computed(() => {
value: store.admin.riskFlags.length, value: store.admin.riskFlags.length,
to: '/admin/risk', to: '/admin/risk',
state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger', state: store.admin.riskFlags.length === 0 ? 'ok' : 'danger',
note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst pruefen.', note: store.admin.riskFlags.length === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
}, },
] ]
}) })
@@ -162,7 +162,7 @@ const operationChecks = computed(() => {
<AdminPageHeader <AdminPageHeader
eyebrow="Dashboard" eyebrow="Dashboard"
title="Was braucht gerade Aufmerksamkeit?" title="Was braucht gerade Aufmerksamkeit?"
description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebuendelt, damit du schneller entscheiden kannst, was als Naechstes drankommt." description="Trends, offene Aufgaben und Kategorie-Performance sind hier gebündelt, damit du schneller entscheiden kannst, was als Nächstes drankommt."
:icon="LayoutDashboard" :icon="LayoutDashboard"
/> />
@@ -174,11 +174,11 @@ const operationChecks = computed(() => {
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p> <p class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">Live-Lage</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-5xl leading-none text-violet-800">Community Momentum</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600"> <p class="mt-3 max-w-2xl text-sm leading-6 text-slate-600">
Voting und Nominierungen ziehen an, waehrend der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert. Voting und Nominierungen ziehen an, während der Review-Backlog sinkt. Gute Lage, aber Risikohinweise bleiben priorisiert.
</p> </p>
</div> </div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700"> <div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
+9.8% Gesamtaktivitaet +9.8% Gesamtaktivität
</div> </div>
</div> </div>
</div> </div>
@@ -354,10 +354,10 @@ const operationChecks = computed(() => {
<Card class="p-7"> <Card class="p-7">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> <div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitaeten</p> <p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Was gerade passiert ist</h2>
</div> </div>
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert fuer den schnellen Blick.</p> <p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
</div> </div>
<div class="mt-6 grid gap-4 md:grid-cols-3"> <div class="mt-6 grid gap-4 md:grid-cols-3">
@@ -370,7 +370,7 @@ const operationChecks = computed(() => {
<p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p> <p class="mt-2 text-sm text-slate-500">{{ activity.age }}</p>
</div> </div>
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine aktuellen Audit-Aktivitaeten vorhanden. Noch keine aktuellen Audit-Aktivitäten vorhanden.
</p> </p>
</div> </div>
</Card> </Card>
+4 -4
View File
@@ -39,14 +39,14 @@ const navGroups = [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` }, { label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` }, { label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` }, { label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien pruefen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` }, { label: 'Clips', to: '/admin/clips', description: 'Clip-Kategorien prüfen', icon: Film, badge: () => `${store.adminSeasonDetail.categories.filter((category) => `${category.groupName} ${category.name}`.toLowerCase().includes('clip')).length}` },
], ],
}, },
{ {
label: 'Kontrolle', label: 'Kontrolle',
items: [ items: [
{ label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Faelle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` }, { label: 'Reviews', to: '/admin/reviews', description: 'Freitext-Fälle entscheiden', icon: Sparkles, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags pruefen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` }, { label: 'Risiko & Audit', to: '/admin/risk', description: 'Flags prüfen', icon: AlertTriangle, badge: () => `${store.admin.riskFlags.length}` },
{ label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` }, { label: 'User & Logs', to: '/admin/users-logs', description: 'User-Spuren und Aktionen', icon: UserCog, badge: () => `${store.admin.auditEntries.length}` },
], ],
}, },
@@ -119,7 +119,7 @@ onMounted(async () => {
<Card class="p-3"> <Card class="p-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p> <p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p>
<p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p> <p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswaehlen.' }}</p> <p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswählen.' }}</p>
<div class="mt-3 grid grid-cols-3 gap-1.5"> <div class="mt-3 grid grid-cols-3 gap-1.5">
<div <div
v-for="item in seasonSummary" v-for="item in seasonSummary"
@@ -150,7 +150,7 @@ const statusFilters = computed(() => [
v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)" v-if="!seasonDetail.candidates.some((candidate) => candidate.categoryId === nomination.categoryId)"
class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700" class="rounded-full border border-amber-100 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700"
> >
erst Kandidatenbasis klaeren erst Kandidatenbasis klären
</span> </span>
</div> </div>
</div> </div>
@@ -100,9 +100,9 @@ async function approveNomination(nominationId: number) {
try { try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId]) await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.' adminMessage.value = 'Nominierung wurde in die Kandidatenliste übernommen.'
} catch (error) { } catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.' adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen werden.'
} finally { } finally {
reviewSaving.value = null reviewSaving.value = null
} }
@@ -149,7 +149,7 @@ function setPlatform(platform: string) {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Offene Nominierungen</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Kompakte Liste fuer viele Freitext-Faelle. Waehle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird. Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
</p> </p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]"> <div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
@@ -229,10 +229,10 @@ function setPlatform(platform: string) {
</button> </button>
<p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-if="seasonDetail.pendingNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine offenen Review-Faelle im aktuell gewaehlten Award-Jahr. Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
</p> </p>
<p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-else-if="filteredNominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Faelle passen zum aktuellen Filter. Keine Review-Fälle passen zum aktuellen Filter.
</p> </p>
</div> </div>
@@ -251,7 +251,7 @@ function setPlatform(platform: string) {
</div> </div>
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4"> <div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat uebernehmen</p> <p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
<div class="mt-4 grid gap-4 md:grid-cols-3"> <div class="mt-4 grid gap-4 md:grid-cols-3">
<label class="space-y-2"> <label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
@@ -294,7 +294,7 @@ function setPlatform(platform: string) {
</button> </button>
</div> </div>
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800"> <p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Moegliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden.
</p> </p>
</div> </div>
@@ -305,7 +305,7 @@ function setPlatform(platform: string) {
</Button> </Button>
<Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)"> <Button :disabled="reviewSaving === selectedNomination.id" @click="approveNomination(selectedNomination.id)">
<CheckCircle2 class="mr-2 h-4 w-4" /> <CheckCircle2 class="mr-2 h-4 w-4" />
{{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }} {{ reviewSaving === selectedNomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
</Button> </Button>
</div> </div>
</div> </div>
+10 -10
View File
@@ -69,8 +69,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<div class="space-y-6"> <div class="space-y-6">
<AdminPageHeader <AdminPageHeader
eyebrow="Risiko & Audit" eyebrow="Risiko & Audit"
title="Auffaellige Muster und Admin-Aktionen verfolgen" title="Auffällige Muster und Admin-Aktionen verfolgen"
description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgefuehrte Eingriffe deutlich schneller." description="Dieser Bereich trennt operative Risiko-Sichtung von der Nachvollziehbarkeit. So findest du sowohl offene Flags als auch bereits ausgeführte Eingriffe deutlich schneller."
:icon="ShieldAlert" :icon="ShieldAlert"
/> />
@@ -78,8 +78,8 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<Card class="p-7"> <Card class="p-7">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikopruefung</h2> <h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Risikoprüfung</h2>
<p class="mt-2 text-sm text-slate-500">Auffaellige Login-, Nominierungs- und Voting-Muster fuer die manuelle Sichtung.</p> <p class="mt-2 text-sm text-slate-500">Auffällige Login-, Nominierungs- und Voting-Muster für die manuelle Sichtung.</p>
</div> </div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500"> <span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen {{ filteredRiskFlags.length }} / {{ riskFlags.length }} offen
@@ -110,7 +110,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
/> />
</label> </label>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600"> <div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprueften Fall. Tipp: Filtere erst auf den Problemtyp und markiere dann nur den geprüften Fall.
</div> </div>
</div> </div>
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
@@ -170,10 +170,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div> <div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2> <h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Audit-Protokoll</h2>
<p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen fuer Kategorie-, Kandidaten- und Review-Aenderungen.</p> <p class="mt-2 text-sm text-slate-500">Nachvollziehbare Admin-Aktionen für Kategorie-, Kandidaten- und Review-Änderungen.</p>
</div> </div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500"> <span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ filteredAuditEntries.length }} / {{ auditEntries.length }} Eintraege {{ filteredAuditEntries.length }} / {{ auditEntries.length }} Einträge
</span> </span>
</div> </div>
@@ -182,7 +182,7 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
v-model="auditFilter" v-model="auditFilter"
type="text" type="text"
class="w-full rounded-2xl border border-violet-200 px-4 py-3" class="w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Audit-Eintraege nach Aktion, Admin oder Objekt filtern" placeholder="Audit-Einträge nach Aktion, Admin oder Objekt filtern"
/> />
</div> </div>
@@ -204,10 +204,10 @@ async function resolveRiskFlag(riskFlagId: number, status = 'resolved') {
</div> </div>
<p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-if="auditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Noch keine Audit-Eintraege vorhanden. Noch keine Audit-Einträge vorhanden.
</p> </p>
<p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500"> <p v-else-if="filteredAuditEntries.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Audit-Eintraege passen zum aktuellen Filter. Keine Audit-Einträge passen zum aktuellen Filter.
</p> </p>
</div> </div>
</Card> </Card>
+10 -10
View File
@@ -176,11 +176,11 @@ async function createCategory() {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Jahresstatus</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Award-Jahr steuern</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Award-Jahr steuern</h2>
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500"> <p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr oeffentlich fuer Community, Voting und Archiv sichtbar ist. Hier legst du fest, in welcher Phase das Jahr ist und ob genau dieses Jahr öffentlich für Community, Voting und Archiv sichtbar ist.
</p> </p>
</div> </div>
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'"> <div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="seasonForm.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
{{ seasonForm.isCurrent ? 'Oeffentlich aktiv' : 'Intern vorbereitet' }} {{ seasonForm.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
</div> </div>
</div> </div>
</div> </div>
@@ -195,16 +195,16 @@ async function createCategory() {
placeholder="z.B. Community Voting, Nominierung, Archiviert" placeholder="z.B. Community Voting, Nominierung, Archiviert"
/> />
<span class="block text-xs leading-5 text-slate-500"> <span class="block text-xs leading-5 text-slate-500">
Diese Phase wird als Orientierung fuer Team und spaeter auch fuer Public-Kommunikation genutzt. Diese Phase wird als Orientierung für Team und später auch für Public-Kommunikation genutzt.
</span> </span>
</label> </label>
<label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50"> <label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" /> <input v-model="seasonForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span> <span>
<span class="block font-semibold text-slate-800">Dieses Award-Jahr oeffentlich schalten</span> <span class="block font-semibold text-slate-800">Dieses Award-Jahr öffentlich schalten</span>
<span class="mt-1 block text-sm leading-6 text-slate-500"> <span class="mt-1 block text-sm leading-6 text-slate-500">
Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig oeffentlich sein. Wenn aktiv, gilt dieses Jahr als aktueller Public-Kontext. Nur ein Award-Jahr sollte gleichzeitig öffentlich sein.
</span> </span>
</span> </span>
</label> </label>
@@ -226,7 +226,7 @@ async function createCategory() {
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-6 text-slate-500"> <p class="text-sm leading-6 text-slate-500">
Speichert Phase und Public-Status fuer <strong class="text-slate-700">{{ seasonDetail.name }}</strong>. Speichert Phase und Public-Status für <strong class="text-slate-700">{{ seasonDetail.name }}</strong>.
</p> </p>
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason"> <Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }} {{ seasonSaving ? 'Speichert ...' : 'Jahresstatus speichern' }}
@@ -248,7 +248,7 @@ async function createCategory() {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie planen</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorie planen</h2>
<p class="mt-2 text-sm leading-6 text-slate-500"> <p class="mt-2 text-sm leading-6 text-slate-500">
Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen spaeter URL, Anzeige und Reihenfolge im Voting. Lege zuerst Gruppe, Namen und Limit fest. Slug und Sortierung bestimmen später URL, Anzeige und Reihenfolge im Voting.
</p> </p>
</div> </div>
</div> </div>
@@ -268,7 +268,7 @@ async function createCategory() {
<label class="space-y-2"> <label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span> <span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklaeren, wofuer diese Kategorie steht." /> <textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Kurz erklären, wofür diese Kategorie steht." />
</label> </label>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
@@ -288,7 +288,7 @@ async function createCategory() {
<div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 border-t border-violet-100 pt-5 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm leading-6 text-slate-500"> <p class="text-sm leading-6 text-slate-500">
Neue Kategorien sind sofort Teil des gewaehlten Award-Jahres und koennen danach unten weiter bearbeitet werden. Neue Kategorien sind sofort Teil des gewählten Award-Jahres und können danach unten weiter bearbeitet werden.
</p> </p>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory"> <Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }} {{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
@@ -305,7 +305,7 @@ async function createCategory() {
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategorien</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien dieses Jahres</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien dieses Jahres</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500"> <p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
Pruefe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten. Prüfe Struktur, Slug, Limit und Kandidatenzahl pro Kategorie. Erst filtern, dann gezielt bearbeiten.
</p> </p>
</div> </div>
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]"> <div class="grid gap-2 sm:grid-cols-3 xl:min-w-[380px]">
@@ -29,7 +29,7 @@ const checks = computed(() => [
{ {
label: 'Public-Jahr gesetzt', label: 'Public-Jahr gesetzt',
value: seasonDetail.value.isCurrent, value: seasonDetail.value.isCurrent,
note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist oeffentlich markiert.' : 'Dieses Jahr ist aktuell intern.', note: seasonDetail.value.isCurrent ? 'Dieses Jahr ist öffentlich markiert.' : 'Dieses Jahr ist aktuell intern.',
icon: CheckCircle2, icon: CheckCircle2,
}, },
{ {
@@ -57,8 +57,8 @@ const featureGates = computed(() => [
}, },
{ {
label: 'Clip-Moderation', label: 'Clip-Moderation',
state: false, state: true,
note: 'Admin-API fuer ClipSubmissions fehlt noch und sollte spaeter ergaenzt werden.', 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"> <label class="flex cursor-pointer gap-4 rounded-[24px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50">
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" /> <input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
<span> <span>
<span class="block font-semibold text-slate-900">Dieses Award-Jahr oeffentlich markieren</span> <span class="block font-semibold text-slate-900">Dieses Award-Jahr öffentlich markieren</span>
<span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext fuer Community, Voting und spaeter Archiv.</span> <span class="mt-1 block text-sm leading-6 text-slate-500">Aktiviert dieses Jahr als Public-Kontext für Community, Voting und später Archiv.</span>
</span> </span>
</label> </label>
</div> </div>
@@ -40,7 +40,7 @@ const adminCounts = computed(() => {
return [...counts.entries()].map(([admin, count]) => ({ admin, count })) return [...counts.entries()].map(([admin, count]) => ({ admin, count }))
}) })
const logStats = computed(() => [ const logStats = computed(() => [
{ label: 'Audit-Eintraege', value: auditEntries.value.length }, { label: 'Audit-Einträge', value: auditEntries.value.length },
{ label: 'Admins aktiv', value: adminCounts.value.length }, { label: 'Admins aktiv', value: adminCounts.value.length },
{ label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size }, { label: 'Risk-User', value: new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size },
]) ])
@@ -51,7 +51,7 @@ const logStats = computed(() => [
<AdminPageHeader <AdminPageHeader
eyebrow="User & Logs" eyebrow="User & Logs"
title="User-Spuren und Admin-Aktionen" title="User-Spuren und Admin-Aktionen"
description="Eine kompakte Kontrollansicht fuer Audit-Eintraege, auffaellige User und Admin-Aktivitaet. Fuer Detailentscheidungen bleibt Risiko & Audit der Hauptbereich." description="Eine kompakte Kontrollansicht für Audit-Einträge, auffällige User und Admin-Aktivität. Für Detailentscheidungen bleibt Risiko & Audit der Hauptbereich."
:icon="UserCog" :icon="UserCog"
/> />
@@ -73,14 +73,14 @@ const logStats = computed(() => [
<section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]"> <section class="grid gap-6 xl:grid-cols-[0.86fr_1.14fr]">
<Card class="p-6"> <Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Admins</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivitaet</h2> <h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">Aktivität</h2>
<div class="mt-5 space-y-3"> <div class="mt-5 space-y-3">
<div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3"> <div v-for="item in adminCounts" :key="item.admin" class="flex items-center justify-between rounded-2xl border border-violet-100 bg-white/90 px-4 py-3">
<span class="font-semibold text-slate-900">{{ item.admin }}</span> <span class="font-semibold text-slate-900">{{ item.admin }}</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span> <span class="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-700">{{ item.count }}</span>
</div> </div>
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500"> <p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-8 text-center text-sm text-slate-500">
Noch keine Admin-Aktivitaet vorhanden. Noch keine Admin-Aktivität vorhanden.
</p> </p>
</div> </div>
</Card> </Card>
@@ -100,7 +100,7 @@ const logStats = computed(() => [
<span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span> <span class="text-sm text-slate-500">{{ new Date(entry.createdAt).toLocaleString('de-DE') }}</span>
</div> </div>
</div> </div>
<p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Eintraege gefunden.</p> <p v-if="filteredAuditEntries.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">Keine Log-Einträge gefunden.</p>
</div> </div>
</Card> </Card>
</section> </section>
@@ -112,7 +112,7 @@ const logStats = computed(() => [
<ShieldAlert class="h-5 w-5" /> <ShieldAlert class="h-5 w-5" />
</div> </div>
<div> <div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffaellige User</p> <p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Auffällige User</p>
<h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2> <h2 class="font-[Cormorant_Garamond] text-3xl text-violet-800">Aus Risk-Flags abgeleitet</h2>
</div> </div>
</div> </div>
@@ -127,7 +127,7 @@ const logStats = computed(() => [
<span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span> <span class="rounded-full border border-rose-100 bg-rose-50 px-3 py-1 text-center text-xs font-semibold uppercase tracking-[0.14em] text-rose-700">{{ user.severity }}</span>
</div> </div>
<p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500"> <p v-if="filteredRiskUsers.length === 0" class="px-5 py-10 text-center text-sm text-slate-500">
Keine auffaelligen User fuer den aktuellen Filter. Keine auffälligen User für den aktuellen Filter.
</p> </p>
</div> </div>
</Card> </Card>
+4 -4
View File
@@ -57,7 +57,7 @@ const votingChecklist = computed(() => [
<AdminPageHeader <AdminPageHeader
eyebrow="Voting" eyebrow="Voting"
title="Voting-Status und Rankings" title="Voting-Status und Rankings"
description="Pruefe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivitaet erzeugen." description="Prüfe, ob Kategorien Kandidaten besitzen, ob das Jahr in der richtigen Phase ist und welche Kategorien aktuell die meiste Aktivität erzeugen."
:icon="Vote" :icon="Vote"
/> />
@@ -97,7 +97,7 @@ const votingChecklist = computed(() => [
<strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong> <strong class="text-violet-800">{{ category.votes.toLocaleString('de-DE') }}</strong>
</div> </div>
<div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50"> <div class="mt-3 h-3 overflow-hidden rounded-full bg-violet-50">
<div class="h-full rounded-full bg-[linear-gradient(90deg,#a78bfa,#f5a9d6)]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" /> <div class="h-full rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]" :style="{ width: `${(category.votes / maxVotes) * 100}%` }" />
</div> </div>
</div> </div>
<p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500"> <p v-if="store.admin.topCategories.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-8 text-center text-sm text-slate-500">
@@ -118,7 +118,7 @@ const votingChecklist = computed(() => [
<p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</p> <p class="mt-1 truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidateCount }} Kandidaten</p>
</div> </div>
<span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'"> <span class="h-fit rounded-full border px-3 py-1 text-xs font-semibold" :class="category.ready ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-amber-100 bg-amber-50 text-amber-700'">
{{ category.ready ? 'bereit' : 'pruefen' }} {{ category.ready ? 'bereit' : 'prüfen' }}
</span> </span>
</div> </div>
</div> </div>
@@ -142,7 +142,7 @@ const votingChecklist = computed(() => [
<p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p> <p class="mt-1 text-sm leading-5 text-slate-500">{{ item.note }}</p>
</div> </div>
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'"> <span class="rounded-full px-3 py-1 text-xs font-semibold" :class="item.done ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'">
{{ item.done ? 'ok' : 'pruefen' }} {{ item.done ? 'ok' : 'prüfen' }}
</span> </span>
</div> </div>
</RouterLink> </RouterLink>
+3
View File
@@ -5,4 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(), tailwindcss()], plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.PORT) || 5173,
},
}) })