Refine admin workflows and team access
This commit is contained in:
@@ -17,6 +17,8 @@ public sealed record AdminTeamMemberDto(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? UpdatedAt,
|
||||
DateTimeOffset? LastLoginAt,
|
||||
DateTimeOffset? LastOnlineAt,
|
||||
bool IsOnline,
|
||||
DateTimeOffset? TwitchBoundAt,
|
||||
DateTimeOffset? PasswordResetAt);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
+1471
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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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)]">
|
||||
<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,19 +64,35 @@ export function useAdminTeamManager() {
|
||||
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
|
||||
}
|
||||
|
||||
async function loadTeam() {
|
||||
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) {
|
||||
if (!silent) {
|
||||
errorMessage.value = error instanceof ApiRequestError
|
||||
? error.message
|
||||
: 'Team-Daten konnten nicht geladen werden.'
|
||||
}
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
loading.value = false
|
||||
}
|
||||
refreshingPresence.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCreateMember() {
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,43 +1,390 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="native-select" :class="{ 'native-select--open': open, 'native-select--disabled': disabled }">
|
||||
<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"
|
||||
class="native-select__native"
|
||||
:value="selectedKey"
|
||||
:disabled="disabled"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<option
|
||||
v-for="option in options"
|
||||
:key="`${option.value}`"
|
||||
:value="stringifyValue(option.value)"
|
||||
: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>
|
||||
@@ -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' },
|
||||
)
|
||||
}
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,13 +132,13 @@ const {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Attention + Insights -->
|
||||
<div class="space-y-6">
|
||||
<!-- Attention items -->
|
||||
<Card class="p-6">
|
||||
<div>
|
||||
<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">
|
||||
<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="item in attentionItems"
|
||||
:key="item.key"
|
||||
@@ -94,8 +151,13 @@ const {
|
||||
<span class="mt-1 block text-xs leading-5 opacity-80">{{ item.note }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="mt-6 grid gap-3 md:grid-cols-3">
|
||||
<!-- 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>
|
||||
@@ -104,64 +166,150 @@ const {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<!-- 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/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 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="max-h-[520px] overflow-y-auto p-4">
|
||||
<div class="grid gap-3">
|
||||
<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 categoryHealth"
|
||||
v-for="category in filteredHealth"
|
||||
: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]"
|
||||
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">
|
||||
<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 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>
|
||||
Stimmen
|
||||
<span class="text-violet-600 font-semibold">{{ category.votePct }}%</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center justify-center rounded-full border px-3 py-1 text-xs font-bold" :class="category.statusClass">
|
||||
|
||||
<!-- 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>
|
||||
</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 v-else class="px-5 py-12 text-center text-sm text-slate-500">
|
||||
Keine Kategorien für diesen Filter.
|
||||
</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>
|
||||
</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" />
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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,25 +126,51 @@ 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>
|
||||
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="space-y-6">
|
||||
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
|
||||
<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"
|
||||
@@ -123,13 +188,16 @@ function openFooterPreview(key: FooterPreviewKey) {
|
||||
: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"
|
||||
@@ -137,8 +205,7 @@ function openFooterPreview(key: FooterPreviewKey) {
|
||||
:save-site-settings="saveSiteSettings"
|
||||
@open-preview="privacyPreviewOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
|
||||
<AdminContentPrivacyPreviewModal
|
||||
:open="privacyPreviewOpen"
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 text-left"
|
||||
@click="categoryFilter = categoryFilter === category.id ? null : category.id"
|
||||
>
|
||||
<span class="min-w-0">
|
||||
<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>
|
||||
<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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<AdminSeasonTimelineEditor
|
||||
:form="form"
|
||||
:saving="saving"
|
||||
:selected-season-id="selectedSeasonId"
|
||||
:save-season="saveSeason"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
|
||||
@@ -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,12 +261,38 @@ 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>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-5 rounded-xl border border-dashed border-violet-200 p-4 text-sm text-slate-500">
|
||||
Team wird geladen...
|
||||
@@ -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) }}
|
||||
<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" />
|
||||
<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>
|
||||
<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-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
|
||||
<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 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">
|
||||
<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-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 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/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>
|
||||
<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-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'"
|
||||
<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}`"
|
||||
@change="handlePermissionChange(role.key, permission.key, $event)"
|
||||
: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>
|
||||
</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'"
|
||||
>
|
||||
<option v-for="role in assignableRoleOptions" :key="role.value" :value="role.value">
|
||||
{{ role.label }}
|
||||
</option>
|
||||
</select>
|
||||
<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'"
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user