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:
@@ -5,9 +5,11 @@ import { Star } from '@lucide/vue'
|
||||
|
||||
import Button from './ui/Button.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePhases } from '../composables/usePhases'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { infoForPhaseKey } = usePhases()
|
||||
const loginOpen = ref(false)
|
||||
const loginError = ref('')
|
||||
const loginForm = reactive({
|
||||
@@ -18,8 +20,9 @@ const loginForm = reactive({
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ label: 'Nominierung', to: '/nominations' },
|
||||
{ label: 'Voting', to: '/voting' },
|
||||
{ label: 'Nominierung', to: '/nominations', requiresAuth: true, phase: 'nomination' },
|
||||
{ label: 'Voting', to: '/voting', requiresAuth: true, phase: 'voting' },
|
||||
{ label: 'Clips', to: '/clips', requiresAuth: true, phase: 'nomination' },
|
||||
{ label: 'Gewinner', to: '/winners' },
|
||||
{ label: 'Admin', to: '/admin' },
|
||||
]
|
||||
@@ -29,9 +32,15 @@ const currentLabel = computed(
|
||||
)
|
||||
|
||||
const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) => item.to !== '/admin' || authStore.isAdmin),
|
||||
navItems.filter((item) => {
|
||||
if (item.to === '/admin') return authStore.isAdmin
|
||||
// Teilnahme-Seiten erst nach Login zeigen
|
||||
if (item.requiresAuth && !authStore.isLoggedIn) return false
|
||||
// ... und nur, solange ihre Phase aktiv ist
|
||||
if (item.phase && infoForPhaseKey(item.phase).state !== 'active') return false
|
||||
return true
|
||||
}),
|
||||
)
|
||||
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
async function login(role: 'viewer' | 'admin') {
|
||||
loginError.value = ''
|
||||
@@ -58,20 +67,18 @@ async function login(role: 'viewer' | 'admin') {
|
||||
</div>
|
||||
|
||||
<header
|
||||
class="flex flex-col rounded-[24px] border border-white/70 bg-white/72 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7"
|
||||
:class="isAdminRoute ? 'mb-5 gap-3 px-4 py-3' : 'mb-10 gap-6 px-5 py-5'"
|
||||
class="mb-10 flex flex-col gap-6 rounded-[24px] border border-white/70 bg-white/72 px-5 py-5 shadow-[0_18px_55px_rgba(93,63,135,0.08)] backdrop-blur lg:px-7"
|
||||
>
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between" :class="isAdminRoute ? 'gap-3' : 'gap-5'">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-4 text-slate-800 no-underline">
|
||||
<div
|
||||
class="grid place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]"
|
||||
:class="isAdminRoute ? 'h-10 w-10' : 'h-12 w-12'"
|
||||
class="grid h-12 w-12 place-items-center rounded-[1.1rem] bg-[linear-gradient(135deg,#f6e3b2,#f5c877)] text-amber-950 shadow-[0_16px_28px_rgba(245,200,119,0.35)]"
|
||||
>
|
||||
<Star :class="isAdminRoute ? 'h-4 w-4' : 'h-5 w-5'" />
|
||||
<Star class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<strong class="block text-sm tracking-[0.35em]">VTUBER</strong>
|
||||
<span v-if="!isAdminRoute" class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
|
||||
<span class="block text-[11px] tracking-[0.45em] text-slate-500">STAR AWARDS</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
@@ -84,7 +91,10 @@ async function login(role: 'viewer' | 'admin') {
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="ghost" @click="loginOpen = !loginOpen">Einloggen</Button>
|
||||
<Button @click="login('viewer')">Mit Twitch Login</Button>
|
||||
<Button class="gap-2" @click="login('viewer')">
|
||||
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16"><path d="M2.5 0 0 2.5v11h3.5V16h2l2.5-2.5h3l5-5V0H2.5Zm12 8L12 10.5H8.5L6 13v-2.5H2.5V2h12v6Z"/><path d="M11.5 4h1.5v3.5h-1.5V4Zm-3.5 0h1.5v3.5H8V4Z"/></svg>
|
||||
Anmelden mit Twitch
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,13 +114,15 @@ async function login(role: 'viewer' | 'admin') {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-t border-black/6 pt-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<nav class="flex flex-wrap items-center gap-2 text-sm text-slate-600">
|
||||
<nav class="flex flex-wrap items-center gap-1 rounded-full border border-violet-100/80 bg-violet-50/50 p-1.5 text-sm font-medium text-slate-500">
|
||||
<RouterLink
|
||||
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 || route.path.startsWith(`${item.to}/`) ? 'bg-violet-100 text-violet-800' : ''"
|
||||
class="rounded-full px-4 py-2 transition"
|
||||
:class="route.path === item.to || route.path.startsWith(`${item.to}/`)
|
||||
? 'bg-white text-violet-800 shadow-[0_4px_14px_rgba(124,92,255,0.18)] ring-1 ring-violet-100'
|
||||
: 'hover:bg-white/60 hover:text-violet-700'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
/** Dekoratives Icon oben rechts – analog zu den Frontend-Bannern */
|
||||
icon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 border-b border-violet-100 pb-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p v-if="eyebrow" class="text-xs font-semibold uppercase tracking-[0.28em] text-violet-500">{{ eyebrow }}</p>
|
||||
<h2 class="mt-1 text-2xl font-semibold text-slate-900">{{ title }}</h2>
|
||||
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-9 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-11">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
|
||||
|
||||
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||
<g fill="#f6b938" opacity="0.7">
|
||||
<circle cx="74%" cy="24%" r="3" />
|
||||
<circle cx="92%" cy="62%" r="2.5" />
|
||||
<circle cx="63%" cy="78%" r="2.5" />
|
||||
</g>
|
||||
<g fill="#a78bff" opacity="0.6">
|
||||
<circle cx="83%" cy="40%" r="2.5" />
|
||||
<circle cx="69%" cy="50%" r="2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-if="icon"
|
||||
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
|
||||
>
|
||||
<div class="grid h-40 w-40 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
|
||||
<component :is="icon" class="h-[4.5rem] w-[4.5rem]" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="max-w-xl text-sm leading-6 text-slate-500">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-2xl space-y-4">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
|
||||
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ eyebrow ?? 'Admin' }}
|
||||
</span>
|
||||
<h1 class="font-[Cormorant_Garamond] text-4xl leading-[1.02] text-violet-800 sm:text-5xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="max-w-xl text-sm leading-6 text-slate-600 sm:text-base">{{ description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="absolute inset-0 bg-violet-950/30 backdrop-blur-sm" @click="emit('close')" />
|
||||
<div class="modal-panel relative z-10 w-full max-w-lg overflow-hidden rounded-[28px] border border-violet-200/70 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f3edff,#fff4e6)] px-6 py-5">
|
||||
<div>
|
||||
<h3 v-if="title" class="font-[Cormorant_Garamond] text-3xl text-violet-800">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="mt-1 text-sm text-slate-500">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-white/70 hover:text-violet-700"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[70vh] overflow-y-auto px-6 py-6">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="flex justify-end gap-3 border-t border-violet-100 bg-violet-50/40 px-6 py-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
animation: modal-fade 0.16s ease;
|
||||
}
|
||||
.modal-panel {
|
||||
animation: modal-pop 0.18s ease;
|
||||
}
|
||||
@keyframes modal-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes modal-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
eyebrow: string
|
||||
title: string
|
||||
description?: string
|
||||
/** Großes, dekoratives Icon oben rechts */
|
||||
icon?: Component
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="relative overflow-hidden rounded-[32px] border border-violet-200/60 bg-[linear-gradient(135deg,#ece2ff_0%,#f6ecff_42%,#fff2dd_100%)] px-7 py-10 shadow-[0_24px_60px_rgba(124,92,255,0.12)] sm:px-12 sm:py-14">
|
||||
<!-- dekorativer Schein -->
|
||||
<div class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_88%_18%,rgba(255,255,255,0.7),transparent_42%)]" />
|
||||
|
||||
<!-- Sterne -->
|
||||
<svg class="pointer-events-none absolute inset-0 h-full w-full" aria-hidden="true">
|
||||
<g fill="#f6b938" opacity="0.7">
|
||||
<circle cx="74%" cy="24%" r="3" />
|
||||
<circle cx="92%" cy="62%" r="2.5" />
|
||||
<circle cx="63%" cy="78%" r="2.5" />
|
||||
</g>
|
||||
<g fill="#a78bff" opacity="0.6">
|
||||
<circle cx="83%" cy="40%" r="2.5" />
|
||||
<circle cx="69%" cy="50%" r="2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- großes dekoratives Icon -->
|
||||
<div
|
||||
v-if="icon"
|
||||
class="pointer-events-none absolute -right-6 top-1/2 hidden -translate-y-1/2 sm:block"
|
||||
>
|
||||
<div class="grid h-44 w-44 place-items-center rounded-full bg-white/35 text-amber-400/80 ring-1 ring-white/60 backdrop-blur-sm">
|
||||
<component :is="icon" class="h-20 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-2xl space-y-5">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-white/70 bg-white/70 px-4 py-1.5 text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-600 shadow-sm backdrop-blur">
|
||||
<Sparkles class="h-3.5 w-3.5 text-amber-500" />
|
||||
{{ eyebrow }}
|
||||
</span>
|
||||
|
||||
<h1 class="font-[Cormorant_Garamond] text-5xl leading-[0.95] text-violet-800 sm:text-6xl">
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p v-if="description" class="max-w-xl text-lg leading-8 text-slate-600">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user