Initial VTuber Awards implementation

This commit is contained in:
AzuTear
2026-06-17 11:35:45 +02:00
commit 670259a983
74 changed files with 15797 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
frontend/node_modules/
frontend/dist/
Backend/bin/
Backend/obj/
.DS_Store
+1
View File
@@ -0,0 +1 @@
VTSA_POSTGRES=Host=localhost;Port=5432;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@Backend_HostAddress = http://localhost:0
GET {{Backend_HostAddress}}/weatherforecast/
Accept: application/json
###
+78
View File
@@ -0,0 +1,78 @@
namespace Backend.Contracts;
public sealed record AdminMetricDto(string Label, int Value, string Note);
public sealed record AdminActivityDto(string Label, string Age);
public sealed record AdminTopCategoryDto(string Category, int Votes);
public sealed record AdminDashboardResponse(
IEnumerable<AdminMetricDto> Metrics,
IEnumerable<AdminActivityDto> Activities,
IEnumerable<AdminTopCategoryDto> TopCategories);
public sealed record AdminSeasonListItemDto(
int Id,
int Year,
string Name,
string CurrentPhase,
bool IsCurrent,
int CategoryCount);
public sealed record AdminCategoryItemDto(
int Id,
string GroupName,
string Name,
string Slug,
string Description,
int SortOrder,
int MaxNomineesPerUser,
int CandidateCount);
public sealed record AdminCandidateItemDto(
int Id,
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record AdminNominationReviewItemDto(
int Id,
int CategoryId,
string CategoryName,
string SubmittedByTwitchId,
string CandidateText,
DateTimeOffset CreatedAt);
public sealed record AdminSeasonDetailResponse(
int Id,
int Year,
string Name,
string CurrentPhase,
bool IsCurrent,
IEnumerable<AdminCategoryItemDto> Categories,
IEnumerable<AdminCandidateItemDto> Candidates,
IEnumerable<AdminNominationReviewItemDto> PendingNominations);
public sealed record UpdateSeasonRequest(
string CurrentPhase,
bool IsCurrent);
public sealed record UpsertCategoryRequest(
string GroupName,
string Name,
string Slug,
string Description,
int SortOrder,
int MaxNomineesPerUser);
public sealed record UpsertCandidateRequest(
int CategoryId,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record ApproveNominationRequest(
string? DisplayName,
string? ChannelSlug,
string? Platform);
+12
View File
@@ -0,0 +1,12 @@
namespace Backend.Contracts;
public sealed record LoginRequest(
string TwitchUserId,
string DisplayName,
string Role);
public sealed record AuthSessionDto(
string SessionToken,
string TwitchUserId,
string DisplayName,
string Role);
+79
View File
@@ -0,0 +1,79 @@
namespace Backend.Contracts;
public sealed record TimelineItem(
string Key,
string Title,
DateOnly StartsAt,
DateOnly EndsAt,
string State);
public sealed record FeaturedCategoryDto(
int Id,
string GroupName,
string Name,
string Description,
int MaxNomineesPerUser);
public sealed record WinnerPreviewDto(
int Year,
string Category,
string WinnerName,
string WinnerSlug);
public sealed record FaqItemDto(string Question, string Answer);
public sealed record OverviewResponse(
int SeasonId,
int Year,
string Title,
DateOnly ShowDate,
string CurrentPhase,
bool IsCommunityOnly,
string LoginProvider,
IEnumerable<TimelineItem> Timeline,
IEnumerable<FeaturedCategoryDto> FeaturedCategories,
IEnumerable<WinnerPreviewDto> WinnersPreview,
IEnumerable<FaqItemDto> Faq);
public sealed record CandidateSummaryDto(
int Id,
string DisplayName,
string ChannelSlug,
string Platform);
public sealed record PublicCategoryDetailDto(
int Id,
string Name,
string GroupName,
string Description,
int MaxNomineesPerUser,
IEnumerable<CandidateSummaryDto> Candidates);
public sealed record SeasonCategoriesResponse(
int SeasonId,
int Year,
IEnumerable<PublicCategoryDetailDto> Categories);
public sealed record WinnerArchiveItemDto(
string Category,
string WinnerName,
string WinnerSlug);
public sealed record WinnerArchiveResponse(
int Year,
IEnumerable<WinnerArchiveItemDto> Items);
public sealed record CreateNominationRequest(
int Year,
int CategoryId,
string TwitchUserId,
string[] Nominees);
public sealed record VoteEntryRequest(
int CategoryId,
int CandidateId);
public sealed record CreateVoteRequest(
int SeasonId,
string TwitchUserId,
VoteEntryRequest[] Entries);
+69
View File
@@ -0,0 +1,69 @@
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) : DbContext(options)
{
public DbSet<Season> Seasons => Set<Season>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<Candidate> Candidates => Set<Candidate>();
public DbSet<AwardResult> Results => Set<AwardResult>();
public DbSet<Nomination> Nominations => Set<Nomination>();
public DbSet<VoteBallot> VoteBallots => Set<VoteBallot>();
public DbSet<VoteEntry> VoteEntries => Set<VoteEntry>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Season>(entity =>
{
entity.HasIndex(item => item.Year).IsUnique();
entity.Property(item => item.Name).HasMaxLength(160);
entity.Property(item => item.CurrentPhase).HasMaxLength(60);
});
modelBuilder.Entity<Category>(entity =>
{
entity.HasIndex(item => new { item.SeasonId, item.Slug }).IsUnique();
entity.Property(item => item.GroupName).HasMaxLength(80);
entity.Property(item => item.Name).HasMaxLength(120);
entity.Property(item => item.Description).HasMaxLength(400);
});
modelBuilder.Entity<Candidate>(entity =>
{
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.ChannelSlug).HasMaxLength(120);
entity.Property(item => item.Platform).HasMaxLength(40);
});
modelBuilder.Entity<Nomination>(entity =>
{
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.CandidateText).HasMaxLength(120);
});
modelBuilder.Entity<VoteBallot>(entity =>
{
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.Status).HasMaxLength(30);
});
modelBuilder.Entity<AwardResult>(entity =>
{
entity.Property(item => item.CategoryName).HasMaxLength(120);
});
modelBuilder.Entity<UserSession>(entity =>
{
entity.HasIndex(item => item.SessionToken).IsUnique();
entity.Property(item => item.SessionToken).HasMaxLength(120);
entity.Property(item => item.TwitchUserId).HasMaxLength(120);
entity.Property(item => item.DisplayName).HasMaxLength(120);
entity.Property(item => item.Role).HasMaxLength(40);
});
SeedData.Apply(modelBuilder);
}
}
@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Backend.Data;
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AwardsDbContext>
{
public AwardsDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AwardsDbContext>();
var connectionString = Environment.GetEnvironmentVariable("VTSA_POSTGRES")
?? "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres";
optionsBuilder.UseNpgsql(connectionString);
return new AwardsDbContext(optionsBuilder.Options);
}
}
+125
View File
@@ -0,0 +1,125 @@
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static class SeedData
{
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Season>().HasData(
new Season
{
Id = 1,
Year = 2026,
Name = "VTuber Star Awards 2026",
IsCurrent = true,
IsCommunityOnly = true,
CurrentPhase = "Community Voting",
NominationStartsAt = new DateOnly(2026, 5, 1),
NominationEndsAt = new DateOnly(2026, 5, 31),
VotingStartsAt = new DateOnly(2026, 6, 1),
VotingEndsAt = new DateOnly(2026, 6, 30),
ReviewStartsAt = new DateOnly(2026, 7, 1),
ReviewEndsAt = new DateOnly(2026, 7, 10),
ShowDate = new DateOnly(2026, 7, 20),
},
new Season
{
Id = 2,
Year = 2025,
Name = "VTuber Star Awards 2025",
IsCurrent = false,
IsCommunityOnly = true,
CurrentPhase = "Archived",
NominationStartsAt = new DateOnly(2025, 5, 1),
NominationEndsAt = new DateOnly(2025, 5, 31),
VotingStartsAt = new DateOnly(2025, 6, 1),
VotingEndsAt = new DateOnly(2025, 6, 30),
ReviewStartsAt = new DateOnly(2025, 7, 1),
ReviewEndsAt = new DateOnly(2025, 7, 10),
ShowDate = new DateOnly(2025, 7, 20),
},
new Season
{
Id = 3,
Year = 2024,
Name = "VTuber Star Awards 2024",
IsCurrent = false,
IsCommunityOnly = true,
CurrentPhase = "Archived",
NominationStartsAt = new DateOnly(2024, 5, 1),
NominationEndsAt = new DateOnly(2024, 5, 31),
VotingStartsAt = new DateOnly(2024, 6, 1),
VotingEndsAt = new DateOnly(2024, 6, 30),
ReviewStartsAt = new DateOnly(2024, 7, 1),
ReviewEndsAt = new DateOnly(2024, 7, 10),
ShowDate = new DateOnly(2024, 7, 20),
},
new Season
{
Id = 4,
Year = 2023,
Name = "VTuber Star Awards 2023",
IsCurrent = false,
IsCommunityOnly = true,
CurrentPhase = "Archived",
NominationStartsAt = new DateOnly(2023, 5, 1),
NominationEndsAt = new DateOnly(2023, 5, 31),
VotingStartsAt = new DateOnly(2023, 6, 1),
VotingEndsAt = new DateOnly(2023, 6, 30),
ReviewStartsAt = new DateOnly(2023, 7, 1),
ReviewEndsAt = new DateOnly(2023, 7, 10),
ShowDate = new DateOnly(2023, 7, 20),
});
modelBuilder.Entity<Category>().HasData(
new Category { Id = 1, SeasonId = 1, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Die groesste Auszeichnung des Jahres.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 2, SeasonId = 1, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Events, Konzerte und 3D-Shows.", SortOrder = 2, MaxNomineesPerUser = 3 },
new Category { Id = 3, SeasonId = 1, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Der lustigste oder emotionalste Clip des Jahres.", SortOrder = 3, MaxNomineesPerUser = 3 },
new Category { Id = 4, SeasonId = 1, GroupName = "Main Awards", Name = "Beste Community", Slug = "beste-community", Description = "Die aktivste und freundlichste Community.", SortOrder = 4, MaxNomineesPerUser = 3 },
new Category { Id = 5, SeasonId = 2, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2025.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 6, SeasonId = 2, GroupName = "Performance", Name = "Bestes Live Event", Slug = "bestes-live-event", Description = "Archivkategorie 2025.", SortOrder = 2, MaxNomineesPerUser = 3 },
new Category { Id = 7, SeasonId = 2, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Archivkategorie 2025.", SortOrder = 3, MaxNomineesPerUser = 3 },
new Category { Id = 8, SeasonId = 3, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2024.", SortOrder = 1, MaxNomineesPerUser = 3 },
new Category { Id = 9, SeasonId = 3, GroupName = "Clips & Highlights", Name = "Clip des Jahres", Slug = "clip-des-jahres", Description = "Archivkategorie 2024.", SortOrder = 2, MaxNomineesPerUser = 3 },
new Category { Id = 10, SeasonId = 4, GroupName = "Main Awards", Name = "VTuber des Jahres", Slug = "vtuber-des-jahres", Description = "Archivkategorie 2023.", SortOrder = 1, MaxNomineesPerUser = 3 });
modelBuilder.Entity<Candidate>().HasData(
new Candidate { Id = 1, SeasonId = 1, CategoryId = 1, DisplayName = "Hoshimi Miyu", ChannelSlug = "@hoshimimiyu", Platform = "Twitch" },
new Candidate { Id = 2, SeasonId = 1, CategoryId = 1, DisplayName = "Kurainu", ChannelSlug = "@kurainu", Platform = "Twitch" },
new Candidate { Id = 3, SeasonId = 1, CategoryId = 1, DisplayName = "Shiro Ch.", ChannelSlug = "@shiroch", Platform = "Twitch" },
new Candidate { Id = 4, SeasonId = 1, CategoryId = 2, DisplayName = "Kurainu 3D Live", ChannelSlug = "@kurainu", Platform = "Twitch" },
new Candidate { Id = 5, SeasonId = 1, CategoryId = 2, DisplayName = "Aoi Sakura Showcase", ChannelSlug = "@aoisakura", Platform = "YouTube" },
new Candidate { Id = 6, SeasonId = 1, CategoryId = 3, DisplayName = "Pyonkichi Kingdom", ChannelSlug = "@pyonkichikingdom", Platform = "Twitch" },
new Candidate { Id = 7, SeasonId = 1, CategoryId = 4, DisplayName = "Moonrelay", ChannelSlug = "@moonrelay", Platform = "Twitch" },
new Candidate { Id = 8, SeasonId = 2, CategoryId = 5, DisplayName = "Hoshimi Miyu", ChannelSlug = "@hoshimimiyu", Platform = "Twitch" },
new Candidate { Id = 9, SeasonId = 2, CategoryId = 6, DisplayName = "Kurainu 3D Live", ChannelSlug = "@kurainu", Platform = "Twitch" },
new Candidate { Id = 10, SeasonId = 2, CategoryId = 7, DisplayName = "Pyonkichi Kingdom", ChannelSlug = "@pyonkichikingdom", Platform = "Twitch" },
new Candidate { Id = 11, SeasonId = 3, CategoryId = 8, DisplayName = "Aoi Sakura", ChannelSlug = "@aoisakura", Platform = "YouTube" },
new Candidate { Id = 12, SeasonId = 3, CategoryId = 9, DisplayName = "Starbyte", ChannelSlug = "@starbyte", Platform = "Twitch" },
new Candidate { Id = 13, SeasonId = 4, CategoryId = 10, DisplayName = "Tenshi Vox", ChannelSlug = "@tenshivox", Platform = "Twitch" });
modelBuilder.Entity<AwardResult>().HasData(
new AwardResult { Id = 1, SeasonId = 2, CandidateId = 8, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 2, SeasonId = 2, CandidateId = 9, CategoryName = "Bestes Live Event" },
new AwardResult { Id = 3, SeasonId = 2, CandidateId = 10, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 4, SeasonId = 3, CandidateId = 11, CategoryName = "VTuber des Jahres" },
new AwardResult { Id = 5, SeasonId = 3, CandidateId = 12, CategoryName = "Clip des Jahres" },
new AwardResult { Id = 6, SeasonId = 4, CandidateId = 13, CategoryName = "VTuber des Jahres" });
modelBuilder.Entity<Nomination>().HasData(
new Nomination { Id = 1, SeasonId = 1, CategoryId = 1, SubmittedByTwitchId = "twitch_hoshi", CandidateText = "Hoshimi Miyu", CreatedAt = new DateTimeOffset(2026, 6, 10, 13, 0, 0, TimeSpan.Zero) },
new Nomination { Id = 2, SeasonId = 1, CategoryId = 2, SubmittedByTwitchId = "twitch_kurainu", CandidateText = "Kurainu 3D Live", CreatedAt = new DateTimeOffset(2026, 6, 10, 14, 0, 0, TimeSpan.Zero) });
modelBuilder.Entity<VoteBallot>().HasData(
new VoteBallot { Id = 1, SeasonId = 1, SubmittedByTwitchId = "twitch_vote_1", Status = "submitted", SubmittedAt = new DateTimeOffset(2026, 6, 11, 12, 0, 0, TimeSpan.Zero) },
new VoteBallot { Id = 2, SeasonId = 1, SubmittedByTwitchId = "twitch_vote_2", Status = "submitted", SubmittedAt = new DateTimeOffset(2026, 6, 11, 12, 5, 0, TimeSpan.Zero) });
modelBuilder.Entity<VoteEntry>().HasData(
new VoteEntry { Id = 1, BallotId = 1, CategoryId = 1, CandidateId = 1 },
new VoteEntry { Id = 2, BallotId = 1, CategoryId = 2, CandidateId = 4 },
new VoteEntry { Id = 3, BallotId = 2, CategoryId = 1, CandidateId = 2 },
new VoteEntry { Id = 4, BallotId = 2, CategoryId = 3, CandidateId = 6 });
}
}
+24
View File
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public static class SessionBootstrapper
{
public static Task EnsureAsync(AwardsDbContext db) =>
db.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" uuid NOT NULL PRIMARY KEY,
"SessionToken" character varying(120) NOT NULL,
"TwitchUserId" character varying(120) NOT NULL,
"DisplayName" character varying(120) NOT NULL,
"Role" character varying(40) NOT NULL,
"CreatedAt" timestamp with time zone NOT NULL,
"LastSeenAt" timestamp with time zone NOT NULL,
"IsActive" boolean NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_UserSessions_SessionToken"
ON "UserSessions" ("SessionToken");
""");
}
+11
View File
@@ -0,0 +1,11 @@
namespace Backend.Domain;
public sealed class AwardResult
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public int CandidateId { get; set; }
public Candidate Candidate { get; set; } = null!;
public string CategoryName { get; set; } = string.Empty;
}
+13
View File
@@ -0,0 +1,13 @@
namespace Backend.Domain;
public sealed class Candidate
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
public string DisplayName { get; set; } = string.Empty;
public string ChannelSlug { get; set; } = string.Empty;
public string Platform { get; set; } = "Twitch";
}
+15
View File
@@ -0,0 +1,15 @@
namespace Backend.Domain;
public sealed class Category
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public string GroupName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int SortOrder { get; set; }
public int MaxNomineesPerUser { get; set; }
public ICollection<Candidate> Candidates { get; set; } = [];
}
+15
View File
@@ -0,0 +1,15 @@
namespace Backend.Domain;
public sealed class Nomination
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
public string SubmittedByTwitchId { get; set; } = string.Empty;
public int? CandidateId { get; set; }
public Candidate? Candidate { get; set; }
public string? CandidateText { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
namespace Backend.Domain;
public sealed class Season
{
public int Id { get; set; }
public int Year { get; set; }
public string Name { get; set; } = string.Empty;
public bool IsCurrent { get; set; }
public bool IsCommunityOnly { get; set; }
public string CurrentPhase { get; set; } = string.Empty;
public DateOnly NominationStartsAt { get; set; }
public DateOnly NominationEndsAt { get; set; }
public DateOnly VotingStartsAt { get; set; }
public DateOnly VotingEndsAt { get; set; }
public DateOnly ReviewStartsAt { get; set; }
public DateOnly ReviewEndsAt { get; set; }
public DateOnly ShowDate { get; set; }
public ICollection<Category> Categories { get; set; } = [];
public ICollection<AwardResult> Results { get; set; } = [];
}
+13
View File
@@ -0,0 +1,13 @@
namespace Backend.Domain;
public sealed class UserSession
{
public Guid Id { get; set; }
public string SessionToken { get; set; } = string.Empty;
public string TwitchUserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Role { get; set; } = "viewer";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public bool IsActive { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace Backend.Domain;
public sealed class VoteBallot
{
public int Id { get; set; }
public int SeasonId { get; set; }
public Season Season { get; set; } = null!;
public string SubmittedByTwitchId { get; set; } = string.Empty;
public string Status { get; set; } = "draft";
public DateTimeOffset SubmittedAt { get; set; }
public ICollection<VoteEntry> Entries { get; set; } = [];
}
+12
View File
@@ -0,0 +1,12 @@
namespace Backend.Domain;
public sealed class VoteEntry
{
public int Id { get; set; }
public int BallotId { get; set; }
public VoteBallot Ballot { get; set; } = null!;
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
public int CandidateId { get; set; }
public Candidate Candidate { get; set; } = null!;
}
@@ -0,0 +1,833 @@
// <auto-generated />
using System;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
[DbContext(typeof(AwardsDbContext))]
[Migration("20260617060000_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CandidateId")
.HasColumnType("integer");
b.Property<string>("CategoryName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CandidateId");
b.HasIndex("SeasonId");
b.ToTable("Results");
b.HasData(
new
{
Id = 1,
CandidateId = 8,
CategoryName = "VTuber des Jahres",
SeasonId = 2
},
new
{
Id = 2,
CandidateId = 9,
CategoryName = "Bestes Live Event",
SeasonId = 2
},
new
{
Id = 3,
CandidateId = 10,
CategoryName = "Clip des Jahres",
SeasonId = 2
},
new
{
Id = 4,
CandidateId = 11,
CategoryName = "VTuber des Jahres",
SeasonId = 3
},
new
{
Id = 5,
CandidateId = 12,
CategoryName = "Clip des Jahres",
SeasonId = 3
},
new
{
Id = 6,
CandidateId = 13,
CategoryName = "VTuber des Jahres",
SeasonId = 4
});
});
modelBuilder.Entity("Backend.Domain.Candidate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<string>("ChannelSlug")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("SeasonId");
b.ToTable("Candidates");
b.HasData(
new
{
Id = 1,
CategoryId = 1,
ChannelSlug = "@hoshimimiyu",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 2,
CategoryId = 1,
ChannelSlug = "@kurainu",
DisplayName = "Kurainu",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 3,
CategoryId = 1,
ChannelSlug = "@shiroch",
DisplayName = "Shiro Ch.",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 4,
CategoryId = 2,
ChannelSlug = "@kurainu",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 5,
CategoryId = 2,
ChannelSlug = "@aoisakura",
DisplayName = "Aoi Sakura Showcase",
Platform = "YouTube",
SeasonId = 1
},
new
{
Id = 6,
CategoryId = 3,
ChannelSlug = "@pyonkichikingdom",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 7,
CategoryId = 4,
ChannelSlug = "@moonrelay",
DisplayName = "Moonrelay",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 8,
CategoryId = 5,
ChannelSlug = "@hoshimimiyu",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 2
},
new
{
Id = 9,
CategoryId = 6,
ChannelSlug = "@kurainu",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 2
},
new
{
Id = 10,
CategoryId = 7,
ChannelSlug = "@pyonkichikingdom",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 2
},
new
{
Id = 11,
CategoryId = 8,
ChannelSlug = "@aoisakura",
DisplayName = "Aoi Sakura",
Platform = "YouTube",
SeasonId = 3
},
new
{
Id = 12,
CategoryId = 9,
ChannelSlug = "@starbyte",
DisplayName = "Starbyte",
Platform = "Twitch",
SeasonId = 3
},
new
{
Id = 13,
CategoryId = 10,
ChannelSlug = "@tenshivox",
DisplayName = "Tenshi Vox",
Platform = "Twitch",
SeasonId = 4
});
});
modelBuilder.Entity("Backend.Domain.Category", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("GroupName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<int>("MaxNomineesPerUser")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SeasonId", "Slug")
.IsUnique();
b.ToTable("Categories");
b.HasData(
new
{
Id = 1,
Description = "Die groesste Auszeichnung des Jahres.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 1,
Slug = "vtuber-des-jahres",
SortOrder = 1
},
new
{
Id = 2,
Description = "Events, Konzerte und 3D-Shows.",
GroupName = "Performance",
MaxNomineesPerUser = 3,
Name = "Bestes Live Event",
SeasonId = 1,
Slug = "bestes-live-event",
SortOrder = 2
},
new
{
Id = 3,
Description = "Der lustigste oder emotionalste Clip des Jahres.",
GroupName = "Clips & Highlights",
MaxNomineesPerUser = 3,
Name = "Clip des Jahres",
SeasonId = 1,
Slug = "clip-des-jahres",
SortOrder = 3
},
new
{
Id = 4,
Description = "Die aktivste und freundlichste Community.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "Beste Community",
SeasonId = 1,
Slug = "beste-community",
SortOrder = 4
},
new
{
Id = 5,
Description = "Archivkategorie 2025.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 2,
Slug = "vtuber-des-jahres",
SortOrder = 1
},
new
{
Id = 6,
Description = "Archivkategorie 2025.",
GroupName = "Performance",
MaxNomineesPerUser = 3,
Name = "Bestes Live Event",
SeasonId = 2,
Slug = "bestes-live-event",
SortOrder = 2
},
new
{
Id = 7,
Description = "Archivkategorie 2025.",
GroupName = "Clips & Highlights",
MaxNomineesPerUser = 3,
Name = "Clip des Jahres",
SeasonId = 2,
Slug = "clip-des-jahres",
SortOrder = 3
},
new
{
Id = 8,
Description = "Archivkategorie 2024.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 3,
Slug = "vtuber-des-jahres",
SortOrder = 1
},
new
{
Id = 9,
Description = "Archivkategorie 2024.",
GroupName = "Clips & Highlights",
MaxNomineesPerUser = 3,
Name = "Clip des Jahres",
SeasonId = 3,
Slug = "clip-des-jahres",
SortOrder = 2
},
new
{
Id = 10,
Description = "Archivkategorie 2023.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 4,
Slug = "vtuber-des-jahres",
SortOrder = 1
});
});
modelBuilder.Entity("Backend.Domain.Nomination", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CandidateId")
.HasColumnType("integer");
b.Property<string>("CandidateText")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("SubmittedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("CandidateId");
b.HasIndex("CategoryId");
b.HasIndex("SeasonId");
b.ToTable("Nominations");
b.HasData(
new
{
Id = 1,
CandidateText = "Hoshimi Miyu",
CategoryId = 1,
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SeasonId = 1,
SubmittedByTwitchId = "twitch_hoshi"
},
new
{
Id = 2,
CandidateText = "Kurainu 3D Live",
CategoryId = 2,
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SeasonId = 1,
SubmittedByTwitchId = "twitch_kurainu"
});
});
modelBuilder.Entity("Backend.Domain.Season", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CurrentPhase")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsCommunityOnly")
.HasColumnType("boolean");
b.Property<bool>("IsCurrent")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateOnly>("NominationEndsAt")
.HasColumnType("date");
b.Property<DateOnly>("NominationStartsAt")
.HasColumnType("date");
b.Property<DateOnly>("ReviewEndsAt")
.HasColumnType("date");
b.Property<DateOnly>("ReviewStartsAt")
.HasColumnType("date");
b.Property<DateOnly>("ShowDate")
.HasColumnType("date");
b.Property<DateOnly>("VotingEndsAt")
.HasColumnType("date");
b.Property<DateOnly>("VotingStartsAt")
.HasColumnType("date");
b.Property<int>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Year")
.IsUnique();
b.ToTable("Seasons");
b.HasData(
new
{
Id = 1,
CurrentPhase = "Community Voting",
IsCommunityOnly = true,
IsCurrent = true,
Name = "VTuber Star Awards 2026",
NominationEndsAt = new DateOnly(2026, 5, 31),
NominationStartsAt = new DateOnly(2026, 5, 1),
ReviewEndsAt = new DateOnly(2026, 7, 10),
ReviewStartsAt = new DateOnly(2026, 7, 1),
ShowDate = new DateOnly(2026, 7, 20),
VotingEndsAt = new DateOnly(2026, 6, 30),
VotingStartsAt = new DateOnly(2026, 6, 1),
Year = 2026
},
new
{
Id = 2,
CurrentPhase = "Archived",
IsCommunityOnly = true,
IsCurrent = false,
Name = "VTuber Star Awards 2025",
NominationEndsAt = new DateOnly(2025, 5, 31),
NominationStartsAt = new DateOnly(2025, 5, 1),
ReviewEndsAt = new DateOnly(2025, 7, 10),
ReviewStartsAt = new DateOnly(2025, 7, 1),
ShowDate = new DateOnly(2025, 7, 20),
VotingEndsAt = new DateOnly(2025, 6, 30),
VotingStartsAt = new DateOnly(2025, 6, 1),
Year = 2025
},
new
{
Id = 3,
CurrentPhase = "Archived",
IsCommunityOnly = true,
IsCurrent = false,
Name = "VTuber Star Awards 2024",
NominationEndsAt = new DateOnly(2024, 5, 31),
NominationStartsAt = new DateOnly(2024, 5, 1),
ReviewEndsAt = new DateOnly(2024, 7, 10),
ReviewStartsAt = new DateOnly(2024, 7, 1),
ShowDate = new DateOnly(2024, 7, 20),
VotingEndsAt = new DateOnly(2024, 6, 30),
VotingStartsAt = new DateOnly(2024, 6, 1),
Year = 2024
},
new
{
Id = 4,
CurrentPhase = "Archived",
IsCommunityOnly = true,
IsCurrent = false,
Name = "VTuber Star Awards 2023",
NominationEndsAt = new DateOnly(2023, 5, 31),
NominationStartsAt = new DateOnly(2023, 5, 1),
ReviewEndsAt = new DateOnly(2023, 7, 10),
ReviewStartsAt = new DateOnly(2023, 7, 1),
ShowDate = new DateOnly(2023, 7, 20),
VotingEndsAt = new DateOnly(2023, 6, 30),
VotingStartsAt = new DateOnly(2023, 6, 1),
Year = 2023
});
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTimeOffset>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SubmittedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("SeasonId");
b.ToTable("VoteBallots");
b.HasData(
new
{
Id = 1,
SeasonId = 1,
Status = "submitted",
SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SubmittedByTwitchId = "twitch_vote_1"
},
new
{
Id = 2,
SeasonId = 1,
Status = "submitted",
SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SubmittedByTwitchId = "twitch_vote_2"
});
});
modelBuilder.Entity("Backend.Domain.VoteEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BallotId")
.HasColumnType("integer");
b.Property<int>("CandidateId")
.HasColumnType("integer");
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BallotId");
b.HasIndex("CandidateId");
b.HasIndex("CategoryId");
b.ToTable("VoteEntries");
b.HasData(
new
{
Id = 1,
BallotId = 1,
CandidateId = 1,
CategoryId = 1
},
new
{
Id = 2,
BallotId = 1,
CandidateId = 4,
CategoryId = 2
},
new
{
Id = 3,
BallotId = 2,
CandidateId = 2,
CategoryId = 1
},
new
{
Id = 4,
BallotId = 2,
CandidateId = 6,
CategoryId = 3
});
});
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season")
.WithMany("Results")
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Candidate");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Candidate", b =>
{
b.HasOne("Backend.Domain.Category", "Category")
.WithMany("Candidates")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Category", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany("Categories")
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Nomination", b =>
{
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId");
b.HasOne("Backend.Domain.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Candidate");
b.Navigation("Category");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteEntry", b =>
{
b.HasOne("Backend.Domain.VoteBallot", "Ballot")
.WithMany("Entries")
.HasForeignKey("BallotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ballot");
b.Navigation("Candidate");
b.Navigation("Category");
});
modelBuilder.Entity("Backend.Domain.Category", b =>
{
b.Navigation("Candidates");
});
modelBuilder.Entity("Backend.Domain.Season", b =>
{
b.Navigation("Categories");
b.Navigation("Results");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,395 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Seasons",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Year = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
IsCurrent = table.Column<bool>(type: "boolean", nullable: false),
IsCommunityOnly = table.Column<bool>(type: "boolean", nullable: false),
CurrentPhase = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
NominationStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
NominationEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
VotingStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
VotingEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
ReviewStartsAt = table.Column<DateOnly>(type: "date", nullable: false),
ReviewEndsAt = table.Column<DateOnly>(type: "date", nullable: false),
ShowDate = table.Column<DateOnly>(type: "date", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Seasons", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
GroupName = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Slug = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
MaxNomineesPerUser = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
table.ForeignKey(
name: "FK_Categories_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VoteBallots",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
SubmittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VoteBallots", x => x.Id);
table.ForeignKey(
name: "FK_VoteBallots_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Candidates",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
CategoryId = table.Column<int>(type: "integer", nullable: false),
DisplayName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
ChannelSlug = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Platform = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Candidates", x => x.Id);
table.ForeignKey(
name: "FK_Candidates_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Candidates_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Nominations",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
CategoryId = table.Column<int>(type: "integer", nullable: false),
SubmittedByTwitchId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
CandidateId = table.Column<int>(type: "integer", nullable: true),
CandidateText = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Nominations", x => x.Id);
table.ForeignKey(
name: "FK_Nominations_Candidates_CandidateId",
column: x => x.CandidateId,
principalTable: "Candidates",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Nominations_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Nominations_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Results",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SeasonId = table.Column<int>(type: "integer", nullable: false),
CandidateId = table.Column<int>(type: "integer", nullable: false),
CategoryName = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Results", x => x.Id);
table.ForeignKey(
name: "FK_Results_Candidates_CandidateId",
column: x => x.CandidateId,
principalTable: "Candidates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Results_Seasons_SeasonId",
column: x => x.SeasonId,
principalTable: "Seasons",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VoteEntries",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BallotId = table.Column<int>(type: "integer", nullable: false),
CategoryId = table.Column<int>(type: "integer", nullable: false),
CandidateId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VoteEntries", x => x.Id);
table.ForeignKey(
name: "FK_VoteEntries_Candidates_CandidateId",
column: x => x.CandidateId,
principalTable: "Candidates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VoteEntries_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VoteEntries_VoteBallots_BallotId",
column: x => x.BallotId,
principalTable: "VoteBallots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "Seasons",
columns: new[] { "Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year" },
values: new object[,]
{
{ 1, "Community Voting", true, true, "VTuber Star Awards 2026", new DateOnly(2026, 5, 31), new DateOnly(2026, 5, 1), new DateOnly(2026, 7, 10), new DateOnly(2026, 7, 1), new DateOnly(2026, 7, 20), new DateOnly(2026, 6, 30), new DateOnly(2026, 6, 1), 2026 },
{ 2, "Archived", true, false, "VTuber Star Awards 2025", new DateOnly(2025, 5, 31), new DateOnly(2025, 5, 1), new DateOnly(2025, 7, 10), new DateOnly(2025, 7, 1), new DateOnly(2025, 7, 20), new DateOnly(2025, 6, 30), new DateOnly(2025, 6, 1), 2025 },
{ 3, "Archived", true, false, "VTuber Star Awards 2024", new DateOnly(2024, 5, 31), new DateOnly(2024, 5, 1), new DateOnly(2024, 7, 10), new DateOnly(2024, 7, 1), new DateOnly(2024, 7, 20), new DateOnly(2024, 6, 30), new DateOnly(2024, 6, 1), 2024 },
{ 4, "Archived", true, false, "VTuber Star Awards 2023", new DateOnly(2023, 5, 31), new DateOnly(2023, 5, 1), new DateOnly(2023, 7, 10), new DateOnly(2023, 7, 1), new DateOnly(2023, 7, 20), new DateOnly(2023, 6, 30), new DateOnly(2023, 6, 1), 2023 }
});
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder" },
values: new object[,]
{
{ 1, "Die groesste Auszeichnung des Jahres.", "Main Awards", 3, "VTuber des Jahres", 1, "vtuber-des-jahres", 1 },
{ 2, "Events, Konzerte und 3D-Shows.", "Performance", 3, "Bestes Live Event", 1, "bestes-live-event", 2 },
{ 3, "Der lustigste oder emotionalste Clip des Jahres.", "Clips & Highlights", 3, "Clip des Jahres", 1, "clip-des-jahres", 3 },
{ 4, "Die aktivste und freundlichste Community.", "Main Awards", 3, "Beste Community", 1, "beste-community", 4 },
{ 5, "Archivkategorie 2025.", "Main Awards", 3, "VTuber des Jahres", 2, "vtuber-des-jahres", 1 },
{ 6, "Archivkategorie 2025.", "Performance", 3, "Bestes Live Event", 2, "bestes-live-event", 2 },
{ 7, "Archivkategorie 2025.", "Clips & Highlights", 3, "Clip des Jahres", 2, "clip-des-jahres", 3 },
{ 8, "Archivkategorie 2024.", "Main Awards", 3, "VTuber des Jahres", 3, "vtuber-des-jahres", 1 },
{ 9, "Archivkategorie 2024.", "Clips & Highlights", 3, "Clip des Jahres", 3, "clip-des-jahres", 2 },
{ 10, "Archivkategorie 2023.", "Main Awards", 3, "VTuber des Jahres", 4, "vtuber-des-jahres", 1 }
});
migrationBuilder.InsertData(
table: "VoteBallots",
columns: new[] { "Id", "SeasonId", "Status", "SubmittedAt", "SubmittedByTwitchId" },
values: new object[,]
{
{ 1, 1, "submitted", new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "twitch_vote_1" },
{ 2, 1, "submitted", new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), "twitch_vote_2" }
});
migrationBuilder.InsertData(
table: "Candidates",
columns: new[] { "Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId" },
values: new object[,]
{
{ 1, 1, "@hoshimimiyu", "Hoshimi Miyu", "Twitch", 1 },
{ 2, 1, "@kurainu", "Kurainu", "Twitch", 1 },
{ 3, 1, "@shiroch", "Shiro Ch.", "Twitch", 1 },
{ 4, 2, "@kurainu", "Kurainu 3D Live", "Twitch", 1 },
{ 5, 2, "@aoisakura", "Aoi Sakura Showcase", "YouTube", 1 },
{ 6, 3, "@pyonkichikingdom", "Pyonkichi Kingdom", "Twitch", 1 },
{ 7, 4, "@moonrelay", "Moonrelay", "Twitch", 1 },
{ 8, 5, "@hoshimimiyu", "Hoshimi Miyu", "Twitch", 2 },
{ 9, 6, "@kurainu", "Kurainu 3D Live", "Twitch", 2 },
{ 10, 7, "@pyonkichikingdom", "Pyonkichi Kingdom", "Twitch", 2 },
{ 11, 8, "@aoisakura", "Aoi Sakura", "YouTube", 3 },
{ 12, 9, "@starbyte", "Starbyte", "Twitch", 3 },
{ 13, 10, "@tenshivox", "Tenshi Vox", "Twitch", 4 }
});
migrationBuilder.InsertData(
table: "Nominations",
columns: new[] { "Id", "CandidateId", "CandidateText", "CategoryId", "CreatedAt", "SeasonId", "SubmittedByTwitchId" },
values: new object[,]
{
{ 1, null, "Hoshimi Miyu", 1, new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 1, "twitch_hoshi" },
{ 2, null, "Kurainu 3D Live", 2, new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), 1, "twitch_kurainu" }
});
migrationBuilder.InsertData(
table: "Results",
columns: new[] { "Id", "CandidateId", "CategoryName", "SeasonId" },
values: new object[,]
{
{ 1, 8, "VTuber des Jahres", 2 },
{ 2, 9, "Bestes Live Event", 2 },
{ 3, 10, "Clip des Jahres", 2 },
{ 4, 11, "VTuber des Jahres", 3 },
{ 5, 12, "Clip des Jahres", 3 },
{ 6, 13, "VTuber des Jahres", 4 }
});
migrationBuilder.InsertData(
table: "VoteEntries",
columns: new[] { "Id", "BallotId", "CandidateId", "CategoryId" },
values: new object[,]
{
{ 1, 1, 1, 1 },
{ 2, 1, 4, 2 },
{ 3, 2, 2, 1 },
{ 4, 2, 6, 3 }
});
migrationBuilder.CreateIndex(
name: "IX_Candidates_CategoryId",
table: "Candidates",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Candidates_SeasonId",
table: "Candidates",
column: "SeasonId");
migrationBuilder.CreateIndex(
name: "IX_Categories_SeasonId_Slug",
table: "Categories",
columns: new[] { "SeasonId", "Slug" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Nominations_CandidateId",
table: "Nominations",
column: "CandidateId");
migrationBuilder.CreateIndex(
name: "IX_Nominations_CategoryId",
table: "Nominations",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Nominations_SeasonId",
table: "Nominations",
column: "SeasonId");
migrationBuilder.CreateIndex(
name: "IX_Results_CandidateId",
table: "Results",
column: "CandidateId");
migrationBuilder.CreateIndex(
name: "IX_Results_SeasonId",
table: "Results",
column: "SeasonId");
migrationBuilder.CreateIndex(
name: "IX_Seasons_Year",
table: "Seasons",
column: "Year",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VoteBallots_SeasonId",
table: "VoteBallots",
column: "SeasonId");
migrationBuilder.CreateIndex(
name: "IX_VoteEntries_BallotId",
table: "VoteEntries",
column: "BallotId");
migrationBuilder.CreateIndex(
name: "IX_VoteEntries_CandidateId",
table: "VoteEntries",
column: "CandidateId");
migrationBuilder.CreateIndex(
name: "IX_VoteEntries_CategoryId",
table: "VoteEntries",
column: "CategoryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Nominations");
migrationBuilder.DropTable(
name: "Results");
migrationBuilder.DropTable(
name: "VoteEntries");
migrationBuilder.DropTable(
name: "Candidates");
migrationBuilder.DropTable(
name: "VoteBallots");
migrationBuilder.DropTable(
name: "Categories");
migrationBuilder.DropTable(
name: "Seasons");
}
}
}
@@ -0,0 +1,830 @@
// <auto-generated />
using System;
using Backend.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Backend.Migrations
{
[DbContext(typeof(AwardsDbContext))]
partial class AwardsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CandidateId")
.HasColumnType("integer");
b.Property<string>("CategoryName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CandidateId");
b.HasIndex("SeasonId");
b.ToTable("Results");
b.HasData(
new
{
Id = 1,
CandidateId = 8,
CategoryName = "VTuber des Jahres",
SeasonId = 2
},
new
{
Id = 2,
CandidateId = 9,
CategoryName = "Bestes Live Event",
SeasonId = 2
},
new
{
Id = 3,
CandidateId = 10,
CategoryName = "Clip des Jahres",
SeasonId = 2
},
new
{
Id = 4,
CandidateId = 11,
CategoryName = "VTuber des Jahres",
SeasonId = 3
},
new
{
Id = 5,
CandidateId = 12,
CategoryName = "Clip des Jahres",
SeasonId = 3
},
new
{
Id = 6,
CandidateId = 13,
CategoryName = "VTuber des Jahres",
SeasonId = 4
});
});
modelBuilder.Entity("Backend.Domain.Candidate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<string>("ChannelSlug")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Platform")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("SeasonId");
b.ToTable("Candidates");
b.HasData(
new
{
Id = 1,
CategoryId = 1,
ChannelSlug = "@hoshimimiyu",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 2,
CategoryId = 1,
ChannelSlug = "@kurainu",
DisplayName = "Kurainu",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 3,
CategoryId = 1,
ChannelSlug = "@shiroch",
DisplayName = "Shiro Ch.",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 4,
CategoryId = 2,
ChannelSlug = "@kurainu",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 5,
CategoryId = 2,
ChannelSlug = "@aoisakura",
DisplayName = "Aoi Sakura Showcase",
Platform = "YouTube",
SeasonId = 1
},
new
{
Id = 6,
CategoryId = 3,
ChannelSlug = "@pyonkichikingdom",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 7,
CategoryId = 4,
ChannelSlug = "@moonrelay",
DisplayName = "Moonrelay",
Platform = "Twitch",
SeasonId = 1
},
new
{
Id = 8,
CategoryId = 5,
ChannelSlug = "@hoshimimiyu",
DisplayName = "Hoshimi Miyu",
Platform = "Twitch",
SeasonId = 2
},
new
{
Id = 9,
CategoryId = 6,
ChannelSlug = "@kurainu",
DisplayName = "Kurainu 3D Live",
Platform = "Twitch",
SeasonId = 2
},
new
{
Id = 10,
CategoryId = 7,
ChannelSlug = "@pyonkichikingdom",
DisplayName = "Pyonkichi Kingdom",
Platform = "Twitch",
SeasonId = 2
},
new
{
Id = 11,
CategoryId = 8,
ChannelSlug = "@aoisakura",
DisplayName = "Aoi Sakura",
Platform = "YouTube",
SeasonId = 3
},
new
{
Id = 12,
CategoryId = 9,
ChannelSlug = "@starbyte",
DisplayName = "Starbyte",
Platform = "Twitch",
SeasonId = 3
},
new
{
Id = 13,
CategoryId = 10,
ChannelSlug = "@tenshivox",
DisplayName = "Tenshi Vox",
Platform = "Twitch",
SeasonId = 4
});
});
modelBuilder.Entity("Backend.Domain.Category", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("GroupName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<int>("MaxNomineesPerUser")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("SeasonId", "Slug")
.IsUnique();
b.ToTable("Categories");
b.HasData(
new
{
Id = 1,
Description = "Die groesste Auszeichnung des Jahres.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 1,
Slug = "vtuber-des-jahres",
SortOrder = 1
},
new
{
Id = 2,
Description = "Events, Konzerte und 3D-Shows.",
GroupName = "Performance",
MaxNomineesPerUser = 3,
Name = "Bestes Live Event",
SeasonId = 1,
Slug = "bestes-live-event",
SortOrder = 2
},
new
{
Id = 3,
Description = "Der lustigste oder emotionalste Clip des Jahres.",
GroupName = "Clips & Highlights",
MaxNomineesPerUser = 3,
Name = "Clip des Jahres",
SeasonId = 1,
Slug = "clip-des-jahres",
SortOrder = 3
},
new
{
Id = 4,
Description = "Die aktivste und freundlichste Community.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "Beste Community",
SeasonId = 1,
Slug = "beste-community",
SortOrder = 4
},
new
{
Id = 5,
Description = "Archivkategorie 2025.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 2,
Slug = "vtuber-des-jahres",
SortOrder = 1
},
new
{
Id = 6,
Description = "Archivkategorie 2025.",
GroupName = "Performance",
MaxNomineesPerUser = 3,
Name = "Bestes Live Event",
SeasonId = 2,
Slug = "bestes-live-event",
SortOrder = 2
},
new
{
Id = 7,
Description = "Archivkategorie 2025.",
GroupName = "Clips & Highlights",
MaxNomineesPerUser = 3,
Name = "Clip des Jahres",
SeasonId = 2,
Slug = "clip-des-jahres",
SortOrder = 3
},
new
{
Id = 8,
Description = "Archivkategorie 2024.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 3,
Slug = "vtuber-des-jahres",
SortOrder = 1
},
new
{
Id = 9,
Description = "Archivkategorie 2024.",
GroupName = "Clips & Highlights",
MaxNomineesPerUser = 3,
Name = "Clip des Jahres",
SeasonId = 3,
Slug = "clip-des-jahres",
SortOrder = 2
},
new
{
Id = 10,
Description = "Archivkategorie 2023.",
GroupName = "Main Awards",
MaxNomineesPerUser = 3,
Name = "VTuber des Jahres",
SeasonId = 4,
Slug = "vtuber-des-jahres",
SortOrder = 1
});
});
modelBuilder.Entity("Backend.Domain.Nomination", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CandidateId")
.HasColumnType("integer");
b.Property<string>("CandidateText")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("SubmittedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("CandidateId");
b.HasIndex("CategoryId");
b.HasIndex("SeasonId");
b.ToTable("Nominations");
b.HasData(
new
{
Id = 1,
CandidateText = "Hoshimi Miyu",
CategoryId = 1,
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 13, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SeasonId = 1,
SubmittedByTwitchId = "twitch_hoshi"
},
new
{
Id = 2,
CandidateText = "Kurainu 3D Live",
CategoryId = 2,
CreatedAt = new DateTimeOffset(new DateTime(2026, 6, 10, 14, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SeasonId = 1,
SubmittedByTwitchId = "twitch_kurainu"
});
});
modelBuilder.Entity("Backend.Domain.Season", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CurrentPhase")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsCommunityOnly")
.HasColumnType("boolean");
b.Property<bool>("IsCurrent")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateOnly>("NominationEndsAt")
.HasColumnType("date");
b.Property<DateOnly>("NominationStartsAt")
.HasColumnType("date");
b.Property<DateOnly>("ReviewEndsAt")
.HasColumnType("date");
b.Property<DateOnly>("ReviewStartsAt")
.HasColumnType("date");
b.Property<DateOnly>("ShowDate")
.HasColumnType("date");
b.Property<DateOnly>("VotingEndsAt")
.HasColumnType("date");
b.Property<DateOnly>("VotingStartsAt")
.HasColumnType("date");
b.Property<int>("Year")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Year")
.IsUnique();
b.ToTable("Seasons");
b.HasData(
new
{
Id = 1,
CurrentPhase = "Community Voting",
IsCommunityOnly = true,
IsCurrent = true,
Name = "VTuber Star Awards 2026",
NominationEndsAt = new DateOnly(2026, 5, 31),
NominationStartsAt = new DateOnly(2026, 5, 1),
ReviewEndsAt = new DateOnly(2026, 7, 10),
ReviewStartsAt = new DateOnly(2026, 7, 1),
ShowDate = new DateOnly(2026, 7, 20),
VotingEndsAt = new DateOnly(2026, 6, 30),
VotingStartsAt = new DateOnly(2026, 6, 1),
Year = 2026
},
new
{
Id = 2,
CurrentPhase = "Archived",
IsCommunityOnly = true,
IsCurrent = false,
Name = "VTuber Star Awards 2025",
NominationEndsAt = new DateOnly(2025, 5, 31),
NominationStartsAt = new DateOnly(2025, 5, 1),
ReviewEndsAt = new DateOnly(2025, 7, 10),
ReviewStartsAt = new DateOnly(2025, 7, 1),
ShowDate = new DateOnly(2025, 7, 20),
VotingEndsAt = new DateOnly(2025, 6, 30),
VotingStartsAt = new DateOnly(2025, 6, 1),
Year = 2025
},
new
{
Id = 3,
CurrentPhase = "Archived",
IsCommunityOnly = true,
IsCurrent = false,
Name = "VTuber Star Awards 2024",
NominationEndsAt = new DateOnly(2024, 5, 31),
NominationStartsAt = new DateOnly(2024, 5, 1),
ReviewEndsAt = new DateOnly(2024, 7, 10),
ReviewStartsAt = new DateOnly(2024, 7, 1),
ShowDate = new DateOnly(2024, 7, 20),
VotingEndsAt = new DateOnly(2024, 6, 30),
VotingStartsAt = new DateOnly(2024, 6, 1),
Year = 2024
},
new
{
Id = 4,
CurrentPhase = "Archived",
IsCommunityOnly = true,
IsCurrent = false,
Name = "VTuber Star Awards 2023",
NominationEndsAt = new DateOnly(2023, 5, 31),
NominationStartsAt = new DateOnly(2023, 5, 1),
ReviewEndsAt = new DateOnly(2023, 7, 10),
ReviewStartsAt = new DateOnly(2023, 7, 1),
ShowDate = new DateOnly(2023, 7, 20),
VotingEndsAt = new DateOnly(2023, 6, 30),
VotingStartsAt = new DateOnly(2023, 6, 1),
Year = 2023
});
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("SeasonId")
.HasColumnType("integer");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<DateTimeOffset>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SubmittedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("SeasonId");
b.ToTable("VoteBallots");
b.HasData(
new
{
Id = 1,
SeasonId = 1,
Status = "submitted",
SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SubmittedByTwitchId = "twitch_vote_1"
},
new
{
Id = 2,
SeasonId = 1,
Status = "submitted",
SubmittedAt = new DateTimeOffset(new DateTime(2026, 6, 11, 12, 5, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
SubmittedByTwitchId = "twitch_vote_2"
});
});
modelBuilder.Entity("Backend.Domain.VoteEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("BallotId")
.HasColumnType("integer");
b.Property<int>("CandidateId")
.HasColumnType("integer");
b.Property<int>("CategoryId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("BallotId");
b.HasIndex("CandidateId");
b.HasIndex("CategoryId");
b.ToTable("VoteEntries");
b.HasData(
new
{
Id = 1,
BallotId = 1,
CandidateId = 1,
CategoryId = 1
},
new
{
Id = 2,
BallotId = 1,
CandidateId = 4,
CategoryId = 2
},
new
{
Id = 3,
BallotId = 2,
CandidateId = 2,
CategoryId = 1
},
new
{
Id = 4,
BallotId = 2,
CandidateId = 6,
CategoryId = 3
});
});
modelBuilder.Entity("Backend.Domain.AwardResult", b =>
{
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season")
.WithMany("Results")
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Candidate");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Candidate", b =>
{
b.HasOne("Backend.Domain.Category", "Category")
.WithMany("Candidates")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Category", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany("Categories")
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.Nomination", b =>
{
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId");
b.HasOne("Backend.Domain.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Candidate");
b.Navigation("Category");
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.HasOne("Backend.Domain.Season", "Season")
.WithMany()
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity("Backend.Domain.VoteEntry", b =>
{
b.HasOne("Backend.Domain.VoteBallot", "Ballot")
.WithMany("Entries")
.HasForeignKey("BallotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Candidate", "Candidate")
.WithMany()
.HasForeignKey("CandidateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Backend.Domain.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ballot");
b.Navigation("Candidate");
b.Navigation("Category");
});
modelBuilder.Entity("Backend.Domain.Category", b =>
{
b.Navigation("Candidates");
});
modelBuilder.Entity("Backend.Domain.Season", b =>
{
b.Navigation("Categories");
b.Navigation("Results");
});
modelBuilder.Entity("Backend.Domain.VoteBallot", b =>
{
b.Navigation("Entries");
});
#pragma warning restore 612, 618
}
}
}
+159
View File
@@ -0,0 +1,159 @@
CREATE TABLE "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
CREATE TABLE "Seasons" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"Year" integer NOT NULL,
"Name" character varying(160) NOT NULL,
"IsCurrent" boolean NOT NULL,
"IsCommunityOnly" boolean NOT NULL,
"CurrentPhase" character varying(60) NOT NULL,
"NominationStartsAt" date NOT NULL,
"NominationEndsAt" date NOT NULL,
"VotingStartsAt" date NOT NULL,
"VotingEndsAt" date NOT NULL,
"ReviewStartsAt" date NOT NULL,
"ReviewEndsAt" date NOT NULL,
"ShowDate" date NOT NULL
);
CREATE UNIQUE INDEX "IX_Seasons_Year" ON "Seasons" ("Year");
CREATE TABLE "Categories" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL REFERENCES "Seasons" ("Id") ON DELETE CASCADE,
"GroupName" character varying(80) NOT NULL,
"Name" character varying(120) NOT NULL,
"Slug" text NOT NULL,
"Description" character varying(400) NOT NULL,
"SortOrder" integer NOT NULL,
"MaxNomineesPerUser" integer NOT NULL
);
CREATE UNIQUE INDEX "IX_Categories_SeasonId_Slug" ON "Categories" ("SeasonId", "Slug");
CREATE TABLE "VoteBallots" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL REFERENCES "Seasons" ("Id") ON DELETE CASCADE,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"Status" character varying(30) NOT NULL,
"SubmittedAt" timestamp with time zone NOT NULL
);
CREATE INDEX "IX_VoteBallots_SeasonId" ON "VoteBallots" ("SeasonId");
CREATE TABLE "Candidates" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL REFERENCES "Seasons" ("Id") ON DELETE CASCADE,
"CategoryId" integer NOT NULL REFERENCES "Categories" ("Id") ON DELETE CASCADE,
"DisplayName" character varying(120) NOT NULL,
"ChannelSlug" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL
);
CREATE INDEX "IX_Candidates_CategoryId" ON "Candidates" ("CategoryId");
CREATE INDEX "IX_Candidates_SeasonId" ON "Candidates" ("SeasonId");
CREATE TABLE "Nominations" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL REFERENCES "Seasons" ("Id") ON DELETE CASCADE,
"CategoryId" integer NOT NULL REFERENCES "Categories" ("Id") ON DELETE CASCADE,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"CandidateId" integer NULL REFERENCES "Candidates" ("Id"),
"CandidateText" character varying(120) NULL,
"CreatedAt" timestamp with time zone NOT NULL
);
CREATE INDEX "IX_Nominations_CandidateId" ON "Nominations" ("CandidateId");
CREATE INDEX "IX_Nominations_CategoryId" ON "Nominations" ("CategoryId");
CREATE INDEX "IX_Nominations_SeasonId" ON "Nominations" ("SeasonId");
CREATE TABLE "Results" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"SeasonId" integer NOT NULL REFERENCES "Seasons" ("Id") ON DELETE CASCADE,
"CandidateId" integer NOT NULL REFERENCES "Candidates" ("Id") ON DELETE CASCADE,
"CategoryName" character varying(120) NOT NULL
);
CREATE INDEX "IX_Results_CandidateId" ON "Results" ("CandidateId");
CREATE INDEX "IX_Results_SeasonId" ON "Results" ("SeasonId");
CREATE TABLE "VoteEntries" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
"BallotId" integer NOT NULL REFERENCES "VoteBallots" ("Id") ON DELETE CASCADE,
"CategoryId" integer NOT NULL REFERENCES "Categories" ("Id") ON DELETE CASCADE,
"CandidateId" integer NOT NULL REFERENCES "Candidates" ("Id") ON DELETE CASCADE
);
CREATE INDEX "IX_VoteEntries_BallotId" ON "VoteEntries" ("BallotId");
CREATE INDEX "IX_VoteEntries_CandidateId" ON "VoteEntries" ("CandidateId");
CREATE INDEX "IX_VoteEntries_CategoryId" ON "VoteEntries" ("CategoryId");
INSERT INTO "Seasons" ("Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year") VALUES
(1, 'Community Voting', true, true, 'VTuber Star Awards 2026', '2026-05-31', '2026-05-01', '2026-07-10', '2026-07-01', '2026-07-20', '2026-06-30', '2026-06-01', 2026),
(2, 'Archived', true, false, 'VTuber Star Awards 2025', '2025-05-31', '2025-05-01', '2025-07-10', '2025-07-01', '2025-07-20', '2025-06-30', '2025-06-01', 2025),
(3, 'Archived', true, false, 'VTuber Star Awards 2024', '2024-05-31', '2024-05-01', '2024-07-10', '2024-07-01', '2024-07-20', '2024-06-30', '2024-06-01', 2024),
(4, 'Archived', true, false, 'VTuber Star Awards 2023', '2023-05-31', '2023-05-01', '2023-07-10', '2023-07-01', '2023-07-20', '2023-06-30', '2023-06-01', 2023);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder") VALUES
(1, 'Die groesste Auszeichnung des Jahres.', 'Main Awards', 3, 'VTuber des Jahres', 1, 'vtuber-des-jahres', 1),
(2, 'Events, Konzerte und 3D-Shows.', 'Performance', 3, 'Bestes Live Event', 1, 'bestes-live-event', 2),
(3, 'Der lustigste oder emotionalste Clip des Jahres.', 'Clips & Highlights', 3, 'Clip des Jahres', 1, 'clip-des-jahres', 3),
(4, 'Die aktivste und freundlichste Community.', 'Main Awards', 3, 'Beste Community', 1, 'beste-community', 4),
(5, 'Archivkategorie 2025.', 'Main Awards', 3, 'VTuber des Jahres', 2, 'vtuber-des-jahres', 1),
(6, 'Archivkategorie 2025.', 'Performance', 3, 'Bestes Live Event', 2, 'bestes-live-event', 2),
(7, 'Archivkategorie 2025.', 'Clips & Highlights', 3, 'Clip des Jahres', 2, 'clip-des-jahres', 3),
(8, 'Archivkategorie 2024.', 'Main Awards', 3, 'VTuber des Jahres', 3, 'vtuber-des-jahres', 1),
(9, 'Archivkategorie 2024.', 'Clips & Highlights', 3, 'Clip des Jahres', 3, 'clip-des-jahres', 2),
(10, 'Archivkategorie 2023.', 'Main Awards', 3, 'VTuber des Jahres', 4, 'vtuber-des-jahres', 1);
INSERT INTO "VoteBallots" ("Id", "SeasonId", "Status", "SubmittedAt", "SubmittedByTwitchId") VALUES
(1, 1, 'submitted', '2026-06-11T12:00:00+00:00', 'twitch_vote_1'),
(2, 1, 'submitted', '2026-06-11T12:05:00+00:00', 'twitch_vote_2');
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId") VALUES
(1, 1, '@hoshimimiyu', 'Hoshimi Miyu', 'Twitch', 1),
(2, 1, '@kurainu', 'Kurainu', 'Twitch', 1),
(3, 1, '@shiroch', 'Shiro Ch.', 'Twitch', 1),
(4, 2, '@kurainu', 'Kurainu 3D Live', 'Twitch', 1),
(5, 2, '@aoisakura', 'Aoi Sakura Showcase', 'YouTube', 1),
(6, 3, '@pyonkichikingdom', 'Pyonkichi Kingdom', 'Twitch', 1),
(7, 4, '@moonrelay', 'Moonrelay', 'Twitch', 1),
(8, 5, '@hoshimimiyu', 'Hoshimi Miyu', 'Twitch', 2),
(9, 6, '@kurainu', 'Kurainu 3D Live', 'Twitch', 2),
(10, 7, '@pyonkichikingdom', 'Pyonkichi Kingdom', 'Twitch', 2),
(11, 8, '@aoisakura', 'Aoi Sakura', 'YouTube', 3),
(12, 9, '@starbyte', 'Starbyte', 'Twitch', 3),
(13, 10, '@tenshivox', 'Tenshi Vox', 'Twitch', 4);
INSERT INTO "Nominations" ("Id", "CandidateId", "CandidateText", "CategoryId", "CreatedAt", "SeasonId", "SubmittedByTwitchId") VALUES
(1, NULL, 'Hoshimi Miyu', 1, '2026-06-10T13:00:00+00:00', 1, 'twitch_hoshi'),
(2, NULL, 'Kurainu 3D Live', 2, '2026-06-10T14:00:00+00:00', 1, 'twitch_kurainu');
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId") VALUES
(1, 8, 'VTuber des Jahres', 2),
(2, 9, 'Bestes Live Event', 2),
(3, 10, 'Clip des Jahres', 2),
(4, 11, 'VTuber des Jahres', 3),
(5, 12, 'Clip des Jahres', 3),
(6, 13, 'VTuber des Jahres', 4);
INSERT INTO "VoteEntries" ("Id", "BallotId", "CandidateId", "CategoryId") VALUES
(1, 1, 1, 1),
(2, 1, 4, 2),
(3, 2, 2, 1),
(4, 2, 6, 3);
SELECT setval(pg_get_serial_sequence('"Seasons"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "Seasons";
SELECT setval(pg_get_serial_sequence('"Categories"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "Categories";
SELECT setval(pg_get_serial_sequence('"VoteBallots"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "VoteBallots";
SELECT setval(pg_get_serial_sequence('"Candidates"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "Candidates";
SELECT setval(pg_get_serial_sequence('"Nominations"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "Nominations";
SELECT setval(pg_get_serial_sequence('"Results"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "Results";
SELECT setval(pg_get_serial_sequence('"VoteEntries"', 'Id'), COALESCE(MAX("Id"), 1), true) FROM "VoteEntries";
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260617060000_InitialCreate', '8.0.11');
+258
View File
@@ -0,0 +1,258 @@
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
CREATE TABLE "Seasons" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"Year" integer NOT NULL,
"Name" character varying(160) NOT NULL,
"IsCurrent" boolean NOT NULL,
"IsCommunityOnly" boolean NOT NULL,
"CurrentPhase" character varying(60) NOT NULL,
"NominationStartsAt" date NOT NULL,
"NominationEndsAt" date NOT NULL,
"VotingStartsAt" date NOT NULL,
"VotingEndsAt" date NOT NULL,
"ReviewStartsAt" date NOT NULL,
"ReviewEndsAt" date NOT NULL,
"ShowDate" date NOT NULL,
CONSTRAINT "PK_Seasons" PRIMARY KEY ("Id")
);
CREATE TABLE "Categories" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"GroupName" character varying(80) NOT NULL,
"Name" character varying(120) NOT NULL,
"Slug" text NOT NULL,
"Description" character varying(400) NOT NULL,
"SortOrder" integer NOT NULL,
"MaxNomineesPerUser" integer NOT NULL,
CONSTRAINT "PK_Categories" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Categories_Seasons_SeasonId" FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") ON DELETE CASCADE
);
CREATE TABLE "VoteBallots" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"Status" character varying(30) NOT NULL,
"SubmittedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_VoteBallots" PRIMARY KEY ("Id"),
CONSTRAINT "FK_VoteBallots_Seasons_SeasonId" FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Candidates" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"CategoryId" integer NOT NULL,
"DisplayName" character varying(120) NOT NULL,
"ChannelSlug" character varying(120) NOT NULL,
"Platform" character varying(40) NOT NULL,
CONSTRAINT "PK_Candidates" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Candidates_Categories_CategoryId" FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Candidates_Seasons_SeasonId" FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Nominations" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"CategoryId" integer NOT NULL,
"SubmittedByTwitchId" character varying(120) NOT NULL,
"CandidateId" integer,
"CandidateText" character varying(120),
"CreatedAt" timestamp with time zone NOT NULL,
CONSTRAINT "PK_Nominations" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Nominations_Candidates_CandidateId" FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id"),
CONSTRAINT "FK_Nominations_Categories_CategoryId" FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Nominations_Seasons_SeasonId" FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") ON DELETE CASCADE
);
CREATE TABLE "Results" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"SeasonId" integer NOT NULL,
"CandidateId" integer NOT NULL,
"CategoryName" character varying(120) NOT NULL,
CONSTRAINT "PK_Results" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Results_Candidates_CandidateId" FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_Results_Seasons_SeasonId" FOREIGN KEY ("SeasonId") REFERENCES "Seasons" ("Id") ON DELETE CASCADE
);
CREATE TABLE "VoteEntries" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"BallotId" integer NOT NULL,
"CategoryId" integer NOT NULL,
"CandidateId" integer NOT NULL,
CONSTRAINT "PK_VoteEntries" PRIMARY KEY ("Id"),
CONSTRAINT "FK_VoteEntries_Candidates_CandidateId" FOREIGN KEY ("CandidateId") REFERENCES "Candidates" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_VoteEntries_Categories_CategoryId" FOREIGN KEY ("CategoryId") REFERENCES "Categories" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_VoteEntries_VoteBallots_BallotId" FOREIGN KEY ("BallotId") REFERENCES "VoteBallots" ("Id") ON DELETE CASCADE
);
INSERT INTO "Seasons" ("Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year")
VALUES (1, 'Community Voting', TRUE, TRUE, 'VTuber Star Awards 2026', DATE '2026-05-31', DATE '2026-05-01', DATE '2026-07-10', DATE '2026-07-01', DATE '2026-07-20', DATE '2026-06-30', DATE '2026-06-01', 2026);
INSERT INTO "Seasons" ("Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year")
VALUES (2, 'Archived', TRUE, FALSE, 'VTuber Star Awards 2025', DATE '2025-05-31', DATE '2025-05-01', DATE '2025-07-10', DATE '2025-07-01', DATE '2025-07-20', DATE '2025-06-30', DATE '2025-06-01', 2025);
INSERT INTO "Seasons" ("Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year")
VALUES (3, 'Archived', TRUE, FALSE, 'VTuber Star Awards 2024', DATE '2024-05-31', DATE '2024-05-01', DATE '2024-07-10', DATE '2024-07-01', DATE '2024-07-20', DATE '2024-06-30', DATE '2024-06-01', 2024);
INSERT INTO "Seasons" ("Id", "CurrentPhase", "IsCommunityOnly", "IsCurrent", "Name", "NominationEndsAt", "NominationStartsAt", "ReviewEndsAt", "ReviewStartsAt", "ShowDate", "VotingEndsAt", "VotingStartsAt", "Year")
VALUES (4, 'Archived', TRUE, FALSE, 'VTuber Star Awards 2023', DATE '2023-05-31', DATE '2023-05-01', DATE '2023-07-10', DATE '2023-07-01', DATE '2023-07-20', DATE '2023-06-30', DATE '2023-06-01', 2023);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (1, 'Die groesste Auszeichnung des Jahres.', 'Main Awards', 3, 'VTuber des Jahres', 1, 'vtuber-des-jahres', 1);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (2, 'Events, Konzerte und 3D-Shows.', 'Performance', 3, 'Bestes Live Event', 1, 'bestes-live-event', 2);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (3, 'Der lustigste oder emotionalste Clip des Jahres.', 'Clips & Highlights', 3, 'Clip des Jahres', 1, 'clip-des-jahres', 3);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (4, 'Die aktivste und freundlichste Community.', 'Main Awards', 3, 'Beste Community', 1, 'beste-community', 4);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (5, 'Archivkategorie 2025.', 'Main Awards', 3, 'VTuber des Jahres', 2, 'vtuber-des-jahres', 1);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (6, 'Archivkategorie 2025.', 'Performance', 3, 'Bestes Live Event', 2, 'bestes-live-event', 2);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (7, 'Archivkategorie 2025.', 'Clips & Highlights', 3, 'Clip des Jahres', 2, 'clip-des-jahres', 3);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (8, 'Archivkategorie 2024.', 'Main Awards', 3, 'VTuber des Jahres', 3, 'vtuber-des-jahres', 1);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (9, 'Archivkategorie 2024.', 'Clips & Highlights', 3, 'Clip des Jahres', 3, 'clip-des-jahres', 2);
INSERT INTO "Categories" ("Id", "Description", "GroupName", "MaxNomineesPerUser", "Name", "SeasonId", "Slug", "SortOrder")
VALUES (10, 'Archivkategorie 2023.', 'Main Awards', 3, 'VTuber des Jahres', 4, 'vtuber-des-jahres', 1);
INSERT INTO "VoteBallots" ("Id", "SeasonId", "Status", "SubmittedAt", "SubmittedByTwitchId")
VALUES (1, 1, 'submitted', TIMESTAMPTZ '2026-06-11T12:00:00+00:00', 'twitch_vote_1');
INSERT INTO "VoteBallots" ("Id", "SeasonId", "Status", "SubmittedAt", "SubmittedByTwitchId")
VALUES (2, 1, 'submitted', TIMESTAMPTZ '2026-06-11T12:05:00+00:00', 'twitch_vote_2');
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (1, 1, '@hoshimimiyu', 'Hoshimi Miyu', 'Twitch', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (2, 1, '@kurainu', 'Kurainu', 'Twitch', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (3, 1, '@shiroch', 'Shiro Ch.', 'Twitch', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (4, 2, '@kurainu', 'Kurainu 3D Live', 'Twitch', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (5, 2, '@aoisakura', 'Aoi Sakura Showcase', 'YouTube', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (6, 3, '@pyonkichikingdom', 'Pyonkichi Kingdom', 'Twitch', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (7, 4, '@moonrelay', 'Moonrelay', 'Twitch', 1);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (8, 5, '@hoshimimiyu', 'Hoshimi Miyu', 'Twitch', 2);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (9, 6, '@kurainu', 'Kurainu 3D Live', 'Twitch', 2);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (10, 7, '@pyonkichikingdom', 'Pyonkichi Kingdom', 'Twitch', 2);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (11, 8, '@aoisakura', 'Aoi Sakura', 'YouTube', 3);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (12, 9, '@starbyte', 'Starbyte', 'Twitch', 3);
INSERT INTO "Candidates" ("Id", "CategoryId", "ChannelSlug", "DisplayName", "Platform", "SeasonId")
VALUES (13, 10, '@tenshivox', 'Tenshi Vox', 'Twitch', 4);
INSERT INTO "Nominations" ("Id", "CandidateId", "CandidateText", "CategoryId", "CreatedAt", "SeasonId", "SubmittedByTwitchId")
VALUES (1, NULL, 'Hoshimi Miyu', 1, TIMESTAMPTZ '2026-06-10T13:00:00+00:00', 1, 'twitch_hoshi');
INSERT INTO "Nominations" ("Id", "CandidateId", "CandidateText", "CategoryId", "CreatedAt", "SeasonId", "SubmittedByTwitchId")
VALUES (2, NULL, 'Kurainu 3D Live', 2, TIMESTAMPTZ '2026-06-10T14:00:00+00:00', 1, 'twitch_kurainu');
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId")
VALUES (1, 8, 'VTuber des Jahres', 2);
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId")
VALUES (2, 9, 'Bestes Live Event', 2);
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId")
VALUES (3, 10, 'Clip des Jahres', 2);
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId")
VALUES (4, 11, 'VTuber des Jahres', 3);
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId")
VALUES (5, 12, 'Clip des Jahres', 3);
INSERT INTO "Results" ("Id", "CandidateId", "CategoryName", "SeasonId")
VALUES (6, 13, 'VTuber des Jahres', 4);
INSERT INTO "VoteEntries" ("Id", "BallotId", "CandidateId", "CategoryId")
VALUES (1, 1, 1, 1);
INSERT INTO "VoteEntries" ("Id", "BallotId", "CandidateId", "CategoryId")
VALUES (2, 1, 4, 2);
INSERT INTO "VoteEntries" ("Id", "BallotId", "CandidateId", "CategoryId")
VALUES (3, 2, 2, 1);
INSERT INTO "VoteEntries" ("Id", "BallotId", "CandidateId", "CategoryId")
VALUES (4, 2, 6, 3);
CREATE INDEX "IX_Candidates_CategoryId" ON "Candidates" ("CategoryId");
CREATE INDEX "IX_Candidates_SeasonId" ON "Candidates" ("SeasonId");
CREATE UNIQUE INDEX "IX_Categories_SeasonId_Slug" ON "Categories" ("SeasonId", "Slug");
CREATE INDEX "IX_Nominations_CandidateId" ON "Nominations" ("CandidateId");
CREATE INDEX "IX_Nominations_CategoryId" ON "Nominations" ("CategoryId");
CREATE INDEX "IX_Nominations_SeasonId" ON "Nominations" ("SeasonId");
CREATE INDEX "IX_Results_CandidateId" ON "Results" ("CandidateId");
CREATE INDEX "IX_Results_SeasonId" ON "Results" ("SeasonId");
CREATE UNIQUE INDEX "IX_Seasons_Year" ON "Seasons" ("Year");
CREATE INDEX "IX_VoteBallots_SeasonId" ON "VoteBallots" ("SeasonId");
CREATE INDEX "IX_VoteEntries_BallotId" ON "VoteEntries" ("BallotId");
CREATE INDEX "IX_VoteEntries_CandidateId" ON "VoteEntries" ("CandidateId");
CREATE INDEX "IX_VoteEntries_CategoryId" ON "VoteEntries" ("CategoryId");
SELECT setval(
pg_get_serial_sequence('"Seasons"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "Seasons") + 1,
nextval(pg_get_serial_sequence('"Seasons"', 'Id'))),
false);
SELECT setval(
pg_get_serial_sequence('"Categories"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "Categories") + 1,
nextval(pg_get_serial_sequence('"Categories"', 'Id'))),
false);
SELECT setval(
pg_get_serial_sequence('"VoteBallots"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "VoteBallots") + 1,
nextval(pg_get_serial_sequence('"VoteBallots"', 'Id'))),
false);
SELECT setval(
pg_get_serial_sequence('"Candidates"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "Candidates") + 1,
nextval(pg_get_serial_sequence('"Candidates"', 'Id'))),
false);
SELECT setval(
pg_get_serial_sequence('"Nominations"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "Nominations") + 1,
nextval(pg_get_serial_sequence('"Nominations"', 'Id'))),
false);
SELECT setval(
pg_get_serial_sequence('"Results"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "Results") + 1,
nextval(pg_get_serial_sequence('"Results"', 'Id'))),
false);
SELECT setval(
pg_get_serial_sequence('"VoteEntries"', 'Id'),
GREATEST(
(SELECT MAX("Id") FROM "VoteEntries") + 1,
nextval(pg_get_serial_sequence('"VoteEntries"', 'Id'))),
false);
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260617060000_InitialCreate', '8.0.11');
COMMIT;
+816
View File
@@ -0,0 +1,816 @@
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration["VTSA_POSTGRES"]
?? builder.Configuration.GetConnectionString("Postgres");
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddCors(options =>
{
options.AddPolicy("frontend", policy =>
{
policy
.WithOrigins(
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddDbContext<AwardsDbContext>(options =>
{
options.UseNpgsql(connectionString);
});
var app = builder.Build();
static string? ReadBearerToken(HttpContext context)
{
var header = context.Request.Headers.Authorization.ToString();
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? header["Bearer ".Length..].Trim()
: null;
}
static async Task<UserSession?> ResolveSessionAsync(HttpContext context, AwardsDbContext db)
{
var token = ReadBearerToken(context);
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
var session = await db.UserSessions.FirstOrDefaultAsync(item => item.SessionToken == token && item.IsActive);
if (session is not null)
{
session.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
return session;
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("frontend");
app.UseHttpsRedirection();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AwardsDbContext>();
if (app.Environment.IsDevelopment())
{
try
{
db.Database.Migrate();
}
catch
{
// In local environments without PostgreSQL yet, the API should still boot
// so frontend work and migration generation can continue independently.
}
}
try
{
await SessionBootstrapper.EnsureAsync(db);
}
catch
{
// If the session table bootstrap fails, the rest of the API can still start.
}
}
app.MapPost("/api/auth/dev-login", async (LoginRequest request, AwardsDbContext db) =>
{
var session = new UserSession
{
Id = Guid.NewGuid(),
SessionToken = Guid.NewGuid().ToString("N"),
TwitchUserId = request.TwitchUserId.Trim(),
DisplayName = request.DisplayName.Trim(),
Role = string.Equals(request.Role, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "viewer",
CreatedAt = DateTimeOffset.UtcNow,
LastSeenAt = DateTimeOffset.UtcNow,
IsActive = true,
};
db.UserSessions.Add(session);
await db.SaveChangesAsync();
return Results.Ok(new AuthSessionDto(
session.SessionToken,
session.TwitchUserId,
session.DisplayName,
session.Role));
})
.WithName("DevLogin")
.WithOpenApi();
app.MapGet("/api/auth/session", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session is null)
{
return Results.Unauthorized();
}
return Results.Ok(new AuthSessionDto(
session.SessionToken,
session.TwitchUserId,
session.DisplayName,
session.Role));
})
.WithName("GetSession")
.WithOpenApi();
app.MapPost("/api/auth/logout", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session is null)
{
return Results.Ok(new { loggedOut = true });
}
session.IsActive = false;
await db.SaveChangesAsync();
return Results.Ok(new { loggedOut = true });
})
.WithName("Logout")
.WithOpenApi();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" }))
.WithName("GetHealth")
.WithOpenApi();
app.MapGet("/api/health/database", async (AwardsDbContext db) =>
{
try
{
var canConnect = await db.Database.CanConnectAsync();
var pendingMigrations = canConnect
? await db.Database.GetPendingMigrationsAsync()
: Array.Empty<string>();
return Results.Ok(new
{
provider = "postgres",
canConnect,
pendingMigrations,
configuredConnection = new
{
source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings",
},
});
}
catch (Exception exception)
{
return Results.Ok(new
{
provider = "postgres",
canConnect = false,
pendingMigrations = Array.Empty<string>(),
configuredConnection = new
{
source = builder.Configuration["VTSA_POSTGRES"] is not null ? "environment" : "appsettings",
},
error = exception.Message,
});
}
})
.WithName("GetDatabaseHealth")
.WithOpenApi();
app.MapGet("/api/public/overview", async (AwardsDbContext db) =>
{
var season = await db.Seasons
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.Include(item => item.Results)
.ThenInclude(result => result.Candidate)
.FirstOrDefaultAsync(item => item.IsCurrent);
if (season is null)
{
return Results.NotFound();
}
var response = new OverviewResponse(
season.Id,
season.Year,
season.Name,
season.ShowDate,
season.CurrentPhase,
season.IsCommunityOnly,
"Twitch",
new[]
{
new TimelineItem("nomination", "Nominierung", season.NominationStartsAt, season.NominationEndsAt, "done"),
new TimelineItem("voting", "Voting", season.VotingStartsAt, season.VotingEndsAt, "active"),
new TimelineItem("review", "Auswertung", season.ReviewStartsAt, season.ReviewEndsAt, "upcoming"),
new TimelineItem("show", "Award Show", season.ShowDate, season.ShowDate, "upcoming"),
},
season.Categories
.Take(6)
.Select(category => new FeaturedCategoryDto(
category.Id,
category.GroupName,
category.Name,
category.Description,
category.MaxNomineesPerUser))
.ToArray(),
season.Results
.OrderByDescending(result => result.SeasonId)
.Take(4)
.Select(result => new WinnerPreviewDto(
season.Year,
result.CategoryName,
result.Candidate.DisplayName,
result.Candidate.ChannelSlug))
.ToArray(),
new[]
{
new FaqItemDto("Wer kann nominieren und voten?", "Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt."),
new FaqItemDto("Wie werden Gewinner bestimmt?", "Aktuell rein community-basiert. Eine Mischlogik mit Jury oder Panel kann spaeter eingefuehrt werden."),
new FaqItemDto("Wer verwaltet Kategorien und Unterkategorien?", "Das Team pflegt diese pro Jahr im Admin-Bereich."),
});
return Results.Ok(response);
})
.WithName("GetOverview")
.WithOpenApi();
app.MapGet("/api/public/seasons/{year:int}/categories", async (int year, AwardsDbContext db) =>
{
var season = await db.Seasons
.AsNoTracking()
.Include(item => item.Categories.OrderBy(category => category.SortOrder))
.ThenInclude(category => category.Candidates.OrderBy(candidate => candidate.DisplayName))
.FirstOrDefaultAsync(item => item.Year == year);
if (season is null)
{
return Results.NotFound();
}
return Results.Ok(new SeasonCategoriesResponse(
season.Id,
season.Year,
season.Categories.Select(category => new PublicCategoryDetailDto(
category.Id,
category.Name,
category.GroupName,
category.Description,
category.MaxNomineesPerUser,
category.Candidates.Select(candidate => new CandidateSummaryDto(
candidate.Id,
candidate.DisplayName,
candidate.ChannelSlug,
candidate.Platform))
.ToArray()))
.ToArray()));
})
.WithName("GetSeasonCategories")
.WithOpenApi();
app.MapGet("/api/public/seasons/{year:int}/winners", async (int year, AwardsDbContext db) =>
{
var items = await db.Results
.AsNoTracking()
.Include(result => result.Candidate)
.Where(result => result.Season.Year == year)
.OrderBy(result => result.CategoryName)
.Select(result => new WinnerArchiveItemDto(
result.CategoryName,
result.Candidate.DisplayName,
result.Candidate.ChannelSlug))
.ToArrayAsync();
return Results.Ok(new WinnerArchiveResponse(year, items));
})
.WithName("GetWinnerArchive")
.WithOpenApi();
app.MapPost("/api/public/nominations", async (HttpContext context, CreateNominationRequest request, AwardsDbContext db) =>
{
if (request.Nominees.Length is 0 or > 3)
{
return Results.BadRequest(new { message = "A nomination request must include between 1 and 3 nominees." });
}
var distinctNominees = request.Nominees
.Select(item => item.Trim())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (distinctNominees.Length != request.Nominees.Length)
{
return Results.BadRequest(new { message = "Duplicate nominees are not allowed inside one category." });
}
var category = await db.Categories
.Include(item => item.Season)
.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.Season.Year == request.Year);
if (category is null)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
var session = await ResolveSessionAsync(context, db);
var submitterId = session?.TwitchUserId ?? request.TwitchUserId;
if (string.IsNullOrWhiteSpace(submitterId))
{
return Results.BadRequest(new { message = "A logged in user is required to submit nominations." });
}
var records = distinctNominees.Select(name => new Nomination
{
SeasonId = category.SeasonId,
CategoryId = category.Id,
SubmittedByTwitchId = submitterId,
CandidateText = name,
CreatedAt = DateTimeOffset.UtcNow,
});
await db.Nominations.AddRangeAsync(records);
await db.SaveChangesAsync();
return Results.Ok(new { saved = distinctNominees.Length, category = category.Name });
})
.WithName("CreateNomination")
.WithOpenApi();
app.MapPost("/api/public/votes", async (HttpContext context, CreateVoteRequest request, AwardsDbContext db) =>
{
if (request.Entries.Length == 0)
{
return Results.BadRequest(new { message = "At least one vote entry is required." });
}
var distinctCategoryCount = request.Entries
.Select(item => item.CategoryId)
.Distinct()
.Count();
if (distinctCategoryCount != request.Entries.Length)
{
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
}
var session = await ResolveSessionAsync(context, db);
var submitterId = session?.TwitchUserId ?? request.TwitchUserId;
if (string.IsNullOrWhiteSpace(submitterId))
{
return Results.BadRequest(new { message = "A logged in user is required to submit votes." });
}
var ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
SubmittedAt = DateTimeOffset.UtcNow,
Status = "submitted",
};
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
await db.VoteBallots.AddAsync(ballot);
await db.SaveChangesAsync();
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count });
})
.WithName("CreateVote")
.WithOpenApi();
app.MapGet("/api/admin/dashboard", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var currentSeason = await db.Seasons.AsNoTracking().FirstOrDefaultAsync(item => item.IsCurrent);
if (currentSeason is null)
{
return Results.NotFound();
}
var nominationCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id);
var voteCount = await db.VoteEntries.CountAsync(item => item.Ballot.SeasonId == currentSeason.Id);
var categoryCount = await db.Categories.CountAsync(item => item.SeasonId == currentSeason.Id);
var reviewCount = await db.Nominations.CountAsync(item => item.SeasonId == currentSeason.Id && item.CandidateText != null);
var topCategoryNames = await db.VoteEntries
.AsNoTracking()
.Where(item => item.Ballot.SeasonId == currentSeason.Id)
.Select(item => item.Category.Name)
.ToListAsync();
var topCategories = topCategoryNames
.GroupBy(name => name)
.Select(group => new AdminTopCategoryDto(group.Key, group.Count()))
.OrderByDescending(item => item.Votes)
.Take(5)
.ToArray();
var response = new AdminDashboardResponse(
new[]
{
new AdminMetricDto("Nominierungen", nominationCount, "+12.4% vs. gestern"),
new AdminMetricDto("Votes", voteCount, "+8.7% vs. gestern"),
new AdminMetricDto("Kategorien", categoryCount, "aktiv im aktuellen Jahr"),
new AdminMetricDto("Reviews offen", reviewCount, "Freitext und Dubletten"),
},
new[]
{
new AdminActivityDto("Neue Nominierung in Best New VTuber", "vor 2 Min."),
new AdminActivityDto("Clip-Dublette erkannt in Clip des Jahres", "vor 7 Min."),
new AdminActivityDto("Alias-Merge fuer Hoshimi Miyu reviewt", "vor 18 Min."),
},
topCategories);
return Results.Ok(response);
})
.WithName("GetAdminDashboard")
.WithOpenApi();
app.MapGet("/api/admin/seasons", async (HttpContext context, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var seasons = await db.Seasons
.AsNoTracking()
.OrderByDescending(item => item.Year)
.Select(item => new AdminSeasonListItemDto(
item.Id,
item.Year,
item.Name,
item.CurrentPhase,
item.IsCurrent,
item.Categories.Count))
.ToArrayAsync();
return Results.Ok(seasons);
})
.WithName("GetAdminSeasons")
.WithOpenApi();
app.MapGet("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var candidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.DisplayName)
.Select(item => new AdminCandidateItemDto(
item.Id,
item.CategoryId,
item.DisplayName,
item.ChannelSlug,
item.Platform))
.ToArrayAsync();
var candidateCounts = candidates
.GroupBy(item => item.CategoryId)
.ToDictionary(group => group.Key, group => group.Count());
var categoryRows = await db.Categories
.AsNoTracking()
.Where(item => item.SeasonId == seasonId)
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Select(category => new
{
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
})
.ToArrayAsync();
var categories = categoryRows
.Select(category => new AdminCategoryItemDto(
category.Id,
category.GroupName,
category.Name,
category.Slug,
category.Description,
category.SortOrder,
category.MaxNomineesPerUser,
candidateCounts.TryGetValue(category.Id, out var count) ? count : 0))
.ToArray();
var pendingNominations = await db.Nominations
.AsNoTracking()
.Where(item => item.SeasonId == seasonId && item.CandidateText != null)
.OrderByDescending(item => item.CreatedAt)
.Take(20)
.Select(item => new AdminNominationReviewItemDto(
item.Id,
item.CategoryId,
item.Category.Name,
item.SubmittedByTwitchId,
item.CandidateText!,
item.CreatedAt))
.ToArrayAsync();
return Results.Ok(new AdminSeasonDetailResponse(
season.Id,
season.Year,
season.Name,
season.CurrentPhase,
season.IsCurrent,
categories,
candidates,
pendingNominations));
})
.WithName("GetAdminSeasonDetail")
.WithOpenApi();
app.MapPut("/api/admin/seasons/{seasonId:int}", async (HttpContext context, int seasonId, UpdateSeasonRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
season.CurrentPhase = request.CurrentPhase.Trim();
if (request.IsCurrent && !season.IsCurrent)
{
var activeSeasons = await db.Seasons.Where(item => item.IsCurrent && item.Id != seasonId).ToListAsync();
foreach (var activeSeason in activeSeasons)
{
activeSeason.IsCurrent = false;
}
}
season.IsCurrent = request.IsCurrent;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, seasonId = season.Id });
})
.WithName("UpdateAdminSeason")
.WithOpenApi();
app.MapPost("/api/admin/seasons/{seasonId:int}/categories", async (HttpContext context, int seasonId, UpsertCategoryRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Id == seasonId);
if (season is null)
{
return Results.NotFound();
}
var category = new Category
{
SeasonId = seasonId,
GroupName = request.GroupName.Trim(),
Name = request.Name.Trim(),
Slug = request.Slug.Trim(),
Description = request.Description.Trim(),
SortOrder = request.SortOrder,
MaxNomineesPerUser = request.MaxNomineesPerUser,
};
db.Categories.Add(category);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id });
})
.WithName("CreateAdminCategory")
.WithOpenApi();
app.MapPut("/api/admin/categories/{categoryId:int}", async (HttpContext context, int categoryId, UpsertCategoryRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == categoryId);
if (category is null)
{
return Results.NotFound();
}
category.GroupName = request.GroupName.Trim();
category.Name = request.Name.Trim();
category.Slug = request.Slug.Trim();
category.Description = request.Description.Trim();
category.SortOrder = request.SortOrder;
category.MaxNomineesPerUser = request.MaxNomineesPerUser;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, categoryId = category.Id });
})
.WithName("UpdateAdminCategory")
.WithOpenApi();
app.MapPost("/api/admin/seasons/{seasonId:int}/candidates", async (HttpContext context, int seasonId, UpsertCandidateRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var category = await db.Categories.FirstOrDefaultAsync(item => item.Id == request.CategoryId && item.SeasonId == seasonId);
if (category is null)
{
return Results.BadRequest(new { message = "The selected category does not exist in this season." });
}
var candidate = new Candidate
{
SeasonId = seasonId,
CategoryId = request.CategoryId,
DisplayName = request.DisplayName.Trim(),
ChannelSlug = request.ChannelSlug.Trim(),
Platform = request.Platform.Trim(),
};
db.Candidates.Add(candidate);
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id });
})
.WithName("CreateAdminCandidate")
.WithOpenApi();
app.MapPut("/api/admin/candidates/{candidateId:int}", async (HttpContext context, int candidateId, UpsertCandidateRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var candidate = await db.Candidates.FirstOrDefaultAsync(item => item.Id == candidateId);
if (candidate is null)
{
return Results.NotFound();
}
candidate.CategoryId = request.CategoryId;
candidate.DisplayName = request.DisplayName.Trim();
candidate.ChannelSlug = request.ChannelSlug.Trim();
candidate.Platform = request.Platform.Trim();
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, candidateId = candidate.Id });
})
.WithName("UpdateAdminCandidate")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/approve", async (HttpContext context, int nominationId, ApproveNominationRequest request, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var nomination = await db.Nominations
.Include(item => item.Category)
.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
var rawDisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
? nomination.CandidateText
: request.DisplayName.Trim();
if (string.IsNullOrWhiteSpace(rawDisplayName))
{
return Results.BadRequest(new { message = "A display name is required to approve the nomination." });
}
var channelSlug = request.ChannelSlug?.Trim() ?? string.Empty;
var platform = string.IsNullOrWhiteSpace(request.Platform) ? "Twitch" : request.Platform.Trim();
var existingCandidate = await db.Candidates.FirstOrDefaultAsync(item =>
item.SeasonId == nomination.SeasonId
&& item.CategoryId == nomination.CategoryId
&& item.DisplayName.ToLower() == rawDisplayName.ToLower());
var candidate = existingCandidate;
if (candidate is null)
{
candidate = new Candidate
{
SeasonId = nomination.SeasonId,
CategoryId = nomination.CategoryId,
DisplayName = rawDisplayName,
ChannelSlug = channelSlug,
Platform = platform,
};
db.Candidates.Add(candidate);
await db.SaveChangesAsync();
}
else
{
if (!string.IsNullOrWhiteSpace(channelSlug))
{
candidate.ChannelSlug = channelSlug;
}
if (!string.IsNullOrWhiteSpace(platform))
{
candidate.Platform = platform;
}
}
nomination.CandidateId = candidate.Id;
nomination.CandidateText = null;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, candidateId = candidate.Id, created = existingCandidate is null });
})
.WithName("ApproveAdminNomination")
.WithOpenApi();
app.MapPost("/api/admin/nominations/{nominationId:int}/reject", async (HttpContext context, int nominationId, AwardsDbContext db) =>
{
var session = await ResolveSessionAsync(context, db);
if (session?.Role != "admin")
{
return Results.Unauthorized();
}
var nomination = await db.Nominations.FirstOrDefaultAsync(item => item.Id == nominationId);
if (nomination is null)
{
return Results.NotFound();
}
nomination.CandidateText = null;
nomination.CandidateId = null;
await db.SaveChangesAsync();
return Results.Ok(new { saved = true, nominationId = nomination.Id, rejected = true });
})
.WithName("RejectAdminNomination")
.WithOpenApi();
app.Run();
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:0",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://127.0.0.1:5084",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://127.0.0.1:7241;http://127.0.0.1:5084",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
# Backend
## PostgreSQL
The API targets PostgreSQL through EF Core 8 and `Npgsql.EntityFrameworkCore.PostgreSQL`.
Default local development connection string:
```text
Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres
```
You can override it with:
- `Backend/appsettings.Development.json`
- environment variable `VTSA_POSTGRES`
If Docker is available locally, start a dev database from the repository root with:
```bash
docker compose -f docker-compose.dev.yml up -d
```
## Commands
Restore and build:
```bash
dotnet restore
dotnet build
```
Create a migration:
```bash
dotnet ef migrations add InitialCreate
```
Generate a SQL migration script:
```bash
dotnet ef migrations script 0 20260617060000_InitialCreate --output Migrations/InitialCreate.sql
```
Apply migrations once PostgreSQL is running:
```bash
dotnet ef database update
```
Fallback bootstrap if `dotnet ef` is not usable in the current environment:
```bash
psql "Host=localhost Port=5433 Database=vtuber_star_awards_dev Username=postgres Password=postgres" -f Migrations/InitialCreate.manual.sql
```
Run the API:
```bash
ASPNETCORE_ENVIRONMENT=Development dotnet run
```
Check the API and database wiring:
```bash
curl http://localhost:5084/api/health
curl http://localhost:5084/api/health/database
```
Development auth/session:
```bash
curl -X POST http://localhost:5084/api/auth/dev-login \
-H "Content-Type: application/json" \
-d '{"twitchUserId":"admin_demo","displayName":"Admin Demo","role":"admin"}'
```
+11
View File
@@ -0,0 +1,11 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=vtuber_star_awards;Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+87
View File
@@ -0,0 +1,87 @@
# VTuber Star Awards
Monorepo for the VTuber Star Awards MVP.
## Stack
- Frontend: Vue 3, Vite, Pinia, Tailwind CSS, PrimeVue, shadcn-style Vue UI primitives
- Backend: ASP.NET Core 8, EF Core 8, PostgreSQL
## Structure
- `frontend/` Vue application
- `Backend/` ASP.NET Core API
- `prototype/` earlier visual prototype work
- `docker-compose.dev.yml` optional local PostgreSQL container
## Frontend
```bash
cd frontend
npm install
cp .env.example .env
npm run dev
```
The frontend uses a lightweight local session flow for development:
- Sign in from the header
- `Viewer Login` unlocks nomination and voting
- `Admin Login` unlocks the admin routes and management views
## Backend
Update `Backend/appsettings.json` or set `VTSA_POSTGRES`, then:
```bash
cd Backend
dotnet restore
dotnet build
dotnet run
```
## Local Database
If Docker is available locally:
```bash
docker compose -f docker-compose.dev.yml up -d
cd Backend
dotnet ef database update
```
Default dev database:
```text
Host=localhost;Port=5433;Database=vtuber_star_awards_dev;Username=postgres;Password=postgres
```
If `dotnet ef database update` is unavailable in the current environment, the repository also contains a bootstrap SQL script:
```bash
cd Backend
psql "Host=localhost Port=5433 Database=vtuber_star_awards_dev Username=postgres Password=postgres" -f Migrations/InitialCreate.manual.sql
```
Verify the runtime wiring:
```bash
curl http://localhost:5084/api/health
curl http://localhost:5084/api/health/database
```
Development auth/session endpoints:
```bash
curl -X POST http://localhost:5084/api/auth/dev-login \
-H "Content-Type: application/json" \
-d '{"twitchUserId":"jayuhime_demo","displayName":"Jayuhime","role":"admin"}'
```
## Notes
- Public endpoints live under `/api/public/*`
- Admin summary endpoint lives under `/api/admin/dashboard`
- Session endpoints live under `/api/auth/*`
- Database connectivity and pending migrations are exposed at `/api/health/database`
- Current frontend store falls back to static seed-like data if the API is unavailable
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
services:
postgres:
image: postgres:16
container_name: vtubeawards-postgres
restart: unless-stopped
environment:
POSTGRES_DB: vtuber_star_awards_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432"
volumes:
- vtubeawards-postgres-data:/var/lib/postgresql/data
volumes:
vtubeawards-postgres-data:
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:5084
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+7825
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@lucide/vue": "^1.20.0",
"@primeuix/themes": "^2.0.3",
"@tailwindcss/vite": "^4.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"shadcn-vue": "^2.7.4",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1",
"vue": "^3.5.34",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import AppShell from './components/AppShell.vue'
</script>
<template>
<AppShell>
<RouterView />
</AppShell>
</template>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Star } from '@lucide/vue'
import Button from './ui/Button.vue'
import { useAuthStore } from '../stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const loginOpen = ref(false)
const loginError = ref('')
const loginForm = reactive({
twitchUserId: 'jayuhime_demo',
displayName: 'Jayuhime',
role: 'viewer' as 'viewer' | 'admin',
})
const navItems = [
{ label: 'Home', to: '/' },
{ label: 'Nominierung', to: '/nominations' },
{ label: 'Voting', to: '/voting' },
{ label: 'Gewinner', to: '/winners' },
{ label: 'Admin', to: '/admin' },
]
const currentLabel = computed(
() => navItems.find((item) => item.to === route.path)?.label ?? 'Awards',
)
const visibleNavItems = computed(() =>
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
)
async function login(role: 'viewer' | 'admin') {
loginError.value = ''
loginForm.role = role
try {
await authStore.login(loginForm)
loginOpen.value = false
} catch (error) {
loginError.value = error instanceof Error ? error.message : 'Login fehlgeschlagen.'
}
}
</script>
<template>
<div class="min-h-screen overflow-x-hidden bg-[linear-gradient(180deg,#fffdf9_0%,#fff6ee_38%,#fff9f4_100%)] text-slate-800">
<div class="pointer-events-none absolute inset-x-0 top-0 h-[520px] bg-[radial-gradient(circle_at_top_left,_rgba(237,214,167,0.38),transparent_34%),radial-gradient(circle_at_top_right,_rgba(210,195,255,0.32),transparent_28%)]" />
<div class="pointer-events-none absolute left-1/2 top-36 h-[560px] w-[560px] -translate-x-1/2 rounded-full border border-white/70 opacity-70 blur-3xl" />
<div class="relative mx-auto w-full max-w-[1460px] px-4 py-4 sm:px-6 lg:px-10">
<div class="mb-5 flex items-center justify-between border-b border-black/8 px-2 pb-3 text-[11px] uppercase tracking-[0.34em] text-slate-500">
<span>VTuber Star Awards 2026</span>
<span>{{ currentLabel }}</span>
</div>
<header class="mb-10 flex flex-col gap-6 rounded-[34px] border border-white/70 bg-white/72 px-5 py-5 shadow-[0_24px_80px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7">
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
<div class="grid h-12 w-12 place-items-center rounded-[1.4rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]">
<Star class="h-5 w-5" />
</div>
<div>
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
<span class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
</div>
</RouterLink>
<div class="flex flex-wrap items-center gap-3">
<template v-if="authStore.session">
<div class="rounded-full border border-violet-100 bg-violet-50/70 px-4 py-2 text-sm text-violet-800">
{{ authStore.session.displayName }} · {{ authStore.session.role }}
</div>
<Button variant="ghost" @click="authStore.logout()">Logout</Button>
</template>
<template v-else>
<Button variant="ghost" @click="loginOpen = !loginOpen">Sign in</Button>
<Button @click="login('viewer')">Mit Twitch Login</Button>
</template>
</div>
</div>
<div v-if="loginOpen && !authStore.session" class="rounded-[28px] border border-violet-100 bg-white/80 p-5">
<div class="grid gap-4 md:grid-cols-3">
<input v-model="loginForm.displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="loginForm.twitchUserId" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Twitch User ID" />
<div class="flex flex-wrap gap-3">
<Button :disabled="authStore.loading" @click="login('viewer')">
{{ authStore.loading ? 'Loggt ein ...' : 'Viewer Login' }}
</Button>
<Button variant="secondary" :disabled="authStore.loading" @click="login('admin')">Admin Login</Button>
</div>
</div>
<p v-if="loginError" class="mt-3 text-sm text-rose-700">{{ loginError }}</p>
</div>
<div class="flex flex-col gap-4 border-t border-black/6 pt-4 lg:flex-row lg:items-center lg:justify-between">
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
<RouterLink
v-for="item in visibleNavItems"
:key="item.to"
:to="item.to"
class="rounded-full px-4 py-2 transition hover:bg-violet-50 hover:text-violet-700"
:class="route.path === item.to ? 'bg-violet-100 text-violet-800' : ''"
>
{{ item.label }}
</RouterLink>
</nav>
<div class="flex items-center gap-3 text-xs uppercase tracking-[0.24em] text-slate-500">
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
Community Voting live
</div>
</div>
</header>
<slot />
</div>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
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',
{
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',
},
size: {
default: 'h-11 px-5',
lg: 'h-12 px-6',
sm: 'h-9 px-4',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
const props = defineProps<{
variant?: VariantProps<typeof buttonVariants>['variant']
size?: VariantProps<typeof buttonVariants>['size']
class?: string
}>()
const classes = computed(() =>
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class),
)
</script>
<template>
<button :class="classes">
<slot />
</button>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '../../lib/utils'
defineProps<{
class?: string
}>()
</script>
<template>
<div
:class="
cn(
'rounded-[28px] border border-violet-200/70 bg-white/80 shadow-[0_24px_60px_rgba(168,145,214,0.12)] backdrop-blur',
$props.class,
)
"
>
<slot />
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
import type {
AdminDashboardResponse,
AdminSeasonDetailResponse,
AdminSeasonListItem,
AuthSession,
CreateNominationPayload,
CreateVotePayload,
LoginPayload,
OverviewResponse,
SeasonCategoriesResponse,
UpdateSeasonPayload,
ApproveNominationPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
WinnerArchiveResponse,
} from '../types/awards'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:5084'
const AUTH_TOKEN_KEY = 'vtsa-session-token'
function getAuthToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(AUTH_TOKEN_KEY)
}
async function getJson<T>(path: string): Promise<T> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
})
if (!response.ok) {
throw new Error(`API request failed for ${path}`)
}
return response.json() as Promise<T>
}
async function sendJson<TResponse>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<TResponse> {
const token = getAuthToken()
const response = await fetch(`${API_URL}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
})
if (!response.ok) {
const error = await response.text()
throw new Error(error || `API request failed for ${path}`)
}
return response.json() as Promise<TResponse>
}
export const api = {
getOverview: () => getJson<OverviewResponse>('/api/public/overview'),
getSeasonCategories: (year: number) =>
getJson<SeasonCategoriesResponse>(`/api/public/seasons/${year}/categories`),
getWinnerArchive: (year: number) =>
getJson<WinnerArchiveResponse>(`/api/public/seasons/${year}/winners`),
getAdminDashboard: () => getJson<AdminDashboardResponse>('/api/admin/dashboard'),
getAdminSeasons: () => getJson<AdminSeasonListItem[]>('/api/admin/seasons'),
getAdminSeasonDetail: (seasonId: number) =>
getJson<AdminSeasonDetailResponse>(`/api/admin/seasons/${seasonId}`),
getSession: () => getJson<AuthSession>('/api/auth/session'),
login: (payload: LoginPayload) => sendJson<AuthSession>('/api/auth/dev-login', 'POST', payload),
logout: () => sendJson<{ loggedOut: boolean }>('/api/auth/logout', 'POST', {}),
submitNomination: (payload: CreateNominationPayload) =>
sendJson<{ saved: number; category: string }>('/api/public/nominations', 'POST', payload),
submitVote: (payload: CreateVotePayload) =>
sendJson<{ ballotId: number; entries: number }>('/api/public/votes', 'POST', payload),
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
sendJson<{ saved: boolean; seasonId: number }>(`/api/admin/seasons/${seasonId}`, 'PUT', payload),
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
sendJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, 'POST', payload),
updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) =>
sendJson<{ saved: boolean; categoryId: number }>(`/api/admin/categories/${categoryId}`, 'PUT', payload),
createAdminCandidate: (seasonId: number, payload: UpsertCandidatePayload) =>
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/seasons/${seasonId}/candidates`, 'POST', payload),
updateAdminCandidate: (candidateId: number, payload: UpsertCandidatePayload) =>
sendJson<{ saved: boolean; candidateId: number }>(`/api/admin/candidates/${candidateId}`, 'PUT', payload),
approveAdminNomination: (nominationId: number, payload: ApproveNominationPayload) =>
sendJson<{ saved: boolean; nominationId: number; candidateId: number; created: boolean }>(
`/api/admin/nominations/${nominationId}/approve`,
'POST',
payload,
),
rejectAdminNomination: (nominationId: number) =>
sendJson<{ saved: boolean; nominationId: number; rejected: boolean }>(
`/api/admin/nominations/${nominationId}/reject`,
'POST',
{},
),
}
export { AUTH_TOKEN_KEY }
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+29
View File
@@ -0,0 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import './style.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: false,
},
},
})
const authStore = useAuthStore(pinia)
void authStore.hydrate()
app.mount('#app')
+71
View File
@@ -0,0 +1,71 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from './stores/auth'
import AdminView from './views/AdminView.vue'
import HomeView from './views/HomeView.vue'
import NominationsView from './views/NominationsView.vue'
import VotingView from './views/VotingView.vue'
import WinnersView from './views/WinnersView.vue'
const router = createRouter({
history: createWebHistory(),
scrollBehavior() {
return { top: 0 }
},
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/nominations',
name: 'nominations',
component: NominationsView,
meta: {
requiresAuth: true,
},
},
{
path: '/voting',
name: 'voting',
component: VotingView,
meta: {
requiresAuth: true,
},
},
{
path: '/winners',
name: 'winners',
component: WinnersView,
},
{
path: '/admin',
name: 'admin',
component: AdminView,
meta: {
requiresAdmin: true,
},
},
],
})
router.beforeEach(async (to) => {
const authStore = useAuthStore()
if (!authStore.hydrated) {
await authStore.hydrate()
}
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'home' }
}
if (to.meta.requiresAdmin && !authStore.isAdmin) {
return { name: 'home' }
}
return true
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { AUTH_TOKEN_KEY, api } from '../lib/api'
import type { AuthSession, LoginPayload } from '../types/awards'
function readStoredToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(AUTH_TOKEN_KEY)
}
function writeStoredToken(token: string | null) {
if (typeof window === 'undefined') return
if (token) {
window.localStorage.setItem(AUTH_TOKEN_KEY, token)
} else {
window.localStorage.removeItem(AUTH_TOKEN_KEY)
}
}
export const useAuthStore = defineStore('auth', {
state: () => ({
session: null as AuthSession | null,
hydrated: false,
loading: false,
}),
getters: {
isLoggedIn: (state) => Boolean(state.session),
isAdmin: (state) => state.session?.role === 'admin',
},
actions: {
async hydrate() {
if (!readStoredToken()) {
this.hydrated = true
return
}
try {
this.session = await api.getSession()
} catch {
this.session = null
writeStoredToken(null)
} finally {
this.hydrated = true
}
},
async login(payload: LoginPayload) {
this.loading = true
try {
const session = await api.login(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async logout() {
this.loading = true
try {
await api.logout()
} finally {
this.session = null
writeStoredToken(null)
this.loading = false
}
},
},
})
+273
View File
@@ -0,0 +1,273 @@
import { defineStore } from 'pinia'
import { api } from '../lib/api'
import type {
AdminDashboardResponse,
AdminSeasonDetailResponse,
ApproveNominationPayload,
AdminSeasonListItem,
CreateNominationPayload,
CreateVotePayload,
OverviewResponse,
SeasonCategoriesResponse,
UpdateSeasonPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
WinnerArchiveResponse,
} from '../types/awards'
const fallbackOverview: OverviewResponse = {
seasonId: 1,
year: 2026,
title: 'VTuber Star Awards 2026',
showDate: '2026-01-24',
currentPhase: 'Community Voting',
isCommunityOnly: true,
loginProvider: 'Twitch',
timeline: [
{ key: 'nomination', title: 'Nominierung', startsAt: '2026-05-01', endsAt: '2026-05-31', state: 'done' },
{ key: 'voting', title: 'Voting', startsAt: '2026-06-01', endsAt: '2026-06-30', state: 'active' },
{ key: 'review', title: 'Auswertung', startsAt: '2026-07-01', endsAt: '2026-07-10', state: 'upcoming' },
{ key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' },
],
featuredCategories: [
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die groesste Auszeichnung des Jahres.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Events, Konzerte und Showformate.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der lustigste oder emotionalste Clip.', maxNomineesPerUser: 3 },
],
winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
{ year: 2025, category: 'Bestes Live Event', winnerName: 'Kurainu 3D Live', winnerSlug: '@kurainu' },
{ year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
],
faq: [
{ question: 'Wer kann nominieren und voten?', answer: 'Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt.' },
{ question: 'Wie werden Gewinner bestimmt?', answer: 'Aktuell rein community-basiert. Eine Mischlogik kann spaeter aktiviert werden.' },
{ question: 'Wer verwaltet Kategorien und Unterkategorien?', answer: 'Das Team pflegt diese pro Jahr im Admin-Bereich.' },
],
}
const fallbackCategories: SeasonCategoriesResponse = {
seasonId: 1,
year: 2026,
categories: [
{
id: 1,
name: 'VTuber des Jahres',
groupName: 'Main Awards',
description: 'Die Hauptkategorie fuer die praegendste Creator-Praesenz des Jahres.',
maxNomineesPerUser: 3,
candidates: [
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch' },
],
},
{
id: 2,
name: 'Bestes Live Event',
groupName: 'Performance',
description: 'Konzerte, Sonderformate und grosse Community-Shows.',
maxNomineesPerUser: 3,
candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube' },
],
},
],
}
const fallbackArchive: WinnerArchiveResponse = {
year: 2025,
items: [
{ category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
{ category: 'Bestes Live Event', winnerName: 'Kurainu 3D Live', winnerSlug: '@kurainu' },
{ category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
],
}
const fallbackAdmin: AdminDashboardResponse = {
metrics: [
{ label: 'Nominierungen', value: 12341, note: '+12.4% vs. gestern' },
{ label: 'Votes', value: 587231, note: '+8.7% vs. gestern' },
{ label: 'Kategorien', value: 28, note: 'aktiv im Jahr 2026' },
{ label: 'Reviews offen', value: 47, note: '14 neu' },
],
activities: [
{ label: 'Neue Nominierung in Best New VTuber', age: 'vor 2 Min.' },
{ label: 'Clip-Dublette erkannt in Clip des Jahres', age: 'vor 7 Min.' },
{ label: 'Alias-Merge fuer Hoshimi Miyu reviewt', age: 'vor 18 Min.' },
],
topCategories: [
{ category: 'VTuber des Jahres', votes: 186321 },
{ category: 'Bestes Live Event', votes: 132550 },
{ category: 'Clip des Jahres', votes: 98210 },
],
}
const fallbackAdminSeasons: AdminSeasonListItem[] = [
{ id: 1, year: 2026, name: 'VTuber Star Awards 2026', currentPhase: 'Community Voting', isCurrent: true, categoryCount: 4 },
{ id: 2, year: 2025, name: 'VTuber Star Awards 2025', currentPhase: 'Archived', isCurrent: false, categoryCount: 3 },
]
const fallbackAdminSeasonDetail: AdminSeasonDetailResponse = {
id: 1,
year: 2026,
name: 'VTuber Star Awards 2026',
currentPhase: 'Community Voting',
isCurrent: true,
categories: [
{
id: 1,
groupName: 'Main Awards',
name: 'VTuber des Jahres',
slug: 'vtuber-des-jahres',
description: 'Die groesste Auszeichnung des Jahres.',
sortOrder: 1,
maxNomineesPerUser: 3,
candidateCount: 3,
},
{
id: 2,
groupName: 'Performance',
name: 'Bestes Live Event',
slug: 'bestes-live-event',
description: 'Events, Konzerte und 3D-Shows.',
sortOrder: 2,
maxNomineesPerUser: 3,
candidateCount: 2,
},
],
candidates: [
{ id: 1, categoryId: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' },
{ id: 2, categoryId: 1, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' },
],
pendingNominations: [
{
id: 1,
categoryId: 1,
categoryName: 'VTuber des Jahres',
submittedByTwitchId: 'demo_user',
candidateText: 'Session Nominee',
createdAt: '2026-06-17T08:00:00Z',
},
],
}
const emptyAdmin: AdminDashboardResponse = {
metrics: [],
activities: [],
topCategories: [],
}
const emptyAdminSeasons: AdminSeasonListItem[] = []
const emptyAdminSeasonDetail: AdminSeasonDetailResponse = {
id: 0,
year: 0,
name: '',
currentPhase: '',
isCurrent: false,
categories: [],
candidates: [],
pendingNominations: [],
}
export const useAwardsStore = defineStore('awards', {
state: () => ({
overview: fallbackOverview as OverviewResponse,
categories: fallbackCategories as SeasonCategoriesResponse,
archive: fallbackArchive as WinnerArchiveResponse,
admin: fallbackAdmin as AdminDashboardResponse,
adminSeasons: fallbackAdminSeasons as AdminSeasonListItem[],
adminSeasonDetail: fallbackAdminSeasonDetail as AdminSeasonDetailResponse,
loading: false,
apiMode: 'fallback' as 'api' | 'fallback',
}),
actions: {
async loadHomeData() {
this.loading = true
try {
this.overview = await api.getOverview()
this.categories = await api.getSeasonCategories(this.overview.year)
this.archive = await api.getWinnerArchive(this.overview.winnersPreview[0]?.year ?? this.overview.year - 1)
this.apiMode = 'api'
} catch {
this.apiMode = 'fallback'
} finally {
this.loading = false
}
},
async loadArchive(year: number) {
try {
this.archive = await api.getWinnerArchive(year)
this.apiMode = 'api'
} catch {
this.archive = { ...fallbackArchive, year }
}
},
async loadAdmin() {
try {
this.admin = await api.getAdminDashboard()
this.adminSeasons = await api.getAdminSeasons()
this.adminSeasonDetail = await api.getAdminSeasonDetail(this.adminSeasons[0]?.id ?? 1)
this.apiMode = 'api'
} catch {
this.admin = emptyAdmin
this.adminSeasons = emptyAdminSeasons
this.adminSeasonDetail = emptyAdminSeasonDetail
}
},
async loadAdminSeasonDetail(seasonId: number) {
try {
this.adminSeasonDetail = await api.getAdminSeasonDetail(seasonId)
this.apiMode = 'api'
} catch {
this.adminSeasonDetail = emptyAdminSeasonDetail
}
},
submitNomination(payload: CreateNominationPayload) {
return api.submitNomination(payload)
},
submitVote(payload: CreateVotePayload) {
return api.submitVote(payload)
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin()
return result
},
async createAdminCategory(seasonId: number, payload: UpsertCategoryPayload) {
const result = await api.createAdminCategory(seasonId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async updateAdminCategory(categoryId: number, seasonId: number, payload: UpsertCategoryPayload) {
const result = await api.updateAdminCategory(categoryId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async createAdminCandidate(seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.createAdminCandidate(seasonId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async updateAdminCandidate(candidateId: number, seasonId: number, payload: UpsertCandidatePayload) {
const result = await api.updateAdminCandidate(candidateId, payload)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId)
await this.loadAdmin()
return result
},
async rejectAdminNomination(nominationId: number, seasonId: number) {
const result = await api.rejectAdminNomination(nominationId)
await this.loadAdminSeasonDetail(seasonId)
await this.loadAdmin()
return result
},
},
})
+29
View File
@@ -0,0 +1,29 @@
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap");
@import "tailwindcss";
@import "primeicons/primeicons.css";
@theme {
--font-display: "Cormorant Garamond", serif;
--font-sans: "Manrope", sans-serif;
}
html,
body,
#app {
min-height: 100%;
}
body {
font-family: var(--font-sans);
color: #1f2430;
background-color: #fffaf4;
}
::selection {
background: rgba(137, 92, 246, 0.18);
color: #2e1065;
}
a {
color: inherit;
}
+202
View File
@@ -0,0 +1,202 @@
export interface TimelineItem {
key: string
title: string
startsAt: string
endsAt: string
state: 'done' | 'active' | 'upcoming'
}
export interface FeaturedCategory {
id: number
groupName: string
name: string
description: string
maxNomineesPerUser: number
}
export interface WinnerPreview {
year: number
category: string
winnerName: string
winnerSlug: string
}
export interface FaqItem {
question: string
answer: string
}
export interface OverviewResponse {
seasonId: number
year: number
title: string
showDate: string
currentPhase: string
isCommunityOnly: boolean
loginProvider: string
timeline: TimelineItem[]
featuredCategories: FeaturedCategory[]
winnersPreview: WinnerPreview[]
faq: FaqItem[]
}
export interface CandidateSummary {
id: number
displayName: string
channelSlug: string
platform: string
}
export interface PublicCategoryDetail {
id: number
name: string
groupName: string
description: string
maxNomineesPerUser: number
candidates: CandidateSummary[]
}
export interface SeasonCategoriesResponse {
seasonId: number
year: number
categories: PublicCategoryDetail[]
}
export interface WinnerArchiveItem {
category: string
winnerName: string
winnerSlug: string
}
export interface WinnerArchiveResponse {
year: number
items: WinnerArchiveItem[]
}
export interface AdminMetric {
label: string
value: number
note: string
}
export interface AdminActivity {
label: string
age: string
}
export interface AdminTopCategory {
category: string
votes: number
}
export interface AdminDashboardResponse {
metrics: AdminMetric[]
activities: AdminActivity[]
topCategories: AdminTopCategory[]
}
export interface AdminSeasonListItem {
id: number
year: number
name: string
currentPhase: string
isCurrent: boolean
categoryCount: number
}
export interface AdminCategoryItem {
id: number
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
candidateCount: number
}
export interface AdminCandidateItem {
id: number
categoryId: number
displayName: string
channelSlug: string
platform: string
}
export interface AdminNominationReviewItem {
id: number
categoryId: number
categoryName: string
submittedByTwitchId: string
candidateText: string
createdAt: string
}
export interface AdminSeasonDetailResponse {
id: number
year: number
name: string
currentPhase: string
isCurrent: boolean
categories: AdminCategoryItem[]
candidates: AdminCandidateItem[]
pendingNominations: AdminNominationReviewItem[]
}
export interface CreateNominationPayload {
year: number
categoryId: number
twitchUserId: string
nominees: string[]
}
export interface VoteEntryPayload {
categoryId: number
candidateId: number
}
export interface CreateVotePayload {
seasonId: number
twitchUserId: string
entries: VoteEntryPayload[]
}
export interface UpdateSeasonPayload {
currentPhase: string
isCurrent: boolean
}
export interface UpsertCategoryPayload {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}
export interface UpsertCandidatePayload {
categoryId: number
displayName: string
channelSlug: string
platform: string
}
export interface ApproveNominationPayload {
displayName: string
channelSlug: string
platform: string
}
export interface AuthSession {
sessionToken: string
twitchUserId: string
displayName: string
role: 'viewer' | 'admin'
}
export interface LoginPayload {
twitchUserId: string
displayName: string
role: 'viewer' | 'admin'
}
+560
View File
@@ -0,0 +1,560 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Select from 'primevue/select'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedSeasonId = ref<number | null>(null)
const seasonSaving = ref(false)
const categorySaving = ref<number | 'new' | null>(null)
const candidateSaving = ref<number | 'new' | null>(null)
const reviewSaving = ref<number | null>(null)
const adminMessage = ref('')
const adminError = ref('')
const seasonForm = reactive({
currentPhase: '',
isCurrent: false,
})
const newCategoryForm = reactive({
groupName: '',
name: '',
slug: '',
description: '',
sortOrder: 1,
maxNomineesPerUser: 3,
})
const newCandidateForm = reactive({
categoryId: 0,
displayName: '',
channelSlug: '',
platform: 'Twitch',
})
const editForms = reactive<Record<number, {
groupName: string
name: string
slug: string
description: string
sortOrder: number
maxNomineesPerUser: number
}>>({})
const candidateForms = reactive<Record<number, {
categoryId: number
displayName: string
channelSlug: string
platform: string
}>>({})
const reviewForms = reactive<Record<number, {
displayName: string
channelSlug: string
platform: string
}>>({})
onMounted(async () => {
if (!authStore.isAdmin) return
await store.loadAdmin()
selectedSeasonId.value = store.adminSeasons[0]?.id ?? null
})
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
const topCategories = computed(() => store.admin.topCategories)
const seasons = computed(() => store.adminSeasons)
const seasonDetail = computed(() => store.adminSeasonDetail)
const categoryOptions = computed(() =>
seasonDetail.value.categories.map((category) => ({
label: `${category.groupName} · ${category.name}`,
value: category.id,
})),
)
watch(selectedSeasonId, async (seasonId) => {
if (!seasonId) return
await store.loadAdminSeasonDetail(seasonId)
})
watch(
seasonDetail,
(detail) => {
seasonForm.currentPhase = detail.currentPhase
seasonForm.isCurrent = detail.isCurrent
for (const category of detail.categories) {
editForms[category.id] = {
groupName: category.groupName,
name: category.name,
slug: category.slug,
description: category.description,
sortOrder: category.sortOrder,
maxNomineesPerUser: category.maxNomineesPerUser,
}
}
for (const candidate of detail.candidates) {
candidateForms[candidate.id] = {
categoryId: candidate.categoryId,
displayName: candidate.displayName,
channelSlug: candidate.channelSlug,
platform: candidate.platform,
}
}
for (const nomination of detail.pendingNominations) {
reviewForms[nomination.id] = {
displayName: nomination.candidateText,
channelSlug: '',
platform: 'Twitch',
}
}
newCandidateForm.categoryId = detail.categories[0]?.id ?? 0
},
{ immediate: true },
)
const seasonOptions = computed(() =>
seasons.value.map((season) => ({
label: `${season.year} · ${season.name}`,
value: season.id,
})),
)
async function saveSeason() {
if (!selectedSeasonId.value) return
seasonSaving.value = true
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminSeason(selectedSeasonId.value, {
currentPhase: seasonForm.currentPhase,
isCurrent: seasonForm.isCurrent,
})
await store.loadAdminSeasonDetail(selectedSeasonId.value)
adminMessage.value = 'Season-Einstellungen gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Season konnte nicht gespeichert werden.'
} finally {
seasonSaving.value = false
}
}
async function saveCategory(categoryId: number) {
if (!selectedSeasonId.value) return
categorySaving.value = categoryId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCategory(categoryId, selectedSeasonId.value, editForms[categoryId])
adminMessage.value = 'Kategorie gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht gespeichert werden.'
} finally {
categorySaving.value = null
}
}
async function createCategory() {
if (!selectedSeasonId.value) return
categorySaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
adminMessage.value = 'Neue Kategorie angelegt.'
newCategoryForm.groupName = ''
newCategoryForm.name = ''
newCategoryForm.slug = ''
newCategoryForm.description = ''
newCategoryForm.sortOrder = seasonDetail.value.categories.length + 1
newCategoryForm.maxNomineesPerUser = 3
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
} finally {
categorySaving.value = null
}
}
async function saveCandidate(candidateId: number) {
if (!selectedSeasonId.value) return
candidateSaving.value = candidateId
adminMessage.value = ''
adminError.value = ''
try {
await store.updateAdminCandidate(candidateId, selectedSeasonId.value, candidateForms[candidateId])
adminMessage.value = 'Kandidat gespeichert.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht gespeichert werden.'
} finally {
candidateSaving.value = null
}
}
async function createCandidate() {
if (!selectedSeasonId.value || !newCandidateForm.categoryId) return
candidateSaving.value = 'new'
adminMessage.value = ''
adminError.value = ''
try {
await store.createAdminCandidate(selectedSeasonId.value, newCandidateForm)
adminMessage.value = 'Kandidat angelegt.'
newCandidateForm.displayName = ''
newCandidateForm.channelSlug = ''
newCandidateForm.platform = 'Twitch'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Kandidat konnte nicht angelegt werden.'
} finally {
candidateSaving.value = null
}
}
async function approveNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.approveAdminNomination(nominationId, selectedSeasonId.value, reviewForms[nominationId])
adminMessage.value = 'Nominierung wurde in die Kandidatenliste uebernommen.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht uebernommen werden.'
} finally {
reviewSaving.value = null
}
}
async function rejectNomination(nominationId: number) {
if (!selectedSeasonId.value) return
reviewSaving.value = nominationId
adminMessage.value = ''
adminError.value = ''
try {
await store.rejectAdminNomination(nominationId, selectedSeasonId.value)
adminMessage.value = 'Nominierung wurde aus der Review Queue entfernt.'
} catch (error) {
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
} finally {
reviewSaving.value = null
}
}
</script>
<template>
<div class="space-y-10 pb-14">
<Card v-if="!authStore.isAdmin" class="p-8">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin Access</p>
<h1 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Admin Login erforderlich</h1>
<p class="mt-4 max-w-2xl text-lg leading-8 text-slate-600">
Bitte melde dich ueber den Header mit einem Admin-Login an, damit Season-, Category-, Candidate- und Review-Management verfuegbar werden.
</p>
</Card>
<template v-else>
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Admin</p>
<h1 class="max-w-[13ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Betriebswerkzeug fuer Seasons, Kategorien, Kandidaten und Review-Flows</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Das Team pflegt das Jahres-Setup und die operativen Awards-Inhalte direkt aus einer zusammenhaengenden Admin-Oberflaeche.
</p>
</div>
<div class="grid gap-5 lg:grid-cols-4">
<Card
v-for="metric in metrics"
:key="metric.label"
class="p-7"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ metric.label }}</p>
<strong class="mt-4 block text-4xl text-violet-800">{{ metric.value.toLocaleString('de-DE') }}</strong>
<p class="mt-2 text-sm text-slate-500">{{ metric.note }}</p>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[0.9fr_1.1fr]">
<Card class="p-7">
<div class="flex flex-col gap-6">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Season Setup</h2>
<p class="mt-2 text-sm text-slate-500">Aktive Season auswaehlen, Phase anpassen und bei Bedarf zum aktuellen Jahr machen.</p>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Season</label>
<Select
v-model="selectedSeasonId"
:options="seasonOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Phase</label>
<input
v-model="seasonForm.currentPhase"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
/>
</div>
<label class="flex items-center gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-4 text-sm text-slate-700">
<input v-model="seasonForm.isCurrent" type="checkbox" class="h-4 w-4 accent-violet-600" />
Diese Season ist die aktuelle Public Season
</label>
<div class="flex flex-wrap items-center gap-4">
<Button :disabled="seasonSaving || !selectedSeasonId" @click="saveSeason">
{{ seasonSaving ? 'Speichert ...' : 'Season speichern' }}
</Button>
<span class="text-sm text-slate-500">
{{ seasonDetail.year }} · {{ seasonDetail.name }}
</span>
</div>
<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>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Top Kategorien nach Votes</h2>
<DataTable :value="topCategories" class="mt-6" striped-rows>
<Column field="category" header="Kategorie" />
<Column field="votes" header="Votes">
<template #body="{ data }">
{{ Number(data.votes).toLocaleString('de-DE') }}
</template>
</Column>
</DataTable>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kategorien der Season</h2>
<p class="mt-2 text-sm text-slate-500">Sortierung, Slugs und Limits werden hier pro Jahr gepflegt.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.categories.length }} Kategorien
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="category in seasonDetail.categories"
:key="category.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<input v-model="editForms[category.id].groupName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group" />
<input v-model="editForms[category.id].name" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Name" />
<input v-model="editForms[category.id].slug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<input v-model="editForms[category.id].sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="editForms[category.id].maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Limit" />
<div class="flex items-center rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
{{ category.candidateCount }} Kandidaten in dieser Kategorie
</div>
</div>
<textarea
v-model="editForms[category.id].description"
class="mt-4 min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Beschreibung"
/>
<div class="mt-4 flex justify-end">
<Button :disabled="categorySaving === category.id" @click="saveCategory(category.id)">
{{ categorySaving === category.id ? 'Speichert ...' : 'Kategorie speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neue Kategorie</h2>
<div class="mt-6 space-y-4">
<input v-model="newCategoryForm.groupName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Group Name" />
<input v-model="newCategoryForm.name" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Kategorie-Name" />
<input v-model="newCategoryForm.slug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Slug" />
<textarea v-model="newCategoryForm.description" class="min-h-28 w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Beschreibung" />
<div class="grid gap-4 sm:grid-cols-2">
<input v-model="newCategoryForm.sortOrder" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Sortierung" />
<input v-model="newCategoryForm.maxNomineesPerUser" type="number" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Max Nominees" />
</div>
<Button :disabled="categorySaving === 'new' || !selectedSeasonId" @click="createCategory">
{{ categorySaving === 'new' ? 'Erstellt ...' : 'Kategorie anlegen' }}
</Button>
</div>
</Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Kandidatenpflege</h2>
<p class="mt-2 text-sm text-slate-500">Bekannte Kandidaten koennen pro Kategorie gepflegt und fuer Voting/Archiv genutzt werden.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.candidates.length }} Kandidaten
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="candidate in seasonDetail.candidates"
:key="candidate.id"
class="rounded-[26px] border border-violet-100 bg-white/90 p-5"
>
<div class="grid gap-4 md:grid-cols-2">
<Select
v-model="candidateForms[candidate.id].categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="candidateForms[candidate.id].displayName" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="candidateForms[candidate.id].channelSlug" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="candidateForms[candidate.id].platform" type="text" class="rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
</div>
<div class="mt-4 flex justify-end">
<Button :disabled="candidateSaving === candidate.id" @click="saveCandidate(candidate.id)">
{{ candidateSaving === candidate.id ? 'Speichert ...' : 'Kandidat speichern' }}
</Button>
</div>
</div>
</div>
</Card>
<Card class="p-7">
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Neuer Kandidat</h2>
<div class="mt-6 space-y-4">
<Select
v-model="newCandidateForm.categoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
<input v-model="newCandidateForm.displayName" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Display Name" />
<input v-model="newCandidateForm.channelSlug" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="@channel" />
<input v-model="newCandidateForm.platform" type="text" class="w-full rounded-2xl border border-violet-200 px-4 py-3" placeholder="Platform" />
<Button :disabled="candidateSaving === 'new' || !selectedSeasonId || !newCandidateForm.categoryId" @click="createCandidate">
{{ candidateSaving === 'new' ? 'Erstellt ...' : 'Kandidat anlegen' }}
</Button>
</div>
<h3 class="mt-10 font-[Cormorant_Garamond] text-3xl text-violet-800">Letzte Aktivitaeten</h3>
<div class="mt-4 space-y-4">
<div
v-for="activity in activities"
:key="activity.label"
class="rounded-[26px] border border-violet-100 bg-violet-50/60 px-5 py-5"
>
<p class="font-semibold text-slate-800">{{ activity.label }}</p>
<p class="mt-1 text-sm text-slate-500">{{ activity.age }}</p>
</div>
</div>
</Card>
</div>
<Card class="p-7">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Review Queue</h2>
<p class="mt-2 text-sm text-slate-500">Freitext-Nominierungen und Alias-Faelle, die das Team direkt in Kandidaten ueberfuehren oder verwerfen kann.</p>
</div>
<span class="text-sm uppercase tracking-[0.2em] text-slate-500">
{{ seasonDetail.pendingNominations.length }} offen
</span>
</div>
<div class="mt-6 space-y-4">
<div
v-for="nomination in seasonDetail.pendingNominations"
:key="nomination.id"
class="rounded-[26px] border border-violet-100 bg-white/90 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">{{ nomination.categoryName }}</p>
<h3 class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">{{ nomination.candidateText }}</h3>
<p class="mt-2 text-sm text-slate-500">
Von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
</div>
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
ID {{ nomination.id }}
</div>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-3">
<input
v-model="reviewForms[nomination.id].displayName"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Display Name"
/>
<input
v-model="reviewForms[nomination.id].channelSlug"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="@channel"
/>
<input
v-model="reviewForms[nomination.id].platform"
type="text"
class="rounded-2xl border border-violet-200 px-4 py-3"
placeholder="Platform"
/>
</div>
<div class="mt-4 flex flex-wrap justify-end gap-3">
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="rejectNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
</Button>
<Button :disabled="reviewSaving === nomination.id" @click="approveNomination(nomination.id)">
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat uebernehmen' }}
</Button>
</div>
</div>
</div>
</Card>
</template>
</div>
</template>
+311
View File
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Tag from 'primevue/tag'
import { ArrowRight, Sparkles, Star, Trophy, WandSparkles } from '@lucide/vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import hostVisual from '../assets/collector-editorial-reference.png'
const store = useAwardsStore()
const authStore = useAuthStore()
onMounted(() => {
void store.loadHomeData()
})
const heroYear = computed(() => store.overview.year)
</script>
<template>
<div class="space-y-20 pb-16">
<section class="grid gap-10 lg:grid-cols-[0.82fr_1.18fr] lg:items-start">
<div class="space-y-10 pt-3">
<div class="space-y-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Die groesste Community-Auszeichnung</p>
<h1 class="max-w-[8ch] font-[Cormorant_Garamond] text-6xl leading-[0.88] text-violet-800 sm:text-7xl xl:text-[6.6rem]">
VTuber Star Awards
</h1>
<p class="text-2xl font-medium italic tracking-wide text-violet-500">
Presented by Jayuhime
</p>
<p class="max-w-lg text-lg leading-8 text-slate-600">
Feiere die talentiertesten VTuber, Creator und Showmomente des Jahres.
Kategorien und Unterkategorien werden vom Team pro Jahr gepflegt, die Gewinner sind aktuell rein community-basiert.
</p>
</div>
<div class="flex flex-wrap gap-3">
<RouterLink to="/nominations">
<Button size="lg">Jetzt nominieren</Button>
</RouterLink>
<RouterLink to="/voting">
<Button variant="secondary" size="lg">Jetzt voten</Button>
</RouterLink>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<Card class="min-h-[210px] p-7">
<div class="flex items-center gap-3 text-violet-600">
<Sparkles class="h-5 w-5 text-amber-500" />
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Community powered</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Twitch Login only, keine Konto-Huerde, editierbare Votes und Nominierungen bis zur Deadline.
</p>
</Card>
<Card class="min-h-[210px] p-7">
<div class="flex items-center gap-3 text-violet-600">
<WandSparkles class="h-5 w-5 text-amber-500" />
<span class="text-xs font-semibold uppercase tracking-[0.25em]">Team verwaltet pro Jahr</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600">
Kategorien und Unterkategorien werden im Admin-Bereich je Season gepflegt und freigeschaltet.
</p>
</Card>
</div>
<Card class="p-7">
<div class="flex flex-col gap-6 xl:flex-row xl:items-center xl:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Aktuelle Phase</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">
{{ store.overview.currentPhase }}
</h2>
<p class="mt-2 max-w-md text-slate-600">
Login bleibt leichtgewichtig: Twitch only, kein separates Community-Konto.
</p>
</div>
<div class="rounded-[26px] border border-violet-100 bg-violet-50/70 px-5 py-5 text-sm text-slate-700">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Session Status</p>
<p class="mt-2 font-semibold text-violet-800">
{{ authStore.isLoggedIn ? `${authStore.session?.displayName} · ${authStore.session?.role}` : 'Noch nicht eingeloggt' }}
</p>
<p class="mt-2 leading-7 text-slate-600">
{{ authStore.isLoggedIn ? 'Nominierung und Voting sind jetzt direkt freigeschaltet.' : 'Bitte oben im Header einloggen, um Nominierung, Voting oder Admin zu nutzen.' }}
</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">41</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Tage</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">08</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Std</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">24</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Min</span>
</div>
<div class="rounded-2xl bg-violet-50 px-4 py-3 text-center">
<strong class="block text-2xl text-violet-800">16</strong>
<span class="text-xs uppercase tracking-[0.2em] text-slate-500">Sek</span>
</div>
</div>
</div>
</Card>
</div>
<Card class="overflow-hidden p-0">
<div class="relative min-h-[760px] bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.82),transparent_26%),linear-gradient(160deg,rgba(224,214,255,0.72),rgba(255,240,217,0.68))]">
<div class="absolute inset-0 bg-[radial-gradient(circle_at_75%_15%,rgba(255,255,255,0.82),transparent_25%)]" />
<div class="absolute left-10 top-10 rounded-full border border-white/60 bg-white/60 px-4 py-1 text-xs uppercase tracking-[0.3em] text-violet-600">
Presented by Jayuhime
</div>
<img
:src="hostVisual"
alt="Jayuhime Host Keyvisual"
class="absolute inset-0 h-full w-full object-cover object-center"
/>
<div class="absolute inset-y-0 right-0 flex w-full max-w-[340px] flex-col justify-between border-l border-white/40 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.38))] p-7 backdrop-blur md:p-8">
<div class="space-y-8">
<div class="rounded-[28px] border border-white/50 bg-white/30 p-5">
<div class="flex items-center gap-3 text-violet-700">
<Star class="h-5 w-5 text-amber-500" />
<span class="text-sm font-semibold uppercase tracking-[0.2em]">Jayuhime · Host of the Show</span>
</div>
<p class="mt-4 text-sm leading-7 text-slate-700">
Editoriales Hero-Panel mit klarer Host-Praesenz, aber mehr White Space und weniger competing elements.
</p>
</div>
<Tag value="Collector Editorial" severity="warn" class="self-start" />
</div>
<div class="grid gap-3">
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Show Date</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">24. Jan 2026</p>
</div>
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Winner Model</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Community only</p>
</div>
<div class="rounded-[26px] border border-white/70 bg-white/72 px-5 py-5">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Login</p>
<p class="mt-2 font-[Cormorant_Garamond] text-3xl text-violet-800">Twitch</p>
</div>
</div>
</div>
</div>
</Card>
</section>
<section class="grid gap-5 lg:grid-cols-4">
<Card
v-for="item in store.overview.timeline"
:key="item.key"
class="p-7"
>
<Tag :value="item.state === 'active' ? 'live' : item.state" severity="secondary" class="mb-4" />
<h3 class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ item.title }}</h3>
<p class="mt-2 text-sm text-slate-600">{{ item.startsAt }} - {{ item.endsAt }}</p>
</Card>
</section>
<section class="grid gap-5 lg:grid-cols-3">
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<Trophy class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">How it works</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Nominate, vote, celebrate.</h2>
<p class="mt-3 text-slate-600">
Die Plattform trennt bewusst zwischen showhafter Startseite und ruhigen Produktflows. So bleibt der Einstieg emotional, waehrend die Interaktion klar bleibt.
</p>
</Card>
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<Sparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Rules</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Moderater Abuse-Schutz</h2>
<p class="mt-3 text-slate-600">
Rate limits, serverseitige Pruefung und Risikoflags laufen im Hintergrund. Die User-Huerde bleibt niedrig, der operative Blick landet im Admin.
</p>
</Card>
<Card class="min-h-[260px] p-8">
<div class="flex items-center gap-3">
<WandSparkles class="h-5 w-5 text-amber-500" />
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-violet-500">Admin</p>
</div>
<h2 class="mt-4 font-[Cormorant_Garamond] text-4xl text-violet-800">Season-first Management</h2>
<p class="mt-3 text-slate-600">
Jahre, Kategorien, Unterkategorien, Gewinnerarchiv und Reviews werden als saisonale Inhalte gedacht, nicht als harte statische App-Texte.
</p>
</Card>
</section>
<section class="space-y-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Featured Kategorien</p>
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">Team-gesteuerte Awards fuer {{ heroYear }}</h2>
</div>
<span class="text-sm text-slate-500">
API-Modus:
<strong class="text-violet-700">{{ store.apiMode }}</strong>
</span>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Card
v-for="category in store.overview.featuredCategories"
:key="category.id"
class="p-6"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category.groupName }}</p>
<h3 class="mt-3 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ category.name }}</h3>
<p class="mt-3 text-slate-600">{{ category.description }}</p>
<div class="mt-6 flex items-center justify-between text-sm text-slate-500">
<span>Max. {{ category.maxNomineesPerUser }} Nominierungen</span>
<ArrowRight class="h-4 w-4 text-amber-500" />
</div>
</Card>
</div>
</section>
<section class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierung</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Bis zu drei Favoriten, direkt validiert</h2>
<ul class="mt-5 space-y-3 text-slate-600">
<li>Pro Kategorie keine doppelte Nominierung derselben Person.</li>
<li>Regeln werden direkt im Formular sichtbar gemacht.</li>
<li>Freitext-Ideen und Alias-Faelle gehen spaeter in die Review Queue.</li>
</ul>
<div class="mt-6">
<RouterLink to="/nominations">
<Button>Zur Nominierungsansicht</Button>
</RouterLink>
</div>
</Card>
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Ein Kandidat pro Kategorie, bis zur Deadline editierbar</h2>
<ul class="mt-5 space-y-3 text-slate-600">
<li>Nur eine Stimme pro Kategorie.</li>
<li>Videos, Clips und spaetere Detail-Previews koennen direkt im Flow eingebettet werden.</li>
<li>Die Ballot-Logik lebt im Backend ueber VoteBallot und VoteEntry.</li>
</ul>
<div class="mt-6">
<RouterLink to="/voting">
<Button variant="secondary">Zur Voting-Ansicht</Button>
</RouterLink>
</div>
</Card>
</section>
<section class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinner Archiv</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Vergangene Seasons sichtbar machen</h2>
<p class="mt-4 text-slate-600">
Gewinner, Nominierte und Banner werden pro Jahr archiviert. So bleibt die Show-Historie dauerhaft sichtbar und teilbar.
</p>
<div class="mt-6 space-y-3">
<div
v-for="entry in store.overview.winnersPreview"
:key="`${entry.year}-${entry.category}`"
class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3"
>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-semibold text-slate-800">{{ entry.category }}</p>
<p class="text-sm text-slate-500">{{ entry.winnerName }} · {{ entry.winnerSlug }}</p>
</div>
<Tag :value="entry.year.toString()" severity="info" />
</div>
</div>
</div>
</Card>
<Card class="p-6">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">FAQ</p>
<h2 class="mt-3 font-[Cormorant_Garamond] text-5xl text-violet-800">Regeln, Voting, Missbrauchsschutz</h2>
<Accordion value="0" class="mt-6">
<AccordionPanel v-for="(item, index) in store.overview.faq" :key="item.question" :value="String(index)">
<AccordionHeader>{{ item.question }}</AccordionHeader>
<AccordionContent>
<p class="leading-7 text-slate-600">{{ item.answer }}</p>
</AccordionContent>
</AccordionPanel>
</Accordion>
</Card>
</section>
</div>
</template>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const nomineeName = ref('')
const nominees = ref<string[]>(['Hoshimi Miyu', 'Kurainu'])
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
selectedCategoryId.value = store.categories.categories[0]?.id ?? null
})
const categories = computed(() =>
store.categories.categories.map((category) => ({
label: category.name,
value: category.id,
})),
)
const selectedCategory = computed(() =>
store.categories.categories.find((category) => category.id === selectedCategoryId.value),
)
function addNominee() {
const value = nomineeName.value.trim()
if (!value || nominees.value.includes(value) || nominees.value.length >= 3) return
nominees.value = [...nominees.value, value]
nomineeName.value = ''
}
function removeNominee(name: string) {
nominees.value = nominees.value.filter((entry) => entry !== name)
}
async function submitNomination() {
if (!selectedCategoryId.value || nominees.value.length === 0) return
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
const response = await store.submitNomination({
year: store.categories.year,
categoryId: selectedCategoryId.value,
twitchUserId: authStore.session?.twitchUserId ?? '',
nominees: nominees.value,
})
submitMessage.value = `${response.saved} Nominierungen fuer ${response.category} gespeichert.`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-10 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Nominierungs-Flow</p>
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Kategorien waehlen, Regeln live pruefen</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Nur Twitch Login, kein separates Konto. Das Team pflegt Kategorien pro Jahr, waehrend die UI sofort Limits, Dubletten und editierbare Entwuerfe abbildet.
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[0.68fr_1.32fr]">
<Card class="p-7">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">Regeln</p>
<ul class="mt-5 space-y-4 text-slate-600">
<li>Pro Kategorie nur eine Nominierung derselben Person.</li>
<li>Insgesamt maximal drei Nominierungen in diesem Draft.</li>
<li>Freitext-Ideen landen spaeter in der Review Queue.</li>
<li>Bereits gespeicherte Entwuerfe koennen bis zur Deadline bearbeitet werden.</li>
</ul>
</Card>
<Card class="p-7">
<div class="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
Bitte zuerst ueber den Header mit einem Twitch-Account einloggen, damit die Nominierung gespeichert werden kann.
</p>
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categories"
option-label="label"
option-value="value"
class="w-full"
/>
<div v-if="selectedCategory" class="rounded-[28px] bg-violet-50/70 p-6">
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ selectedCategory.groupName }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ selectedCategory.name }}</h2>
<p class="mt-2 text-slate-600">{{ selectedCategory.description }}</p>
</div>
<div class="space-y-3 rounded-[28px] border border-violet-100 bg-white/70 p-5">
<label class="text-sm font-semibold text-slate-600">Neuen Namen hinzufuegen</label>
<input
v-model="nomineeName"
type="text"
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3"
placeholder="z. B. Shiro Ch."
/>
<Button @click="addNominee">Nominierung hinzufuegen</Button>
</div>
</div>
<div class="space-y-4">
<h3 class="font-[Cormorant_Garamond] text-4xl text-violet-800">Dein Entwurf</h3>
<div
v-for="name in nominees"
:key="name"
class="flex items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5"
>
<div>
<p class="font-semibold text-slate-800">{{ name }}</p>
<p class="text-sm text-slate-500">@{{ name.toLowerCase().replace(/\s+/g, '') }}</p>
</div>
<button class="text-sm font-semibold text-rose-500" @click="removeNominee(name)">Entfernen</button>
</div>
<p class="rounded-[26px] border border-dashed border-violet-200 bg-violet-50/60 px-5 py-5 text-sm text-slate-600">
Live-Status: {{ nominees.length }}/3 Slots belegt.
</p>
<p v-if="submitMessage" class="rounded-[26px] border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-700">
{{ submitMessage }}
</p>
<p v-if="submitError" class="rounded-[26px] border border-rose-200 bg-rose-50 px-5 py-4 text-sm text-rose-700">
{{ submitError }}
</p>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCategoryId || nominees.length === 0" @click="submitNomination">
{{ submitting ? 'Speichert ...' : 'Nominierung speichern' }}
</Button>
</div>
</div>
</Card>
</div>
</div>
</template>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Select from 'primevue/select'
import RadioButton from 'primevue/radiobutton'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
import { useAuthStore } from '../stores/auth'
const store = useAwardsStore()
const authStore = useAuthStore()
const selectedCategoryId = ref<number | null>(null)
const selectedCandidateId = ref<number | null>(null)
const submitting = ref(false)
const submitMessage = ref('')
const submitError = ref('')
onMounted(async () => {
await store.loadHomeData()
selectedCategoryId.value = store.categories.categories[0]?.id ?? null
})
const categoryOptions = computed(() =>
store.categories.categories.map((category) => ({
label: category.name,
value: category.id,
})),
)
const category = computed(() =>
store.categories.categories.find((item) => item.id === selectedCategoryId.value) ?? store.categories.categories[0],
)
async function submitVote() {
if (!category.value || !selectedCandidateId.value) return
submitting.value = true
submitMessage.value = ''
submitError.value = ''
try {
const response = await store.submitVote({
seasonId: store.categories.seasonId,
twitchUserId: authStore.session?.twitchUserId ?? '',
entries: [
{
categoryId: category.value.id,
candidateId: selectedCandidateId.value,
},
],
})
submitMessage.value = `Ballot #${response.ballotId} mit ${response.entries} Eintrag gespeichert.`
} catch (error) {
submitError.value = error instanceof Error ? error.message : 'Vote konnte nicht gespeichert werden.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="space-y-10 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Voting</p>
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Ein ruhiger, schneller Community-Voting-Flow</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Der V2-Flow priorisiert geringe Reibung: Twitch Login, ein Kandidat pro Kategorie, spaeter editierbar bis zur Deadline und klarer Review-Screen.
</p>
</div>
<Card class="p-7">
<div class="grid gap-7 lg:grid-cols-[0.72fr_1.28fr]">
<div class="space-y-5">
<p v-if="!authStore.isLoggedIn" class="rounded-[26px] border border-amber-200 bg-amber-50 px-5 py-4 text-sm text-amber-700">
Bitte zuerst ueber den Header mit einem Twitch-Account einloggen, damit deine Stimme gespeichert werden kann.
</p>
<div class="space-y-3">
<label class="text-sm font-semibold text-slate-600">Kategorie</label>
<Select
v-model="selectedCategoryId"
:options="categoryOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ category?.groupName }}</p>
<h2 class="font-[Cormorant_Garamond] text-5xl text-violet-800">{{ category?.name }}</h2>
<p class="text-slate-600">{{ category?.description }}</p>
<div class="rounded-[28px] bg-violet-50/70 p-6 text-sm leading-7 text-slate-600">
Nur eine Stimme pro Kategorie. Videos/Clips koennen spaeter direkt auf Karten oder Detailmodals referenziert werden.
</div>
</div>
<div class="space-y-4">
<label
v-for="candidate in category?.candidates ?? []"
:key="candidate.id"
class="flex cursor-pointer items-center justify-between rounded-[26px] border border-violet-100 bg-white/85 px-5 py-5 transition hover:border-violet-300 hover:bg-white"
>
<div>
<p class="font-semibold text-slate-800">{{ candidate.displayName }}</p>
<p class="text-sm text-slate-500">{{ candidate.channelSlug }} · {{ candidate.platform }}</p>
</div>
<RadioButton
v-model="selectedCandidateId"
:input-id="`candidate-${candidate.id}`"
:name="category?.name"
:value="candidate.id"
/>
</label>
</div>
</div>
<div class="mt-7 flex flex-wrap items-center justify-between gap-4 rounded-[28px] bg-violet-50/60 px-6 py-5">
<div class="space-y-2">
<p class="text-sm text-slate-600">
Auswahl:
<strong class="text-violet-700">
{{ category?.candidates.find((candidate) => candidate.id === selectedCandidateId)?.displayName ?? 'Noch keine Stimme abgegeben' }}
</strong>
</p>
<p v-if="submitMessage" class="text-sm text-emerald-700">{{ submitMessage }}</p>
<p v-if="submitError" class="text-sm text-rose-700">{{ submitError }}</p>
</div>
<Button :disabled="submitting || !authStore.isLoggedIn || !selectedCandidateId" @click="submitVote">
{{ submitting ? 'Speichert ...' : 'Stimme speichern' }}
</Button>
</div>
</Card>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import Button from '../components/ui/Button.vue'
import Card from '../components/ui/Card.vue'
import { useAwardsStore } from '../stores/awards'
const store = useAwardsStore()
const years = [2025, 2024, 2023, 2022]
const activeYear = ref(2025)
onMounted(async () => {
await store.loadHomeData()
await store.loadArchive(activeYear.value)
})
async function selectYear(year: number) {
activeYear.value = year
await store.loadArchive(year)
}
</script>
<template>
<div class="space-y-10 pb-14">
<div class="space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.35em] text-amber-500">Gewinnerarchiv</p>
<h1 class="max-w-[12ch] font-[Cormorant_Garamond] text-6xl leading-[0.92] text-violet-800">Seasons, Gewinner und Show-Historie</h1>
<p class="max-w-3xl text-lg leading-8 text-slate-600">
Das Archiv macht Awards dauerhaft sichtbar und verlinkbar. Kategorien und Banner bleiben pro Jahr nachvollziehbar.
</p>
</div>
<div class="flex flex-wrap gap-3">
<Button
v-for="year in years"
:key="year"
:variant="activeYear === year ? 'default' : 'ghost'"
@click="selectYear(year)"
>
{{ year }}
</Button>
</div>
<div class="grid gap-5 lg:grid-cols-3">
<Card
v-for="item in store.archive.items"
:key="`${store.archive.year}-${item.category}`"
class="p-7"
>
<p class="text-xs font-semibold uppercase tracking-[0.25em] text-violet-500">{{ store.archive.year }}</p>
<h2 class="mt-2 font-[Cormorant_Garamond] text-4xl text-violet-800">{{ item.category }}</h2>
<p class="mt-4 text-lg font-semibold text-slate-800">{{ item.winnerName }}</p>
<p class="mt-1 text-sm text-slate-500">{{ item.winnerSlug }}</p>
</Card>
</div>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+269
View File
@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VTuber Star Awards</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="site-frame">
<header class="topbar">
<a class="brand" href="#home">
<span class="brand-star"></span>
<span class="brand-copy">
<strong>VTUBER</strong>
<small>STAR AWARDS</small>
</span>
</a>
<nav class="nav">
<a href="#home" class="is-active">Home</a>
<a href="#process">Ablauf</a>
<a href="#featured">Kategorien</a>
<a href="#archive">Gewinner</a>
<a href="#faq">FAQ</a>
<a href="#about">Ueber die Show</a>
</nav>
<div class="topbar-actions">
<button class="ghost-button" type="button">Anmelden</button>
<button class="primary-button" type="button">Jetzt voten</button>
</div>
</header>
<main>
<section class="hero" id="home">
<div class="hero-copy">
<p class="eyebrow">Die groesste Community-Auszeichnung</p>
<h1>VTuber Star Awards</h1>
<p class="hero-body">
Feiere die talentiertesten VTuber, Creator und Momente des Jahres -
gewaehlt von der Community, fuer die Community.
</p>
<div class="hero-cta">
<a class="primary-button" href="#nomination-cta">Jetzt nominieren</a>
<a class="secondary-button" href="#voting-cta">Jetzt voten</a>
</div>
<div class="show-meta">
<p class="eyebrow muted">Die Show findet statt am</p>
<h2>24. Januar 2026</h2>
<p>Live auf YouTube & Twitch</p>
</div>
</div>
<div class="hero-art">
<div class="hero-illustration">
<div class="hero-placeholder" aria-hidden="true"></div>
</div>
<div class="hero-signature">
<span>Jayuhime</span>
<small>Host of the Show</small>
</div>
</div>
</section>
<section class="phase-panel" aria-label="Aktuelle Phase">
<div class="phase-copy">
<p class="eyebrow">Aktuelle Phase</p>
<h3>Voting</h3>
<p>
Stimme in deinen Lieblingskategorien ab und unterstuetze deine
Favoriten.
</p>
<div class="timer">
<div><strong>14</strong><span>Tage</span></div>
<div><strong>07</strong><span>Std</span></div>
<div><strong>32</strong><span>Min</span></div>
<div><strong>45</strong><span>Sek</span></div>
</div>
</div>
<div class="phase-track">
<div class="phase-step is-complete">
<span></span>
<strong>Nominierung</strong>
<small>01. Mai - 31. Mai</small>
</div>
<div class="phase-step is-active">
<span></span>
<strong>Voting</strong>
<small>01. Jun - 30. Jun</small>
</div>
<div class="phase-step">
<span></span>
<strong>Auswertung</strong>
<small>01. Jul - 10. Jul</small>
</div>
<div class="phase-step">
<span>🏆</span>
<strong>Award Show</strong>
<small>24. Januar 2026</small>
</div>
</div>
</section>
<section class="section" id="process">
<div class="section-title centered">
<h2>So funktioniert's</h2>
</div>
<div class="how-grid">
<article class="how-card">
<div class="how-icon"></div>
<h3>1. Nominieren</h3>
<p>Nominiere deine Favoriten in jeder Kategorie.</p>
</article>
<article class="how-card">
<div class="how-icon"></div>
<h3>2. Voten</h3>
<p>Stimme ab und unterstuetze deine Lieblings-Creator.</p>
</article>
<article class="how-card">
<div class="how-icon"></div>
<h3>3. Ergebnisse</h3>
<p>Die Community entscheidet - 100% deiner Stimme zaehlt.</p>
</article>
<article class="how-card">
<div class="how-icon">🏆</div>
<h3>4. Award Show</h3>
<p>Feiere die Gewinner bei der grossen Live-Show.</p>
</article>
</div>
</section>
<section class="section" id="featured">
<div class="section-title split">
<h2>Gefeatured Kategorien</h2>
<a href="#">Alle Kategorien ansehen →</a>
</div>
<div class="category-grid" id="category-grid"></div>
</section>
<section class="section" id="archive">
<div class="section-title split">
<h2>Gewinner Archiv</h2>
<a href="#">Alle Jahre ansehen →</a>
</div>
<div class="archive-panel">
<aside class="archive-lead">
<h3 id="archive-year-label">2025</h3>
<p>
Die letzten Gewinner. Sieh dir an, wer im letzten Jahr die
Community begeistert hat.
</p>
<button class="primary-button" type="button">Archiv entdecken</button>
</aside>
<div class="archive-content">
<div class="year-pills" id="year-pills">
<button class="year-pill is-active" data-year="2025">2025</button>
<button class="year-pill" data-year="2024">2024</button>
<button class="year-pill" data-year="2023">2023</button>
<button class="year-pill" data-year="2022">2022</button>
</div>
<div class="winner-grid" id="winner-grid"></div>
</div>
</div>
</section>
<section class="section cta-row">
<article class="cta-panel" id="nomination-cta">
<div class="cta-copy">
<p class="eyebrow">Nominieren</p>
<h2>Nominiere pro Kategorie bis zu 3 VTuber.</h2>
<ul>
<li>Pro Kategorie nur 1 Nominierung fuer dieselbe Person</li>
<li>Insgesamt nur 2 Nominierungen pro Person</li>
<li>Live-Validierung & Feedback</li>
</ul>
<a class="primary-button" href="#">Jetzt nominieren</a>
</div>
<div class="placeholder-visual ballot-box">
<div class="placeholder-card"></div>
</div>
</article>
<article class="cta-panel accent" id="voting-cta">
<div class="cta-copy">
<p class="eyebrow">Voten</p>
<h2>Stimme in deinen Lieblingskategorien ab.</h2>
<ul>
<li>Nur 1 Stimme pro Kategorie</li>
<li>Twitch Login erforderlich</li>
<li>Videos, Clips & Songs ansehen</li>
</ul>
<a class="secondary-button" href="#">Jetzt voten</a>
</div>
<div class="placeholder-visual trophy-visual">
<div class="trophy"></div>
</div>
</article>
</section>
<section class="section lower-grid" id="faq">
<article class="faq-panel">
<div class="section-title">
<h2>Haeufige Fragen</h2>
</div>
<div class="faq-list" id="faq-list"></div>
</article>
<article class="updates-panel" id="about">
<div class="section-title">
<h2>Bleib up to date</h2>
</div>
<p>
Erhalte Updates zu Phasen, Regeln und der Show direkt in dein
Postfach.
</p>
<div class="newsletter">
<input type="email" placeholder="Deine E-Mail-Adresse" />
<button class="primary-button" type="button">Abonnieren</button>
</div>
<div class="social-row">
<span>Twitch</span>
<span>YouTube</span>
<span>X</span>
<span>Instagram</span>
<span>TikTok</span>
<span>Discord</span>
</div>
</article>
</section>
</main>
<footer class="footer">
<a class="brand compact" href="#home">
<span class="brand-star"></span>
<span class="brand-copy">
<strong>VTUBER</strong>
<small>STAR AWARDS</small>
</span>
</a>
<div class="footer-links">
<a href="#">Impressum</a>
<a href="#">Datenschutz</a>
<a href="#">Kontakt</a>
<a href="#">Media Kit</a>
</div>
<div class="footer-meta">© 2025 VTuber Star Awards. All rights reserved.</div>
</footer>
</div>
<script src="./script.js"></script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
const categories = [
{
icon: "✦",
color: "linear-gradient(160deg, #c49aef, #8b6ce7)",
title: "VTuber des Jahres",
text: "Die herausragendste VTuberin des Jahres 2025.",
},
{
icon: "🎙",
color: "linear-gradient(160deg, #d7e7ff, #8ea9ef)",
title: "Bestes Live Event",
text: "Das beste Live Event, das die Community begeistert hat.",
},
{
icon: "🎬",
color: "linear-gradient(160deg, #f7dfb2, #dba84b)",
title: "Bester Content Creator",
text: "Kreativitaet, Konsistenz und Impact in einem.",
},
{
icon: "🌿",
color: "linear-gradient(160deg, #e9f3bb, #a7c95d)",
title: "Bester New VTuber",
text: "Rising Stars, die dieses Jahr durchgestartet sind.",
},
{
icon: "🤍",
color: "linear-gradient(160deg, #ffd4e4, #f28ab1)",
title: "Beste Community",
text: "Die herzlichste und aktivste Community des Jahres.",
},
];
const winners = {
2025: [
["Hoshimi Miyu", "VTuber des Jahres", "Conveyed"],
["Eclipse Euphoria", "Bestes Live Event", "Conveyed"],
["Kurainu", "Bester Content Creator", "Conveyed"],
["Lumi Airi", "Bester New VTuber", "Conveyed"],
["Pyonkichi Kingdom", "Beste Community", "Conveyed"],
],
2024: [
["Miyu Astra", "VTuber des Jahres", "Winner"],
["Nova Hall", "Bestes Live Event", "Winner"],
["Shiro Ch.", "Bester Content Creator", "Winner"],
["Airi Bloom", "Bester New VTuber", "Winner"],
["Moonrelay", "Beste Community", "Winner"],
],
2023: [
["Tenshi Vox", "VTuber des Jahres", "Winner"],
["Umi Nights", "Bestes Live Event", "Winner"],
["Rin Atelier", "Bester Content Creator", "Winner"],
["Starbyte", "Bester New VTuber", "Winner"],
["Luna Port", "Beste Community", "Winner"],
],
2022: [
["Aoi Sakura", "VTuber des Jahres", "Winner"],
["Kitsu Stage", "Bestes Live Event", "Winner"],
["Kohaku Live", "Bester Content Creator", "Winner"],
["Mira Veil", "Bester New VTuber", "Winner"],
["Neko Choir", "Beste Community", "Winner"],
],
};
const faqs = [
["Wer kann nominieren und voten?", "Jede Person mit Twitch Login. Das Konto wird bei der ersten Anmeldung im Hintergrund angelegt."],
["Wie oft kann ich abstimmen?", "Pro Kategorie eine Stimme. Bis zur Deadline kannst du deine Auswahl erneut bearbeiten und absenden."],
["Wie werden Gewinner bestimmt?", "Aktuell rein community-basiert. Eine spaetere Mischung mit Jury oder Panel bleibt offen."],
["Was ist mit Bots und Betrug?", "Missbrauchsschutz laeuft moeglichst unsichtbar: Twitch Login, Rate Limits und interne Risiko-Flags."],
["Kann das Team Kategorien aendern?", "Ja. Kategorien und Unterkategorien werden pro Jahr durch das Team gepflegt und freigeschaltet."],
];
function renderCategories() {
const root = document.getElementById("category-grid");
root.innerHTML = categories
.map(
(item) => `
<article class="category-card">
<div class="category-orb" style="background:${item.color}">${item.icon}</div>
<h3>${item.title}</h3>
<p>${item.text}</p>
</article>
`,
)
.join("");
}
function renderWinners(year = "2025") {
const root = document.getElementById("winner-grid");
const yearLabel = document.getElementById("archive-year-label");
yearLabel.textContent = year;
root.innerHTML = winners[year]
.map(
([name, category, tag], index) => `
<article class="winner-card">
<div class="winner-thumb" data-tag="${tag}" style="background:${winnerGradient(index)}"></div>
<div class="winner-info">
<strong>${name}</strong>
<small>${category}</small>
</div>
</article>
`,
)
.join("");
}
function winnerGradient(index) {
const gradients = [
"linear-gradient(160deg, rgba(208,189,255,.78), rgba(255,231,198,.72))",
"linear-gradient(160deg, rgba(151,128,212,.84), rgba(248,222,180,.52))",
"linear-gradient(160deg, rgba(201,192,255,.82), rgba(250,215,168,.6))",
"linear-gradient(160deg, rgba(196,221,255,.82), rgba(250,235,195,.68))",
"linear-gradient(160deg, rgba(255,213,225,.82), rgba(249,225,176,.62))",
];
return gradients[index % gradients.length];
}
function bindYears() {
document.querySelectorAll(".year-pill").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".year-pill").forEach((pill) => pill.classList.remove("is-active"));
button.classList.add("is-active");
renderWinners(button.dataset.year);
});
});
}
function renderFaq() {
const root = document.getElementById("faq-list");
root.innerHTML = faqs
.map(
([question, answer], index) => `
<div class="faq-item ${index === 0 ? "is-open" : ""}">
<button class="faq-button" type="button">
<span>${question}</span>
<strong>+</strong>
</button>
<div class="faq-panel-copy">
<p>${answer}</p>
</div>
</div>
`,
)
.join("");
root.querySelectorAll(".faq-item").forEach((item) => {
item.querySelector(".faq-button").addEventListener("click", () => {
item.classList.toggle("is-open");
});
});
}
renderCategories();
renderWinners();
bindYears();
renderFaq();
+846
View File
@@ -0,0 +1,846 @@
:root {
--bg: #fffdf9;
--surface: #ffffff;
--surface-soft: rgba(255, 255, 255, 0.78);
--line: rgba(146, 121, 198, 0.18);
--line-strong: rgba(146, 121, 198, 0.28);
--text: #40395b;
--muted: #7a7492;
--purple: #7f5ad6;
--purple-strong: #6841bb;
--gold: #dfab4e;
--gold-soft: #f5e0b6;
--peach: #f3c4a0;
--pink: #f0c0d8;
--shadow: 0 28px 70px rgba(170, 151, 214, 0.13);
--radius-xl: 34px;
--radius-lg: 26px;
--radius-md: 18px;
--max: 1440px;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family: "Manrope", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 12% 18%, rgba(245, 224, 182, 0.22), transparent 24%),
radial-gradient(circle at 86% 18%, rgba(196, 188, 255, 0.18), transparent 22%),
linear-gradient(180deg, #fffefc 0%, #fffaf4 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 14% 30%, rgba(216, 198, 255, 0.14) 0, transparent 9px),
radial-gradient(circle at 80% 12%, rgba(255, 210, 171, 0.16) 0, transparent 8px),
radial-gradient(circle at 72% 42%, rgba(196, 178, 255, 0.12) 0, transparent 7px);
opacity: 0.9;
}
.site-frame {
width: min(calc(100% - 32px), var(--max));
margin: 0 auto;
padding: 18px 0 40px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 16px 22px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.74);
backdrop-filter: blur(18px);
box-shadow: 0 16px 36px rgba(185, 163, 223, 0.08);
position: sticky;
top: 10px;
z-index: 20;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
color: var(--text);
text-decoration: none;
}
.brand-star {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 14px;
color: var(--gold);
background: linear-gradient(135deg, rgba(223, 171, 78, 0.15), rgba(127, 90, 214, 0.08));
font-size: 20px;
}
.brand-copy strong,
.brand-copy small {
display: block;
letter-spacing: 0.18em;
}
.brand-copy strong {
font-size: 0.95rem;
}
.brand-copy small {
color: var(--muted);
font-size: 0.64rem;
}
.nav {
display: flex;
gap: 24px;
}
.nav a,
.footer a {
color: var(--text);
text-decoration: none;
}
.nav a {
position: relative;
padding: 6px 0;
font-size: 0.93rem;
}
.nav a.is-active::after,
.nav a:hover::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -8px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, var(--gold), var(--purple));
}
.topbar-actions {
display: flex;
gap: 10px;
}
.ghost-button,
.primary-button,
.secondary-button,
.year-pill {
border: 0;
cursor: pointer;
font: inherit;
}
.ghost-button,
.primary-button,
.secondary-button {
border-radius: 14px;
padding: 13px 18px;
font-weight: 700;
}
.ghost-button {
background: rgba(255, 255, 255, 0.8);
border: 1px solid var(--line);
color: var(--text);
}
.primary-button,
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.primary-button {
background: linear-gradient(135deg, var(--purple), var(--purple-strong));
color: white;
box-shadow: 0 16px 30px rgba(122, 90, 194, 0.2);
}
.secondary-button {
background: white;
color: var(--gold);
border: 1px solid rgba(223, 171, 78, 0.36);
}
.hero {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
align-items: center;
gap: 36px;
padding: 42px 8px 18px;
}
.eyebrow {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.77rem;
color: var(--gold);
font-weight: 700;
}
.eyebrow.muted {
color: var(--muted);
}
.hero h1,
.section-title h2,
.show-meta h2,
.archive-lead h3,
.phase-copy h3 {
font-family: "Cormorant Garamond", serif;
}
.hero h1 {
margin: 0 0 22px;
font-size: clamp(4.3rem, 8vw, 7rem);
line-height: 0.9;
letter-spacing: -0.04em;
color: var(--purple-strong);
max-width: 520px;
}
.hero-body {
max-width: 460px;
font-size: 1.18rem;
line-height: 1.75;
color: #60597c;
}
.hero-cta {
display: flex;
gap: 14px;
margin: 34px 0 40px;
}
.show-meta h2 {
margin: 8px 0 6px;
font-size: 2.2rem;
color: var(--text);
}
.show-meta p:last-child {
margin: 0;
color: var(--muted);
}
.hero-art {
position: relative;
min-height: 720px;
}
.hero-illustration {
position: absolute;
inset: 0;
border-radius: 42px;
overflow: hidden;
background:
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.74), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.66), rgba(255, 255, 255, 0.08));
}
.hero-illustration::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid rgba(223, 171, 78, 0.16);
pointer-events: none;
}
.hero-illustration img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 68% 18%;
transform: scale(1.2);
filter: saturate(1.02) brightness(1.02);
}
.hero-signature {
position: absolute;
right: 46px;
bottom: 44px;
display: grid;
gap: 4px;
text-align: right;
}
.hero-signature span {
font-family: "Cormorant Garamond", serif;
font-size: 3.5rem;
font-style: italic;
color: #7e62c9;
}
.hero-signature small {
color: var(--muted);
font-size: 1rem;
}
.phase-panel,
.how-card,
.archive-panel,
.cta-panel,
.faq-panel,
.updates-panel {
border-radius: var(--radius-xl);
border: 1px solid var(--line);
background: var(--surface-soft);
backdrop-filter: blur(18px);
box-shadow: var(--shadow);
}
.phase-panel {
display: grid;
grid-template-columns: 320px 1fr;
gap: 32px;
padding: 34px 38px;
}
.phase-copy h3 {
margin: 8px 0 10px;
font-size: 3rem;
color: var(--purple-strong);
}
.phase-copy p {
color: var(--muted);
line-height: 1.7;
}
.timer {
display: flex;
gap: 10px;
margin-top: 20px;
}
.timer div {
min-width: 68px;
padding: 12px 10px;
border-radius: 16px;
background: rgba(245, 239, 255, 0.9);
text-align: center;
}
.timer strong {
display: block;
font-size: 1.9rem;
color: var(--purple-strong);
}
.timer span {
color: var(--muted);
font-size: 0.78rem;
}
.phase-track {
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: center;
gap: 18px;
}
.phase-step {
text-align: center;
color: var(--muted);
}
.phase-step span {
display: inline-grid;
place-items: center;
width: 62px;
height: 62px;
border-radius: 999px;
margin-bottom: 14px;
border: 1px solid var(--line-strong);
background: rgba(255, 255, 255, 0.94);
font-size: 1.25rem;
}
.phase-step.is-active span {
color: white;
background: linear-gradient(135deg, #9a78eb, #7f5ad6);
box-shadow: 0 14px 28px rgba(127, 90, 214, 0.2);
}
.phase-step.is-complete span {
color: var(--purple);
}
.phase-step strong,
.phase-step small {
display: block;
}
.phase-step strong {
margin-bottom: 6px;
color: var(--text);
}
.section {
padding-top: 56px;
}
.section-title {
margin-bottom: 28px;
}
.section-title h2 {
margin: 0;
font-size: clamp(2.1rem, 4vw, 3.2rem);
color: var(--purple-strong);
}
.section-title.centered {
text-align: center;
}
.section-title.split {
display: flex;
justify-content: space-between;
align-items: end;
gap: 20px;
}
.section-title.split a {
color: var(--purple);
text-decoration: none;
font-weight: 600;
}
.how-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.how-card {
padding: 28px 22px;
text-align: center;
}
.how-icon {
width: 86px;
height: 86px;
margin: 0 auto 18px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 1.85rem;
color: var(--gold);
background:
radial-gradient(circle at 35% 35%, rgba(255, 255, 255, 0.94), rgba(244, 234, 255, 0.82)),
linear-gradient(135deg, rgba(223, 171, 78, 0.14), rgba(127, 90, 214, 0.06));
border: 1px solid rgba(223, 171, 78, 0.22);
}
.how-card h3 {
margin: 0 0 10px;
color: var(--text);
}
.how-card p {
margin: 0;
color: var(--muted);
line-height: 1.65;
}
.category-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 18px;
}
.category-card {
border-radius: 22px;
padding: 24px 20px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 50px rgba(197, 177, 229, 0.1);
}
.category-orb {
width: 92px;
height: 92px;
border-radius: 999px;
display: grid;
place-items: center;
margin: 0 auto 18px;
color: white;
font-size: 2rem;
box-shadow: inset 0 0 0 10px rgba(255, 255, 255, 0.4);
}
.category-card h3 {
margin: 0 0 10px;
text-align: center;
font-family: "Cormorant Garamond", serif;
font-size: 1.7rem;
color: var(--text);
}
.category-card p {
margin: 0;
text-align: center;
color: var(--muted);
line-height: 1.6;
}
.archive-panel {
display: grid;
grid-template-columns: 280px 1fr;
gap: 26px;
padding: 28px;
}
.archive-lead {
padding: 10px 8px;
}
.archive-lead h3 {
margin: 0 0 10px;
font-size: 4rem;
color: var(--text);
}
.archive-lead p {
color: var(--muted);
line-height: 1.7;
margin-bottom: 28px;
}
.year-pills {
display: flex;
gap: 10px;
margin-bottom: 22px;
}
.year-pill {
border-radius: 999px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.7);
color: var(--muted);
border: 1px solid var(--line);
font-weight: 700;
}
.year-pill.is-active {
background: linear-gradient(135deg, rgba(127, 90, 214, 0.12), rgba(223, 171, 78, 0.16));
color: var(--purple-strong);
}
.winner-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
}
.winner-card {
border-radius: 18px;
overflow: hidden;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.86);
}
.winner-thumb {
aspect-ratio: 0.92;
padding: 12px;
display: flex;
align-items: flex-end;
background: linear-gradient(160deg, rgba(207, 191, 255, 0.54), rgba(255, 227, 190, 0.48));
}
.winner-thumb::after {
content: attr(data-tag);
display: inline-flex;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 238, 195, 0.92);
color: #936313;
font-size: 0.7rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.winner-info {
padding: 14px;
}
.winner-info strong,
.winner-info small {
display: block;
}
.winner-info strong {
color: var(--text);
margin-bottom: 6px;
}
.winner-info small {
color: var(--muted);
}
.cta-row,
.lower-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 22px;
}
.cta-panel {
display: grid;
grid-template-columns: 1fr 220px;
gap: 22px;
padding: 30px;
}
.cta-panel.accent {
background:
radial-gradient(circle at top right, rgba(255, 240, 212, 0.36), transparent 26%),
rgba(255, 255, 255, 0.78);
}
.cta-copy h2 {
margin: 0 0 16px;
font-family: "Cormorant Garamond", serif;
font-size: 2.3rem;
color: var(--purple-strong);
}
.cta-copy ul {
margin: 0 0 24px;
padding-left: 20px;
color: var(--muted);
line-height: 1.8;
}
.placeholder-visual {
position: relative;
min-height: 260px;
border-radius: 24px;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.86), transparent 40%),
linear-gradient(180deg, rgba(205, 188, 255, 0.34), rgba(255, 255, 255, 0.3));
}
.ballot-box::before,
.trophy-visual::before {
content: "";
position: absolute;
inset: 28px;
border-radius: 28px;
border: 1px solid rgba(127, 90, 214, 0.1);
}
.placeholder-card {
width: 120px;
height: 160px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(147, 118, 220, 0.46));
box-shadow: 0 18px 40px rgba(149, 119, 206, 0.18);
position: relative;
}
.placeholder-card::before {
content: "✦";
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2rem;
color: white;
}
.trophy {
width: 122px;
height: 152px;
position: relative;
border-radius: 0 0 28px 28px;
background: linear-gradient(180deg, #f2cc82, #d89e36);
box-shadow: inset 0 0 0 10px rgba(255, 248, 224, 0.25);
}
.trophy::before {
content: "";
position: absolute;
left: 50%;
top: -78px;
width: 0;
height: 0;
border-left: 46px solid transparent;
border-right: 46px solid transparent;
border-bottom: 78px solid #e1b158;
transform: translateX(-50%);
}
.faq-panel,
.updates-panel {
padding: 30px;
}
.faq-list {
display: grid;
}
.faq-item {
border-bottom: 1px solid rgba(127, 90, 214, 0.12);
}
.faq-button {
width: 100%;
padding: 16px 0;
display: flex;
justify-content: space-between;
align-items: center;
background: transparent;
border: 0;
font: inherit;
color: var(--text);
cursor: pointer;
}
.faq-panel-copy {
max-height: 0;
overflow: hidden;
color: var(--muted);
transition: max-height 0.28s ease;
}
.faq-item.is-open .faq-panel-copy {
max-height: 200px;
padding-bottom: 14px;
}
.newsletter {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
margin-top: 20px;
}
.newsletter input {
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.82);
font: inherit;
}
.social-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 22px;
color: var(--purple);
font-weight: 600;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 34px 8px 10px;
color: var(--muted);
font-size: 0.92rem;
}
.footer-links {
display: flex;
gap: 18px;
}
@media (max-width: 1220px) {
.hero,
.phase-panel,
.archive-panel,
.cta-row,
.lower-grid,
.cta-panel,
.how-grid,
.category-grid,
.winner-grid {
grid-template-columns: 1fr;
}
.nav {
flex-wrap: wrap;
justify-content: center;
}
.topbar,
.footer,
.section-title.split {
flex-direction: column;
align-items: flex-start;
}
.hero-art {
min-height: 560px;
}
}
@media (max-width: 760px) {
.site-frame {
width: min(calc(100% - 18px), var(--max));
}
.topbar {
border-radius: 30px;
padding: 18px;
}
.topbar-actions,
.hero-cta,
.newsletter,
.timer {
width: 100%;
grid-template-columns: 1fr;
flex-direction: column;
}
.hero {
padding-top: 28px;
}
.hero h1 {
font-size: clamp(3.2rem, 17vw, 5rem);
}
.hero-art {
min-height: 420px;
}
.hero-signature {
right: 20px;
bottom: 20px;
}
.hero-signature span {
font-size: 2.6rem;
}
}
+38
View File
@@ -0,0 +1,38 @@
from docx import Document
from docx.shared import Pt
DOC_PATH = "/Users/azu/Desktop/VTubeAwards/VTuber Star Awards - Website V2.docx"
def add_paragraph(doc: Document, text: str, style: str | None = None):
paragraph = doc.add_paragraph(style=style)
paragraph.paragraph_format.space_after = Pt(5)
paragraph.paragraph_format.line_spacing = 1.1
paragraph.add_run(text)
def main():
doc = Document(DOC_PATH)
doc.add_heading("26. Session- und Login-Stand", level=1)
add_paragraph(
doc,
"Der aktuelle Entwicklungsstand enthaelt nun eine echte, datenbankgestuetzte Session-Schicht statt rein manueller Demo-User-IDs in den Produktflows. Fuer die Entwicklung wird ein leichtgewichtiges Dev-Login verwendet, das Viewer- und Admin-Sessions erstellt und damit die End-to-End-Kopplung zwischen Frontend, Backend und Datenbank abbildet.",
)
add_paragraph(doc, "Aktueller Umfang", style="Heading 2")
add_paragraph(
doc,
"Viewer-Sessions schalten Nominierung und Voting frei; Admin-Sessions schalten den Admin-Bereich und die Season-/Category-Verwaltung frei. Die Public-Submit-Endpunkte koennen die User-Identitaet nun direkt aus der Session lesen, und die Admin-Endpunkte sind serverseitig gegen nicht autorisierte Zugriffe abgesichert.",
)
add_paragraph(doc, "Produktive Weiterentwicklung", style="Heading 2")
add_paragraph(
doc,
"Diese Session-Schicht ist bewusst so angelegt, dass sie spaeter durch echten Twitch OAuth ersetzt werden kann, ohne den restlichen App-Flow neu modellieren zu muessen. Damit ist der MVP bereits als zusammenhaengende Application nutzbar, waehrend die produktive Authentifizierung als naechster Ausbaupunkt klar vorbereitet ist.",
)
doc.save(DOC_PATH)
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
from docx import Document
from docx.shared import Pt
DOC_PATH = "/Users/azu/Desktop/VTubeAwards/VTuber Star Awards - Website V2.docx"
def add_paragraph(doc: Document, text: str, style: str | None = None):
paragraph = doc.add_paragraph(style=style)
paragraph.paragraph_format.space_after = Pt(5)
paragraph.paragraph_format.line_spacing = 1.1
paragraph.add_run(text)
def main():
doc = Document(DOC_PATH)
doc.add_heading("24. Umsetzungsstand Sprint Update", level=1)
add_paragraph(
doc,
"Dieses Sprint-Update dokumentiert den aktuellen technischen Umsetzungsstand fuer die zuvor priorisierten Punkte 1 und 3: lokales PostgreSQL-/Migration-Setup fuer die Entwicklung sowie die naehere Angleichung des Frontends an die freigegebene Collector-Editorial-Richtung.",
)
add_paragraph(doc, "Backend / Punkt 1", style="Heading 2")
add_paragraph(
doc,
"Die erste EF-Core-Migration wurde bereits erzeugt. Zusaetzlich wurde das lokale Setup so vorbereitet, dass die API ihre PostgreSQL-Verbindung per appsettings oder Umgebungsvariable VTSA_POSTGRES beziehen kann. Fuer die lokale Entwicklung wurde ausserdem eine optionale docker-compose.dev.yml fuer PostgreSQL hinterlegt. Ein eigener Health-Endpunkt fuer die Datenbank liefert den Verbindungsstatus sowie eventuell noch ausstehende Migrationen.",
)
add_paragraph(doc, "Frontend / Punkt 3", style="Heading 2")
add_paragraph(
doc,
"Die Startseite und zentrale Produktflaechen wurden visuell weiter in Richtung Collector Editorial verfeinert: mehr White Space, ruhigere Karten, staerkere typografische Hierarchie, eine hochwertigere AppShell sowie ein klarer getrenntes, host-getriebenes Hero-Panel. Auch Nominierungs-, Voting- und Admin-Flaechen wurden in dieselbe Formsprache ueberfuehrt, damit die Seite nicht nur auf der Landingpage, sondern ueber den gesamten MVP hinweg konsistent premium wirkt.",
)
add_paragraph(doc, "Offene technische Hinweise", style="Heading 2")
add_paragraph(
doc,
"In der aktuellen Arbeitsumgebung konnte keine laufende lokale PostgreSQL-Instanz gestartet werden, da Docker bzw. lokale PostgreSQL-Binaries hier nicht verfuegbar waren. Der Code- und Setup-Stand ist jedoch so vorbereitet, dass die Migrationen direkt angewendet werden koennen, sobald lokal eine Datenbankinstanz bereitsteht.",
)
doc.save(DOC_PATH)
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
from docx import Document
from docx.shared import Pt
DOC_PATH = "/Users/azu/Desktop/VTubeAwards/VTuber Star Awards - Website V2.docx"
def add_paragraph(doc: Document, text: str, style: str | None = None):
paragraph = doc.add_paragraph(style=style)
paragraph.paragraph_format.space_after = Pt(5)
paragraph.paragraph_format.line_spacing = 1.1
paragraph.add_run(text)
def main():
doc = Document(DOC_PATH)
doc.add_heading("25. Laufzeitstand Application", level=1)
add_paragraph(
doc,
"Der MVP steht nun als zusammenhaengende Application aus Frontend, Backend und PostgreSQL-Entwicklungsdatenbank. Die Public API laeuft gegen die lokale Dev-Datenbank, Frontend-Build und Backend-Build kompilieren erfolgreich, und die wichtigsten Produktfluesse wurden gegen die laufende API verifiziert.",
)
add_paragraph(doc, "Verifizierte Fluesse", style="Heading 2")
add_paragraph(
doc,
"Public Overview, Kategorieauslieferung, Nominierungs-Submit und Voting-Submit wurden erfolgreich gegen die lokale API geprueft. Zusaetzlich wurden Admin-Dashboard, Seasons-Liste, Season-Detail sowie Season-Update und Category-Create gegen die laufende Dev-Datenbank verifiziert.",
)
add_paragraph(doc, "Admin-Umfang im aktuellen Stand", style="Heading 2")
add_paragraph(
doc,
"Das Admin-Panel bildet aktuell Kennzahlen, Season-Auswahl, Phase/Current-Status sowie die Pflege der Award-Kategorien pro Jahr ab. Damit ist die in diesem Dokument festgelegte team-gesteuerte Season-/Category-Verwaltung fuer den MVP technisch umgesetzt.",
)
add_paragraph(doc, "Lokales Datenbank-Setup", style="Heading 2")
add_paragraph(
doc,
"Das Projekt verwendet lokal eine eigene PostgreSQL-Entwicklungsinstanz auf Port 5433, um Kollisionen mit bereits laufenden lokalen Datenbanken zu vermeiden. Fuer die Erstinitialisierung existieren sowohl EF-Core-Migrationen als auch ein manuelles SQL-Bootstrap-Skript als Fallback.",
)
doc.save(DOC_PATH)
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
from docx import Document
from docx.shared import Pt
DOC_PATH = "/Users/azu/Desktop/VTubeAwards/VTuber Star Awards - Website V2.docx"
def add_paragraph(doc: Document, text: str):
p = doc.add_paragraph()
p.paragraph_format.space_after = Pt(6)
p.paragraph_format.line_spacing = 1.1
p.add_run(text)
def add_bullet(doc: Document, text: str):
p = doc.add_paragraph(style="List Bullet")
p.paragraph_format.space_after = Pt(4)
p.paragraph_format.line_spacing = 1.15
p.add_run(text)
def add_number(doc: Document, text: str):
p = doc.add_paragraph(style="List Number")
p.paragraph_format.space_after = Pt(4)
p.paragraph_format.line_spacing = 1.15
p.add_run(text)
def add_table(doc: Document, headers: list[str], rows: list[list[str]]):
table = doc.add_table(rows=1, cols=len(headers))
table.style = "Table Grid"
for index, header in enumerate(headers):
table.rows[0].cells[index].text = header
for row in rows:
cells = table.add_row().cells
for index, value in enumerate(row):
cells[index].text = value
def main():
doc = Document(DOC_PATH)
doc.add_heading("15. Festgelegte Produktentscheidungen seit V2", level=1)
add_bullet(doc, "Award-Kategorien und Unterkategorien werden nicht hart im Code festgelegt, sondern vom Team pro Jahr im Admin-Bereich gepflegt.")
add_bullet(doc, "Das Endergebnis ist fuer den aktuellen MVP-Stand rein community-basiert. Eine spaetere Jury-/Panel-Gewichtung bleibt technisch als Erweiterung offen.")
add_bullet(doc, "Die technische Umsetzung wird als Monorepo mit getrenntem Frontend und Backend aufgebaut.")
add_bullet(doc, "Dieses Dokument bleibt die fachliche Referenz fuer weitere Iterationen. Code und Architektur sollen sich an den hier beschriebenen Produktregeln orientieren.")
doc.add_heading("16. Finaler Tech-Stack fuer die Umsetzung", level=1)
add_paragraph(
doc,
"Die Implementierung wird in zwei klar getrennten Anwendungen aufgebaut: ein Vue-basiertes Frontend fuer Public- und Admin-Oberflaechen sowie ein ASP.NET-Core-Backend fuer API, Datenmodell und Persistenz. Damit bleibt die Awards-Plattform spaeter erweiterbar fuer Auth, Moderation, CMS und Analytics."
)
add_table(
doc,
["Schicht", "Technologie", "Rolle im System"],
[
["Frontend", "Vue 3 + Vite", "SPA fuer Landingpage, Voting, Nominierung, Gewinnerarchiv und Admin-Oberflaechen"],
["State", "Pinia", "Zentraler Client-State fuer Overview-, Voting-, Nominierungs- und Admin-Daten"],
["Styling", "Tailwind CSS", "Token-basierte Layout-, Spacing- und Komponenten-Styling-Schicht"],
["UI Library", "PrimeVue", "Tabellen, Accordions, Form Controls und datenlastige Admin-Bausteine"],
["UI Patterns", "shadcn-inspirierte Vue Komponenten", "Buttons, Cards und leichtgewichtige, konsistente Primitive fuer das Designsystem"],
["Backend", "ASP.NET Core 8", "REST API, Domänenlogik, Validierung und Integrationsschicht"],
["ORM", "EF Core 8", "Mapping des Awards-Datenmodells und Query-/Persistenzlogik"],
["Datenbank", "PostgreSQL", "Primäre relationale Persistenz fuer Seasons, Kategorien, Kandidaten, Nominierungen und Votes"],
],
)
doc.add_heading("17. Frontend-Architektur", level=1)
add_paragraph(
doc,
"Das Frontend wird als Vite-Vue-Anwendung mit Router und Pinia aufgebaut. Die oeffentlichen Bereiche und der Admin-Bereich teilen sich dieselbe Design-Basis, werden aber als getrennte Views organisiert. PrimeVue kommt dort zum Einsatz, wo datenlastige oder interaktive Controls den Entwicklungsaufwand senken; leichtgewichtige Basis-UI wie Buttons und Cards werden als shadcn-artige Vue-Primitives lokal gepflegt."
)
add_number(doc, "AppShell mit globaler Navigation, Branding und CTA-Struktur fuer oeffentliche und interne Oberflaechen.")
add_number(doc, "Router-basierte Views fuer Home, Nominierung, Voting, Gewinner und Admin.")
add_number(doc, "Pinia Store als API-Orchestrator fuer Overview, Saisonkategorien, Gewinnerarchiv und Admin Dashboard.")
add_number(doc, "API-Layer mit konfigurierbarer Backend-URL ueber Vite-Environment-Variable.")
add_number(doc, "Fallback-Daten im Frontend, damit UI-Entwicklung und Demo-Zustaende nicht vom sofort verfuegbaren Backend abhaengen.")
doc.add_heading("18. Backend-Architektur", level=1)
add_paragraph(
doc,
"Das Backend wird als ASP.NET-Core-Web-API mit EF Core 8 und PostgreSQL aufgebaut. Die erste Version priorisiert ein sauberes Domänenmodell und lesbare Public-/Admin-Endpunkte vor Auth-, Moderations- oder CMS-Komplexität. Die API ist damit spaeter gut erweiterbar fuer Twitch OAuth, Session-Management, Missbrauchsschutz und Review-Workflows."
)
add_table(
doc,
["Bereich", "Implementationsidee"],
[
["Public API", "Overview fuer Landingpage, saisonbezogene Kategorien, Gewinnerarchiv, Nominierungs- und Voting-Endpunkte"],
["Admin API", "Dashboard-Metriken, Aktivitaeten und spaeter Management-Endpunkte fuer Kategorien, Reviews und Risk Flags"],
["Persistenz", "EF-Core-DbContext mit PostgreSQL Provider und relationalem Datenmodell"],
["Seed Data", "Starterdaten fuer aktuelle und vergangene Seasons, Kategorien, Kandidaten und Gewinner"],
["CORS", "Explizite Freigabe fuer lokale Frontend-Hosts waehrend der Entwicklung"],
],
)
doc.add_heading("19. Finales Datenmodell fuer den MVP", level=1)
add_bullet(doc, "Season: Jahr, Phase, Show-Datum, Community-only-Flag und Zeitfenster fuer Nominierung/Voting/Review.")
add_bullet(doc, "Category: saisongebundene Kategorie mit Grouping, Beschreibung, Sortierung und Nominierungslimits.")
add_bullet(doc, "Candidate: saison- und kategorienbezogene Kandidatenbasis fuer Voting und Archiv.")
add_bullet(doc, "AwardResult: Gewinnerabbildung fuer Archiv und Rueckblicke.")
add_bullet(doc, "Nomination: vom User eingereichte Nominierungen inkl. Freitext-/Alias-Faellen.")
add_bullet(doc, "VoteBallot + VoteEntry: eingereichte Votes pro User und Kategorie.")
doc.add_heading("20. API-Schnittstellen fuer den aktuellen Stand", level=1)
add_bullet(doc, "GET /api/public/overview - Landingpage- und Event-Zusammenfassung.")
add_bullet(doc, "GET /api/public/seasons/{year}/categories - Kategorien und Kandidaten eines Jahres.")
add_bullet(doc, "GET /api/public/seasons/{year}/winners - Gewinnerarchiv je Jahr.")
add_bullet(doc, "POST /api/public/nominations - Speichern von Nominierungs-Entwuerfen/Einreichungen.")
add_bullet(doc, "POST /api/public/votes - Speichern eines Voting-Ballots.")
add_bullet(doc, "GET /api/admin/dashboard - Kennzahlen und Aktivitaeten fuer die Admin-Uebersicht.")
doc.add_heading("21. Projektstruktur im Repository", level=1)
add_bullet(doc, "frontend/ - Vue 3 + Vite Anwendung")
add_bullet(doc, "Backend/ - ASP.NET Core 8 Web API")
add_bullet(doc, "prototype/ - fruehere visuelle Prototyping-Artefakte und Designexperimente")
add_bullet(doc, "README.md - lokaler Startpunkt fuer Setup und Entwicklungsworkflow")
doc.add_heading("22. Implementierungsstatus", level=1)
add_bullet(doc, "Frontend-Grundgeruest aufgebaut: Router, Pinia Store, AppShell, Public Views und Admin View.")
add_bullet(doc, "PrimeVue und Tailwind in die Frontend-Basis integriert.")
add_bullet(doc, "shadcn-artige Basis-Komponenten fuer Buttons und Cards lokal angelegt.")
add_bullet(doc, "Backend-Web-API mit EF-Core-Domänenmodell, Seed-Daten und Public-/Admin-Endpunkten implementiert.")
add_bullet(doc, "Frontend und Backend kompilieren erfolgreich. Datenbank-Migrationen und produktive Auth-/Session-Flows sind der naechste technische Schritt.")
doc.add_heading("23. Empfohlene naechste technische Schritte", level=1)
add_number(doc, "PostgreSQL lokal oder in einer Dev-Umgebung bereitstellen und erste EF-Core-Migration erzeugen.")
add_number(doc, "Twitch OAuth serverseitig ins Backend integrieren und Session-Management anschliessen.")
add_number(doc, "Nominierungs- und Voting-Endpunkte um echte User-Identitaet, Limits und Idempotenz erweitern.")
add_number(doc, "Admin-Endpunkte fuer Season-/Category-Management, Reviews und Risk Flags ausbauen.")
add_number(doc, "Frontend-Design auf final freigegebene visuelle Richtung und Assets angleichen.")
doc.save(DOC_PATH)
if __name__ == "__main__":
main()