Redesign public site, add phase gating and clip submission

- Rebuild landing page and Nominations/Voting/Winners views in the
  Jayuhime brand style (purple/gold/pastel-rainbow, stars, PageHero banner)
- Gate participation by season phase (nominate/clip share the nomination
  window, vote in the voting window); hide closed/locked pages from nav
- Add login-gated clip submission flow (link-based) + voting clip links
- Make candidate admin scalable: searchable, filterable, paginated table
  with modal create/edit and confirm-delete; add delete API/store actions
- New reusable Modal and PageHero components, usePhases composable
- Segmented top nav and full-size header on admin routes
- vite: honor PORT env for preview

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-06-18 06:46:41 +02:00
parent a2d6a5edc0
commit cca297a4eb
17 changed files with 1735 additions and 691 deletions
+28 -13
View File
@@ -6,6 +6,7 @@ import type {
AdminSeasonDetailResponse,
ApproveNominationPayload,
AdminSeasonListItem,
CreateClipPayload,
CreateNominationPayload,
CreateVotePayload,
OverviewResponse,
@@ -31,9 +32,9 @@ const fallbackOverview: OverviewResponse = {
{ key: 'show', title: 'Award Show', startsAt: '2026-07-20', endsAt: '2026-07-20', state: 'upcoming' },
],
featuredCategories: [
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die groesste Auszeichnung des Jahres.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Events, Konzerte und Showformate.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der lustigste oder emotionalste Clip.', maxNomineesPerUser: 3 },
{ id: 1, groupName: 'Main Awards', name: 'VTuber des Jahres', description: 'Die VTuberin oder der VTuber, der dieses Jahr einfach alle verzaubert hat.', maxNomineesPerUser: 3 },
{ id: 2, groupName: 'Performance', name: 'Bestes Live Event', description: 'Das Event, das die Community zum Beben gebracht hat Konzert, Watchalong oder Mega-Stream.', maxNomineesPerUser: 3 },
{ id: 3, groupName: 'Clips & Highlights', name: 'Clip des Jahres', description: 'Der eine Clip, den du seit Monaten in jeden Chat spammst.', maxNomineesPerUser: 3 },
],
winnersPreview: [
{ year: 2025, category: 'VTuber des Jahres', winnerName: 'Hoshimi Miyu', winnerSlug: '@hoshimimiyu' },
@@ -41,9 +42,10 @@ const fallbackOverview: OverviewResponse = {
{ year: 2024, category: 'Clip des Jahres', winnerName: 'Pyonkichi Kingdom', winnerSlug: '@pyonkichikingdom' },
],
faq: [
{ question: 'Wer kann nominieren und voten?', answer: 'Jede Person mit Twitch Login. Das Konto wird beim ersten Login implizit erstellt.' },
{ question: 'Wie werden Gewinner bestimmt?', answer: 'Aktuell rein community-basiert. Eine Mischlogik kann spaeter aktiviert werden.' },
{ question: 'Wer verwaltet Kategorien und Unterkategorien?', answer: 'Das Team pflegt diese pro Jahr im Admin-Bereich.' },
{ question: 'Wer darf mitmachen?', answer: 'Jede:r mit einem Twitch-Account. Einmal einloggen genügt kein extra Konto, kein Papierkram.' },
{ question: 'Wie werden die Gewinner bestimmt?', answer: 'Komplett durch eure Stimmen. Die Community entscheidet, wer auf die Bühne darf kein Jury-Geheimnis.' },
{ question: 'Kann ich meine Wahl noch ändern?', answer: 'Klar! Bis zum Ende der Voting-Phase kannst du Nominierungen und Stimmen jederzeit anpassen.' },
{ question: 'Wer kuratiert die Kategorien?', answer: 'Das Jayuhime-Team stellt die Kategorien jedes Jahr frisch zusammen, passend zur Community.' },
],
}
@@ -55,23 +57,23 @@ const fallbackCategories: SeasonCategoriesResponse = {
id: 1,
name: 'VTuber des Jahres',
groupName: 'Main Awards',
description: 'Die Hauptkategorie fuer die praegendste Creator-Praesenz des Jahres.',
description: 'Die Hauptkategorie r die prägendste Creator-Präsenz des ganzen Jahres.',
maxNomineesPerUser: 3,
candidates: [
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch' },
{ id: 1, displayName: 'Hoshimi Miyu', channelSlug: '@hoshimimiyu', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/HoshimiHighlight' },
{ id: 2, displayName: 'Kurainu', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu' },
{ id: 3, displayName: 'Shiro Ch.', channelSlug: '@shiroch', platform: 'Twitch', clipUrl: 'https://clips.twitch.tv/ShiroMoment' },
],
},
{
id: 2,
name: 'Bestes Live Event',
groupName: 'Performance',
description: 'Konzerte, Sonderformate und grosse Community-Shows.',
description: 'Konzerte, Sonderformate und große Community-Shows, die in Erinnerung bleiben.',
maxNomineesPerUser: 3,
candidates: [
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube' },
{ id: 4, displayName: 'Kurainu 3D Live', channelSlug: '@kurainu', platform: 'Twitch', clipUrl: 'https://www.youtube.com/watch?v=kurainu3d' },
{ id: 5, displayName: 'Aoi Sakura Showcase', channelSlug: '@aoisakura', platform: 'YouTube', clipUrl: 'https://www.youtube.com/watch?v=aoisakura' },
],
},
],
@@ -277,6 +279,9 @@ export const useAwardsStore = defineStore('awards', {
submitVote(payload: CreateVotePayload) {
return api.submitVote(payload)
},
submitClip(payload: CreateClipPayload) {
return api.submitClip(payload)
},
async updateAdminSeason(seasonId: number, payload: UpdateSeasonPayload) {
const result = await api.updateAdminSeason(seasonId, payload)
await this.loadAdmin()
@@ -302,6 +307,16 @@ export const useAwardsStore = defineStore('awards', {
await this.loadAdminSeasonDetail(seasonId)
return result
},
async deleteAdminCandidate(candidateId: number, seasonId: number) {
const result = await api.deleteAdminCandidate(candidateId)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async deleteAdminCategory(categoryId: number, seasonId: number) {
const result = await api.deleteAdminCategory(categoryId)
await this.loadAdminSeasonDetail(seasonId)
return result
},
async approveAdminNomination(nominationId: number, seasonId: number, payload: ApproveNominationPayload) {
const result = await api.approveAdminNomination(nominationId, payload)
await this.loadAdminSeasonDetail(seasonId)