Refine admin workflows and team access

This commit is contained in:
AzuTear
2026-06-26 18:09:29 +02:00
parent b7804e10ea
commit 8b69dfbafb
71 changed files with 5066 additions and 751 deletions
+2
View File
@@ -17,6 +17,8 @@ public sealed record AdminTeamMemberDto(
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt,
DateTimeOffset? LastLoginAt,
DateTimeOffset? LastOnlineAt,
bool IsOnline,
DateTimeOffset? TwitchBoundAt,
DateTimeOffset? PasswordResetAt);
-8
View File
@@ -14,18 +14,10 @@ public sealed record TeamLoginRequest(
string Login,
string Password);
public sealed record TeamTwitchLoginRequest(
string TwitchUserId,
string? DisplayName);
public sealed record ChangePasswordRequest(
string CurrentPassword,
string NewPassword);
public sealed record BindTeamTwitchRequest(
string TwitchUserId,
string? DisplayName);
public sealed record TwitchAuthorizeRequest(
string Purpose,
string? ReturnUrl,
+1
View File
@@ -103,6 +103,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
modelBuilder.Entity<VoteBallot>(entity =>
{
entity.HasIndex(item => new { item.SeasonId, item.SubmittedByTwitchId }).IsUnique();
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.Status).HasMaxLength(30);
});
+38 -1
View File
@@ -17,7 +17,7 @@ public static class TeamAccountBootstrapper
defaultLogin: "jayuhime",
defaultDisplayName: "Jayuhime",
role: AdminRoles.Owner,
fallbackPassword: configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
fallbackPassword: null);
var creatorSeed = BuildSeed(
configuration,
@@ -33,6 +33,11 @@ public static class TeamAccountBootstrapper
await EnsureMemberAsync(db, seed);
}
await DeactivateDemoBackedSeedAccountAsync(
db,
ownerSeed,
configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
await db.SaveChangesAsync();
}
@@ -80,6 +85,18 @@ public static class TeamAccountBootstrapper
changed = true;
}
if (member.CreatedByTwitchId == SeedActor
&& (string.IsNullOrWhiteSpace(member.UpdatedByTwitchId)
|| string.Equals(member.UpdatedByTwitchId, SeedActor, StringComparison.Ordinal))
&& (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal)
|| !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal)))
{
member.PasswordHash = seed.Credentials.Value.Hash;
member.PasswordSalt = seed.Credentials.Value.Salt;
member.MustChangePassword = false;
changed = true;
}
if (changed)
{
member.UpdatedAt = DateTimeOffset.UtcNow;
@@ -87,6 +104,26 @@ public static class TeamAccountBootstrapper
}
}
private static async Task DeactivateDemoBackedSeedAccountAsync(AwardsDbContext db, TeamAccountSeed seed, string? demoPassword)
{
if (seed.Credentials is not null || string.IsNullOrWhiteSpace(demoPassword))
{
return;
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
if (member is null
|| member.CreatedByTwitchId != SeedActor
|| !DemoCredentialHasher.VerifyPassword(demoPassword, member.PasswordHash, member.PasswordSalt))
{
return;
}
member.IsActive = false;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = SeedActor;
}
private static TeamAccountSeed BuildSeed(
IConfiguration configuration,
string sectionName,
@@ -75,9 +75,15 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
if (urlValidationError is not null)
{
return urlValidationError;
}
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = request.NewsletterUrl.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
@@ -88,13 +94,13 @@ public static class AdminSiteSettingsEndpoints
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
}
settings.ImprintUrl = request.ImprintUrl.Trim();
settings.ImprintUrl = normalizedUrls.ImprintUrl;
settings.ImprintContent = request.ImprintContent.Trim();
settings.ContactUrl = request.ContactUrl.Trim();
settings.ContactUrl = normalizedUrls.ContactUrl;
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = request.SponsorsUrl.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
adminAuditService.AddEntry(
@@ -107,7 +113,7 @@ public static class AdminSiteSettingsEndpoints
{
settings.HostDisplayName,
privacyChanged,
socialLinkCount = request.SocialLinks?.Length ?? 0,
socialLinkCount = socialLinks.Length,
faqCount = request.Faq?.Length ?? 0,
},
RequestMetadataReader.Read(context));
@@ -116,6 +122,73 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(new { saved = true });
}
private static IResult? NormalizeSiteSettingsUrls(
UpdateSiteSettingsRequest request,
out PublicSiteUrlSettings normalizedUrls,
out PublicSocialLinkDto[] socialLinks)
{
normalizedUrls = new PublicSiteUrlSettings();
socialLinks = [];
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
normalizedUrls = new PublicSiteUrlSettings
{
NewsletterUrl = newsletterUrl,
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
};
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
foreach (var social in request.SocialLinks ?? [])
{
if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
normalizedSocialLinks.Add(social with { Url = normalizedUrl });
}
socialLinks = normalizedSocialLinks.ToArray();
return null;
}
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
{
normalizedUrl = (value ?? string.Empty).Trim();
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return true;
}
if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
errorMessage = $"{fieldName} muss eine absolute http/https-URL sein.";
return false;
}
normalizedUrl = uri.ToString();
return true;
}
private sealed class PublicSiteUrlSettings
{
public string NewsletterUrl { get; set; } = string.Empty;
public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
}
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
+67 -10
View File
@@ -13,6 +13,7 @@ namespace Backend.Endpoints;
public static class AdminTeamEndpoints
{
private const int MinPasswordLength = 10;
private static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5);
private const string PersonalOwnerLogin = "jayuhime";
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
@@ -79,11 +80,21 @@ public static class AdminTeamEndpoints
.OrderByDescending(item => item.Role == AdminRoles.Owner)
.ThenBy(item => item.Role)
.ThenBy(item => item.DisplayName)
.Select(item => ToMemberDto(item))
.ToArrayAsync();
var sessionKeys = members
.SelectMany(MemberSessionKeys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var sessions = sessionKeys.Length == 0
? []
: await db.UserSessions
.AsNoTracking()
.Where(item => sessionKeys.Contains(item.TwitchUserId))
.ToArrayAsync();
var now = DateTimeOffset.UtcNow;
return Results.Ok(new AdminTeamResponse(
members,
members.Select(member => ToMemberDto(member, sessions, now)),
await BuildRoleDtosAsync(db),
PermissionCatalog));
}
@@ -322,10 +333,15 @@ public static class AdminTeamEndpoints
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.IsPrivilegedFullControlRole(session.Role))
{
return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
var roleChanges = new List<object>();
foreach (var roleUpdate in request.Roles ?? [])
{
@@ -339,14 +355,18 @@ public static class AdminTeamEndpoints
.Select(item => item.Trim())
.Where(item => allowedPermissions.Contains(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys);
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
{
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
}
var beforePermissions = existingRows.TryGetValue(role, out var existingRow)
? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role)))
: AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role));
if (!existingRows.TryGetValue(role, out var row))
{
row = new TeamRolePermission { Role = role };
@@ -356,6 +376,18 @@ public static class AdminTeamEndpoints
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
row.UpdatedAt = DateTimeOffset.UtcNow;
row.UpdatedByTwitchId = session.TwitchUserId;
if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase))
{
roleChanges.Add(new
{
role,
before = beforePermissions,
after = permissionKeys,
added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(),
removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(),
});
}
}
adminAuditService.AddEntry(
@@ -364,7 +396,7 @@ public static class AdminTeamEndpoints
"team-role",
"matrix",
"Team-Rollenberechtigungen wurden gespeichert.",
new { roleCount = request.Roles?.Length ?? 0 },
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
@@ -384,18 +416,30 @@ public static class AdminTeamEndpoints
? ReadPermissionKeys(json, role.PermissionKeys)
: role.PermissionKeys;
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
{
permissions = AdminPermissionCatalog.AllPermissionKeys;
}
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
return role with { PermissionKeys = permissions };
})
.ToArray();
}
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
new(
private static AdminTeamMemberDto ToMemberDto(
TeamMember member,
IReadOnlyCollection<UserSession>? sessions = null,
DateTimeOffset? now = null)
{
var memberSessions = sessions?
.Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase))
.ToArray() ?? [];
var lastSeenAt = memberSessions
.Select(session => (DateTimeOffset?)session.LastSeenAt)
.Max() ?? member.LastLoginAt;
var isOnline = member.IsActive
&& memberSessions.Any(session =>
session.IsActive
&& session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow));
return new(
member.Id,
member.Login,
member.DisplayName,
@@ -407,8 +451,21 @@ public static class AdminTeamEndpoints
member.CreatedAt,
member.UpdatedAt,
member.LastLoginAt,
lastSeenAt,
isOnline,
member.TwitchBoundAt,
member.PasswordResetAt);
}
private static IEnumerable<string> MemberSessionKeys(TeamMember member)
{
yield return $"team:{member.Login}";
if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId))
{
yield return member.BoundTwitchUserId;
}
}
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
{
-10
View File
@@ -23,21 +23,11 @@ public static partial class AuthEndpoints
.WithName("TeamLogin")
.WithOpenApi();
group.MapPost("/team-twitch-login", TeamTwitchLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("TeamTwitchLogin")
.WithOpenApi();
group.MapPost("/password/change", ChangePassword)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("ChangePassword")
.WithOpenApi();
group.MapPost("/team/twitch-binding", BindTeamTwitch)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("BindTeamTwitch")
.WithOpenApi();
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("StartTwitchAuthorization")
@@ -47,38 +47,6 @@ public static partial class AuthEndpoints
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> TeamTwitchLogin(
HttpContext context,
TeamTwitchLoginRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.Unauthorized();
}
var member = await db.TeamMembers.FirstOrDefaultAsync(
item => item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.Unauthorized();
}
member.LastLoginAt = DateTimeOffset.UtcNow;
var session = await userSessionService.CreateSessionAsync(
twitchUserId,
member.DisplayName,
member.Role,
RequestMetadataReader.Read(context),
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> ChangePassword(
HttpContext context,
ChangePasswordRequest request,
@@ -127,64 +95,6 @@ public static partial class AuthEndpoints
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static async Task<IResult> BindTeamTwitch(
HttpContext context,
BindTeamTwitchRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
}
if (member.MustChangePassword)
{
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
}
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
}
var alreadyBound = await db.TeamMembers.AnyAsync(
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (alreadyBound)
{
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
}
var previousTwitchUserId = member.BoundTwitchUserId;
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
{
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
{
oldSession.IsActive = false;
}
}
member.BoundTwitchUserId = twitchUserId;
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
member.TwitchBoundAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static string BuildTeamSessionId(string login) =>
$"{TeamSessionPrefix}{login}";
@@ -215,7 +125,4 @@ public static partial class AuthEndpoints
private static string NormalizeTwitchUserId(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchDisplayName(string? value) =>
(value ?? string.Empty).Trim();
}
+55 -24
View File
@@ -4,6 +4,7 @@ using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Backend.Endpoints;
@@ -72,29 +73,7 @@ public static partial class PublicEndpoints
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
@@ -102,7 +81,21 @@ public static partial class PublicEndpoints
item.SubmittedByTwitchId == submitterId
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
await db.SaveChangesAsync(context.RequestAborted);
try
{
await db.SaveChangesAsync(context.RequestAborted);
}
catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error))
{
db.ChangeTracker.Clear();
ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
isResubmission = true;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
var ballotLink = new
{
label = "Voting-Analytics öffnen",
@@ -142,4 +135,42 @@ public static partial class PublicEndpoints
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
}
private static async Task<VoteBallot> ApplyVoteEntriesAsync(
AwardsDbContext db,
VoteBallot? ballot,
int seasonId,
string submitterId,
VoteEntryRequest[] entries,
CancellationToken cancellationToken)
{
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = seasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot, cancellationToken);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
return ballot;
}
private static bool IsUniqueVoteBallotViolation(DbUpdateException error) =>
error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
}
@@ -9,10 +9,25 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.Where(item => item.Year == year)
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
.FirstOrDefaultAsync();
if (season is null)
{
return Results.NotFound();
}
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
{
return Results.Ok(new WinnerArchiveResponse(year, []));
}
var winnerRows = await db.Results
.AsNoTracking()
.Include(result => result.Candidate)
.Where(result => result.Season.Year == year)
.Where(result => result.SeasonId == season.Id)
.OrderBy(result => result.CategoryName)
.Select(result => new
{
@@ -34,4 +49,10 @@ public static partial class PublicEndpoints
return Results.Ok(new WinnerArchiveResponse(year, items));
}
private static bool CanExposeCurrentSeasonWinners(string currentPhase)
{
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
return phaseKey is "completed";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddVoteBallotSubmitterUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VoteBallots_SeasonId",
table: "VoteBallots");
migrationBuilder.AddColumn<bool>(
name: "TwitchAuthManagedByDatabase",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "TwitchClientId",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TwitchClientSecret",
table: "SiteSettings",
type: "character varying(180)",
maxLength: 180,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TwitchRedirectUri",
table: "SiteSettings",
type: "character varying(400)",
maxLength: 400,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TwitchScope",
table: "SiteSettings",
type: "character varying(300)",
maxLength: 300,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "TwitchAuthManagedByDatabase", "TwitchClientId", "TwitchClientSecret", "TwitchRedirectUri", "TwitchScope" },
values: new object[] { false, "", "", "", "" });
migrationBuilder.Sql("""
DELETE FROM "VoteEntries"
WHERE "BallotId" IN (
SELECT "Id"
FROM (
SELECT
"Id",
ROW_NUMBER() OVER (
PARTITION BY "SeasonId", "SubmittedByTwitchId"
ORDER BY "SubmittedAt" DESC, "Id" DESC
) AS duplicate_rank
FROM "VoteBallots"
) ranked_ballots
WHERE duplicate_rank > 1
);
DELETE FROM "VoteBallots"
WHERE "Id" IN (
SELECT "Id"
FROM (
SELECT
"Id",
ROW_NUMBER() OVER (
PARTITION BY "SeasonId", "SubmittedByTwitchId"
ORDER BY "SubmittedAt" DESC, "Id" DESC
) AS duplicate_rank
FROM "VoteBallots"
) ranked_ballots
WHERE duplicate_rank > 1
);
""");
migrationBuilder.CreateIndex(
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
table: "VoteBallots",
columns: new[] { "SeasonId", "SubmittedByTwitchId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
table: "VoteBallots");
migrationBuilder.DropColumn(
name: "TwitchAuthManagedByDatabase",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchClientId",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchClientSecret",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchRedirectUri",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchScope",
table: "SiteSettings");
migrationBuilder.CreateIndex(
name: "IX_VoteBallots_SeasonId",
table: "VoteBallots",
column: "SeasonId");
}
}
}
@@ -966,6 +966,29 @@ namespace Backend.Migrations
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<bool>("TwitchAuthManagedByDatabase")
.HasColumnType("boolean");
b.Property<string>("TwitchClientId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TwitchClientSecret")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("character varying(180)");
b.Property<string>("TwitchRedirectUri")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("TwitchScope")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.HasKey("Id");
b.ToTable("SiteSettings");
@@ -999,7 +1022,12 @@ namespace Backend.Migrations
RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]",
SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]",
SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.",
SponsorsUrl = "https://vtuber-star-awards.de/partner"
SponsorsUrl = "https://vtuber-star-awards.de/partner",
TwitchAuthManagedByDatabase = false,
TwitchClientId = "",
TwitchClientSecret = "",
TwitchRedirectUri = "",
TwitchScope = ""
});
});
@@ -1011,14 +1039,6 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("BoundTwitchDisplayName")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
@@ -1027,6 +1047,14 @@ namespace Backend.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
@@ -1076,10 +1104,10 @@ namespace Backend.Migrations
b.HasKey("Id");
b.HasIndex("Login")
b.HasIndex("BoundTwitchUserId")
.IsUnique();
b.HasIndex("BoundTwitchUserId")
b.HasIndex("Login")
.IsUnique();
b.ToTable("TeamMembers");
@@ -1197,7 +1225,8 @@ namespace Backend.Migrations
b.HasKey("Id");
b.HasIndex("SeasonId");
b.HasIndex("SeasonId", "SubmittedByTwitchId")
.IsUnique();
b.ToTable("VoteBallots");
+7
View File
@@ -105,6 +105,13 @@ VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin
The frontend route is `/login`. `VTSA_DEMO_ADMIN_LOGIN` may be a username or an email-style identifier; the backend also accepts the configured email, Twitch ID, and display name for admin flexibility. Disable the demo login for release with `VTSA_DEMO_LOGIN_ENABLED=false`.
Team owner bootstrap credentials are separate from demo login credentials:
```text
VTSA_TEAM_OWNER_PASSWORD=<set-secure-owner-password>
VTSA_TEAM_CREATOR_PASSWORD=<set-secure-creator-password>
```
Frontend app-wide demo gate:
```text
+26 -6
View File
@@ -58,7 +58,7 @@ public static class AdminPermissionCatalog
{
AdminRoles.Owner => AllPermissionKeys,
AdminRoles.Creator => AllPermissionKeys,
AdminRoles.Admin => AllPermissionKeys.Where(key => key is not Settings).ToArray(),
AdminRoles.Admin => AllPermissionKeys,
AdminRoles.Member => [Dashboard, Nominations, Categories, Candidates, Clips, Content],
AdminRoles.Reviewer => [Dashboard, Nominations, Clips, Risk, Audit],
AdminRoles.OrganizationTeam => [Dashboard, Content, Analytics, Winners, Settings],
@@ -67,6 +67,30 @@ public static class AdminPermissionCatalog
};
}
public static string[] NormalizePermissionKeys(string? role, IEnumerable<string> permissionKeys)
{
var normalizedRole = AdminRoles.Normalize(role);
var permissions = permissionKeys
.Where(IsKnownPermission)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedRole is AdminRoles.Owner or AdminRoles.Creator)
{
return AllPermissionKeys;
}
if (DefaultPermissionKeys(normalizedRole).Contains(Settings, StringComparer.OrdinalIgnoreCase)
&& !permissions.Contains(Settings, StringComparer.OrdinalIgnoreCase))
{
permissions.Add(Settings);
}
return permissions
.OrderBy(item => item)
.ToArray();
}
public static async Task<string[]> GetPermissionKeysAsync(
AwardsDbContext db,
string? role,
@@ -92,11 +116,7 @@ public static class AdminPermissionCatalog
try
{
return (JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback)
.Where(IsKnownPermission)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
return NormalizePermissionKeys(normalizedRole, JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback);
}
catch (JsonException)
{
+16 -1
View File
@@ -9,6 +9,9 @@ namespace Backend.Services;
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
{
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
{
var token = ReadBearerToken(context);
@@ -23,7 +26,15 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
return null;
}
session.LastSeenAt = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
if (IsExpired(session, now))
{
session.IsActive = false;
await userSessionRepository.SaveChangesAsync(cancellationToken);
return null;
}
session.LastSeenAt = now;
await userSessionRepository.SaveChangesAsync(cancellationToken);
context.SetCurrentSession(session);
return session;
@@ -83,6 +94,10 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
: null;
}
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
{
await userSessionRepository.SaveChangesAsync(cancellationToken);
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="VTuber Star Awards: Community-Awards, Voting, Clip-Einreichungen und Gewinnerarchiv." />
<title>VTuber Star Awards</title>
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

@@ -16,12 +16,14 @@
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<select :value="selectedPlatformValue" class="h-11 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" @change="$emit('platform-selection', $event)">
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
<option value="custom">Eigene Plattform</option>
</select>
<NativeSelect
:model-value="selectedPlatformValue"
:options="[
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
{ label: 'Eigene Plattform', value: 'custom' },
]"
@update:model-value="$emit('platform-selection', String($event))"
/>
</label>
</div>
<label v-if="selectedPlatformValue === 'custom'" class="block space-y-2">
@@ -65,6 +67,6 @@ defineEmits<{
'update:displayName': [value: string]
'update:channelSlug': [value: string]
'update:platform': [value: string]
'platform-selection': [event: Event]
'platform-selection': [value: string]
}>()
</script>
@@ -59,34 +59,23 @@
</div>
</div>
<div v-if="filteredCount > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCount }}</span>
<div class="flex items-center gap-2">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page <= 1"
@click="$emit('update:page', page - 1)"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="$emit('update:page', page + 1)"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="filteredCount"
@update:page="$emit('update:page', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { ChevronLeft, ChevronRight, Pencil, Trash2, UserPlus } from '@lucide/vue'
import { Pencil, Trash2, UserPlus } from '@lucide/vue'
import type { AdminCandidateItem } from '../../types/awards'
import Button from '../ui/Button.vue'
import PaginationFooter from '../ui/PaginationFooter.vue'
const props = defineProps<{
pagedCandidates: AdminCandidateItem[]
@@ -0,0 +1,68 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[120] flex items-center justify-center bg-[#25123f]/70 px-4 py-8 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-[34px] border border-violet-100 bg-white shadow-[0_34px_100px_rgba(38,18,63,0.35)]">
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-r from-[#f8f2ff] via-white to-[#fff1f8] px-7 py-6">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">FAQ Preview</p>
<h3 class="mt-2 text-xl font-bold text-slate-900">Häufige Fragen</h3>
<p class="mt-2 text-sm text-slate-500">{{ visibleFaq.length }} Fragen in der Vorschau</p>
</div>
<button
type="button"
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
aria-label="Preview schließen"
@click="$emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<div v-if="visibleFaq.length" class="grid gap-3">
<article
v-for="(item, index) in visibleFaq"
:key="`${item.question}-${index}`"
class="rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
>
<p class="text-xs font-bold uppercase tracking-[0.18em] text-violet-500">Frage {{ index + 1 }}</p>
<h4 class="mt-2 text-base font-bold leading-6 text-slate-950">{{ item.question }}</h4>
<p class="mt-3 whitespace-pre-line text-sm font-medium leading-7 text-slate-600">{{ item.answer }}</p>
</article>
</div>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Noch keine vollständigen FAQ-Einträge hinterlegt.
</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { X } from '@lucide/vue'
import { computed } from 'vue'
import type { FaqFormItem } from './adminContentTypes'
const props = defineProps<{
open: boolean
faq: FaqFormItem[]
}>()
defineEmits<{
close: []
}>()
const visibleFaq = computed(() =>
props.faq
.map((item) => ({
question: item.question.trim(),
answer: item.answer.trim(),
}))
.filter((item) => item.question && item.answer),
)
</script>
@@ -7,6 +7,10 @@
<p class="mt-2 text-sm leading-6 text-slate-500">Fragen und Antworten erscheinen im FAQ-Abschnitt der Landingpage.</p>
</div>
<div class="flex flex-wrap gap-2 sm:justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview')">
<Eye class="h-4 w-4" />
FAQ Preview
</Button>
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addFaqItem">
<Plus class="h-4 w-4" />
FAQ hinzufügen
@@ -33,7 +37,7 @@
</template>
<script setup lang="ts">
import { Plus, Save, Trash2 } from '@lucide/vue'
import { Eye, Plus, Save, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
@@ -47,6 +51,10 @@ const props = defineProps<{
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': []
}>()
function onSave() {
return props.saveSiteSettings('FAQ')
}
@@ -33,26 +33,23 @@
</Button>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)_300px]">
<label class="space-y-2">
<div class="grid gap-4 lg:grid-cols-2 2xl:grid-cols-[minmax(220px,0.85fr)_minmax(260px,1fr)_minmax(260px,0.8fr)]">
<label class="min-w-0 space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
<input v-model="social.label" type="text" class="h-11 min-w-0 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" />
<input v-model="social.label" type="text" class="h-11 w-full min-w-0 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" />
</label>
<label class="space-y-2">
<label class="min-w-0 space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform auswählen</span>
<select :value="selectedSocialIconValue(social)" class="h-11 min-w-0 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" @change="handleSocialIconSelection($event, index)">
<optgroup v-for="group in SOCIAL_ICON_OPTION_GROUPS" :key="group.label" :label="group.label">
<option v-for="option in group.options" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</optgroup>
<option value="custom">Andere Plattform / eigenes Icon</option>
</select>
<NativeSelect
:model-value="selectedSocialIconValue(social)"
:options="socialIconSelectOptions"
@update:model-value="handleSocialIconSelection(String($event), index)"
/>
<span class="block text-xs text-slate-400">Bekannte Plattformen nutzen automatisch Bibliotheks-Icons. Neue oder eingestellte Plattformen bleiben über Custom-Key plus Upload möglich.</span>
</label>
<div class="space-y-2">
<div class="min-w-0 space-y-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Custom Icon</span>
<label class="group flex h-11 min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
<label class="group flex h-11 w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
<span class="flex min-w-0 items-center gap-2">
<span class="grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-xl bg-violet-100 text-violet-600">
<Upload class="h-4 w-4" />
@@ -62,12 +59,12 @@
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="sr-only" @change="handleSocialIconUpload($event, index)" />
</label>
</div>
<label v-if="selectedSocialIconValue(social) === 'custom'" class="space-y-2 xl:col-span-3">
<label v-if="selectedSocialIconValue(social) === 'custom'" class="min-w-0 space-y-2 lg:col-span-2 2xl:col-span-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Andere Plattform / eigener Key</span>
<input v-model="social.platform" type="text" class="h-11 w-full min-w-0 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="z.B. cake, booth, neue-plattform" />
<span class="block text-xs text-slate-400">Für neue oder nicht mehr gepflegte Plattformen: Key speichern, eigenes Icon hochladen oder Stern-Fallback nutzen.</span>
</label>
<label class="space-y-2 xl:col-span-3">
<label class="min-w-0 space-y-2 lg:col-span-2 2xl:col-span-3">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">URL</span>
<input v-model="social.url" type="url" class="h-11 w-full min-w-0 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="https://..." />
</label>
@@ -117,6 +114,7 @@ import { Plus, Save, Trash2, Upload } from '@lucide/vue'
import { SOCIAL_ICON_OPTION_GROUPS } from '../../lib/socialIcons'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
const props = defineProps<{
@@ -127,7 +125,7 @@ const props = defineProps<{
removeSocialLink: (index: number) => void
isUploadedIcon: (icon: string) => boolean
selectedSocialIconValue: (social: SocialLinkForm) => string
handleSocialIconSelection: (event: Event, index: number) => void
handleSocialIconSelection: (value: string, index: number) => void
hasSocialIconPreview: (social: SocialLinkForm) => boolean
socialIconModeLabel: (social: SocialLinkForm) => string
socialSimpleIconPath: (social: SocialLinkForm) => string
@@ -137,6 +135,17 @@ const props = defineProps<{
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
const socialIconSelectOptions = [
...SOCIAL_ICON_OPTION_GROUPS.flatMap((group) =>
group.options.map((option) => ({
label: option.label,
value: option.key,
group: group.label,
})),
),
{ label: 'Andere Plattform / eigenes Icon', value: 'custom' },
]
function onSave() {
return props.saveSiteSettings('Social Links')
}
@@ -7,6 +7,7 @@ import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
import { useAdminReviewsManager } from './useAdminReviewsManager'
import { watchAdminToast } from '../../composables/useAdminToast'
const props = defineProps<{
open: boolean
@@ -42,6 +43,8 @@ const {
extractNominationStreamUrl,
} = useAdminReviewsManager()
watchAdminToast(adminMessage, adminError)
function closeModal() {
emit('close')
}
@@ -113,16 +116,40 @@ onBeforeUnmount(() => {
/>
<div class="space-y-4 p-4 sm:p-6">
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
<div class="space-y-2">
<!-- Keyboard shortcut legend fixed above scrollable list -->
<div v-if="filteredNominations.length > 0" class="flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-2xl border border-violet-100 bg-violet-50/60 px-3 py-2">
<span class="text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">Tastaturkürzel</span>
<span class="flex items-center gap-1.5">
<span class="inline-flex gap-0.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm"></kbd>
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm"></kbd>
</span>
<span class="text-[11px] text-slate-500">/ </span>
<span class="inline-flex gap-0.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">J</kbd>
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-violet-200 bg-white px-1 text-[10px] font-bold text-violet-700 shadow-sm">K</kbd>
</span>
<span class="text-[11px] text-slate-500">Navigieren</span>
</span>
<span class="flex items-center gap-1.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-emerald-200 bg-emerald-50 px-1.5 text-[10px] font-bold text-emerald-700 shadow-sm">A</kbd>
<span class="text-[11px] text-slate-500">Annehmen</span>
</span>
<span class="flex items-center gap-1.5">
<kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border border-rose-200 bg-rose-50 px-1.5 text-[10px] font-bold text-rose-700 shadow-sm">R</kbd>
<span class="text-[11px] text-slate-500">Ablehnen</span>
</span>
</div>
<AdminReviewsQueueList
:nominations="filteredNominations"
:total-pending="seasonDetail.pendingNominations.length"
:selected-nomination-id="selectedNominationId"
@select="selectedNominationId = $event"
/>
</div>
<AdminReviewDecisionPanel
:nomination="selectedNomination"
@@ -265,10 +265,6 @@ function toneClasses(tone: AdminSettingsTone) {
</section>
</div>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
</Card>
@@ -342,10 +338,6 @@ function toneClasses(tone: AdminSettingsTone) {
Zum Aktivieren fehlt noch ein Client Secret.
</p>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
<template #footer>
@@ -2,6 +2,7 @@
import { CheckCircle2, Trash2 } from '@lucide/vue'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
defineProps<{
@@ -27,7 +28,7 @@ defineProps<{
}>()
const emit = defineEmits<{
'platform-change': [event: Event]
'platform-change': [value: string]
approve: [nominationId: number]
reject: [nominationId: number]
}>()
@@ -98,16 +99,14 @@ const emit = defineEmits<{
</label>
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
<select
:value="selectedPlatformValue(reviewForm.platform)"
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"
@change="emit('platform-change', $event)"
>
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
{{ option.label }}
</option>
<option value="custom">Eigene Plattform</option>
</select>
<NativeSelect
:model-value="selectedPlatformValue(reviewForm.platform)"
:options="[
...candidatePlatformOptions.map((option) => ({ label: option.label, value: option.key })),
{ label: 'Eigene Plattform', value: 'custom' },
]"
@update:model-value="emit('platform-change', String($event))"
/>
</label>
</div>
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
@@ -46,5 +46,6 @@ const emit = defineEmits<{
<p v-else-if="nominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
Keine Review-Fälle passen zum aktuellen Filter.
</p>
</div>
</template>
@@ -1,9 +1,9 @@
<template>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ label }}</span>
<div class="overflow-hidden rounded-[28px] border border-violet-200 bg-[#fcfbff] shadow-inner shadow-violet-100/50 transition focus-within:border-violet-400 focus-within:ring-4 focus-within:ring-violet-100">
<div class="flex flex-wrap items-center gap-2 border-b border-violet-100 bg-white/80 px-3 py-3">
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<div class="rich-editor-field">
<span :id="labelId" class="rich-editor-label">{{ label }}</span>
<div class="rich-editor-shell">
<div class="rich-editor-toolbar">
<div class="rich-editor-group" aria-label="Textstil">
<button type="button" class="rich-editor-tool" title="Fett" aria-label="Fett" @mousedown.prevent @click="runCommand('bold')">
<Bold class="h-4 w-4" />
</button>
@@ -14,7 +14,7 @@
<Underline class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<div class="rich-editor-group" aria-label="Ausrichtung">
<button type="button" class="rich-editor-tool" title="Linksbündig" aria-label="Linksbündig" @mousedown.prevent @click="runCommand('justifyLeft')">
<AlignLeft class="h-4 w-4" />
</button>
@@ -25,7 +25,7 @@
<AlignRight class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<div class="rich-editor-group" aria-label="Listen und Formatierung">
<button type="button" class="rich-editor-tool" title="Aufzählung" aria-label="Aufzählung" @mousedown.prevent @click="runCommand('insertUnorderedList')">
<List class="h-4 w-4" />
</button>
@@ -36,26 +36,35 @@
<Eraser class="h-4 w-4" />
</button>
</div>
<label class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
<Type class="h-4 w-4 text-violet-500" />
<select v-model="selectedFont" class="bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applyFont">
<option v-for="font in fontOptions" :key="font" :value="font">{{ font }}</option>
</select>
<label class="rich-editor-select rich-editor-select--font">
<span class="rich-editor-select-label">
<Type class="h-4 w-4" />
Schrift
</span>
<NativeSelect
v-model="selectedFont"
class="rich-editor-native-select"
:options="fontOptions.map((font) => ({ label: font, value: font }))"
@change="applyFont"
/>
</label>
<label class="rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
Größe
<select v-model="selectedSize" class="ml-2 bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applySize">
<option v-for="size in sizeOptions" :key="size.value" :value="size.value">{{ size.label }}</option>
</select>
<label class="rich-editor-select rich-editor-select--size">
<span class="rich-editor-select-label">Größe</span>
<NativeSelect
v-model="selectedSize"
class="rich-editor-native-select"
:options="sizeOptions"
@change="applySize"
/>
</label>
</div>
<div
ref="editorRef"
class="rich-editor w-full px-5 py-4 text-sm leading-7 text-slate-700 outline-none"
class="rich-editor w-full text-sm leading-7 text-slate-700 outline-none"
:class="[minHeightClass, { 'rich-editor--empty': editorEmpty }]"
contenteditable="true"
role="textbox"
:aria-label="label"
:aria-labelledby="labelId"
:data-placeholder="placeholder"
@blur="handleEditorBlur"
@focus="isFocused = true"
@@ -65,7 +74,7 @@
@paste="handlePaste"
/>
</div>
</label>
</div>
</template>
<script setup lang="ts">
@@ -84,6 +93,7 @@ import {
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { privacyContentToHtml, sanitizePrivacyHtml, stripPrivacyHtml } from '../../lib/privacyContent'
import NativeSelect from '../ui/NativeSelect.vue'
const props = withDefaults(defineProps<{
modelValue: string
@@ -102,6 +112,7 @@ const editorRef = ref<HTMLElement | null>(null)
const isFocused = ref(false)
const selectedFont = ref('Outfit')
const selectedSize = ref('3')
const labelId = `rich-editor-label-${Math.random().toString(36).slice(2)}`
let savedSelection: Range | null = null
const fontOptions = ['Outfit', 'Inter', 'Arial', 'Georgia', 'Times New Roman', 'Verdana']
@@ -215,21 +226,146 @@ function handlePaste(event: ClipboardEvent) {
</script>
<style scoped>
.rich-editor-tool {
.rich-editor-field {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border-radius: 999px;
color: #6d5a86;
transition:
background-color 160ms ease,
color 160ms ease;
gap: 14px;
}
.rich-editor-tool:hover {
background: #f3e8ff;
.rich-editor-label {
display: block;
padding-left: 2px;
color: #64748b;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.18em;
line-height: 1.2;
text-transform: uppercase;
}
.rich-editor-shell {
overflow: hidden;
border: 1px solid #ddd6fe;
border-radius: 28px;
background: #fcfbff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82), 0 18px 48px rgba(139, 108, 219, 0.08);
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.rich-editor-shell:focus-within {
border-color: #9b7ce4;
box-shadow: 0 0 0 4px rgba(139, 108, 219, 0.12), 0 18px 48px rgba(139, 108, 219, 0.12);
}
.rich-editor-toolbar {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 10px;
padding: 18px;
border-bottom: 1px solid #ede9fe;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 247, 255, 0.88) 100%);
}
.rich-editor-group,
.rich-editor-select {
min-height: 52px;
border: 1px solid #e4def8;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 22px rgba(139, 108, 219, 0.08);
}
.rich-editor-group {
display: flex;
align-items: center;
gap: 4px;
padding: 7px;
}
.rich-editor-tool {
display: grid;
height: 36px;
width: 36px;
place-items: center;
border: 1px solid transparent;
border-radius: 13px;
background: transparent;
color: #6d5a86;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.rich-editor-tool:hover,
.rich-editor-tool:focus-visible {
border-color: #d8c9fb;
background: #f4edff;
color: #7c3aed;
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(139, 108, 219, 0.14);
}
.rich-editor-tool:active {
transform: translateY(0);
box-shadow: none;
}
.rich-editor-tool:focus-visible {
outline: none;
}
.rich-editor-select {
display: grid;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px 10px;
}
.rich-editor-select--font {
max-width: 260px;
}
.rich-editor-select--size {
max-width: 170px;
}
.rich-editor-select-label {
display: flex;
align-items: center;
gap: 7px;
color: #64748b;
font-size: 0.68rem;
font-weight: 850;
letter-spacing: 0.16em;
line-height: 1;
text-transform: uppercase;
}
.rich-editor-select-label svg {
color: #8b5cf6;
}
.rich-editor-native-select :deep(.native-select__trigger) {
min-height: 42px;
padding: 6px 7px 6px 12px;
border-radius: 15px;
font-size: 0.9rem;
}
.rich-editor-native-select :deep(.native-select__chevron) {
width: 30px;
height: 30px;
border-radius: 12px;
}
.rich-editor {
padding: 24px 26px 28px;
background: #fcfbff;
}
.rich-editor :deep(p) {
@@ -251,4 +387,21 @@ function handlePaste(event: ClipboardEvent) {
color: #a9a1b8;
pointer-events: none;
}
@media (min-width: 640px) {
.rich-editor-select {
width: auto;
}
}
@media (max-width: 639px) {
.rich-editor-toolbar {
padding: 14px;
}
.rich-editor-group {
width: 100%;
justify-content: space-between;
}
}
</style>
@@ -10,8 +10,6 @@ defineProps<{
totalOpen: number
loadedLabel: string
loading: boolean
message: string
error: string
stats: Array<{ label: string; value: number; tone: string }>
severityFilters: Array<{ key: 'all' | 'high' | 'medium' | 'low'; label: string; count: number }>
}>()
@@ -96,11 +94,5 @@ function statToneClass(tone: string) {
</div>
</div>
<p v-if="message" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{{ message }}
</p>
<p v-if="error" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{{ error }}
</p>
</section>
</template>
@@ -3,6 +3,7 @@ import { Save, SlidersHorizontal } from '@lucide/vue'
import type { AdminRiskRule } from '../../types/awards'
import Button from '../ui/Button.vue'
import NativeSelect from '../ui/NativeSelect.vue'
defineProps<{
rules: AdminRiskRule[]
@@ -76,15 +77,15 @@ const emit = defineEmits<{
<label class="space-y-1">
<span class="text-xs font-semibold text-slate-500">Severity</span>
<select
:value="rule.severity"
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
@change="emit('updateRule', rule.key, { severity: ($event.target as HTMLSelectElement).value })"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<NativeSelect
:model-value="rule.severity"
:options="[
{ label: 'Low', value: 'low' },
{ label: 'Medium', value: 'medium' },
{ label: 'High', value: 'high' },
]"
@update:model-value="emit('updateRule', rule.key, { severity: String($event) })"
/>
</label>
<label class="flex h-10 items-center gap-2 rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700">
@@ -52,9 +52,6 @@
</label>
</div>
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" role="status">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">{{ adminError }}</p>
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-4">
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
<Trash2 class="h-4 w-4" />
@@ -34,8 +34,6 @@ defineProps<{
</Button>
</div>
<p v-if="healthError" class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ healthError }}</p>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider</p>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { useAdminToast } from '../../composables/useAdminToast'
const { toast, dismissAdminToast } = useAdminToast()
function iconForTone(tone: string) {
if (tone === 'success') return CheckCircle2
if (tone === 'error') return AlertTriangle
return Info
}
</script>
<template>
<Teleport to="body">
<div class="pointer-events-none fixed inset-x-0 top-4 z-[1000] flex justify-center px-4 sm:top-5">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="-translate-y-8 scale-95 opacity-0"
enter-to-class="translate-y-0 scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 scale-100 opacity-100"
leave-to-class="-translate-y-6 scale-95 opacity-0"
>
<div
v-if="toast"
:key="toast.id"
class="pointer-events-auto flex w-full max-w-[min(560px,calc(100vw-2rem))] items-center gap-3 rounded-full border bg-white/96 px-4 py-3 shadow-[0_24px_70px_rgba(76,40,160,0.22)] backdrop-blur-xl sm:px-5"
:class="toast.tone === 'success'
? 'border-emerald-200 text-emerald-900'
: toast.tone === 'error'
? 'border-rose-200 text-rose-900'
: 'border-violet-200 text-violet-950'"
:role="toast.tone === 'error' ? 'alert' : 'status'"
aria-live="polite"
>
<span
class="grid h-9 w-9 shrink-0 place-items-center rounded-full"
:class="toast.tone === 'success'
? 'bg-emerald-50 text-emerald-600'
: toast.tone === 'error'
? 'bg-rose-50 text-rose-600'
: 'bg-violet-50 text-violet-700'"
>
<component :is="iconForTone(toast.tone)" class="h-4.5 w-4.5" />
</span>
<p class="min-w-0 flex-1 text-sm font-semibold leading-5 text-slate-800">
{{ toast.message }}
</p>
<button
type="button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
aria-label="Toast schließen"
@click="dismissAdminToast(toast.id)"
>
<X class="h-4 w-4" />
</button>
</div>
</Transition>
</div>
</Teleport>
</template>
@@ -32,6 +32,7 @@ export function useAdminAnalyticsManager() {
reviews,
hasWinner,
votes,
votePct: totalVotes.value > 0 ? Math.round((votes / totalVotes.value) * 100) : 0,
status,
statusClass: candidates === 0
? 'border-rose-100 bg-rose-50 text-rose-700'
@@ -40,20 +41,37 @@ export function useAdminAnalyticsManager() {
: hasWinner
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
: 'border-sky-100 bg-sky-50 text-sky-700',
statusDot: candidates === 0 ? 'bg-rose-400' : reviews > 0 ? 'bg-amber-400' : hasWinner ? 'bg-emerald-400' : 'bg-sky-400',
}
})
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
)
const categoryGroups = computed(() => {
const groups = new Map<string, typeof categoryHealth.value>()
for (const cat of categoryHealth.value) {
const key = cat.groupName || 'Ohne Gruppe'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(cat)
}
return [...groups.entries()].map(([name, cats]) => ({ name, cats }))
})
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
const categoriesReady = computed(() => categoryHealth.value.filter((c) => c.status === 'Bereit' || c.status === 'Gewinner gesetzt'))
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const winnerCoveragePct = computed(() => {
const categoryCount = seasonDetail.value.categories.length
if (categoryCount === 0) return 0
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
})
const readinessPct = computed(() => {
const total = categoryHealth.value.length
if (total === 0) return 0
return Math.round((categoriesReady.value.length / total) * 100)
})
const metricCards = computed(() => [
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
@@ -140,14 +158,37 @@ export function useAdminAnalyticsManager() {
},
])
// Top categories enriched with vote share %
const topCategoriesEnriched = computed(() =>
topCategories.value.map((cat) => ({
...cat,
pct: totalVotes.value > 0 ? Math.round((cat.votes / totalVotes.value) * 100) : 0,
barWidth: totalVotes.value > 0 ? Math.max(2, Math.round((cat.votes / maxVotes.value) * 100)) : 2,
})),
)
// Health counts for status summary
const healthSummary = computed(() => ({
leer: emptyCategories.value.length,
reviewOffen: categoriesWithReviews.value.length,
bereit: categoryHealth.value.filter((c) => c.status === 'Bereit').length,
gewinner: categoryHealth.value.filter((c) => c.status === 'Gewinner gesetzt').length,
total: categoryHealth.value.length,
}))
return {
categoryHealth,
categoryGroups,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
topCategoriesEnriched,
maxVotes,
winnerCoveragePct,
readinessPct,
healthSummary,
totalVotes,
}
}
@@ -121,8 +121,7 @@ export function useAdminCandidateManager() {
modalOpen.value = true
}
function handlePlatformSelection(event: Event) {
const value = (event.target as HTMLSelectElement).value
function handlePlatformSelection(value: string) {
if (value === 'custom') {
if (socialIconOptionForValue(form.platform)) {
form.platform = ''
@@ -17,6 +17,7 @@ export function useAdminClipManager() {
const categoryFilter = ref('all')
const deleting = ref(false)
const statusSaving = ref<number | null>(null)
const bulkSaving = ref(false)
const adminMessage = ref('')
const adminError = ref('')
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
@@ -38,6 +39,23 @@ export function useAdminClipManager() {
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
)
})
const PAGE_SIZE = 10
const page = ref(1)
const sortedClips = computed(() =>
[...clips.value].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
)
const totalPages = computed(() => Math.max(1, Math.ceil(sortedClips.value.length / PAGE_SIZE)))
const pagedClips = computed(() => sortedClips.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
const rangeStart = computed(() => sortedClips.value.length === 0 ? 0 : (page.value - 1) * PAGE_SIZE + 1)
const rangeEnd = computed(() => Math.min(page.value * PAGE_SIZE, sortedClips.value.length))
watch([query, statusFilter, platformFilter, categoryFilter, () => submissions.value.length], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
const clipEmbeds = computed<Record<number, ClipEmbed | null>>(() =>
Object.fromEntries(submissions.value.map((clip) => [clip.id, buildClipEmbed(clip.clipUrl)])),
)
@@ -132,6 +150,25 @@ export function useAdminClipManager() {
}
}
async function bulkUpdateStatus(status: 'approved' | 'rejected') {
if (!selectedSeasonId.value || bulkSaving.value) return
const targets = clips.value
if (targets.length === 0) return
bulkSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.bulkUpdateAdminClipStatus(targets.map((c) => c.id), selectedSeasonId.value, status)
const verb = status === 'approved' ? 'freigegeben' : 'abgelehnt'
adminMessage.value = `${targets.length} Clip${targets.length !== 1 ? 's' : ''} wurden ${verb}.`
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Bulk-Aktion fehlgeschlagen.'
} finally {
bulkSaving.value = false
}
}
function duplicateUrlCount(clipUrl: string) {
return duplicateUrls.value.get(normalizeClipUrl(clipUrl)) ?? 0
}
@@ -154,17 +191,24 @@ export function useAdminClipManager() {
submissions,
categoryName,
clips,
page,
totalPages,
pagedClips,
rangeStart,
rangeEnd,
clipEmbeds,
stats,
statusFilters,
platformFilters,
categoryFilters,
bulkSaving,
platformClass,
statusClass,
statusLabel,
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
bulkUpdateStatus,
confirmDelete,
}
}
@@ -150,8 +150,7 @@ export function useAdminContentManager() {
: 'custom'
}
function handleSocialIconSelection(event: Event, index: number) {
const value = (event.target as HTMLSelectElement).value
function handleSocialIconSelection(value: string, index: number) {
const social = form.socialLinks[index]
if (!social) {
return
@@ -1,4 +1,4 @@
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
@@ -207,6 +207,54 @@ export function useAdminReviewsManager() {
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as Element
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
(target as HTMLElement).isContentEditable
) return
if (event.ctrlKey || event.altKey || event.metaKey) return
const nominations = filteredNominations.value
const currentIndex = nominations.findIndex((n) => n.id === selectedNomination.value?.id)
switch (event.key) {
case 'ArrowDown':
case 'j': {
event.preventDefault()
const next = nominations[Math.min(currentIndex + 1, nominations.length - 1)]
if (next) selectedNominationId.value = next.id
break
}
case 'ArrowUp':
case 'k': {
event.preventDefault()
const prev = nominations[Math.max(currentIndex - 1, 0)]
if (prev) selectedNominationId.value = prev.id
break
}
case 'a': {
if (selectedNomination.value && canApproveSelected.value && !reviewSaving.value) {
event.preventDefault()
void approveNomination(selectedNomination.value.id)
}
break
}
case 'r': {
if (selectedNomination.value && !reviewSaving.value) {
event.preventDefault()
void rejectNomination(selectedNomination.value.id)
}
break
}
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
function selectedPlatformValue(platform: string) {
return socialIconOptionForValue(platform)?.key ?? 'custom'
}
@@ -226,8 +274,8 @@ export function useAdminReviewsManager() {
form.platform = socialIconOptionForValue(platform)?.label ?? platform
}
function handlePlatformSelection(event: Event) {
setPlatform((event.target as HTMLSelectElement).value)
function handlePlatformSelection(value: string) {
setPlatform(value)
}
return {
@@ -1,4 +1,4 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import type { AdminTeamMember, AdminTeamPermission, AdminTeamRole } from '../../types/awards'
@@ -17,9 +17,13 @@ const emptyMemberForm = (): TeamMemberForm => ({
isActive: true,
})
const TEAM_PRESENCE_REFRESH_MS = 30_000
export function useAdminTeamManager() {
const loading = ref(true)
const saving = ref(false)
const refreshingPresence = ref(false)
const lastPresenceRefreshAt = ref<Date | null>(null)
const errorMessage = ref('')
const successMessage = ref('')
const generatedPassword = ref('')
@@ -60,18 +64,34 @@ export function useAdminTeamManager() {
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
}
async function loadTeam() {
loading.value = true
resetMessages()
async function loadTeam(options: { silent?: boolean } = {}) {
const silent = options.silent === true
const showLoading = !silent || members.value.length === 0
if (showLoading) {
loading.value = true
} else {
refreshingPresence.value = true
}
if (!silent) {
resetMessages()
}
try {
applyTeamResponse(await api.getAdminTeam())
lastPresenceRefreshAt.value = new Date()
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
if (!silent) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
}
} finally {
loading.value = false
if (showLoading) {
loading.value = false
}
refreshingPresence.value = false
}
}
@@ -123,7 +143,7 @@ export function useAdminTeamManager() {
displayName: memberForm.displayName,
role: memberForm.role,
})
await loadTeam()
await loadTeam({ silent: true })
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = memberForm.login
successMessage.value = 'Team-Login wurde erstellt. Das temporäre Passwort ist nur jetzt sichtbar.'
@@ -134,7 +154,7 @@ export function useAdminTeamManager() {
role: memberForm.role,
isActive: memberForm.isActive,
})
await loadTeam()
await loadTeam({ silent: true })
successMessage.value = 'Team-Mitglied wurde gespeichert.'
}
return true
@@ -156,7 +176,7 @@ export function useAdminTeamManager() {
try {
const result = await api.resetAdminTeamMemberPassword(member.id)
await loadTeam()
await loadTeam({ silent: true })
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = member.login
successMessage.value = 'Passwort wurde zurückgesetzt. Das Mitglied muss es beim nächsten Login ändern.'
@@ -190,7 +210,7 @@ export function useAdminTeamManager() {
if (editingMemberId.value === member.id) {
startCreateMember()
}
await loadTeam()
await loadTeam({ silent: true })
successMessage.value = 'Team-Mitglied wurde gelöscht.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
@@ -244,11 +264,29 @@ export function useAdminTeamManager() {
}
}
onMounted(loadTeam)
let presenceRefreshTimer: ReturnType<typeof window.setInterval> | null = null
onMounted(() => {
void loadTeam()
presenceRefreshTimer = window.setInterval(() => {
if (!saving.value) {
void loadTeam({ silent: true })
}
}, TEAM_PRESENCE_REFRESH_MS)
})
onBeforeUnmount(() => {
if (presenceRefreshTimer) {
window.clearInterval(presenceRefreshTimer)
presenceRefreshTimer = null
}
})
return {
loading,
saving,
refreshingPresence,
lastPresenceRefreshAt,
errorMessage,
successMessage,
generatedPassword,
@@ -40,6 +40,9 @@ const props = defineProps<{
const activeFooterLink = ref<FooterLink | null>(null)
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
const safeNewsletterUrl = computed(() =>
isSafePublicUrl(props.siteContent.newsletterUrl) ? props.siteContent.newsletterUrl : '',
)
function openFooterLink(event: Event, link: FooterLink) {
if (!link.content.trim()) {
@@ -56,6 +59,17 @@ function openFooterLink(event: Event, link: FooterLink) {
function closeFooterLink() {
activeFooterLink.value = null
}
function isSafePublicUrl(value: string | null | undefined) {
if (!value) return false
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
</script>
<template>
@@ -98,7 +112,7 @@ function closeFooterLink() {
</template>
</a>
</div>
<a :href="props.siteContent.newsletterUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:15px 26px;border-radius:14px;background:#fff;border:1px solid #e3d8f5;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 8px 22px rgba(124,86,196,.1);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>Newsletter abonnieren</a>
<a v-if="safeNewsletterUrl" :href="safeNewsletterUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:15px 26px;border-radius:14px;background:#fff;border:1px solid #e3d8f5;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 8px 22px rgba(124,86,196,.1);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>Newsletter abonnieren</a>
</div>
<img class="home-community-card__image" src="/assets/jayu-hero.png" alt="Jayuhime" style="position:absolute;z-index:1;right:-26px;bottom:0;height:430px;width:auto;pointer-events:none;filter:drop-shadow(0 18px 36px rgba(120,80,180,.22));" />
</div>
@@ -7,7 +7,7 @@ import type { OverviewResponse } from '../../types/awards'
export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewResponse['siteContent']>) {
const siteSocialLinks = computed(() =>
(siteContent.value.socialLinks ?? [])
.filter((social) => social?.url && social.platform)
.filter((social) => isSafePublicUrl(social?.url) && social.platform)
.map((social) => ({
label: social.label || social.platform || 'Social Link',
platform: social.platform || 'link',
@@ -27,7 +27,7 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
)
const footerLinks = computed(() =>
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (link.url || link.content)),
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (isSafePublicUrl(link.url) || link.content)),
)
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
@@ -48,6 +48,17 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
return `#${simpleIconForKey(platform)?.hex ?? '5f44ad'}`
}
function isSafePublicUrl(value: string | null | undefined) {
if (!value) return false
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
return {
hostSocialLinks,
communitySocialLinks,
+4 -4
View File
@@ -5,13 +5,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex items-center justify-center rounded-xl border text-sm font-semibold shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg active:translate-y-0 active:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:translate-y-0 disabled:opacity-50 disabled:shadow-none',
{
variants: {
variant: {
default: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 hover:bg-violet-500',
secondary: 'border border-amber-300/60 bg-white text-amber-600 hover:bg-amber-50',
ghost: 'bg-white/70 text-slate-700 hover:bg-white',
default: 'border-violet-600 bg-violet-600 text-white shadow-violet-500/20 hover:border-violet-500 hover:bg-violet-500 hover:shadow-violet-500/30',
secondary: 'border-amber-300/70 bg-amber-50 text-amber-700 shadow-amber-200/30 hover:border-amber-400 hover:bg-amber-100 hover:text-amber-800 hover:shadow-amber-200/60',
ghost: 'border-violet-200 bg-white text-violet-700 shadow-violet-100/50 hover:border-violet-300 hover:bg-violet-50 hover:text-violet-800 hover:shadow-violet-200/70',
},
size: {
default: 'h-11 px-5',
+13 -4
View File
@@ -1,15 +1,24 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { X } from '@lucide/vue'
const props = defineProps<{
const props = withDefaults(defineProps<{
open: boolean
title?: string
subtitle?: string
}>()
size?: 'md' | 'lg' | 'xl'
}>(), {
size: 'md',
})
const emit = defineEmits<{ (e: 'close'): void }>()
const panelSizeClass = computed(() => ({
md: 'max-w-lg',
lg: 'max-w-3xl',
xl: 'max-w-6xl',
}[props.size]))
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape' && props.open) emit('close')
}
@@ -38,7 +47,7 @@ onBeforeUnmount(() => {
@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="modal-panel relative z-10 w-full overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]" :class="panelSizeClass">
<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>
+367 -20
View File
@@ -1,43 +1,390 @@
<template>
<select
:value="modelValue ?? ''"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
@change="onChange"
>
<option
v-for="option in options"
:key="`${option.value}`"
:value="stringifyValue(option.value)"
<div ref="rootEl" class="native-select" :class="{ 'native-select--open': open, 'native-select--disabled': disabled }">
<select
class="native-select__native"
:value="selectedKey"
:disabled="disabled"
aria-hidden="true"
tabindex="-1"
>
{{ option.label }}
</option>
</select>
<option
v-for="option in options"
:key="optionKey(option.value)"
:value="optionKey(option.value)"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
<button
type="button"
class="native-select__trigger"
:disabled="disabled"
:aria-expanded="open"
aria-haspopup="listbox"
:aria-controls="listboxId"
@click="toggleDropdown"
@keydown="onButtonKeydown"
>
<span class="native-select__value">{{ selectedOption?.label ?? placeholder }}</span>
<span class="native-select__chevron" aria-hidden="true">
<ChevronDown :size="20" :stroke-width="2.8" />
</span>
</button>
<div v-if="open" :id="listboxId" class="native-select__menu" role="listbox">
<template v-for="(option, index) in options" :key="optionKey(option.value)">
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
{{ option.group }}
</p>
<button
type="button"
class="native-select__option"
:class="{
'native-select__option--active': index === activeIndex,
'native-select__option--selected': isSelected(option),
}"
:disabled="option.disabled"
role="option"
:aria-selected="isSelected(option)"
@mouseenter="setActiveIndex(index)"
@click="selectOption(option)"
>
<span class="native-select__option-label">{{ option.label }}</span>
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
type SelectOptionValue = string | number | null
type SelectOption = {
label: string
value: SelectOptionValue
disabled?: boolean
group?: string
}
const props = defineProps<{
const props = withDefaults(defineProps<{
modelValue: SelectOptionValue
options: SelectOption[]
}>()
placeholder?: string
disabled?: boolean
}>(), {
placeholder: 'Auswählen',
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: SelectOptionValue]
change: [value: SelectOptionValue, option: SelectOption]
}>()
function stringifyValue(value: SelectOptionValue) {
return value === null ? '' : String(value)
const rootEl = ref<HTMLElement | null>(null)
const open = ref(false)
const activeIndex = ref(0)
const listboxId = `native-select-${Math.random().toString(36).slice(2)}`
const selectedOption = computed(() =>
props.options.find((option) => isSameValue(option.value, props.modelValue)) ?? null,
)
const selectedKey = computed(() => optionKey(selectedOption.value?.value ?? null))
watch(
() => [props.modelValue, props.options] as const,
() => {
const selectedIndex = props.options.findIndex((option) => isSameValue(option.value, props.modelValue))
activeIndex.value = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex()
},
{ immediate: true },
)
function optionKey(value: SelectOptionValue) {
return value === null ? 'null:' : `${typeof value}:${value}`
}
function onChange(event: Event) {
const rawValue = (event.target as HTMLSelectElement).value
const selectedOption = props.options.find((option) => stringifyValue(option.value) === rawValue)
emit('update:modelValue', selectedOption?.value ?? null)
function isSameValue(left: SelectOptionValue, right: SelectOptionValue) {
return optionKey(left) === optionKey(right)
}
function isSelected(option: SelectOption) {
return isSameValue(option.value, props.modelValue)
}
function firstEnabledIndex() {
return Math.max(0, props.options.findIndex((option) => !option.disabled))
}
function setActiveIndex(index: number) {
if (!props.options[index]?.disabled) {
activeIndex.value = index
}
}
function toggleDropdown() {
if (props.disabled || props.options.length === 0) return
open.value = !open.value
if (open.value && props.options[activeIndex.value]?.disabled) {
activeIndex.value = firstEnabledIndex()
}
}
function closeDropdown() {
open.value = false
}
function selectOption(option: SelectOption) {
if (props.disabled || option.disabled) return
emit('update:modelValue', option.value)
emit('change', option.value, option)
closeDropdown()
}
function selectActiveOption() {
const option = props.options[activeIndex.value]
if (option) selectOption(option)
}
function moveActive(delta: number) {
if (!props.options.length || props.disabled) return
if (!open.value) {
open.value = true
}
let nextIndex = activeIndex.value
for (let step = 0; step < props.options.length; step += 1) {
nextIndex = (nextIndex + delta + props.options.length) % props.options.length
if (!props.options[nextIndex]?.disabled) {
activeIndex.value = nextIndex
return
}
}
}
function onButtonKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveActive(1)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
open.value ? selectActiveOption() : toggleDropdown()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (!rootEl.value?.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
</script>
<style scoped>
.native-select{
position:relative;
width:100%;
min-width:0;
z-index:1;
}
.native-select--open{
z-index:45;
}
.native-select__native{
position:absolute;
width:1px;
height:1px;
opacity:0;
pointer-events:none;
}
.native-select__trigger{
display:flex;
align-items:center;
justify-content:space-between;
gap:14px;
width:100%;
min-height:48px;
padding:8px 9px 8px 15px;
border:2px solid #e8def8;
border-radius:16px;
background:linear-gradient(180deg,#fff 0%,#fbf8ff 100%);
box-shadow:0 8px 22px rgba(139,108,219,.08), inset 0 1px 0 rgba(255,255,255,.9);
color:#1f2337;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:14px;
font-weight:750;
line-height:1.2;
outline:none;
text-align:left;
transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease;
}
.native-select__trigger:hover,
.native-select__trigger:focus-visible,
.native-select--open .native-select__trigger{
border-color:#9b7ce4;
background:#fff;
transform:translateY(-2px);
box-shadow:0 12px 30px rgba(139,108,219,.16), 0 0 0 4px rgba(139,108,219,.12);
}
.native-select__trigger:active{
transform:translateY(0);
}
.native-select__trigger:disabled{
cursor:not-allowed;
border-color:#e5e7eb;
background:#f8fafc;
color:#94a3b8;
box-shadow:none;
}
.native-select__value{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.native-select__chevron{
display:grid;
place-items:center;
flex:none;
width:32px;
height:32px;
border-radius:12px;
background:#f1eafd;
color:#7c5bd2;
box-shadow:inset 0 0 0 1px rgba(139,108,219,.09);
transition:transform .18s ease, background .18s ease, color .18s ease;
}
.native-select--open .native-select__chevron{
transform:rotate(180deg);
background:#835fd8;
color:#fff;
}
.native-select--disabled .native-select__chevron{
background:#eef2f7;
color:#94a3b8;
}
.native-select__menu{
position:absolute;
top:calc(100% + 8px);
left:0;
right:0;
display:grid;
gap:4px;
max-height:286px;
padding:8px;
overflow:auto;
border:1px solid rgba(139,108,219,.18);
border-radius:18px;
background:rgba(255,255,255,.98);
box-shadow:0 24px 54px rgba(63,53,86,.22);
backdrop-filter:blur(14px);
-webkit-backdrop-filter:blur(14px);
}
.native-select__group{
margin:8px 8px 2px;
color:#7f728f;
font-size:10px;
font-weight:800;
letter-spacing:.16em;
line-height:1.2;
text-transform:uppercase;
}
.native-select__group:first-child{
margin-top:2px;
}
.native-select__option{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
width:100%;
min-height:42px;
padding:9px 12px;
border:0;
border-radius:13px;
background:transparent;
color:#514765;
cursor:pointer;
font-family:'Outfit',sans-serif;
font-size:14px;
font-weight:650;
line-height:1.2;
text-align:left;
transition:background .16s ease, color .16s ease, transform .16s ease;
}
.native-select__option:hover,
.native-select__option--active{
background:#f6f0ff;
color:#4f3a8a;
transform:translateY(-1px);
}
.native-select__option--selected{
background:linear-gradient(135deg,#efe6ff,#e7dcfb);
color:#3f2f70;
font-weight:800;
}
.native-select__option:disabled{
cursor:not-allowed;
color:#a8a0b5;
background:#f8fafc;
}
.native-select__option-label{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.native-select__check{
flex:none;
color:#7c5bd2;
}
</style>
@@ -0,0 +1,38 @@
<template>
<div v-if="filteredCount > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
<span><strong class="text-violet-800">{{ rangeStart }}{{ rangeEnd }}</strong> von {{ filteredCount }}</span>
<div class="flex items-center gap-2">
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page <= 1"
@click="$emit('update:page', page - 1)"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
<button
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="page >= totalPages"
@click="$emit('update:page', page + 1)"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/vue'
defineProps<{
page: number
totalPages: number
rangeStart: number
rangeEnd: number
filteredCount: number
}>()
defineEmits<{
'update:page': [page: number]
}>()
</script>
+86
View File
@@ -0,0 +1,86 @@
import { computed, ref, watch, type Ref } from 'vue'
export type AdminToastTone = 'success' | 'error' | 'info'
export interface AdminToast {
id: number
message: string
tone: AdminToastTone
}
const activeToast = ref<AdminToast | null>(null)
let toastId = 0
let hideTimer: number | undefined
export function useAdminToast() {
function dismissAdminToast(id?: number) {
if (id && activeToast.value?.id !== id) return
activeToast.value = null
if (hideTimer !== undefined && typeof window !== 'undefined') {
window.clearTimeout(hideTimer)
hideTimer = undefined
}
}
function showAdminToast(message: string, tone: AdminToastTone = 'info', durationMs = tone === 'error' ? 6200 : 4200) {
const trimmedMessage = message.trim()
if (!trimmedMessage) return
if (hideTimer !== undefined && typeof window !== 'undefined') {
window.clearTimeout(hideTimer)
hideTimer = undefined
}
const id = ++toastId
activeToast.value = { id, message: trimmedMessage, tone }
if (typeof window !== 'undefined') {
hideTimer = window.setTimeout(() => dismissAdminToast(id), durationMs)
}
}
return {
toast: computed(() => activeToast.value),
showAdminToast,
dismissAdminToast,
}
}
export function watchAdminToast(adminMessage: Ref<string>, adminError: Ref<string>) {
const { showAdminToast } = useAdminToast()
watch(
adminMessage,
(message) => {
if (message.trim()) {
showAdminToast(message, 'success')
}
},
{ flush: 'post' },
)
watch(
adminError,
(error) => {
if (error.trim()) {
showAdminToast(error, 'error')
}
},
{ flush: 'post' },
)
}
export function watchAdminErrorToast(adminError: Ref<string>) {
const { showAdminToast } = useAdminToast()
watch(
adminError,
(error) => {
if (error.trim()) {
showAdminToast(error, 'error')
}
},
{ flush: 'post' },
)
}
-6
View File
@@ -1,11 +1,9 @@
import type {
AuthSession,
BindTeamTwitchPayload,
ChangePasswordPayload,
DemoLoginPayload,
LoginPayload,
TeamLoginPayload,
TeamTwitchLoginPayload,
TwitchAuthorizePayload,
TwitchAuthorizeResponse,
} from '../../types/awards'
@@ -20,12 +18,8 @@ export const authApi = {
requestJson<AuthSession>('/api/auth/demo-login', jsonRequest('POST', payload)),
teamLogin: (payload: TeamLoginPayload) =>
requestJson<AuthSession>('/api/auth/team-login', jsonRequest('POST', payload)),
teamTwitchLogin: (payload: TeamTwitchLoginPayload) =>
requestJson<AuthSession>('/api/auth/team-twitch-login', jsonRequest('POST', payload)),
changePassword: (payload: ChangePasswordPayload) =>
requestJson<AuthSession>('/api/auth/password/change', jsonRequest('POST', payload)),
bindTeamTwitch: (payload: BindTeamTwitchPayload) =>
requestJson<AuthSession>('/api/auth/team/twitch-binding', jsonRequest('POST', payload)),
startTwitchAuthorization: (payload: TwitchAuthorizePayload) =>
requestJson<TwitchAuthorizeResponse>('/api/auth/twitch/authorize', jsonRequest('POST', payload)),
logout: () =>
+6
View File
@@ -33,6 +33,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminCategoriesView.vue'),
meta: { keepAlive: true },
},
{
path: 'categories_v2',
name: 'admin-categories-v2',
component: () => import('../views/admin/AdminCategoriesViewV2.vue'),
meta: { keepAlive: true },
},
{
path: 'candidates',
name: 'admin-candidates',
-22
View File
@@ -4,12 +4,10 @@ import { AUTH_TOKEN_KEY, api } from '../lib/api'
import { ApiRequestError } from '../lib/http'
import type {
AuthSession,
BindTeamTwitchPayload,
ChangePasswordPayload,
DemoLoginPayload,
LoginPayload,
TeamLoginPayload,
TeamTwitchLoginPayload,
TwitchAuthorizePayload,
} from '../types/awards'
@@ -112,16 +110,6 @@ export const useAuthStore = defineStore('auth', {
this.loading = false
}
},
async teamTwitchLogin(payload: TeamTwitchLoginPayload) {
this.loading = true
try {
const session = await api.teamTwitchLogin(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async changePassword(payload: ChangePasswordPayload) {
this.loading = true
try {
@@ -132,16 +120,6 @@ export const useAuthStore = defineStore('auth', {
this.loading = false
}
},
async bindTeamTwitch(payload: BindTeamTwitchPayload) {
this.loading = true
try {
const session = await api.bindTeamTwitch(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async startTwitchAuthorization(payload: Omit<TwitchAuthorizePayload, 'frontendOrigin'>) {
this.loading = true
try {
+8
View File
@@ -264,6 +264,10 @@ export const useAwardsStore = defineStore('awards', {
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async bulkUpdateAdminClipStatus(clipIds: number[], seasonId: number, status: 'approved' | 'rejected', reviewNote?: string) {
await Promise.allSettled(clipIds.map((id) => api.updateAdminClipStatus(id, { status, reviewNote })))
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
},
async setAdminResult(seasonId: number, payload: SetAwardResultPayload) {
const result = await api.setAdminResult(seasonId, payload)
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true, reloadHome: true })
@@ -284,6 +288,10 @@ export const useAwardsStore = defineStore('awards', {
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
return result
},
async bulkRejectAdminNominations(nominationIds: number[], seasonId: number, reviewNote?: string) {
await Promise.allSettled(nominationIds.map((id) => api.rejectAdminNomination(id, { reviewNote })))
await this.refreshAdminSeasonWorkspace(seasonId, { reloadAdmin: true })
},
async resolveRiskFlag(riskFlagId: number, payload: ResolveRiskFlagPayload) {
const result = await api.resolveRiskFlag(riskFlagId, payload)
await Promise.all([
+48
View File
@@ -30,6 +30,54 @@ body {
color: #3f3556;
}
.admin-panel :is(input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="range"]):not([type="color"]), textarea) {
border-color: #e8def8;
background: linear-gradient(180deg, #fff 0%, #fbf8ff 100%);
box-shadow: 0 8px 22px rgba(139, 108, 219, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9);
color: #1f2337;
font-family: var(--font-sans);
font-weight: 650;
}
.admin-panel :is(input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="range"]):not([type="color"]), textarea):focus {
border-color: #9b7ce4;
background: #fff;
box-shadow: 0 12px 30px rgba(139, 108, 219, 0.16), 0 0 0 4px rgba(139, 108, 219, 0.12);
}
.admin-panel :is(input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="range"]):not([type="color"]), textarea)::placeholder {
color: #9b91aa;
font-weight: 600;
}
.admin-panel :is(input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]):not([type="range"]):not([type="color"]), textarea):disabled {
border-color: #e5e7eb;
background: #f8fafc;
box-shadow: none;
color: #94a3b8;
}
.admin-panel button:is(.border, [class*="border-"]):not(:disabled) {
transition-property: transform, box-shadow, background-color, border-color, color, opacity;
transition-duration: 180ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.admin-panel button:is(.border, [class*="border-"]):not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(139, 108, 219, 0.18);
}
.admin-panel button:is(.border, [class*="border-"]):not(:disabled):active {
transform: translateY(0);
box-shadow: 0 8px 18px rgba(139, 108, 219, 0.12);
}
.admin-panel button:is(.border, [class*="border-"]):focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(139, 108, 219, 0.16), 0 14px 32px rgba(139, 108, 219, 0.18);
}
a {
color: inherit;
}
+2
View File
@@ -287,6 +287,8 @@ export interface AdminTeamMember {
createdAt: string
updatedAt: string | null
lastLoginAt: string | null
lastOnlineAt: string | null
isOnline: boolean
twitchBoundAt: string | null
passwordResetAt: string | null
}
-10
View File
@@ -28,21 +28,11 @@ export interface TeamLoginPayload {
password: string
}
export interface TeamTwitchLoginPayload {
twitchUserId: string
displayName?: string
}
export interface ChangePasswordPayload {
currentPassword: string
newPassword: string
}
export interface BindTeamTwitchPayload {
twitchUserId: string
displayName?: string
}
export type TwitchAuthorizePurpose = 'team-login' | 'team-binding'
export interface TwitchAuthorizePayload {
+233 -85
View File
@@ -1,22 +1,47 @@
<script setup lang="ts">
import { BarChart3, CheckCircle2 } from '@lucide/vue'
import { computed, ref } from 'vue'
import { BarChart3, CheckCircle2, ExternalLink, TrendingUp } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminAnalyticsManager } from '../../components/admin/useAdminAnalyticsManager'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
const {
categoryHealth,
categoryGroups,
metricCards,
readinessCards,
insightCards,
attentionItems,
topCategories,
maxVotes,
topCategoriesEnriched,
winnerCoveragePct,
readinessPct,
healthSummary,
totalVotes,
} = useAdminAnalyticsManager()
const healthFilter = ref<string>('Alle')
const groupFilter = ref<string>('Alle')
const groupOptions = computed(() => ['Alle', ...categoryGroups.value.map((g) => g.name)])
const filteredHealth = computed(() => {
let list = categoryHealth.value
if (healthFilter.value !== 'Alle') list = list.filter((c) => c.status === healthFilter.value)
if (groupFilter.value !== 'Alle') list = list.filter((c) => (c.groupName || 'Ohne Gruppe') === groupFilter.value)
return list
})
const healthStatusOptions = [
{ key: 'Alle', label: 'Alle' },
{ key: 'Leer', label: 'Leer', dot: 'bg-rose-400' },
{ key: 'Review offen', label: 'Review offen', dot: 'bg-amber-400' },
{ key: 'Bereit', label: 'Bereit', dot: 'bg-sky-400' },
{ key: 'Gewinner gesetzt', label: 'Gewinner', dot: 'bg-emerald-400' },
]
</script>
<template>
@@ -29,6 +54,7 @@ const {
<AdminSeasonToolbar />
<!-- Metric cards -->
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card v-for="metric in metricCards" :key="metric.label" class="p-5">
<div class="flex items-start justify-between gap-4">
@@ -44,15 +70,46 @@ const {
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<!-- Readiness + Attention row -->
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<!-- Readiness -->
<Card class="p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Finale Vollständigkeit</h2>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm font-bold text-violet-800">
{{ winnerCoveragePct }}% Gewinner
<div class="text-right">
<div class="text-2xl font-black text-violet-700">{{ winnerCoveragePct }}%</div>
<div class="text-xs font-semibold text-slate-500">Gewinner</div>
</div>
</div>
<!-- Progress bar -->
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between text-xs font-semibold text-slate-500">
<span>Gesamt-Readiness</span><span>{{ readinessPct }}%</span>
</div>
<div class="h-2.5 overflow-hidden rounded-full bg-slate-100">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#22c55e)] transition-all duration-500"
:style="{ width: `${readinessPct}%` }"
/>
</div>
<!-- Health segments legend -->
<div class="flex flex-wrap gap-x-4 gap-y-1 pt-1">
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-rose-600">
<span class="h-2 w-2 rounded-full bg-rose-400" />{{ healthSummary.leer }} Leer
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-amber-600">
<span class="h-2 w-2 rounded-full bg-amber-400" />{{ healthSummary.reviewOffen }} Review offen
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-sky-600">
<span class="h-2 w-2 rounded-full bg-sky-400" />{{ healthSummary.bereit }} Bereit
</span>
<span class="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600">
<span class="h-2 w-2 rounded-full bg-emerald-400" />{{ healthSummary.gewinner }} Gewinner
</span>
</div>
</div>
@@ -75,93 +132,184 @@ const {
</div>
</Card>
<Card class="p-6">
<div>
<!-- Attention + Insights -->
<div class="space-y-6">
<!-- Attention items -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Aufmerksamkeit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Was Admins zuerst prüfen sollten</h2>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-3">
<RouterLink
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
<div class="mt-6 grid gap-3 md:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
</div>
</div>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kandidaten, Reviews und Gewinnerstatus</h2>
</div>
<div class="max-h-[520px] overflow-y-auto p-4">
<div class="grid gap-3">
<h2 class="mt-1 text-xl font-bold text-slate-900">Was zuerst prüfen</h2>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<RouterLink
v-for="category in categoryHealth"
:key="category.id"
to="/admin/categories"
class="grid gap-3 rounded-[22px] border border-violet-100 bg-white p-4 transition hover:bg-violet-50/60 md:grid-cols-[minmax(0,1fr)_110px_110px_130px]"
v-for="item in attentionItems"
:key="item.key"
:to="item.to"
class="rounded-[22px] border p-4 transition hover:translate-y-[-1px]"
:class="item.tone"
>
<span class="min-w-0">
<strong class="block truncate text-slate-950">{{ category.name }}</strong>
<span class="mt-1 block text-sm text-slate-500">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
Stimmen
</span>
<span class="inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
{{ category.status }}
</span>
<strong class="block text-3xl leading-none">{{ item.value }}</strong>
<span class="mt-3 block text-sm font-bold">{{ item.label }}</span>
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
</RouterLink>
</div>
</div>
</Card>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Top Kategorien</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Stimmenverteilung</h2>
<div class="mt-5 space-y-4">
<div v-for="category in topCategories.slice(0, 8)" :key="category.category">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="truncate font-semibold text-slate-800">{{ category.category }}</span>
<span class="shrink-0 text-slate-500">{{ category.votes.toLocaleString('de-DE') }}</span>
</div>
<div class="mt-2 h-2 rounded-full bg-violet-100">
<div class="h-full rounded-full bg-violet-600" :style="{ width: `${Math.max(4, Math.round((category.votes / maxVotes) * 100))}%` }"></div>
<!-- Insight cards -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Einblicke</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kennzahlen</h2>
<div class="mt-5 grid gap-3 sm:grid-cols-3">
<div v-for="insight in insightCards" :key="insight.label" class="rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
<component :is="insight.icon" class="h-5 w-5 text-violet-600" />
<p class="mt-3 text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{{ insight.label }}</p>
<strong class="mt-1 block text-2xl text-slate-950">{{ insight.value }}</strong>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ insight.note }}</p>
</div>
</div>
<div v-if="topCategories.length === 0" class="rounded-[22px] border border-dashed border-violet-100 p-5 text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</div>
<div class="mt-6 flex items-center gap-2 rounded-[22px] border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-800">
<CheckCircle2 class="h-5 w-5 shrink-0" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
</div>
</Card>
</Card>
</div>
</section>
<!-- Vote distribution bar chart (improved) -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] p-5">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Stimmenverteilung</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Votes</h2>
</div>
<div class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-4 py-2.5">
<TrendingUp class="h-4 w-4 text-violet-600" />
<span class="text-sm font-bold text-violet-800">{{ totalVotes.toLocaleString('de-DE') }} Gesamt</span>
</div>
</div>
</div>
<div v-if="topCategoriesEnriched.length > 0" class="divide-y divide-violet-50">
<div
v-for="(cat, index) in topCategoriesEnriched"
:key="cat.category"
class="group/bar px-6 py-4 transition hover:bg-violet-50/40"
>
<div class="flex items-center gap-4">
<!-- Rank -->
<span class="w-6 shrink-0 text-center text-xs font-black text-slate-400">{{ index + 1 }}</span>
<!-- Name + bar -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-4">
<span class="truncate text-sm font-semibold text-slate-800">{{ cat.category }}</span>
<div class="flex shrink-0 items-center gap-3">
<!-- Pct badge -->
<span class="min-w-[44px] text-right text-xs font-bold text-violet-600 opacity-0 transition group-hover/bar:opacity-100">
{{ cat.pct }}%
</span>
<!-- Vote count -->
<span class="min-w-[56px] text-right text-sm font-semibold text-slate-600">
{{ cat.votes.toLocaleString('de-DE') }}
</span>
</div>
</div>
<div class="relative mt-2 h-2.5 overflow-hidden rounded-full bg-violet-100">
<div
class="h-full rounded-full bg-[linear-gradient(90deg,#8b5cf6,#a78bfa)] transition-all duration-500"
:style="{ width: `${cat.barWidth}%` }"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="px-6 py-12 text-center text-sm text-slate-500">
Noch keine Stimmenverteilung vorhanden.
</div>
</Card>
<!-- Category health -->
<Card class="overflow-hidden">
<div class="border-b border-violet-100 p-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Kategoriegesundheit</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Status aller Kategorien</h2>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2">
<NativeSelect
v-model="groupFilter"
class="w-44"
:options="groupOptions.map((group) => ({ label: group, value: group }))"
/>
<div class="flex flex-wrap gap-1.5">
<button
v-for="opt in healthStatusOptions"
:key="opt.key"
type="button"
class="flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold transition"
:class="healthFilter === opt.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="healthFilter = opt.key"
>
<span v-if="opt.dot" class="h-2 w-2 rounded-full" :class="opt.dot" />
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<div v-if="filteredHealth.length > 0" class="divide-y divide-violet-50">
<RouterLink
v-for="category in filteredHealth"
:key="category.id"
to="/admin/categories"
class="grid items-center gap-3 px-5 py-3.5 transition hover:bg-violet-50/50 sm:grid-cols-[minmax(0,1fr)_80px_80px_100px_140px]"
>
<!-- Name + group -->
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="h-2 w-2 shrink-0 rounded-full" :class="category.statusDot" />
<strong class="truncate text-sm text-slate-900">{{ category.name }}</strong>
</span>
<span class="mt-0.5 block truncate text-xs text-slate-500 pl-4">{{ category.groupName || 'Ohne Gruppe' }}</span>
</span>
<!-- Candidates -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.candidates }}</strong>
Kandidaten
</span>
<!-- Reviews -->
<span class="text-sm" :class="category.reviews > 0 ? 'text-amber-600 font-semibold' : 'text-slate-500'">
<strong class="block" :class="category.reviews > 0 ? 'text-amber-700' : 'text-slate-900'">{{ category.reviews }}</strong>
Reviews
</span>
<!-- Votes + pct -->
<span class="text-sm text-slate-500">
<strong class="block text-slate-900">{{ category.votes.toLocaleString('de-DE') }}</strong>
<span class="text-violet-600 font-semibold">{{ category.votePct }}%</span>
</span>
<!-- Status badge -->
<span class="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
<CheckCircle2 v-if="category.status === 'Gewinner gesetzt'" class="h-3 w-3" />
{{ category.status }}
</span>
</RouterLink>
</div>
<div v-else class="px-5 py-12 text-center text-sm text-slate-500">
Keine Kategorien für diesen Filter.
</div>
<div class="flex items-center gap-2 border-t border-violet-100 bg-emerald-50/60 px-5 py-3 text-sm text-emerald-800">
<CheckCircle2 class="h-4 w-4 shrink-0 text-emerald-500" />
Analytics nutzt dieselben Admin-Daten wie Dashboard, Jahre und Kategorien.
<RouterLink to="/admin/categories" class="ml-auto flex items-center gap-1 text-xs font-semibold text-violet-600 hover:text-violet-800">
Kategorien bearbeiten <ExternalLink class="h-3 w-3" />
</RouterLink>
</div>
</Card>
</div>
</template>
@@ -8,6 +8,7 @@ import AdminCandidatesTable from '../../components/admin/AdminCandidatesTable.vu
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCandidateManager } from '../../components/admin/useAdminCandidateManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Card from '../../components/ui/Card.vue'
const {
@@ -43,6 +44,8 @@ const {
saveModal,
confirmDelete,
} = useAdminCandidateManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -95,9 +98,6 @@ const {
@open-create="openCreate"
/>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<AdminCandidatesTable
:paged-candidates="pagedCandidates"
:total-count="seasonDetail.candidates.length"
@@ -4,6 +4,7 @@ import { Layers3, PlusCircle, Search, Tags, Trash2, TriangleAlert } from '@lucid
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
@@ -29,6 +30,8 @@ const {
fillNewSlug,
confirmDeleteCategory,
} = useAdminCategoryManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -108,9 +111,6 @@ const {
</div>
</div>
<p v-if="adminMessage" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
@@ -0,0 +1,462 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
ChevronRight,
ExternalLink,
Layers3,
PlusCircle,
Search,
Tags,
Trash2,
TriangleAlert,
Users,
X,
} from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminCategoryManager } from '../../components/admin/useAdminCategoryManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
const {
selectedSeasonId,
query,
statusFilter,
selectedCategoryId,
saving,
adminMessage,
adminError,
editForms,
newCategoryForm,
filteredCategories,
selectedCategory,
categoryStats,
statusFilters,
categoryToDelete,
deleting,
saveCategory,
createCategory,
fillNewSlug,
confirmDeleteCategory,
} = useAdminCategoryManager()
watchAdminToast(adminMessage, adminError)
// ---------- "Neu" panel toggle ----------
const showCreatePanel = ref(false)
// ---------- Grouped list ----------
const groupedCategories = computed(() => {
const groups = new Map<string, typeof filteredCategories.value>()
for (const cat of filteredCategories.value) {
const key = cat.groupName || 'Ohne Gruppe'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(cat)
}
return [...groups.entries()].map(([name, cats]) => ({ name, cats }))
})
// ---------- Stats für aktuell gewählte Kategorie ----------
const selectedCandidateCount = computed(() =>
selectedCategory.value
? filteredCategories.value.find((c) => c.id === selectedCategory.value!.id)?.candidates ?? 0
: 0,
)
const selectedPendingCount = computed(() =>
selectedCategory.value
? filteredCategories.value.find((c) => c.id === selectedCategory.value!.id)?.pending ?? 0
: 0,
)
// ---------- Top-level stats ----------
const topStats = computed(() => [
{
label: 'Kategorien',
value: categoryStats.value.find((s) => s.label === 'Kategorien')?.value ?? 0,
tone: 'text-violet-700 bg-violet-50 border-violet-100',
},
{
label: 'Kandidaten',
value: categoryStats.value.find((s) => s.label === 'Kandidaten')?.value ?? 0,
tone: 'text-cyan-700 bg-cyan-50 border-cyan-100',
},
{
label: 'Offene Reviews',
value: categoryStats.value.find((s) => s.label === 'Reviews')?.value ?? 0,
tone: 'text-amber-700 bg-amber-50 border-amber-100',
},
{
label: 'Leer',
value: statusFilters.value.find((f) => f.key === 'empty')?.count ?? 0,
tone: 'text-rose-700 bg-rose-50 border-rose-100',
},
])
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Kategorien"
description="Kategorien verwalten, Gruppen pflegen und Kandidatenbasis prüfen."
:icon="Tags"
/>
<AdminSeasonToolbar />
<!-- Top stats -->
<section class="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Card v-for="stat in topStats" :key="stat.label" class="flex items-center gap-4 p-5">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl border text-sm" :class="stat.tone">
<span class="text-lg font-black">{{ stat.value }}</span>
</span>
<span class="text-sm font-semibold text-slate-600">{{ stat.label }}</span>
</Card>
</section>
<section class="grid gap-6 xl:grid-cols-[minmax(340px,0.82fr)_minmax(0,1.18fr)]">
<!-- Linke Spalte: Gruppierte Liste -->
<div class="flex flex-col gap-3">
<Card class="overflow-hidden">
<!-- Search + Filter -->
<div class="space-y-3 border-b border-violet-100 p-4">
<label class="relative block">
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
<input
v-model="query"
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
placeholder="Kategorie, Gruppe oder Slug"
/>
</label>
<div class="flex flex-wrap gap-1.5">
<button
v-for="filter in statusFilters"
:key="filter.key"
type="button"
class="rounded-full border px-3 py-1 text-xs font-semibold transition"
:class="statusFilter === filter.key
? 'border-violet-200 bg-violet-100 text-violet-800'
: 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
@click="statusFilter = filter.key"
>
{{ filter.label }} · {{ filter.count }}
</button>
</div>
</div>
<!-- Grouped category list -->
<div class="max-h-[640px] overflow-y-auto">
<template v-if="groupedCategories.length > 0">
<div v-for="group in groupedCategories" :key="group.name">
<!-- Group header -->
<div class="sticky top-0 z-10 flex items-center gap-2 border-b border-violet-50 bg-violet-50/90 px-4 py-2 backdrop-blur-sm">
<Layers3 class="h-3.5 w-3.5 text-violet-400" />
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-500">{{ group.name }}</span>
<span class="ml-auto rounded-full border border-violet-100 bg-white px-2 py-0.5 text-[10px] font-semibold text-violet-600">{{ group.cats.length }}</span>
</div>
<!-- Categories in group -->
<div class="divide-y divide-violet-50/80">
<button
v-for="cat in group.cats"
:key="cat.id"
type="button"
class="group w-full px-4 py-3 text-left transition"
:class="selectedCategory?.id === cat.id
? 'bg-[linear-gradient(135deg,#ece2ff30,#fff2dd20)] outline-none ring-1 ring-inset ring-violet-200'
: 'hover:bg-violet-50/50'"
@click="selectedCategoryId = cat.id; showCreatePanel = false"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-semibold text-slate-900">{{ cat.name }}</p>
<ChevronRight
class="h-3.5 w-3.5 shrink-0 text-violet-400 opacity-0 transition group-hover:opacity-100"
:class="selectedCategory?.id === cat.id ? 'opacity-100' : ''"
/>
</div>
<p class="mt-0.5 truncate text-xs text-slate-400">/{{ cat.slug }}</p>
<!-- Badges -->
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="cat.candidates > 0 ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'"
>
{{ cat.candidates }} Kandidaten
</span>
<span v-if="cat.pending > 0" class="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
{{ cat.pending }} Reviews
</span>
<span class="rounded-full bg-slate-50 px-2 py-0.5 text-[10px] font-semibold text-slate-500">
Limit {{ cat.maxNomineesPerUser }}
</span>
</div>
</div>
<span class="mt-0.5 shrink-0 rounded-xl border border-violet-100 bg-white px-2 py-0.5 text-[11px] font-bold text-violet-600">
#{{ cat.sortOrder }}
</span>
</div>
</button>
</div>
</div>
</template>
<p v-else class="px-5 py-10 text-center text-sm text-slate-500">
Keine Kategorien passen zu diesem Filter.
</p>
</div>
<!-- Add button -->
<div class="border-t border-violet-100 p-3">
<button
type="button"
class="flex w-full items-center justify-center gap-2 rounded-2xl border py-2.5 text-sm font-semibold transition"
:class="showCreatePanel
? 'border-violet-200 bg-violet-100 text-violet-800'
: 'border-violet-100 bg-white text-violet-700 hover:bg-violet-50'"
@click="showCreatePanel = !showCreatePanel; selectedCategoryId = null"
>
<component :is="showCreatePanel ? X : PlusCircle" class="h-4 w-4" />
{{ showCreatePanel ? 'Abbrechen' : 'Kategorie anlegen' }}
</button>
</div>
</Card>
</div>
<!-- Rechte Spalte: Edit / Create -->
<div class="space-y-0">
<!-- Create panel -->
<Card v-if="showCreatePanel" class="p-6">
<div class="flex items-start gap-4">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<PlusCircle class="h-5 w-5" />
</span>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Neue Kategorie</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Kategorie anlegen</h2>
</div>
</div>
<div class="mt-5 grid gap-4 sm:grid-cols-2">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
<input
v-model="newCategoryForm.groupName"
placeholder="z. B. Streaming"
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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="newCategoryForm.name"
placeholder="Bester Streamer"
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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<div class="flex gap-2">
<input
v-model="newCategoryForm.slug"
placeholder="bester-streamer"
class="h-12 min-w-0 flex-1 rounded-2xl border border-violet-200 px-4 text-sm font-mono outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
<button
type="button"
class="h-12 rounded-2xl border border-violet-100 bg-violet-50 px-4 text-xs font-semibold text-violet-700 transition hover:bg-violet-100"
@click="fillNewSlug"
>
Auto
</button>
</div>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
v-model.number="newCategoryForm.sortOrder"
type="number"
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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<input
v-model.number="newCategoryForm.maxNomineesPerUser"
type="number"
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"
/>
</label>
</div>
</div>
<label class="mt-4 block space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="newCategoryForm.description"
rows="3"
placeholder="Kurze Beschreibung für Team und Kandidaten-Seite"
class="w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="mt-5 flex justify-end gap-3">
<Button variant="ghost" @click="showCreatePanel = false">Abbrechen</Button>
<Button :disabled="saving === 'new' || !selectedSeasonId" @click="createCategory">
{{ saving === 'new' ? 'Erstellt …' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
<!-- Edit panel -->
<Card v-else-if="selectedCategory && editForms[selectedCategory.id]" class="overflow-hidden">
<!-- Header -->
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">
{{ editForms[selectedCategory.id].groupName || 'Ohne Gruppe' }}
</p>
<h2 class="mt-1 truncate text-xl font-bold text-slate-900">{{ selectedCategory.name }}</h2>
<p class="mt-1 font-mono text-sm text-slate-400">/{{ selectedCategory.slug }}</p>
</div>
<span class="shrink-0 rounded-2xl border border-violet-200 bg-white px-3 py-2 text-sm font-bold text-violet-700">
#{{ selectedCategory.sortOrder }}
</span>
</div>
<!-- Quick stats row -->
<div class="mt-4 flex flex-wrap gap-3">
<RouterLink
to="/admin/candidates"
class="flex items-center gap-1.5 rounded-full border border-violet-100 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:border-violet-200 hover:bg-violet-50"
>
<Users class="h-3.5 w-3.5 text-violet-500" />
{{ selectedCandidateCount }} Kandidaten
<ExternalLink class="h-3 w-3 text-slate-400" />
</RouterLink>
<span
v-if="selectedPendingCount > 0"
class="flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-700"
>
{{ selectedPendingCount }} Reviews offen
</span>
<span
v-if="selectedCandidateCount === 0"
class="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-600"
>
Keine Kandidaten
</span>
</div>
</div>
<!-- Form body -->
<div class="p-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Gruppe</span>
<input
v-model="editForms[selectedCategory.id].groupName"
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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="editForms[selectedCategory.id].name"
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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Slug</span>
<input
v-model="editForms[selectedCategory.id].slug"
class="h-12 w-full rounded-2xl border border-violet-200 px-4 font-mono text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Reihenfolge</span>
<input
v-model.number="editForms[selectedCategory.id].sortOrder"
type="number"
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"
/>
</label>
<label class="space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Limit</span>
<input
v-model.number="editForms[selectedCategory.id].maxNomineesPerUser"
type="number"
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"
/>
</label>
</div>
</div>
<label class="mt-4 block space-y-1.5">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Beschreibung</span>
<textarea
v-model="editForms[selectedCategory.id].description"
rows="3"
class="w-full rounded-2xl border border-violet-200 px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
/>
</label>
</div>
<!-- Actions -->
<div class="flex items-center justify-between gap-3 border-t border-violet-100 px-6 py-4">
<Button
variant="ghost"
class="gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100"
@click="categoryToDelete = selectedCategory"
>
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button :disabled="saving === selectedCategory.id" @click="saveCategory(selectedCategory.id)">
{{ saving === selectedCategory.id ? 'Speichert …' : 'Speichern' }}
</Button>
</div>
</Card>
<!-- Empty state when nothing selected -->
<Card v-else class="flex flex-col items-center justify-center gap-4 py-20 text-center">
<span class="grid h-14 w-14 place-items-center rounded-full bg-violet-100 text-violet-400">
<Tags class="h-7 w-7" />
</span>
<div>
<p class="font-semibold text-slate-700">Kategorie auswählen</p>
<p class="mt-1 text-sm text-slate-500">Links eine Kategorie anklicken oder eine neue anlegen.</p>
</div>
<Button class="gap-2 mt-2" @click="showCreatePanel = true; selectedCategoryId = null">
<PlusCircle class="h-4 w-4" /> Neue Kategorie
</Button>
</Card>
</div>
</section>
<!-- Delete modal -->
<Modal :open="!!categoryToDelete" title="Kategorie löschen?" @close="categoryToDelete = null">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
<TriangleAlert class="h-6 w-6" />
</span>
<p class="text-sm leading-7 text-slate-600">
<strong class="text-slate-800">{{ categoryToDelete?.name }}</strong>" und alle zugehörigen Kandidaten werden
aus diesem Award-Jahr entfernt. Das lässt sich nicht rückgängig machen.
</p>
</div>
<template #footer>
<Button variant="ghost" @click="categoryToDelete = null">Abbrechen</Button>
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="confirmDeleteCategory">
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
</Button>
</template>
</Modal>
</div>
</template>
+56 -9
View File
@@ -4,9 +4,12 @@ import { CheckCircle2, ExternalLink, Film, Search, Trash2, TriangleAlert, Undo2,
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminClipManager } from '../../components/admin/useAdminClipManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
const {
query,
@@ -15,6 +18,7 @@ const {
categoryFilter,
deleting,
statusSaving,
bulkSaving,
adminMessage,
adminError,
clipToDelete,
@@ -22,6 +26,11 @@ const {
submissions,
categoryName,
clips,
page,
totalPages,
pagedClips,
rangeStart,
rangeEnd,
clipEmbeds,
stats,
statusFilters,
@@ -33,8 +42,11 @@ const {
duplicateUrlCount,
creatorClipCount,
updateClipStatus,
bulkUpdateStatus,
confirmDelete,
} = useAdminClipManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -71,12 +83,14 @@ const {
placeholder="Titel, Creator, Plattform, URL oder User suchen …"
/>
</label>
<select v-model="platformFilter" class="h-12 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">
<option v-for="filter in platformFilters" :key="filter.key" :value="filter.key">{{ filter.label }} · {{ filter.count }}</option>
</select>
<select v-model="categoryFilter" class="h-12 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">
<option v-for="filter in categoryFilters" :key="filter.id" :value="filter.id">{{ filter.label }} · {{ filter.count }}</option>
</select>
<NativeSelect
v-model="platformFilter"
:options="platformFilters.map((filter) => ({ label: `${filter.label} · ${filter.count}`, value: filter.key }))"
/>
<NativeSelect
v-model="categoryFilter"
:options="categoryFilters.map((filter) => ({ label: `${filter.label} · ${filter.count}`, value: filter.id }))"
/>
</div>
<div class="flex flex-wrap gap-2">
<button
@@ -92,11 +106,35 @@ const {
</div>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div v-if="clips.length > 0" class="flex flex-wrap items-center justify-between gap-3 border-b border-violet-100 bg-violet-50/40 px-5 py-3">
<p class="text-sm text-slate-600">
<strong class="text-violet-800">{{ clips.length }}</strong>
{{ clips.length === 1 ? 'Clip' : 'Clips' }} in dieser Ansicht
</p>
<div class="flex flex-wrap gap-2">
<Button
class="gap-1.5 !bg-emerald-600 hover:!bg-emerald-500"
size="sm"
:disabled="bulkSaving || statusSaving !== null"
@click="bulkUpdateStatus('approved')"
>
<CheckCircle2 class="h-3.5 w-3.5" />
{{ bulkSaving ? 'Verarbeite …' : 'Alle freigeben' }}
</Button>
<Button
class="gap-1.5 !bg-rose-600 hover:!bg-rose-500"
size="sm"
:disabled="bulkSaving || statusSaving !== null"
@click="bulkUpdateStatus('rejected')"
>
<XCircle class="h-3.5 w-3.5" />
Alle ablehnen
</Button>
</div>
</div>
<div class="divide-y divide-violet-50">
<div v-for="clip in clips" :key="clip.id" class="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)_320px] xl:items-start">
<div v-for="clip in pagedClips" :key="clip.id" class="grid gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)_320px] xl:items-start">
<div class="flex min-w-0 gap-4">
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] text-violet-600">
<Film class="h-5 w-5" />
@@ -186,6 +224,15 @@ const {
</p>
</div>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="clips.length"
@update:page="page = $event"
/>
</Card>
<Modal :open="!!clipToDelete" title="Clip entfernen?" @close="clipToDelete = null">
+129 -57
View File
@@ -1,27 +1,23 @@
<script setup lang="ts">
import { FileText } from '@lucide/vue'
import { FileText, Link2, MessageCircleQuestion, Share2, ShieldCheck } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqPreviewModal from '../../components/admin/AdminContentFaqPreviewModal.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
import AdminContentSectionNav from '../../components/admin/AdminContentSectionNav.vue'
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import Modal from '../../components/ui/Modal.vue'
import { watchAdminToast } from '../../composables/useAdminToast'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
const sectionLinks = [
{ href: '#content-basics', label: 'Basis & Host', primary: true },
{ href: '#content-links', label: 'Footer & Kontakt' },
{ href: '#content-socials', label: 'Social Links' },
{ href: '#content-faq', label: 'FAQ' },
{ href: '#content-privacy', label: 'Datenschutz' },
]
type ContentEditorKey = 'links' | 'socials' | 'faq' | 'privacy'
const {
form,
@@ -49,8 +45,12 @@ const {
adminSiteSettings,
} = useAdminContentManager()
watchAdminToast(saveMessage, saveError)
const footerPreviewOpen = ref(false)
const footerPreviewKey = ref<FooterPreviewKey>('imprint')
const faqPreviewOpen = ref(false)
const activeContentEditor = ref<ContentEditorKey | null>(null)
const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, url: string, content: string }>>(() => ({
imprint: {
@@ -72,11 +72,50 @@ const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, ur
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
const activeFooterPreviewHtml = computed(() => privacyContentToHtml(activeFooterPreview.value.content))
const contentEditors = computed(() => [
{
key: 'links' as const,
eyebrow: 'Footer & Kontakt',
title: 'Rechtliche Links',
description: 'Kontaktwege, Impressum und Sponsoren-Inhalte bearbeiten.',
metric: `${[form.contactUrl, form.imprintUrl, form.sponsorsUrl].filter(Boolean).length}/3 URLs`,
icon: Link2,
},
{
key: 'socials' as const,
eyebrow: 'Community',
title: 'Social Links',
description: 'Plattformen, Labels, URLs und Custom Icons pflegen.',
metric: `${form.socialLinks.length} Links`,
icon: Share2,
},
{
key: 'faq' as const,
eyebrow: 'Support',
title: 'FAQ',
description: 'Fragen und Antworten für die Landingpage verwalten.',
metric: `${form.faq.length} Fragen`,
icon: MessageCircleQuestion,
},
{
key: 'privacy' as const,
eyebrow: 'Rechtstexte',
title: 'Datenschutz',
description: 'Datenschutzerklärung mit Preview bearbeiten.',
metric: privacyUpdatedLabel.value,
icon: ShieldCheck,
},
])
const currentContentEditor = computed(() => contentEditors.value.find((item) => item.key === activeContentEditor.value) ?? null)
function openFooterPreview(key: FooterPreviewKey) {
footerPreviewKey.value = key
footerPreviewOpen.value = true
}
function openContentEditor(key: ContentEditorKey) {
activeContentEditor.value = key
}
</script>
<template>
@@ -87,59 +126,87 @@ function openFooterPreview(key: FooterPreviewKey) {
:icon="FileText"
/>
<div class="space-y-3">
<p v-if="saveMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ saveMessage }}</p>
<p v-if="saveError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ saveError }}</p>
</div>
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<section class="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)]">
<aside class="space-y-4 xl:sticky xl:top-32 xl:self-start">
<AdminContentSectionNav :sections="sectionLinks" />
</aside>
<section aria-labelledby="content-editor-title" class="space-y-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Weitere Bereiche</p>
<h2 id="content-editor-title" class="mt-1 text-xl font-bold text-slate-900">Landingpage-Module bearbeiten</h2>
</div>
<div class="space-y-6">
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentLinksSection
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
@open-preview="openFooterPreview"
/>
<AdminContentSocialLinksSection
:form="form"
:saving="saving"
:icon-upload-error="iconUploadError"
:add-social-link="addSocialLink"
:remove-social-link="removeSocialLink"
:is-uploaded-icon="isUploadedIcon"
:selected-social-icon-value="selectedSocialIconValue"
:handle-social-icon-selection="handleSocialIconSelection"
:has-social-icon-preview="hasSocialIconPreview"
:social-icon-mode-label="socialIconModeLabel"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:handle-social-icon-upload="handleSocialIconUpload"
:clear-social-icon="clearSocialIcon"
:save-site-settings="saveSiteSettings"
/>
<AdminContentFaqSection
:form="form"
:saving="saving"
:add-faq-item="addFaqItem"
:remove-faq-item="removeFaqItem"
:save-site-settings="saveSiteSettings"
/>
<AdminContentPrivacySection
:form="form"
:saving="saving"
:updated-by="adminSiteSettings.privacyPolicyUpdatedBy"
:updated-label="privacyUpdatedLabel"
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
<div class="grid gap-3 lg:grid-cols-2">
<button
v-for="editor in contentEditors"
:key="editor.key"
type="button"
class="group flex min-h-[132px] items-start gap-4 rounded-[24px] border border-violet-100 bg-white/86 p-5 text-left shadow-[0_16px_40px_rgba(124,92,255,0.07)] transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70 hover:shadow-[0_20px_48px_rgba(124,92,255,0.12)]"
@click="openContentEditor(editor.key)"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700 transition group-hover:bg-white">
<component :is="editor.icon" class="h-5 w-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">{{ editor.eyebrow }}</span>
<span class="mt-1 block text-lg font-bold text-slate-900">{{ editor.title }}</span>
<span class="mt-2 block text-sm leading-6 text-slate-500">{{ editor.description }}</span>
</span>
<span class="shrink-0 rounded-2xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700">{{ editor.metric }}</span>
</button>
</div>
</section>
<Modal
:open="activeContentEditor !== null"
:title="currentContentEditor?.title"
:subtitle="currentContentEditor?.description"
size="xl"
@close="activeContentEditor = null"
>
<AdminContentLinksSection
v-if="activeContentEditor === 'links'"
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
@open-preview="openFooterPreview"
/>
<AdminContentSocialLinksSection
v-else-if="activeContentEditor === 'socials'"
:form="form"
:saving="saving"
:icon-upload-error="iconUploadError"
:add-social-link="addSocialLink"
:remove-social-link="removeSocialLink"
:is-uploaded-icon="isUploadedIcon"
:selected-social-icon-value="selectedSocialIconValue"
:handle-social-icon-selection="handleSocialIconSelection"
:has-social-icon-preview="hasSocialIconPreview"
:social-icon-mode-label="socialIconModeLabel"
:social-simple-icon-path="socialSimpleIconPath"
:social-simple-icon-color="socialSimpleIconColor"
:handle-social-icon-upload="handleSocialIconUpload"
:clear-social-icon="clearSocialIcon"
:save-site-settings="saveSiteSettings"
/>
<AdminContentFaqSection
v-else-if="activeContentEditor === 'faq'"
:form="form"
:saving="saving"
:add-faq-item="addFaqItem"
:remove-faq-item="removeFaqItem"
:save-site-settings="saveSiteSettings"
@open-preview="faqPreviewOpen = true"
/>
<AdminContentPrivacySection
v-else-if="activeContentEditor === 'privacy'"
:form="form"
:saving="saving"
:updated-by="adminSiteSettings.privacyPolicyUpdatedBy"
:updated-label="privacyUpdatedLabel"
:save-site-settings="saveSiteSettings"
@open-preview="privacyPreviewOpen = true"
/>
</Modal>
<AdminContentPrivacyPreviewModal
:open="privacyPreviewOpen"
:content-html="privacyPreviewHtml"
@@ -153,5 +220,10 @@ function openFooterPreview(key: FooterPreviewKey) {
:content-html="activeFooterPreviewHtml"
@close="footerPreviewOpen = false"
/>
<AdminContentFaqPreviewModal
:open="faqPreviewOpen"
:faq="form.faq"
@close="faqPreviewOpen = false"
/>
</div>
</template>
+117 -24
View File
@@ -9,14 +9,17 @@ import {
Film,
LayoutDashboard,
FileClock,
Menu,
Settings,
Tags,
Trophy,
UserCog,
Users,
FileText,
X,
} from '@lucide/vue'
import AdminToastViewport from '../../components/admin/AdminToastViewport.vue'
import Card from '../../components/ui/Card.vue'
import { getRiskMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
@@ -29,6 +32,7 @@ const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
const pendingClipCount = computed(() => store.adminSeasonDetail.clipSubmissions.filter((clip) => clip.status === 'pending').length)
const adminWorkspaceLoading = ref(false)
const adminWorkspaceLoaded = ref(false)
const mobileNavOpen = ref(false)
const fullNavGroups = computed(() => [
{
@@ -76,16 +80,15 @@ const navGroups = computed(() => {
})
const currentSeason = computed(() => store.adminSeasonDetail)
const seasonSummary = computed(() => [
{ label: 'Kategorien', value: currentSeason.value.categories.length },
{ label: 'Kandidaten', value: currentSeason.value.candidates.length },
{ label: 'Reviews', value: currentSeason.value.pendingNominations.length },
])
function isActive(to: string) {
return route.path === to
}
// Close mobile nav on route change
watch(() => route.path, () => {
mobileNavOpen.value = false
})
async function ensureAdminWorkspace() {
if (!authStore.hydrated || !authStore.canAccessAdmin || adminWorkspaceLoading.value) return
if (adminWorkspaceLoaded.value && store.apiMode === 'api') return
@@ -113,13 +116,119 @@ watch(
</script>
<template>
<div class="pb-10">
<AdminToastViewport />
<!-- Mobile top bar -->
<div class="sticky top-0 z-40 flex items-center gap-3 border-b border-violet-100 bg-white/90 px-4 py-3 backdrop-blur-sm xl:hidden">
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-200 text-violet-600 transition hover:bg-violet-50"
aria-label="Navigation öffnen"
@click="mobileNavOpen = true"
>
<Menu class="h-5 w-5" />
</button>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold text-violet-900">
{{ currentSeason.year ? `Admin · ${currentSeason.year}` : 'Admin' }}
</p>
<p class="truncate text-xs text-slate-500">{{ currentSeason.currentPhase || currentSeason.name || 'Kein Jahr gewählt' }}</p>
</div>
<div v-if="store.adminSeasonDetail.pendingNominations.length > 0" class="shrink-0">
<span class="grid h-6 min-w-6 place-items-center rounded-full border border-violet-200 bg-violet-50 px-2 text-[11px] font-bold text-violet-700">
{{ store.adminSeasonDetail.pendingNominations.length }}
</span>
</div>
</div>
<!-- Mobile drawer (Teleport to body) -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="mobileNavOpen" class="fixed inset-0 z-50 xl:hidden" @click.self="mobileNavOpen = false">
<!-- Backdrop -->
<div class="absolute inset-0 bg-slate-950/40 backdrop-blur-sm" @click="mobileNavOpen = false" />
<!-- Drawer panel -->
<Transition
enter-active-class="transition duration-200"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition duration-150"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"
>
<div v-if="mobileNavOpen" class="absolute inset-y-0 left-0 flex w-72 flex-col gap-3 overflow-y-auto bg-white/98 p-3 shadow-2xl backdrop-blur-md">
<!-- Drawer header -->
<div class="flex items-center justify-between gap-3 px-1 py-1">
<div class="min-w-0">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Admin</p>
<p class="truncate text-sm font-bold text-violet-900">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || '' }}</p>
</div>
<button
type="button"
class="grid h-8 w-8 shrink-0 place-items-center rounded-xl border border-violet-200 text-violet-600 transition hover:bg-violet-50"
aria-label="Navigation schließen"
@click="mobileNavOpen = false"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Nav groups -->
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
<p class="px-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">{{ group.label }}</p>
<RouterLink
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="group block rounded-2xl border px-3 py-2.5 transition"
:class="isActive(item.to) ? 'border-violet-200 bg-gradient-to-br from-violet-50 via-white to-[#f7eef8] text-violet-950 shadow-[0_14px_34px_rgba(168,145,214,0.14)]' : 'border-transparent bg-white/55 text-slate-700 hover:border-violet-100 hover:bg-violet-50/70'"
>
<div class="flex items-center gap-3">
<div
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl transition"
:class="isActive(item.to) ? 'bg-white text-violet-700 shadow-sm' : 'bg-violet-50 text-violet-600 group-hover:bg-white'"
>
<component :is="item.icon" class="h-4.5 w-4.5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center justify-between gap-3">
<p class="truncate text-sm font-semibold leading-5">{{ item.label }}</p>
<span
v-if="item.badge()"
class="grid h-6 min-w-6 shrink-0 place-items-center rounded-full border border-violet-200 bg-white px-2 text-[11px] font-semibold text-violet-700 shadow-sm"
>
{{ item.badge() }}
</span>
</div>
<p class="mt-0.5 truncate text-xs leading-5 text-slate-500">{{ item.description }}</p>
</div>
</div>
</RouterLink>
</section>
</nav>
</div>
</Transition>
</div>
</Transition>
</Teleport>
<div class="admin-panel pb-10 pt-4 xl:pt-0">
<div class="grid gap-6 xl:grid-cols-[292px_minmax(0,1fr)]">
<main class="order-1 min-w-0 xl:order-2">
<RouterView />
</main>
<aside class="order-2 space-y-3 xl:order-1 xl:sticky xl:top-4 xl:h-fit">
<!-- Desktop sidebar (hidden on mobile) -->
<aside class="order-2 hidden space-y-3 xl:order-1 xl:block xl:sticky xl:top-4 xl:h-fit">
<Card class="p-3">
<nav class="space-y-4">
<section v-for="group in navGroups" :key="group.label" class="space-y-1.5">
@@ -155,22 +264,6 @@ watch(
</section>
</nav>
</Card>
<Card class="p-3">
<p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-violet-500">Aktives Jahr</p>
<p class="mt-1 truncate text-sm font-semibold text-violet-800">{{ currentSeason.year || 'Kein Jahr' }} · {{ currentSeason.currentPhase || 'Kein Status' }}</p>
<p class="mt-1 truncate text-xs text-slate-500">{{ currentSeason.name || 'Bitte Jahr auswählen.' }}</p>
<div class="mt-3 grid grid-cols-3 gap-1.5">
<div
v-for="item in seasonSummary"
:key="item.label"
class="rounded-md border border-white/80 bg-white/75 px-2 py-1.5"
>
<p class="truncate text-[9px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ item.label }}</p>
<strong class="block text-base leading-5 text-violet-800">{{ item.value }}</strong>
</div>
</div>
</Card>
</aside>
</div>
</div>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ClipboardList, Search, Sparkles, Tags, Users } from '@lucide/vue'
import { ClipboardList, Search, Sparkles, Tags, Users, XCircle } from '@lucide/vue'
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router'
import AdminNominationReviewModal from '../../components/admin/AdminNominationReviewModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import PaginationFooter from '../../components/ui/PaginationFooter.vue'
import { useAwardsStore } from '../../stores/awards'
const store = useAwardsStore()
@@ -72,6 +73,41 @@ function openReviewModal(nominationId?: number) {
void router.replace({ name: 'admin-nominations', query: nextQuery })
}
const bulkRejecting = ref<number | null>(null)
async function bulkRejectCategory(categoryId: number) {
if (!store.adminSelectedSeasonId || bulkRejecting.value !== null) return
const ids = seasonDetail.value.pendingNominations
.filter((n) => n.categoryId === categoryId)
.map((n) => n.id)
if (ids.length === 0) return
bulkRejecting.value = categoryId
try {
await store.bulkRejectAdminNominations(ids, store.adminSelectedSeasonId)
} finally {
bulkRejecting.value = null
categoryFilter.value = null
}
}
const NOM_PAGE_SIZE = 20
const page = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(filteredNominations.value.length / NOM_PAGE_SIZE)))
const pagedNominations = computed(() =>
filteredNominations.value.slice((page.value - 1) * NOM_PAGE_SIZE, page.value * NOM_PAGE_SIZE),
)
const rangeStart = computed(() =>
filteredNominations.value.length === 0 ? 0 : (page.value - 1) * NOM_PAGE_SIZE + 1,
)
const rangeEnd = computed(() => Math.min(page.value * NOM_PAGE_SIZE, filteredNominations.value.length))
watch([query, categoryFilter, statusFilter], () => {
page.value = 1
})
watch(totalPages, (max) => {
if (page.value > max) page.value = max
})
function closeReviewModal() {
const restQuery: LocationQueryRaw = { ...route.query }
delete restQuery.review
@@ -151,20 +187,35 @@ watch(
<h2 class="mt-1 text-xl font-bold text-slate-900">Wo staut es sich?</h2>
</div>
<div class="divide-y divide-violet-50">
<button
<div
v-for="category in categoryStats"
:key="category.id"
type="button"
class="grid w-full grid-cols-[minmax(0,1fr)_auto] gap-4 px-5 py-4 text-left transition hover:bg-violet-50/50"
class="group/cat grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 transition hover:bg-violet-50/50"
:class="categoryFilter === category.id ? 'bg-violet-50/80' : ''"
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
>
<span class="min-w-0">
<button
type="button"
class="min-w-0 text-left"
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
>
<span class="block truncate font-semibold text-slate-900">{{ category.name }}</span>
<span class="mt-1 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
</span>
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
</button>
<span class="mt-0.5 block truncate text-sm text-slate-500">{{ category.groupName }} · {{ category.candidates }} Kandidaten</span>
</button>
<div class="flex shrink-0 items-center gap-2">
<button
v-if="category.pending > 0"
type="button"
class="hidden h-7 items-center gap-1 rounded-full border border-rose-200 bg-white px-2.5 text-[11px] font-semibold text-rose-600 transition hover:bg-rose-50 disabled:opacity-50 group-hover/cat:flex"
:disabled="bulkRejecting === category.id"
:title="`Alle ${category.pending} Nominierungen ablehnen`"
@click.stop="bulkRejectCategory(category.id)"
>
<XCircle class="h-3 w-3" />
{{ bulkRejecting === category.id ? '…' : 'Alle ablehnen' }}
</button>
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-sm font-semibold text-violet-800">{{ category.pending }}</span>
</div>
</div>
</div>
</Card>
@@ -201,8 +252,8 @@ watch(
</div>
</div>
<div class="max-h-[620px] divide-y divide-violet-50 overflow-y-auto">
<div v-for="nomination in filteredNominations" :key="nomination.id" class="px-5 py-4">
<div class="divide-y divide-violet-50">
<div v-for="nomination in pagedNominations" :key="nomination.id" class="px-5 py-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
@@ -233,6 +284,15 @@ watch(
Keine Nominierungen passen zum aktuellen Filter.
</p>
</div>
<PaginationFooter
:page="page"
:total-pages="totalPages"
:range-start="rangeStart"
:range-end="rangeEnd"
:filtered-count="filteredNominations.length"
@update:page="page = $event"
/>
</Card>
</section>
@@ -9,6 +9,7 @@ import AdminReviewsQueueList from '../../components/admin/AdminReviewsQueueList.
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import Card from '../../components/ui/Card.vue'
import { useAdminReviewsManager } from '../../components/admin/useAdminReviewsManager'
import { watchAdminToast } from '../../composables/useAdminToast'
const {
reviewSaving,
@@ -32,6 +33,8 @@ const {
selectedPlatformValue,
handlePlatformSelection,
} = useAdminReviewsManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -55,9 +58,6 @@ const {
/>
<div class="space-y-4 p-6">
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div class="grid gap-5 xl:grid-cols-[minmax(320px,0.85fr)_minmax(0,1.15fr)]">
<AdminReviewsQueueList
:nominations="filteredNominations"
+67 -4
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ShieldAlert } from '@lucide/vue'
import { FileClock, ShieldAlert, SlidersHorizontal } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminRiskDecisionPanel from '../../components/admin/AdminRiskDecisionPanel.vue'
@@ -8,7 +9,11 @@ import AdminRiskOverviewBoard from '../../components/admin/AdminRiskOverviewBoar
import AdminRiskQueueList from '../../components/admin/AdminRiskQueueList.vue'
import AdminRiskRulesEditor from '../../components/admin/AdminRiskRulesEditor.vue'
import { useAdminRiskManager } from '../../components/admin/useAdminRiskManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Card from '../../components/ui/Card.vue'
import Modal from '../../components/ui/Modal.vue'
type RiskModalKey = 'rules' | 'history'
const {
riskSaving,
@@ -59,6 +64,29 @@ const {
updateRiskRule,
saveRiskRules,
} = useAdminRiskManager()
const activeRiskModal = ref<RiskModalKey | null>(null)
const riskModalSummaries = computed(() => [
{
key: 'rules' as const,
eyebrow: 'Regeln',
title: 'Regel-Editor',
description: 'Thresholds, Zeitfenster und Severity zentral anpassen.',
metric: riskRulesLoading.value ? 'lädt' : `${riskRules.value.length} Regeln`,
icon: SlidersHorizontal,
},
{
key: 'history' as const,
eyebrow: 'Protokoll',
title: 'Entscheidungsprotokoll',
description: 'Entschiedene Hinweise prüfen und bei Bedarf wieder öffnen.',
metric: `${riskHistory.value.length} Einträge`,
icon: FileClock,
},
])
const currentRiskModal = computed(() => riskModalSummaries.value.find((item) => item.key === activeRiskModal.value) ?? null)
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -77,8 +105,6 @@ const {
:total-open="riskFlags.length"
:loaded-label="riskLoadedLabel"
:loading="riskLoading"
:message="adminMessage"
:error="adminError"
:stats="riskStats"
:severity-filters="severityFilters"
@refresh="loadRiskFlags"
@@ -117,7 +143,43 @@ const {
/>
</div>
<section class="border-t border-violet-100 bg-violet-50/25 p-6" aria-labelledby="risk-tools-title">
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Werkzeuge</p>
<h2 id="risk-tools-title" class="text-lg font-bold text-slate-900">Regeln und Protokolle</h2>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
<button
v-for="item in riskModalSummaries"
:key="item.key"
type="button"
class="group flex min-h-[118px] items-start gap-4 rounded-[24px] border border-violet-100 bg-white/88 p-5 text-left shadow-[0_14px_36px_rgba(124,92,255,0.07)] transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-white hover:shadow-[0_18px_44px_rgba(124,92,255,0.12)]"
@click="activeRiskModal = item.key"
>
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700 transition group-hover:bg-violet-50">
<component :is="item.icon" class="h-5 w-5" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.eyebrow }}</span>
<span class="mt-1 block text-lg font-bold text-slate-900">{{ item.title }}</span>
<span class="mt-2 block text-sm leading-6 text-slate-500">{{ item.description }}</span>
</span>
<span class="shrink-0 rounded-2xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-bold text-violet-700">{{ item.metric }}</span>
</button>
</div>
</section>
</Card>
<Modal
:open="activeRiskModal !== null"
:title="currentRiskModal?.title"
:subtitle="currentRiskModal?.description"
size="xl"
@close="activeRiskModal = null"
>
<AdminRiskRulesEditor
v-if="activeRiskModal === 'rules'"
:rules="riskRules"
:loading="riskRulesLoading"
:saving="riskRulesSaving"
@@ -126,6 +188,7 @@ const {
/>
<AdminRiskHistorySection
v-else-if="activeRiskModal === 'history'"
v-model:history-status-filter="historyStatusFilter"
:risk-history="recentRiskHistory"
:total-history="riskHistory.length"
@@ -139,6 +202,6 @@ const {
@decide="updateRiskFlagStatus"
@page="setHistoryPage"
/>
</Card>
</Modal>
</div>
</template>
+549 -52
View File
@@ -1,14 +1,37 @@
<script setup lang="ts">
import { CalendarCog } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import {
AlertTriangle,
CalendarCog,
CheckCircle2,
Clock,
ExternalLink,
Globe2,
History,
LockKeyhole,
Pencil,
PlusCircle,
Trash2,
WandSparkles,
X,
} from '@lucide/vue'
import { RouterLink } from 'vue-router'
import AdminAwardYearsPanel from '../../components/admin/AdminAwardYearsPanel.vue'
import AdminSeasonCreateModal from '../../components/admin/AdminSeasonCreateModal.vue'
import AdminSeasonDeleteModal from '../../components/admin/AdminSeasonDeleteModal.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonPhaseSwitcher from '../../components/admin/AdminSeasonPhaseSwitcher.vue'
import AdminSeasonStatusCard from '../../components/admin/AdminSeasonStatusCard.vue'
import AdminSeasonTimelineEditor from '../../components/admin/AdminSeasonTimelineEditor.vue'
import {
createSeasonTimelineRows,
normalizePhaseKey,
resolveAutoPhase,
SEASON_PHASES,
type PhaseKey,
type PhaseRowConfig,
} from '../../components/admin/adminSeasonTimeline'
import { useAdminSeasonManager } from '../../components/admin/useAdminSeasonManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
const {
store,
@@ -24,14 +47,13 @@ const {
deleting,
seasonDetail,
selectedSeasonId,
selectedSeason,
readinessItems,
archiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
canDeleteSelectedSeason,
canCompleteSelectedSeason,
createPublicReadinessIssues,
copySourceOptions,
loadingSeasonAudit,
latestSeasonAuditSummary,
@@ -46,66 +68,541 @@ const {
openDeleteSeasonModal,
confirmDeleteSeason,
} = useAdminSeasonManager()
watchAdminToast(adminMessage, adminError)
// ---------- Season switching ----------
const switchingSeasonId = ref<number | null>(null)
async function selectSeason(seasonId: number) {
if (seasonId === selectedSeasonId.value || switchingSeasonId.value) return
switchingSeasonId.value = seasonId
try {
await store.loadAdminSeasonDetail(seasonId)
} finally {
switchingSeasonId.value = null
}
}
// ---------- Phase / Timeline ----------
const currentPhaseKey = computed(() => normalizePhaseKey(form.currentPhase))
const autoPhase = computed(() => resolveAutoPhase(form))
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
const editingPhase = ref<PhaseKey | null>(null)
const timelineError = ref('')
const draft = reactive({ start: '', end: '', showStartsAt: '20:00' })
const timelineRows = computed(() =>
createSeasonTimelineRows(form, currentPhaseKey.value, autoPhase.value ?? '', editingPhase.value).map((row) => ({
...row,
finalLocked: row.key === 'completed' && !row.active,
})),
)
function startEdit(row: PhaseRowConfig) {
if (!row.editable) return
timelineError.value = ''
editingPhase.value = row.key
draft.start = row.start ? form[row.start] : ''
draft.end = row.end ? form[row.end] : ''
draft.showStartsAt = form.showStartsAt || '20:00'
}
function cancelEdit() {
editingPhase.value = null
timelineError.value = ''
}
async function saveEdit(row: PhaseRowConfig) {
timelineError.value = ''
if (!row.start || !row.end) return
if (!draft.start || !draft.end) { timelineError.value = 'Start und Ende ausfüllen.'; return }
if (row.key !== 'show' && draft.start > draft.end) { timelineError.value = 'Startdatum darf nicht nach Enddatum liegen.'; return }
form[row.start] = draft.start
form[row.end] = row.key === 'show' ? draft.start : draft.end
if (row.key === 'show') form.showStartsAt = draft.showStartsAt || '20:00'
const saved = await saveSeason()
if (saved !== false) editingPhase.value = null
}
// ---------- Countdown helpers ----------
function daysUntil(dateStr: string | undefined | null): number | null {
if (!dateStr) return null
const target = new Date(`${dateStr}T00:00:00`)
if (Number.isNaN(target.getTime())) return null
const diff = Math.ceil((target.getTime() - Date.now()) / 86_400_000)
return diff
}
function phaseCountdown(row: (typeof timelineRows.value)[number]): string | null {
if (row.active) {
const endField = SEASON_PHASES.find((p) => p.key === row.key)?.end
if (!endField) return null
const days = daysUntil(form[endField])
if (days === null) return null
if (days < 0) return 'Überfällig'
if (days === 0) return 'Heute endet'
if (days === 1) return 'Noch 1 Tag'
return `Noch ${days} Tage`
}
const startField = SEASON_PHASES.find((p) => p.key === row.key)?.start
if (!startField) return null
const days = daysUntil(form[startField])
if (days === null || days <= 0) return null
return `Startet in ${days} Tagen`
}
// ---------- Quick stats ----------
const quickStats = computed(() => {
const d = seasonDetail.value
const pendingClips = d.clipSubmissions.filter((c) => c.status === 'pending').length
return [
{ label: 'Kategorien', value: d.categories.length },
{ label: 'Kandidaten', value: d.candidates.length },
{ label: 'Nominierungen', value: d.pendingNominations.length, warn: d.pendingNominations.length > 0 },
{ label: 'Clips offen', value: pendingClips, warn: pendingClips > 0 },
{ label: 'Gewinner', value: d.results.length },
]
})
// ---------- Readiness score ----------
const readinessScore = computed(() => {
const done = readinessItems.value.filter((i) => i.complete).length
return { done, total: readinessItems.value.length }
})
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Jahre"
description="Jahr, Phase und öffentliche Sichtbarkeit steuern."
description="Award-Jahr wählen, Phase steuern, Timeline pflegen."
:icon="CalendarCog"
/>
<section class="space-y-6">
<AdminSeasonPhaseSwitcher
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:selected-season-id="selectedSeasonId"
:activate-phase="activatePhase"
/>
<!-- Jahr-Auswahl -->
<Card class="overflow-hidden">
<div class="flex items-center justify-between gap-3 border-b border-violet-100 px-5 py-3.5">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Award-Jahre</p>
<Button variant="ghost" size="sm" class="gap-1.5 border border-violet-100 text-violet-700 hover:bg-violet-50" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Neu
</Button>
</div>
<div class="flex gap-2 overflow-x-auto px-5 py-4">
<button
v-for="season in store.adminSeasons"
:key="season.id"
type="button"
class="flex shrink-0 flex-col gap-1.5 rounded-[20px] border px-4 py-3 text-left transition"
:class="season.id === selectedSeasonId
? 'border-violet-300 bg-[linear-gradient(135deg,#ece2ff,#fff2dd)] shadow-[0_12px_28px_rgba(139,108,219,0.18)]'
: 'border-violet-100 bg-white/80 hover:border-violet-200 hover:bg-violet-50/60'"
@click="selectSeason(season.id)"
>
<div class="flex items-center gap-2">
<strong class="text-2xl leading-none" :class="season.id === selectedSeasonId ? 'text-violet-900' : 'text-slate-700'">
{{ season.year }}
</strong>
<span
class="rounded-full border px-2 py-0.5 text-[10px] font-bold"
:class="season.isCurrent
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500'"
>
{{ season.isCurrent ? 'Public' : 'Intern' }}
</span>
</div>
<p class="max-w-[180px] truncate text-xs text-slate-500">{{ season.name }}</p>
<span class="rounded-full border border-violet-100 bg-white/80 px-2.5 py-0.5 text-[11px] font-semibold text-violet-700">
{{ season.currentPhase }}
</span>
</button>
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<AdminSeasonStatusCard
:form="form"
:season-name="seasonDetail.name"
:saving="saving"
:completing="completing"
:admin-message="adminMessage"
:admin-error="adminError"
:readiness-items="readinessItems"
:archive-readiness-issues="archiveReadinessIssues"
:can-activate-public="canActivatePublic"
:loading-season-audit="loadingSeasonAudit"
:latest-season-audit-summary="latestSeasonAuditSummary"
:latest-season-audit-meta="latestSeasonAuditMeta"
:selected-season-id="selectedSeasonId"
:can-delete-selected-season="canDeleteSelectedSeason"
:can-complete-selected-season="canCompleteSelectedSeason"
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
:open-delete-season-modal="openDeleteSeasonModal"
:activate-public-season="activatePublicSeason"
:save-season="saveSeason"
:complete-season="completeSeason"
/>
<button
v-if="store.adminSeasons.length === 0"
type="button"
class="flex shrink-0 items-center gap-2 rounded-[20px] border border-dashed border-violet-200 px-6 py-4 text-sm font-semibold text-violet-500 hover:bg-violet-50"
@click="openCreateModal"
>
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</button>
</div>
</Card>
<AdminAwardYearsPanel
:seasons="store.adminSeasons"
:selected-season-id="selectedSeasonId"
:open-create-modal="openCreateModal"
:load-season-detail="store.loadAdminSeasonDetail"
/>
</section>
<!-- Aktives Jahr: Hero + Quick Stats + Actions -->
<Card v-if="selectedSeasonId" class="overflow-hidden">
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-end gap-4">
<strong class="text-5xl font-black leading-none text-slate-950">{{ form.year }}</strong>
<div class="mb-0.5">
<p class="text-lg font-bold leading-snug text-slate-800">{{ form.name || '' }}</p>
<p class="mt-1 text-sm text-slate-500">{{ form.showStreamUrl || 'Kein Stream-Link' }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="rounded-2xl border px-4 py-2 text-sm font-bold"
:class="form.isCurrent
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-100 text-slate-600'"
>
{{ form.isCurrent ? '🟢 Public aktiv' : '⚫ Intern' }}
</span>
<span class="rounded-2xl border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
{{ form.currentPhase || 'Keine Phase' }}
</span>
</div>
</div>
</div>
<AdminSeasonTimelineEditor
:form="form"
:saving="saving"
:selected-season-id="selectedSeasonId"
:save-season="saveSeason"
/>
<!-- Quick stats -->
<div class="grid grid-cols-2 divide-x divide-y divide-violet-50 border-b border-violet-100 sm:grid-cols-5">
<div
v-for="stat in quickStats"
:key="stat.label"
class="px-5 py-4"
>
<p class="text-xs font-semibold uppercase tracking-[0.18em]" :class="stat.warn ? 'text-amber-600' : 'text-slate-400'">{{ stat.label }}</p>
<strong class="mt-1 block text-2xl" :class="stat.warn ? 'text-amber-700' : 'text-slate-900'">{{ stat.value }}</strong>
</div>
</div>
<!-- Actions footer -->
<div class="flex flex-wrap items-center gap-3 px-5 py-4">
<Button
variant="ghost"
class="gap-1.5 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100"
:disabled="!canDeleteSelectedSeason"
@click="openDeleteSeasonModal"
>
<Trash2 class="h-4 w-4" /> Löschen
</Button>
<Button
variant="ghost"
class="gap-1.5 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
:disabled="saving || form.isCurrent || !canActivatePublic"
@click="activatePublicSeason"
>
<Globe2 class="h-4 w-4" /> {{ saving ? 'Aktiviert …' : 'Public aktivieren' }}
</Button>
<Button
variant="ghost"
class="gap-1.5 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100"
:disabled="completing || !canCompleteSelectedSeason"
@click="completeSeason"
>
<CheckCircle2 class="h-4 w-4" /> {{ completing ? 'Schließt …' : 'Beenden' }}
</Button>
<Button class="gap-1.5 ml-auto" :disabled="saving" @click="saveSeason">
{{ saving ? 'Speichert …' : 'Speichern' }}
</Button>
</div>
</Card>
<!-- Grunddaten + Readiness -->
<section v-if="selectedSeasonId" class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<!-- Grunddaten -->
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Stammdaten bearbeiten</h2>
<div class="mt-5 space-y-4">
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
<input
v-model.number="form.year"
type="number"
placeholder="2027"
:disabled="saving"
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 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
<input
v-model="form.name"
type="text"
placeholder="VTuber Star Awards 2027"
:disabled="saving"
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 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
</div>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
<input
v-model="form.showStreamUrl"
type="url"
placeholder="https://twitch.tv/jayuhime"
:disabled="saving"
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 disabled:bg-slate-50 disabled:text-slate-400"
/>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:opacity-70">
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="saving" />
<span>
<span class="block font-semibold text-slate-800">Community-only</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">Voting und Teilnahme über die Community.</span>
</span>
</label>
<label
class="flex cursor-pointer gap-3 rounded-[22px] border p-4 transition"
:class="form.isCurrent
? 'border-emerald-200 bg-emerald-50/60 hover:bg-emerald-50'
: 'border-violet-100 bg-violet-50/50 hover:bg-violet-50 has-disabled:opacity-70'"
>
<input
v-model="form.isCurrent"
type="checkbox"
class="mt-1 h-4 w-4 shrink-0 accent-violet-600"
:disabled="saving || (!form.isCurrent && !canActivatePublic)"
/>
<span>
<span class="block font-semibold text-slate-800">Public-Kontext</span>
<span class="mt-1 block text-sm leading-5 text-slate-500">
{{ form.isCurrent ? 'Aktiv auf der Landingpage.' : canActivatePublic ? 'Kann aktiviert werden.' : 'Readiness-Blocker auflösen.' }}
</span>
</span>
</label>
</div>
<!-- Audit entry -->
<div class="flex items-start gap-3 rounded-[22px] border border-violet-100 bg-white p-4">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
<History class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Letzte Admin-Aktion</p>
<p class="mt-1 text-sm font-semibold leading-5 text-slate-800">
{{ loadingSeasonAudit ? 'Lädt Audit …' : latestSeasonAuditSummary }}
</p>
<p class="mt-0.5 text-xs text-slate-500">{{ latestSeasonAuditMeta }}</p>
</div>
</div>
</div>
</Card>
<!-- Readiness -->
<Card class="p-6">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Readiness</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Checkliste</h2>
</div>
<div
class="flex items-center gap-2 rounded-2xl border px-3 py-2 text-xs font-bold"
:class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'"
>
<span class="h-2 w-2 rounded-full" :class="canActivatePublic ? 'bg-emerald-500' : 'bg-rose-500'" />
{{ readinessScore.done }}/{{ readinessScore.total }} bereit
</div>
</div>
<!-- Readiness progress bar -->
<div class="mt-4">
<div class="h-2 overflow-hidden rounded-full bg-slate-100">
<div
class="h-full rounded-full transition-all duration-500"
:class="canActivatePublic ? 'bg-emerald-500' : 'bg-violet-500'"
:style="{ width: `${(readinessScore.done / readinessScore.total) * 100}%` }"
/>
</div>
</div>
<div class="mt-4 grid gap-2">
<RouterLink
v-for="item in readinessItems"
:key="item.label"
:to="item.to"
class="group flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:shadow-sm"
:class="item.complete
? 'border-emerald-100 bg-emerald-50/60 hover:bg-emerald-50'
: item.blocking
? 'border-rose-200 bg-rose-50/60 hover:bg-rose-50'
: 'border-amber-100 bg-amber-50/60 hover:bg-amber-50'"
>
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-600' : 'text-amber-600'" />
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-800">
{{ item.label }}
<span v-if="item.blocking && !item.complete" class="rounded-full bg-rose-100 px-2 py-0.5 text-[10px] font-bold text-rose-700">Blocker</span>
</span>
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
</span>
<ExternalLink class="mt-1 h-3.5 w-3.5 shrink-0 text-slate-400 opacity-0 transition group-hover:opacity-100" />
</RouterLink>
</div>
<div v-if="archiveReadinessIssues.length > 0" class="mt-4 rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700">
<strong class="block text-rose-800">Abschluss blockiert.</strong>
{{ archiveReadinessIssues.join(' ') }}
</div>
</Card>
</section>
<!-- Phase & Timeline (kombiniert) -->
<Card v-if="selectedSeasonId" class="overflow-hidden">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-[#f7eef8] px-6 py-5">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phase & Timeline</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Phasenwechsel und Zeitplan</h2>
<p class="mt-1.5 max-w-2xl text-sm leading-5 text-slate-500">
Aktive Phase wechseln und Zeitfenster direkt inline bearbeiten. Die Daten steuern Public-API, Gates und Countdown.
</p>
</div>
<!-- Auto-mismatch compact banner -->
<div v-if="autoMismatch" class="flex items-center gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<AlertTriangle class="h-4 w-4 shrink-0 text-amber-700" />
<div>
<p class="text-sm font-bold text-amber-900">Zeitplan schlägt vor: {{ autoPhase }}</p>
</div>
<Button
variant="ghost"
size="sm"
class="ml-2 border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
:disabled="saving || !selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
@click="autoPhase && !autoPhaseIsCompleted && activatePhase(autoPhase)"
>
{{ autoPhaseIsCompleted ? 'Über Beenden' : 'Aktivieren' }}
</Button>
</div>
</div>
<p v-if="timelineError" class="border-b border-rose-100 bg-rose-50 px-6 py-3 text-sm font-semibold text-rose-700">{{ timelineError }}</p>
<!-- Phase cards grid -->
<div class="p-5">
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<article
v-for="row in timelineRows"
:key="row.key"
class="flex flex-col rounded-[22px] border transition"
:class="row.active
? 'border-violet-300 bg-[linear-gradient(135deg,#ede9fe,#fff)] shadow-[0_16px_40px_rgba(139,108,219,0.16)]'
: row.isEditing
? 'border-violet-200 bg-violet-50/70'
: row.statusLabel === 'Auto'
? 'border-amber-200 bg-amber-50/50'
: 'border-violet-100 bg-white/90 hover:border-violet-200'"
>
<!-- Card header -->
<div class="flex items-center justify-between gap-2 px-4 pt-4">
<span
class="rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.08em]"
:class="row.statusClass"
>
{{ row.statusLabel }}
</span>
<span class="h-2 w-2 rounded-full" :class="row.dotClass" />
</div>
<!-- Card body -->
<div class="flex-1 px-4 pb-2 pt-3">
<h3 class="text-base font-bold text-slate-900">{{ row.title }}</h3>
<p class="mt-1 text-xs leading-5 text-slate-500">{{ row.description }}</p>
<!-- Date display or edit -->
<div class="mt-3">
<template v-if="row.isEditing">
<div class="space-y-2">
<template v-if="row.key === 'show'">
<input v-model="draft.start" type="date" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
<input v-model="draft.showStartsAt" type="time" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
</template>
<template v-else>
<input v-model="draft.start" type="date" placeholder="Start" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
<input v-model="draft.end" type="date" placeholder="Ende" class="h-9 w-full rounded-xl border border-violet-200 bg-white px-3 text-xs outline-none focus:border-violet-400 focus:ring-2 focus:ring-violet-100" />
</template>
</div>
</template>
<template v-else>
<p class="text-sm font-semibold text-slate-700">{{ row.dateRange }}</p>
<!-- Countdown badge for active/upcoming phases -->
<span
v-if="phaseCountdown(row)"
class="mt-2 inline-flex items-center gap-1.5 rounded-full border border-violet-100 bg-violet-50 px-2.5 py-1 text-[11px] font-semibold"
:class="row.active && phaseCountdown(row) === 'Überfällig' ? 'border-rose-200 bg-rose-50 text-rose-700' : 'text-violet-700'"
>
<Clock class="h-3 w-3" />
{{ phaseCountdown(row) }}
</span>
</template>
</div>
</div>
<!-- Card actions -->
<div class="flex flex-wrap gap-2 px-4 pb-4 pt-2">
<template v-if="row.isEditing">
<Button size="sm" class="flex-1" :disabled="saving" @click="saveEdit(row)">OK</Button>
<button
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 hover:bg-violet-50"
@click="cancelEdit"
>
<X class="h-4 w-4" />
</button>
</template>
<template v-else>
<!-- Activate phase button -->
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 rounded-2xl px-3 py-2 text-xs font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
:class="row.active
? 'bg-violet-600 text-white'
: row.finalLocked
? 'border border-slate-200 bg-slate-50 text-slate-500'
: 'border border-violet-200 bg-white text-violet-700 hover:bg-violet-50'"
:disabled="saving || !selectedSeasonId || row.active || row.finalLocked"
@click="activatePhase(row.title)"
>
<CheckCircle2 v-if="row.active" class="h-3.5 w-3.5" />
<LockKeyhole v-else-if="row.finalLocked" class="h-3.5 w-3.5" />
<WandSparkles v-else class="h-3.5 w-3.5" />
{{ row.active ? 'Aktiv' : row.finalLocked ? 'Beenden nutzen' : 'Aktivieren' }}
</button>
<!-- Edit dates button -->
<button
v-if="row.editable"
type="button"
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-white text-violet-600 transition hover:bg-violet-50 disabled:opacity-50"
:disabled="saving || !selectedSeasonId"
title="Datum bearbeiten"
@click="startEdit(row)"
>
<Pencil class="h-3.5 w-3.5" />
</button>
<span v-else class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-100 bg-slate-50">
<LockKeyhole class="h-3.5 w-3.5 text-slate-400" />
</span>
</template>
</div>
</article>
</div>
</div>
<div class="border-t border-violet-100 bg-violet-50/30 px-6 py-3 text-xs leading-5 text-slate-500">
Timeline-Felder steuern Public-API, Vorschau und Teilnahme-Gates. Änderungen werden erst mit Speichern" gespeichert.
</div>
</Card>
<!-- Empty state -->
<Card v-else class="px-8 py-16 text-center">
<CalendarCog class="mx-auto h-8 w-8 text-violet-300" />
<p class="mt-4 text-sm text-slate-500">Noch keine Award-Jahre geladen.</p>
<Button class="mx-auto mt-5 gap-2" @click="openCreateModal">
<PlusCircle class="h-4 w-4" />
Erstes Jahr anlegen
</Button>
</Card>
</div>
<!-- Modals (unverändert) -->
<AdminSeasonCreateModal
:open="createModalOpen"
:create-form="createForm"
@@ -8,6 +8,7 @@ import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import AdminSettingsDatabaseCard from '../../components/admin/AdminSettingsDatabaseCard.vue'
import AdminSettingsOverviewBoard from '../../components/admin/AdminSettingsOverviewBoard.vue'
import { watchAdminErrorToast, watchAdminToast } from '../../composables/useAdminToast'
import { useAuthStore } from '../../stores/auth'
import { useAdminOperationalSettings } from '../../components/admin/useAdminOperationalSettings'
import { useAdminSettingsOverview } from '../../components/admin/useAdminSettingsOverview'
@@ -50,6 +51,9 @@ const {
saveOperationalSettings,
} = useAdminOperationalSettings()
watchAdminToast(operationalSuccess, operationalError)
watchAdminErrorToast(healthError)
function confirmDiscardOperationalChanges() {
if (!hasUnsavedOperationalChanges.value) {
return true
+172 -97
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { KeyRound, LockKeyhole, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { Check, KeyRound, LockKeyhole, Minus, Pencil, RefreshCw, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
import PasswordField from '../../components/ui/PasswordField.vue'
import { useAuthStore } from '../../stores/auth'
import type { AdminTeamMember } from '../../types/awards'
@@ -18,6 +20,7 @@ const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authSt
const passwordModalOpen = ref(false)
const createMemberModalOpen = ref(false)
const editMemberModalOpen = ref(false)
const rolePermissionsModalOpen = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
const ownPasswordForm = reactive({
@@ -29,6 +32,8 @@ const ownPasswordForm = reactive({
const {
loading,
saving,
refreshingPresence,
lastPresenceRefreshAt,
errorMessage,
successMessage,
generatedPassword,
@@ -39,10 +44,10 @@ const {
permissions,
memberForm,
selectedMember,
activeMembers,
pendingPasswordChanges,
assignableRoleOptions,
hasRoleChanges,
loadTeam,
startCreateMember,
startEditMember,
saveMember,
@@ -55,12 +60,16 @@ const {
saveRolePermissions,
} = useAdminTeamManager()
watchAdminToast(successMessage, errorMessage)
watchAdminToast(profileSuccess, profileError)
const memberPendingDeletion = computed(() =>
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
)
const onlineMembers = computed(() => members.value.filter((member) => member.isOnline).length)
const summaryItems = computed(() => [
{ label: 'Mitglieder', value: members.value.length, note: `${activeMembers.value} aktiv` },
{ label: 'Mitglieder', value: members.value.length, note: `${onlineMembers.value} online` },
{ label: 'Rollen', value: roles.value.length, note: 'Owner, Creator und Team' },
{ label: 'PW-Wechsel', value: pendingPasswordChanges.value, note: 'temporäre Passwörter offen' },
])
@@ -77,11 +86,30 @@ function formatDate(value: string | null) {
}).format(new Date(value))
}
function memberStatus(member: AdminTeamMember) {
function formatRefreshTime(value: Date | null) {
if (!value) return 'noch nicht aktualisiert'
return new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
}).format(value)
}
function memberAccountStatus(member: AdminTeamMember) {
if (!member.isActive) return 'Inaktiv'
return member.mustChangePassword ? 'PW-Wechsel offen' : 'Aktiv'
}
function memberOnlineStatus(member: AdminTeamMember) {
return member.isOnline ? 'Online' : 'Offline'
}
function rolePermissionLocked(roleKey: string) {
return roleKey === 'owner' || roleKey === 'creator'
}
function rolePermissionCount(roleKey: string) {
return permissions.value.filter((permission) => roleHasPermission(roleKey, permission.key)).length
}
function isOwnTeamAccount(member: AdminTeamMember) {
const teamLogin = authStore.session?.teamLogin?.toLowerCase()
const boundTwitchUserId = authStore.session?.boundTwitchUserId?.toLowerCase()
@@ -100,10 +128,6 @@ function canDeleteMember(member: AdminTeamMember) {
return true
}
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
}
function openCreateMemberModal() {
startCreateMember()
createMemberModalOpen.value = true
@@ -218,22 +242,8 @@ async function changeOwnPassword() {
Passwort ändern
</Button>
</div>
<div v-if="profileError" class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ profileError }}
</div>
<div v-if="profileSuccess" class="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{{ profileSuccess }}
</div>
</Card>
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{{ successMessage }}
</div>
<Card v-if="generatedPassword" class="border-amber-200 bg-amber-50/80 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
@@ -251,11 +261,37 @@ async function changeOwnPassword() {
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Mitglieder</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Logins und Status</h2>
<p class="mt-1 text-xs font-semibold text-slate-500">
Online = aktive Admin-Session in den letzten 5 Minuten. Auto-Refresh alle 30 Sekunden.
<span class="text-slate-400">Zuletzt geprüft: {{ formatRefreshTime(lastPresenceRefreshAt) }}</span>
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
size="sm"
type="button"
variant="ghost"
:disabled="loading || refreshingPresence"
@click="loadTeam({ silent: true })"
>
<RefreshCw class="mr-2 h-4 w-4" :class="refreshingPresence ? 'animate-spin' : ''" />
Aktualisieren
</Button>
<Button
size="sm"
type="button"
variant="ghost"
class="border border-violet-200 bg-violet-50 text-violet-700 shadow-sm shadow-violet-200/40 hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-100 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/60"
@click="rolePermissionsModalOpen = true"
>
<ShieldCheck class="mr-2 h-4 w-4" />
Rollenrechte
</Button>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
</Button>
</div>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
</Button>
</div>
<div v-if="loading" class="mt-5 rounded-xl border border-dashed border-violet-200 p-4 text-sm text-slate-500">
@@ -268,8 +304,8 @@ async function changeOwnPassword() {
<tr>
<th class="px-5 py-3">Login</th>
<th class="px-5 py-3">Rolle</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Letzter Login</th>
<th class="px-5 py-3">Online</th>
<th class="px-5 py-3">Zuletzt online</th>
<th class="px-5 py-3 text-right">Aktion</th>
</tr>
</thead>
@@ -288,19 +324,26 @@ async function changeOwnPassword() {
</td>
<td class="px-5 py-4 font-semibold text-slate-600">{{ roleLabel(member.role) }}</td>
<td class="px-5 py-4">
<span class="rounded-full border px-3 py-1 text-xs font-bold" :class="member.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
{{ memberStatus(member) }}
</span>
<div class="flex flex-col items-start gap-1.5">
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold" :class="member.isOnline ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
<span class="h-2 w-2 rounded-full" :class="member.isOnline ? 'bg-emerald-500' : 'bg-slate-300'" />
{{ memberOnlineStatus(member) }}
</span>
<span v-if="memberAccountStatus(member) !== 'Aktiv'" class="text-xs font-semibold text-amber-600">
{{ memberAccountStatus(member) }}
</span>
</div>
</td>
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastLoginAt) }}</td>
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastOnlineAt) }}</td>
<td class="px-5 py-4 text-right">
<div class="flex flex-wrap items-center justify-end gap-5">
<button
type="button"
class="text-sm font-bold text-slate-700 transition hover:text-violet-700 disabled:cursor-not-allowed disabled:text-slate-300"
class="inline-flex h-9 items-center gap-2 rounded-xl border border-violet-200 bg-white px-4 text-sm font-bold text-violet-700 shadow-sm shadow-violet-100/60 transition hover:-translate-y-0.5 hover:border-violet-300 hover:bg-violet-50 hover:text-violet-800 hover:shadow-lg hover:shadow-violet-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-300 disabled:shadow-none disabled:hover:translate-y-0"
:disabled="saving"
@click="openEditMemberModal(member)"
>
<Pencil class="h-4 w-4" />
Bearbeiten
</button>
<Button
@@ -315,7 +358,7 @@ async function changeOwnPassword() {
</Button>
<button
type="button"
class="inline-flex items-center gap-2 text-sm font-bold text-slate-700 transition hover:text-rose-600 disabled:cursor-not-allowed disabled:text-slate-300"
class="inline-flex h-9 items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-4 text-sm font-bold text-rose-700 shadow-sm shadow-rose-100/60 transition-all duration-200 hover:-translate-y-0.5 hover:border-rose-300 hover:bg-rose-100 hover:text-rose-800 hover:shadow-lg hover:shadow-rose-200/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-50 disabled:text-slate-300 disabled:shadow-none disabled:hover:translate-y-0"
:disabled="saving || !canDeleteMember(member)"
@click="openDeleteMemberModal(member)"
>
@@ -330,54 +373,86 @@ async function changeOwnPassword() {
</div>
</Card>
<Card class="p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<ShieldCheck class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
<Teleport to="body">
<div
v-if="rolePermissionsModalOpen"
class="fixed inset-0 z-[305] flex items-center justify-center bg-slate-950/50 p-3 backdrop-blur-sm md:p-6"
@click.self="rolePermissionsModalOpen = false"
>
<Card class="flex max-h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-[30px] bg-white p-0 shadow-[0_34px_100px_rgba(39,28,72,0.28)]">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-violet-100 bg-white px-5 py-4 md:px-6">
<div class="flex min-w-0 items-center gap-3">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-violet-50 text-violet-600 ring-1 ring-violet-100">
<ShieldCheck class="h-5 w-5" />
</span>
<div class="min-w-0">
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-950">Berechtigungen einstellen</h2>
</div>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button type="button" variant="ghost" @click="rolePermissionsModalOpen = false">Schließen</Button>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
Speichern
</Button>
</div>
</div>
</div>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
Speichern
</Button>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
<tr>
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
{{ role.label }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white/70">
<tr v-for="permission in permissions" :key="permission.key">
<td class="sticky left-0 z-10 max-w-[260px] bg-white px-3 py-3">
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
<span class="block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
<input
class="h-4 w-4 accent-violet-600 disabled:opacity-60"
type="checkbox"
:checked="roleHasPermission(role.key, permission.key)"
:disabled="role.key === 'owner' || role.key === 'creator'"
:aria-label="`${role.label}: ${permission.label}`"
@change="handlePermissionChange(role.key, permission.key, $event)"
>
</td>
</tr>
</tbody>
</table>
<div class="min-h-0 flex-1 overflow-auto bg-slate-50/70 p-3 md:p-5">
<table class="w-full min-w-[1040px] overflow-hidden rounded-2xl border border-violet-100 bg-white text-sm shadow-sm">
<thead class="sticky top-0 z-20 bg-violet-50 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500 shadow-[0_1px_0_rgba(221,214,254,0.85)]">
<tr>
<th class="sticky left-0 z-30 w-[340px] bg-violet-50 px-5 py-4">Bereich</th>
<th v-for="role in roles" :key="role.key" class="min-w-[112px] px-3 py-4 text-center">
<span class="block text-[11px] leading-4 text-violet-600">{{ role.label }}</span>
<span class="mt-1 block text-[10px] font-extrabold tracking-normal text-slate-400">
{{ rolePermissionCount(role.key) }}/{{ permissions.length }}
</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white">
<tr v-for="permission in permissions" :key="permission.key" class="transition hover:bg-violet-50/35">
<td class="sticky left-0 z-10 w-[340px] bg-white px-5 py-4 shadow-[1px_0_0_rgba(237,233,254,0.95)]">
<span class="block text-base font-bold leading-5 text-slate-950">{{ permission.label }}</span>
<span class="mt-1 block max-w-[300px] text-xs font-semibold leading-5 text-slate-500">{{ permission.description }}</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-4 text-center">
<button
type="button"
class="relative mx-auto grid h-9 w-9 place-items-center rounded-xl ring-1 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
:class="[
roleHasPermission(role.key, permission.key)
? rolePermissionLocked(role.key)
? 'bg-slate-100 text-slate-400 ring-slate-200'
: 'bg-violet-600 text-white shadow-lg shadow-violet-500/20 ring-violet-500 hover:bg-violet-500'
: rolePermissionLocked(role.key)
? 'bg-slate-50 text-slate-300 ring-slate-200'
: 'bg-white text-slate-300 ring-slate-300 hover:text-violet-500 hover:ring-violet-300',
]"
:disabled="rolePermissionLocked(role.key)"
:aria-label="`${role.label}: ${permission.label}`"
:aria-pressed="roleHasPermission(role.key, permission.key)"
@click="setRolePermission(role.key, permission.key, !roleHasPermission(role.key, permission.key))"
>
<Check v-if="roleHasPermission(role.key, permission.key)" class="h-5 w-5" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
<span
v-if="rolePermissionLocked(role.key) && roleHasPermission(role.key, permission.key)"
class="absolute -right-1 -top-1 grid h-4 w-4 place-items-center rounded-full bg-white text-slate-400 ring-1 ring-slate-200"
>
<LockKeyhole class="h-2.5 w-2.5" />
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</div>
</Card>
</Teleport>
<Teleport to="body">
<div
@@ -429,25 +504,28 @@ async function changeOwnPassword() {
<label class="grid gap-2 text-base font-bold text-slate-600">
Rolle
<select
<NativeSelect
v-model="memberForm.role"
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
:options="assignableRoleOptions"
/>
</label>
<label
v-if="editMemberModalOpen"
class="flex h-14 cursor-pointer items-center justify-between gap-3 rounded-2xl border px-4 text-base font-bold shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
:class="memberForm.isActive ? 'border-violet-200 bg-violet-50 text-violet-800 shadow-violet-100/60 hover:border-violet-300 hover:bg-violet-100' : 'border-slate-200 bg-white text-slate-600 shadow-slate-100 hover:border-slate-300 hover:bg-slate-50'"
>
<span>Account aktiv</span>
<span
class="grid h-9 w-9 place-items-center rounded-xl border transition"
:class="memberForm.isActive ? 'border-violet-500 bg-violet-600 text-white shadow-lg shadow-violet-500/20' : 'border-slate-300 bg-white text-slate-300'"
>
<option v-for="role in assignableRoleOptions" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
<Check v-if="memberForm.isActive" class="h-5 w-5" :stroke-width="3" />
<Minus v-else class="h-4 w-4" :stroke-width="3" />
</span>
<input v-model="memberForm.isActive" type="checkbox" class="sr-only">
</label>
<label v-if="editMemberModalOpen" class="flex h-14 items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 text-base font-bold text-slate-700">
Account aktiv
<input v-model="memberForm.isActive" type="checkbox" class="h-5 w-5 accent-violet-600">
</label>
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ errorMessage }}
</div>
<Button type="submit" :disabled="saving" class="h-14 text-base">
<Save class="mr-2 h-5 w-5" />
{{ saving ? 'Speichert...' : createMemberModalOpen ? 'Login erstellen' : 'Speichern' }}
@@ -520,9 +598,6 @@ async function changeOwnPassword() {
Passwort wiederholen
<PasswordField v-model="ownPasswordForm.confirmPassword" autocomplete="new-password" required minlength="10" input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
</label>
<div v-if="profileError" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ profileError }}
</div>
<Button type="submit" :disabled="authStore.loading">
<Save class="mr-2 h-4 w-4" />
{{ authStore.loading ? 'Speichert...' : 'Passwort speichern' }}
@@ -6,6 +6,7 @@ import AdminAuditFocusPanel from '../../components/admin/AdminAuditFocusPanel.vu
import AdminAuditLogList from '../../components/admin/AdminAuditLogList.vue'
import AdminAuditOverviewBar from '../../components/admin/AdminAuditOverviewBar.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { watchAdminErrorToast } from '../../composables/useAdminToast'
import { useAdminAuditManager } from '../../components/admin/useAdminAuditManager'
const {
@@ -42,6 +43,8 @@ const {
openAuditEntry,
closeAuditEntry,
} = useAdminAuditManager()
watchAdminErrorToast(auditError)
</script>
<template>
@@ -72,8 +75,6 @@ const {
@apply-preset="applyPreset"
/>
<p v-if="auditError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ auditError }}</p>
<section class="grid gap-6 2xl:grid-cols-[360px_minmax(0,1fr)]">
<AdminAuditFocusPanel
v-model:selected-admin="selectedAdmin"
+13 -11
View File
@@ -4,8 +4,10 @@ import { Award, CheckCircle2, Filter, Trash2, Trophy } from '@lucide/vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import AdminSeasonToolbar from '../../components/admin/AdminSeasonToolbar.vue'
import { useAdminWinnersManager } from '../../components/admin/useAdminWinnersManager'
import { watchAdminToast } from '../../composables/useAdminToast'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import NativeSelect from '../../components/ui/NativeSelect.vue'
const {
adminError,
@@ -22,6 +24,8 @@ const {
clearWinner,
saveWinner,
} = useAdminWinnersManager()
watchAdminToast(adminMessage, adminError)
</script>
<template>
@@ -98,9 +102,6 @@ const {
</div>
</div>
<p v-if="adminMessage" class="border-b border-emerald-100 bg-emerald-50 px-5 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
<p v-if="adminError" class="border-b border-rose-100 bg-rose-50 px-5 py-3 text-sm text-rose-700">{{ adminError }}</p>
<div v-if="visibleResultRows.length" class="divide-y divide-violet-50">
<article
v-for="row in visibleResultRows"
@@ -130,16 +131,17 @@ const {
</p>
</div>
<select
<NativeSelect
v-model="winnerSelections[row.category.id]"
:disabled="row.isEmpty"
class="h-12 min-w-0 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 disabled:bg-slate-50 disabled:text-slate-400"
>
<option value="">Bitte Gewinner waehlen</option>
<option v-for="candidate in row.candidates" :key="candidate.id" :value="`${candidate.id}`">
{{ candidate.displayName }} · {{ candidate.channelSlug }} · {{ candidate.platform }}
</option>
</select>
:options="[
{ label: 'Bitte Gewinner waehlen', value: '' },
...row.candidates.map((candidate) => ({
label: `${candidate.displayName} · ${candidate.channelSlug} · ${candidate.platform}`,
value: `${candidate.id}`,
})),
]"
/>
<div class="flex flex-wrap justify-end gap-2">
<Button :disabled="savingResultForCategory === row.category.id || row.isEmpty || !winnerSelections[row.category.id]" @click="saveWinner(row.category.id)">