Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -1 +1,2 @@
|
||||
VITE_API_URL=http://127.0.0.1:5084
|
||||
VITE_DEMO_GATE_ENABLED=true
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<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>
|
||||
<meta name="description" content="VTuber Star Awards: Community-Awards, Voting, Clip-Einreichungen und Gewinnerarchiv." />
|
||||
<title>VTuber Star Awards</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+20
@@ -17,6 +17,7 @@
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.5",
|
||||
"shadcn-vue": "^2.7.4",
|
||||
"simple-icons": "^16.24.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vue": "^3.5.34",
|
||||
@@ -6670,6 +6671,25 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/simple-icons": {
|
||||
"version": "16.24.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.24.0.tgz",
|
||||
"integrity": "sha512-lAPW1rqgwPQ4tdIY15TtcKSgSelvJexz8q/B+a7Igg1dJoXR0LPjScLkLMI8UbLSkS41/fLVZWIhsH7HPUwAgQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/simple-icons"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/simple-icons"
|
||||
}
|
||||
],
|
||||
"license": "CC0-1.0",
|
||||
"engines": {
|
||||
"node": ">=0.12.18"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.5",
|
||||
"shadcn-vue": "^2.7.4",
|
||||
"simple-icons": "^16.24.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"vue": "^3.5.34",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1,40 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppShellAccountModals from './AppShellAccountModals.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePhases } from '../composables/usePhases'
|
||||
import { useAwardsStore } from '../stores/awards'
|
||||
import type { AuthRole } from '../types/awards'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { infoForPhaseKey } = usePhases()
|
||||
const awardsStore = useAwardsStore()
|
||||
|
||||
const loginForm = reactive({
|
||||
twitchUserId: 'jayuhime_demo',
|
||||
twitchUserId: 'jayuhime_viewer',
|
||||
displayName: 'Jayuhime',
|
||||
role: 'viewer' as 'viewer' | 'admin',
|
||||
role: 'viewer' as AuthRole,
|
||||
})
|
||||
|
||||
const accountOpen = ref(false)
|
||||
const deleteConfirm = ref(false)
|
||||
const privacyOpen = ref(false)
|
||||
const accountActionError = ref('')
|
||||
|
||||
defineExpose({ privacyOpen })
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Home', to: '/' },
|
||||
{ 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: 'Landingpage', to: '/' },
|
||||
]
|
||||
|
||||
const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) => {
|
||||
if (item.requiresAuth && !authStore.isLoggedIn) return false
|
||||
if (item.phase && infoForPhaseKey(item.phase).state !== 'active') return false
|
||||
return true
|
||||
}),
|
||||
const visibleNavItems = computed(() => navItems)
|
||||
const privacyContent = computed(
|
||||
() => awardsStore.overview.siteContent.privacyPolicyContent || awardsStore.adminSiteSettings.privacyPolicyContent,
|
||||
)
|
||||
const privacyEmail = computed(
|
||||
() => awardsStore.overview.siteContent.privacyEmail || awardsStore.adminSiteSettings.privacyEmail,
|
||||
)
|
||||
const privacyContentBlocks = computed(() =>
|
||||
privacyContent.value
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
async function doLogin() {
|
||||
@@ -44,9 +50,47 @@ async function doLogin() {
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
closeAccountModal()
|
||||
accountActionError.value = ''
|
||||
await authStore.logout()
|
||||
await router.replace({ name: 'login' })
|
||||
}
|
||||
|
||||
async function deleteMyData() {
|
||||
accountActionError.value = ''
|
||||
try {
|
||||
await authStore.deleteMyData()
|
||||
accountOpen.value = false
|
||||
deleteConfirm.value = false
|
||||
await awardsStore.loadHomeData()
|
||||
await router.replace({ name: 'login' })
|
||||
} catch (error) {
|
||||
accountActionError.value = error instanceof Error
|
||||
? error.message
|
||||
: 'Deine Daten konnten gerade nicht gelöscht werden.'
|
||||
}
|
||||
}
|
||||
|
||||
function closeAccountModal() {
|
||||
accountOpen.value = false
|
||||
deleteConfirm.value = false
|
||||
await authStore.logout()
|
||||
}
|
||||
|
||||
function openPrivacyModal() {
|
||||
privacyOpen.value = true
|
||||
}
|
||||
|
||||
function closePrivacyModal() {
|
||||
privacyOpen.value = false
|
||||
}
|
||||
|
||||
function requestAccountDeletion() {
|
||||
deleteConfirm.value = true
|
||||
}
|
||||
|
||||
function cancelAccountDeletion() {
|
||||
deleteConfirm.value = false
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function isActive(to: string) {
|
||||
@@ -61,43 +105,49 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
||||
|
||||
<template>
|
||||
<div style="min-height:100vh;overflow-x:hidden;font-family:'Outfit',sans-serif;line-height:1.5;background:#f4eefb;color:#3f3556;">
|
||||
|
||||
<template v-if="route.name === 'home' || route.meta.bareShell">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Nav -->
|
||||
<nav style="position:sticky;top:0;z-index:50;background:rgba(248,243,254,.86);border-bottom:1px solid #ece2fa;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);">
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 24px;height:64px;display:flex;align-items:center;gap:16px;">
|
||||
<nav class="app-shell-topbar" style="position:sticky;top:0;z-index:50;background:rgba(248,243,254,.86);border-bottom:1px solid #ece2fa;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);">
|
||||
<div class="app-shell-topbar__inner" style="width:100%;padding:0 24px;height:64px;display:flex;align-items:center;gap:16px;">
|
||||
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" style="display:flex;align-items:center;gap:10px;text-decoration:none;flex:none;">
|
||||
<RouterLink to="/" class="app-shell-brand" style="display:flex;align-items:center;gap:10px;text-decoration:none;flex:none;">
|
||||
<div style="width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#8b6cdb,#e7b13e);display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;box-shadow:0 6px 14px rgba(139,108,219,.3);flex:none;">✦</div>
|
||||
<span style="font-family:'Fredoka',sans-serif;font-size:17px;font-weight:600;color:#3f3556;">VTuber Star Award</span>
|
||||
<span class="app-shell-brand__text" style="font-family:'Fredoka',sans-serif;font-size:17px;font-weight:600;color:#3f3556;">VTuber Star Award</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Links -->
|
||||
<nav style="display:flex;align-items:center;gap:4px;margin-left:auto;">
|
||||
<nav class="app-shell-links" style="display:flex;align-items:center;gap:4px;margin-left:auto;justify-content:flex-end;">
|
||||
<RouterLink
|
||||
v-for="item in visibleNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="app-shell-link app-shell-link--secondary"
|
||||
:style="isActive(item.to) ? linkActive : linkInactive"
|
||||
>{{ item.label }}</RouterLink>
|
||||
<RouterLink
|
||||
v-if="authStore.isAdmin"
|
||||
to="/admin"
|
||||
class="app-shell-link"
|
||||
:style="route.path.startsWith('/admin') ? linkActive : linkInactive"
|
||||
>Admin</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- Auth -->
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div class="app-shell-auth" style="display:flex;align-items:center;gap:8px;">
|
||||
<template v-if="authStore.session">
|
||||
<button
|
||||
class="app-shell-account-button"
|
||||
@click="accountOpen = true"
|
||||
style="display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:999px;border:none;background:rgba(139,108,219,.08);color:#6a4fb8;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;cursor:pointer;"
|
||||
>
|
||||
<span style="width:28px;height:28px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex:none;">
|
||||
{{ authStore.session.displayName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
@{{ authStore.session.twitchUserId }}
|
||||
<span class="app-shell-account-label">@{{ authStore.session.twitchUserId }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -116,167 +166,56 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page content: home is full-width, all other views get a max-width container -->
|
||||
<template v-if="route.name === 'home'">
|
||||
<div style="max-width:1460px;margin:0 auto;padding:16px 16px 0;box-sizing:border-box;">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="max-width:1460px;margin:0 auto;padding:16px 16px 0;box-sizing:border-box;">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Account modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="accountOpen"
|
||||
@click.self="accountOpen = false; deleteConfirm = false"
|
||||
style="position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
>
|
||||
<div style="position:relative;width:100%;max-width:480px;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 28px 18px;border-bottom:1px solid #f1ecfb;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Mein Profil</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:24px;margin:0;color:#3f3556;">@{{ authStore.session?.twitchUserId }}</h2>
|
||||
</div>
|
||||
<button
|
||||
@click="accountOpen = false; deleteConfirm = false"
|
||||
style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;"
|
||||
>✕</button>
|
||||
</div>
|
||||
<div style="padding:24px 28px;display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:13px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #ede4fb;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#9146FF">
|
||||
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/>
|
||||
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/>
|
||||
</svg>
|
||||
<span style="font-size:14px;color:#5f44ad;font-weight:500;">Angemeldet über Twitch</span>
|
||||
</div>
|
||||
<div style="padding:16px;border-radius:14px;background:#fafafa;border:1px solid #f0eafc;">
|
||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a99fc0;margin:0 0 10px;">Gespeicherte Daten</p>
|
||||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Twitch-ID</span>
|
||||
<span style="font-weight:600;color:#3f3556;">@{{ authStore.session?.twitchUserId }}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Rolle</span>
|
||||
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ authStore.session?.role }}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Einreichungen</span>
|
||||
<span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Löschfrist</span>
|
||||
<span style="font-weight:600;color:#059669;">März 2027 (auto.)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="privacyOpen = true"
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
Datenschutzerklärung lesen
|
||||
</button>
|
||||
<button
|
||||
@click="doLogout"
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#f8fafc;color:#64748b;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
Abmelden
|
||||
</button>
|
||||
<template v-if="!deleteConfirm">
|
||||
<button
|
||||
@click="deleteConfirm = true"
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
</svg>
|
||||
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="padding:16px;border-radius:14px;background:#fff5f5;border:1.5px solid #fecdd3;">
|
||||
<p style="font-size:13.5px;color:#9f1239;font-weight:600;margin:0 0 6px;">Alle deine Daten werden sofort gelöscht.</p>
|
||||
<p style="font-size:12.5px;color:#e11d48;margin:0 0 14px;line-height:1.5;">Das umfasst deine Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button @click="deleteConfirm = false" style="flex:1;padding:10px;border-radius:10px;border:1px solid #e6dcf6;background:white;color:#6f6685;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;">Abbrechen</button>
|
||||
<button @click="doLogout" style="flex:1;padding:10px;border-radius:10px;border:none;background:#e11d48;color:white;font-family:'Outfit',sans-serif;font-weight:700;font-size:13px;cursor:pointer;">Jetzt löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Privacy modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="privacyOpen"
|
||||
@click.self="privacyOpen = false"
|
||||
style="position:fixed;inset:0;z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
>
|
||||
<div style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Rechtliches</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
|
||||
</div>
|
||||
<button @click="privacyOpen = false" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;">✕</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:22px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
|
||||
<div>
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Verantwortliche:r</h3>
|
||||
<p style="margin:0;">VTuber Star Awards, vertreten durch Jayuhime. Kontakt: datenschutz@vtuber-star-awards.de</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Welche Daten wir verarbeiten</h3>
|
||||
<p style="margin:0 0 8px;">Bei Teilnahme (Voting, Nominierung, Clip-Einreichung) verarbeiten wir ausschließlich deine <strong style="color:#3f3556;">Twitch-User-ID</strong> (ein technischer Bezeichner, kein Klarname) sowie den Zeitstempel deiner Aktion.</p>
|
||||
<p style="margin:0;">Für Show-Erinnerungen speichern wir optional deine E-Mail-Adresse.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Rechtsgrundlage</h3>
|
||||
<p style="margin:0;">Verarbeitung auf Basis von <strong style="color:#3f3556;">Art. 6 Abs. 1 lit. b DSGVO</strong> (Vertragserfüllung / vorvertragliche Maßnahmen) — deine Teilnahme ist freiwillig. Für E-Mail-Erinnerungen gilt Art. 6 Abs. 1 lit. a DSGVO (Einwilligung).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Zweck der Verarbeitung</h3>
|
||||
<p style="margin:0;">Durchführung des VTuber Star Awards: Sicherstellung fairer Abstimmung (1 Stimme / Person), Spam-Prävention, Admin-Review von Clip-Einreichungen.</p>
|
||||
</div>
|
||||
<div style="background:#fef9c3;border:1px solid #fef08a;border-radius:12px;padding:14px 16px;">
|
||||
<h3 style="font-size:15px;font-weight:700;color:#854d0e;margin:0 0 6px;">Löschfristen</h3>
|
||||
<p style="margin:0;color:#92400e;">Alle Teilnahmedaten (Twitch-IDs, Votes, Nominierungen, Clips) werden <strong>spätestens 6 Monate nach der Award-Show</strong> (d. h. bis März 2027) automatisch gelöscht. E-Mail-Adressen werden nach der Show sofort gelöscht.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Deine Rechte (Art. 15–22 DSGVO)</h3>
|
||||
<ul style="margin:0;padding-left:18px;display:flex;flex-direction:column;gap:5px;">
|
||||
<li><strong style="color:#3f3556;">Auskunft</strong> — du kannst jederzeit erfragen, welche Daten wir über dich gespeichert haben.</li>
|
||||
<li><strong style="color:#3f3556;">Löschung</strong> — du kannst die sofortige Löschung deiner Daten verlangen. Nutze den Button in deinem Profil oder schreibe uns.</li>
|
||||
<li><strong style="color:#3f3556;">Widerspruch</strong> — du kannst der Verarbeitung jederzeit widersprechen.</li>
|
||||
<li><strong style="color:#3f3556;">Portabilität</strong> — du kannst eine maschinenlesbare Kopie deiner Daten anfordern.</li>
|
||||
<li><strong style="color:#3f3556;">Beschwerde</strong> — du hast das Recht, dich bei der zuständigen Aufsichtsbehörde zu beschweren (z. B. LfDI Baden-Württemberg).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Weitergabe an Dritte</h3>
|
||||
<p style="margin:0;">Keine Weitergabe an Dritte zu Werbezwecken. Technischer Hosting-Anbieter (EU-basiert) verarbeitet Daten als Auftragsverarbeiter gemäß Art. 28 DSGVO.</p>
|
||||
</div>
|
||||
<p style="font-size:12px;color:#a99fc0;margin:0;">Stand: Juni 2026 · Änderungen werden auf dieser Seite bekannt gegeben.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<AppShellAccountModals
|
||||
:account-open="accountOpen"
|
||||
:privacy-open="privacyOpen"
|
||||
:delete-confirm="deleteConfirm"
|
||||
:session="authStore.session"
|
||||
:privacy-content-blocks="privacyContentBlocks"
|
||||
:privacy-email="privacyEmail"
|
||||
:account-action-error="accountActionError"
|
||||
:auth-loading="authStore.loading"
|
||||
@close-account="closeAccountModal"
|
||||
@close-privacy="closePrivacyModal"
|
||||
@open-privacy="openPrivacyModal"
|
||||
@request-delete="requestAccountDeletion"
|
||||
@cancel-delete="cancelAccountDeletion"
|
||||
@logout="doLogout"
|
||||
@confirm-delete="deleteMyData"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (max-width: 720px) {
|
||||
.app-shell-topbar__inner {
|
||||
gap: 8px !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.app-shell-brand__text,
|
||||
.app-shell-account-label {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.app-shell-links {
|
||||
gap: 2px !important;
|
||||
}
|
||||
|
||||
.app-shell-account-button {
|
||||
padding: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-shell-link--secondary {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { AuthSession } from '../types/awards'
|
||||
|
||||
const props = defineProps<{
|
||||
accountOpen: boolean
|
||||
privacyOpen: boolean
|
||||
deleteConfirm: boolean
|
||||
session: AuthSession | null
|
||||
privacyContentBlocks: string[]
|
||||
privacyEmail: string
|
||||
accountActionError: string
|
||||
authLoading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'close-account': []
|
||||
'close-privacy': []
|
||||
'open-privacy': []
|
||||
'request-delete': []
|
||||
'cancel-delete': []
|
||||
logout: []
|
||||
'confirm-delete': []
|
||||
}>()
|
||||
|
||||
const twitchUserId = computed(() => props.session?.twitchUserId ?? '')
|
||||
const role = computed(() => props.session?.role ?? 'viewer')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="accountOpen"
|
||||
style="position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
@click.self="$emit('close-account')"
|
||||
>
|
||||
<div style="position:relative;width:100%;max-width:480px;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 28px 18px;border-bottom:1px solid #f1ecfb;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Mein Profil</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:24px;margin:0;color:#3f3556;">@{{ twitchUserId }}</h2>
|
||||
</div>
|
||||
<button
|
||||
style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;"
|
||||
@click="$emit('close-account')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style="padding:24px 28px;display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:13px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #ede4fb;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#9146FF">
|
||||
<path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z" />
|
||||
<path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z" />
|
||||
</svg>
|
||||
<span style="font-size:14px;color:#5f44ad;font-weight:500;">Angemeldet über Twitch</span>
|
||||
</div>
|
||||
<div style="padding:16px;border-radius:14px;background:#fafafa;border:1px solid #f0eafc;">
|
||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a99fc0;margin:0 0 10px;">Gespeicherte Daten</p>
|
||||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Twitch-ID</span>
|
||||
<span style="font-weight:600;color:#3f3556;">@{{ twitchUserId }}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Rolle</span>
|
||||
<span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ role }}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Einreichungen</span>
|
||||
<span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:13.5px;">
|
||||
<span style="color:#6f6685;">Löschfrist</span>
|
||||
<span style="font-weight:600;color:#059669;">März 2027 (auto.)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
@click="$emit('open-privacy')"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
Datenschutzerklärung lesen
|
||||
</button>
|
||||
<button
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#f8fafc;color:#64748b;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
Abmelden
|
||||
</button>
|
||||
<template v-if="!deleteConfirm">
|
||||
<button
|
||||
style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;"
|
||||
@click="$emit('request-delete')"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="padding:16px;border-radius:14px;background:#fff5f5;border:1.5px solid #fecdd3;">
|
||||
<p style="font-size:13.5px;color:#9f1239;font-weight:600;margin:0 0 6px;">Alle deine Daten werden sofort gelöscht.</p>
|
||||
<p style="font-size:12.5px;color:#e11d48;margin:0 0 14px;line-height:1.5;">Das umfasst deine Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<p v-if="accountActionError" style="font-size:12.5px;color:#be123c;margin:0 0 12px;line-height:1.5;font-weight:600;">{{ accountActionError }}</p>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button :disabled="authLoading" style="flex:1;padding:10px;border-radius:10px;border:1px solid #e6dcf6;background:white;color:#6f6685;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;" @click="$emit('cancel-delete')">Abbrechen</button>
|
||||
<button :disabled="authLoading" style="flex:1;padding:10px;border-radius:10px;border:none;background:#e11d48;color:white;font-family:'Outfit',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" @click="$emit('confirm-delete')">
|
||||
{{ authLoading ? 'Löscht …' : 'Jetzt löschen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="privacyOpen"
|
||||
style="position:fixed;inset:0;z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);"
|
||||
@click.self="$emit('close-privacy')"
|
||||
>
|
||||
<div style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Rechtliches</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
|
||||
</div>
|
||||
<button style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" @click="$emit('close-privacy')">✕</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:18px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
|
||||
<template v-if="privacyContentBlocks.length">
|
||||
<p
|
||||
v-for="(block, index) in privacyContentBlocks"
|
||||
:key="`shell-privacy-${index}`"
|
||||
style="margin:0;white-space:pre-wrap;"
|
||||
>
|
||||
{{ block }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="background:#f9f6ff;border:1px solid #ede4fb;border-radius:14px;padding:16px;">
|
||||
<h3 style="font-size:15px;font-weight:700;color:#3f3556;margin:0 0 8px;">Datenschutztext wird geladen</h3>
|
||||
<p style="margin:0;">Die Datenschutzerklärung wird aus der Landingpage-Konfiguration geladen.</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:0;">Kontakt: {{ privacyEmail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import './cinematicStarLoader.css'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
text?: string
|
||||
outroText?: string
|
||||
}>(), {
|
||||
eyebrow: 'VTuber Star Award',
|
||||
text: '',
|
||||
outroText: 'Sterne am Himmel gefunden und Erfolgreich verknüpft.',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loader">
|
||||
<div class="loader__nebula loader__nebula--violet"></div>
|
||||
<div class="loader__nebula loader__nebula--rose"></div>
|
||||
<div class="loader__nebula loader__nebula--gold"></div>
|
||||
<div class="loader__vignette"></div>
|
||||
<div class="loader__starfield loader__starfield--far"></div>
|
||||
<div class="loader__starfield loader__starfield--mid"></div>
|
||||
<div class="loader__starfield loader__starfield--near"></div>
|
||||
<div class="loader__constellation" aria-hidden="true">
|
||||
<span class="constellation-star constellation-star--1">✦</span>
|
||||
<span class="constellation-star constellation-star--2">✧</span>
|
||||
<span class="constellation-star constellation-star--3">★</span>
|
||||
<span class="constellation-star constellation-star--4">✦</span>
|
||||
<span class="constellation-star constellation-star--5">✧</span>
|
||||
<span class="constellation-star constellation-star--6">★</span>
|
||||
</div>
|
||||
<div class="loader__scene">
|
||||
<div class="loader__tableau" aria-hidden="true">
|
||||
<div class="loader__aurora loader__aurora--violet"></div>
|
||||
<div class="loader__aurora loader__aurora--peach"></div>
|
||||
<div class="loader__hanger loader__hanger--left">
|
||||
<span class="loader__thread"></span>
|
||||
<span class="loader__charm loader__charm--star">✦</span>
|
||||
</div>
|
||||
<div class="loader__hanger loader__hanger--center">
|
||||
<span class="loader__thread"></span>
|
||||
<span class="loader__charm loader__charm--moon">☾</span>
|
||||
</div>
|
||||
<div class="loader__hanger loader__hanger--right">
|
||||
<span class="loader__thread"></span>
|
||||
<span class="loader__charm loader__charm--spark">✧</span>
|
||||
</div>
|
||||
<div class="loader__mist loader__mist--left"></div>
|
||||
<div class="loader__mist loader__mist--center"></div>
|
||||
<div class="loader__mist loader__mist--right"></div>
|
||||
<div class="loader__dissolve" aria-hidden="true">
|
||||
<svg class="loader__dissolve-lines" viewBox="0 0 340 230" role="presentation" focusable="false">
|
||||
<line class="loader__dissolve-line loader__dissolve-line--one" x1="55" y1="63" x2="160" y2="40" pathLength="1" />
|
||||
<line class="loader__dissolve-line loader__dissolve-line--two" x1="160" y1="40" x2="290" y2="80" pathLength="1" />
|
||||
<line class="loader__dissolve-line loader__dissolve-line--three" x1="55" y1="63" x2="87" y2="155" pathLength="1" />
|
||||
<line class="loader__dissolve-line loader__dissolve-line--four" x1="87" y1="155" x2="195" y2="137" pathLength="1" />
|
||||
<line class="loader__dissolve-line loader__dissolve-line--five" x1="195" y1="137" x2="297" y2="193" pathLength="1" />
|
||||
<line class="loader__dissolve-line loader__dissolve-line--six" x1="195" y1="137" x2="223" y2="213" pathLength="1" />
|
||||
<line class="loader__dissolve-line loader__dissolve-line--seven" x1="87" y1="155" x2="41" y2="205" pathLength="1" />
|
||||
</svg>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--one"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--two"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--three"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--four"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--five"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--six"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--seven"></span>
|
||||
<span class="loader__dissolve-dot loader__dissolve-dot--eight"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loader__copy">
|
||||
<div class="loader__eyebrow">{{ eyebrow }}</div>
|
||||
<h1 class="loader__title">{{ title }}</h1>
|
||||
<p v-if="text" class="loader__text">{{ text }}</p>
|
||||
<p class="loader__outro-text">{{ outroText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="entry" class="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 bg-slate-950/35 backdrop-blur-sm"
|
||||
aria-label="Audit-Details schließen"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
|
||||
<aside class="absolute right-0 top-0 flex h-full w-full max-w-2xl flex-col overflow-hidden bg-white shadow-2xl">
|
||||
<header class="border-b border-slate-100 px-6 py-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Audit Detail</p>
|
||||
<h2 class="mt-2 text-2xl font-bold leading-8 text-slate-950">{{ entry.actionLabel }}</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">{{ entry.summary }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"
|
||||
aria-label="Schließen"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Zeitpunkt</p>
|
||||
<p class="mt-1 font-semibold text-slate-900">{{ entry.createdLabel }}</p>
|
||||
<p class="text-xs text-slate-500">{{ entry.ageLabel }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Admin</p>
|
||||
<p class="mt-1 break-words font-semibold text-slate-900">{{ entry.adminTwitchUserId }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Objekt</p>
|
||||
<p class="mt-1 break-words font-semibold text-slate-900">{{ entry.entityLabel }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Rohaktion</p>
|
||||
<code class="mt-1 inline-block rounded-md bg-white px-2 py-1 text-xs font-semibold text-slate-700">{{ entry.actionType }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-if="entry.relatedLink"
|
||||
:to="entry.relatedLink.to"
|
||||
class="mt-4 inline-flex h-10 items-center gap-2 rounded-2xl border border-sky-200 bg-sky-50 px-4 text-sm font-semibold text-sky-700 transition hover:bg-sky-100"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
{{ entry.relatedLink.label }}
|
||||
</RouterLink>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ShieldCheck class="h-4 w-4 text-slate-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Request-Kontext</h3>
|
||||
</div>
|
||||
<dl class="grid gap-3">
|
||||
<div v-for="item in entry.requestContextItems" :key="item.key" class="rounded-2xl border border-slate-100 px-4 py-3">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-1 break-words text-sm font-semibold text-slate-800">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="entry.changeItems.length > 0" class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<GitCompareArrows class="h-4 w-4 text-amber-600" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Änderungsdiff</h3>
|
||||
</div>
|
||||
<dl class="grid gap-3">
|
||||
<div v-for="change in entry.changeItems" :key="`${change.field}-${change.to}`" class="rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
|
||||
<dt class="flex flex-wrap items-center gap-2 text-sm font-bold text-slate-800">
|
||||
{{ change.label }}
|
||||
<span v-if="change.sensitive" class="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-amber-700">
|
||||
sensibel
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<span class="min-w-0 rounded-xl bg-white/80 px-3 py-2 text-sm text-slate-600">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Vorher</span>
|
||||
<span class="break-words">{{ change.from || 'leer' }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-emerald-600">Nachher</span>
|
||||
<span class="break-words">{{ change.to || 'leer' }}</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ListTree class="h-4 w-4 text-slate-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Metadaten</h3>
|
||||
</div>
|
||||
<dl v-if="entry.metadataItems.length > 0" class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="item in entry.metadataItems" :key="item.key" class="min-w-0 rounded-2xl border border-slate-100 px-4 py-3">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else class="rounded-2xl border border-dashed border-slate-200 px-4 py-5 text-sm text-slate-500">
|
||||
Keine Metadaten erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Braces class="h-4 w-4 text-slate-500" />
|
||||
<h3 class="text-sm font-bold uppercase tracking-[0.14em] text-slate-500">Rohdaten</h3>
|
||||
</div>
|
||||
<pre class="max-h-72 overflow-auto rounded-2xl border border-slate-200 bg-slate-950 p-4 text-xs leading-5 text-slate-100">{{ entry.rawMetadataJson }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { Braces, ExternalLink, GitCompareArrows, ListTree, ShieldCheck, X } from '@lucide/vue'
|
||||
|
||||
import type { AuditLogRow } from './useAdminAuditManager'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: AuditLogRow | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.entry) emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.entry,
|
||||
(entry) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = entry ? 'hidden' : ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', closeOnEscape))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', closeOnEscape)
|
||||
if (typeof document !== 'undefined') document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Überblick</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Schwerpunkte</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div v-for="card in focusCards" :key="card.label" class="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ card.label }}</p>
|
||||
<strong class="mt-1 block truncate text-base text-slate-950">{{ card.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ card.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Admins</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
|
||||
@click="$emit('update:selectedAdmin', 'all')"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="item in adminCounts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="selectedAdmin === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
|
||||
@click="$emit('update:selectedAdmin', item.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
|
||||
</button>
|
||||
<p v-if="adminCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
|
||||
Noch keine Admin-Aktivität.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Aktionen</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
|
||||
@click="$emit('update:selectedAction', 'all')"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-56 space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="item in actionCounts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="selectedAction === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
|
||||
@click="$emit('update:selectedAction', item.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
|
||||
</button>
|
||||
<p v-if="actionCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
|
||||
Noch keine Aktionstypen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Objekte</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-semibold text-sky-600 hover:text-sky-800"
|
||||
@click="$emit('update:selectedEntity', 'all')"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
v-for="item in entityCounts"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-3 py-2 text-left text-sm transition"
|
||||
:class="selectedEntity === item.key ? 'border-sky-200 bg-sky-50 text-sky-900' : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'"
|
||||
@click="$emit('update:selectedEntity', item.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold">{{ item.label }}</span>
|
||||
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-bold text-sky-700">{{ item.count }}</span>
|
||||
</button>
|
||||
<p v-if="entityCounts.length === 0" class="rounded-2xl border border-dashed border-slate-200 px-3 py-5 text-center text-sm text-slate-500">
|
||||
Noch keine Objekttypen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditCountItem, AuditFocusCard } from './useAdminAuditManager'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
focusCards: AuditFocusCard[]
|
||||
adminCounts: AuditCountItem[]
|
||||
actionCounts: AuditCountItem[]
|
||||
entityCounts: AuditCountItem[]
|
||||
selectedAdmin: string
|
||||
selectedAction: string
|
||||
selectedEntity: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:selectedAdmin': [value: string]
|
||||
'update:selectedAction': [value: string]
|
||||
'update:selectedEntity': [value: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-slate-100 p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-sky-500">Audit Log</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Letzte Aktionen</h2>
|
||||
</div>
|
||||
<div class="grid h-10 w-10 place-items-center rounded-2xl bg-sky-100 text-sky-700">
|
||||
<FileClock class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[760px] divide-y divide-slate-100 overflow-y-auto" :aria-busy="loading">
|
||||
<article v-for="entry in entries" :key="entry.id" class="grid gap-4 px-5 py-4 sm:grid-cols-[16px_minmax(0,1fr)]">
|
||||
<span class="mt-2 h-3 w-3 rounded-full ring-4 ring-white" :class="entry.dotClass" />
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="entry.actionToneClass">
|
||||
{{ entry.actionLabel }}
|
||||
</span>
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{{ entry.actionGroup }}</span>
|
||||
</div>
|
||||
<h3 class="mt-3 text-base font-bold leading-6 text-slate-950">{{ entry.summary }}</h3>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-start gap-3">
|
||||
<time class="text-right text-sm text-slate-500" :datetime="entry.createdAt">
|
||||
{{ entry.createdLabel }}
|
||||
<span class="block text-xs text-slate-400">{{ entry.ageLabel }}</span>
|
||||
</time>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-3 text-slate-700 shadow-none hover:bg-slate-50"
|
||||
@click="$emit('openDetail', entry)"
|
||||
>
|
||||
<Eye class="h-4 w-4" />
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 text-sm text-slate-500 md:grid-cols-3">
|
||||
<p>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Admin</span>
|
||||
<span class="font-semibold text-slate-700">{{ entry.adminTwitchUserId }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Objekt</span>
|
||||
<span class="font-semibold text-slate-700">{{ entry.entityLabel }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">Rohaktion</span>
|
||||
<code class="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs font-semibold text-slate-700">{{ entry.actionType }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="entry.changeItems.length > 0" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50/70 px-4 py-3">
|
||||
<p class="text-sm font-semibold text-amber-800">Änderungsdiff</p>
|
||||
<dl class="mt-3 grid gap-2">
|
||||
<div
|
||||
v-for="change in entry.changeItems"
|
||||
:key="`${change.field}-${change.to}`"
|
||||
class="grid gap-2 rounded-xl border border-white/80 bg-white/80 px-3 py-2 text-sm lg:grid-cols-[150px_minmax(0,1fr)]"
|
||||
>
|
||||
<dt class="font-bold text-slate-700">
|
||||
{{ change.label }}
|
||||
<span v-if="change.sensitive" class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] uppercase tracking-[0.12em] text-amber-700">
|
||||
sensibel
|
||||
</span>
|
||||
</dt>
|
||||
<dd class="grid gap-2 sm:grid-cols-2">
|
||||
<span class="min-w-0 rounded-lg bg-slate-50 px-2 py-1 text-slate-600">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-slate-400">Vorher</span>
|
||||
<span class="break-words">{{ change.from || 'leer' }}</span>
|
||||
</span>
|
||||
<span class="min-w-0 rounded-lg bg-emerald-50 px-2 py-1 text-emerald-800">
|
||||
<span class="block text-[10px] font-bold uppercase tracking-[0.14em] text-emerald-600">Nachher</span>
|
||||
<span class="break-words">{{ change.to || 'leer' }}</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<details v-if="entry.metadataItems.length > 0" class="mt-3 rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-sky-700">Metadaten anzeigen</summary>
|
||||
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div v-for="item in entry.metadataItems" :key="item.key" class="min-w-0">
|
||||
<dt class="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-0.5 break-words text-slate-700">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="entries.length === 0" class="px-5 py-14 text-center text-sm text-slate-500">
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4">
|
||||
<p class="text-sm font-semibold text-slate-500">{{ pageSummaryLabel }}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-700 shadow-none hover:bg-slate-50"
|
||||
:disabled="!hasMore || loadingMore || loading"
|
||||
@click="$emit('loadMore')"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4" :class="{ 'animate-bounce': loadingMore }" />
|
||||
{{ hasMore ? (loadingMore ? 'Lädt ...' : 'Mehr laden') : 'Alles geladen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown, Eye, FileClock } from '@lucide/vue'
|
||||
|
||||
import type { AuditLogRow } from './useAdminAuditManager'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
entries: AuditLogRow[]
|
||||
loading: boolean
|
||||
loadingMore: boolean
|
||||
hasMore: boolean
|
||||
pageSummaryLabel: string
|
||||
emptyText: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
openDetail: [entry: AuditLogRow]
|
||||
loadMore: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(260px,1.5fr)_180px_150px_150px]">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-sky-500" />
|
||||
<input
|
||||
:value="query"
|
||||
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
placeholder="Admin, Aktion, Objekt, Summary oder Metadaten suchen"
|
||||
type="search"
|
||||
@input="$emit('update:query', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<NativeSelect
|
||||
:model-value="entityFilter"
|
||||
:options="entityOptions"
|
||||
@update:model-value="$emit('update:entityFilter', String($event ?? 'all'))"
|
||||
/>
|
||||
|
||||
<label class="relative block">
|
||||
<CalendarDays class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
:value="fromDate"
|
||||
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
type="date"
|
||||
@input="$emit('update:fromDate', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="relative block">
|
||||
<CalendarDays class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
:value="toDate"
|
||||
class="h-12 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
type="date"
|
||||
@input="$emit('update:toDate', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-start gap-2 xl:justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-sky-200 bg-sky-50 px-4 text-sky-700 shadow-none hover:bg-sky-100"
|
||||
:disabled="loading"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
{{ loading ? 'Lädt ...' : 'Aktualisieren' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-700 shadow-none hover:bg-slate-50"
|
||||
:disabled="!hasRows"
|
||||
@click="$emit('export')"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeFilterCount > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-slate-200 bg-white px-4 text-slate-600 shadow-none hover:bg-slate-50"
|
||||
@click="$emit('clear')"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
Filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-4 2xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in filterPresets"
|
||||
:key="preset.key"
|
||||
type="button"
|
||||
class="min-h-12 rounded-2xl border px-4 py-2 text-left text-sm transition"
|
||||
:class="appliedPresetKey === preset.key ? 'border-sky-300 bg-sky-50 text-sky-800' : 'border-slate-200 bg-white text-slate-700 hover:border-sky-200 hover:bg-sky-50'"
|
||||
@click="$emit('applyPreset', preset.key)"
|
||||
>
|
||||
<span class="block font-bold">{{ preset.label }}</span>
|
||||
<span class="block max-w-64 truncate text-xs opacity-75">{{ preset.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div v-for="stat in stats" :key="stat.label" class="rounded-2xl border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<p class="truncate text-[10px] font-semibold uppercase tracking-[0.12em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-lg leading-6 text-slate-950">{{ stat.value }}</strong>
|
||||
<p class="truncate text-[11px] text-slate-400">{{ stat.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs font-semibold text-slate-400">
|
||||
<span>Geladen: {{ lastLoadedLabel }} · Quelle: /api/admin/audit-entries</span>
|
||||
<span v-if="exportMessage" class="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-emerald-700">{{ exportMessage }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CalendarDays, Download, RefreshCw, Search, X } from '@lucide/vue'
|
||||
|
||||
import type { AuditEntityOption, AuditFilterPreset, AuditStat } from './useAdminAuditManager'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
query: string
|
||||
entityFilter: string
|
||||
entityOptions: AuditEntityOption[]
|
||||
fromDate: string
|
||||
toDate: string
|
||||
stats: AuditStat[]
|
||||
filterPresets: AuditFilterPreset[]
|
||||
appliedPresetKey: string
|
||||
exportMessage: string
|
||||
lastLoadedLabel: string
|
||||
loading: boolean
|
||||
hasRows: boolean
|
||||
activeFilterCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:query': [value: string]
|
||||
'update:entityFilter': [value: string]
|
||||
'update:fromDate': [value: string]
|
||||
'update:toDate': [value: string]
|
||||
applyPreset: [value: string]
|
||||
refresh: []
|
||||
export: []
|
||||
clear: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<Card class="flex h-[520px] flex-col overflow-hidden xl:h-[684px]">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] px-5 py-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Award-Jahre</p>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ seasons.length }} Jahre im Setup</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="gap-2 border border-violet-100 text-violet-700 hover:bg-violet-50" @click="openCreateModal">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<template v-if="selectedSeason">
|
||||
<div class="rounded-[24px] border border-violet-100 bg-white p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Ausgewählt</p>
|
||||
<strong class="mt-2 block text-5xl leading-none text-slate-950">{{ selectedSeason.year }}</strong>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-2.5 py-1 text-[11px] font-bold" :class="selectedSeason.isCurrent ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
|
||||
{{ selectedSeason.isCurrent ? 'Public' : 'Intern' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-5 line-clamp-2 text-lg font-bold leading-6 text-slate-900">{{ selectedSeason.name }}</p>
|
||||
<div class="mt-5 grid gap-3 border-t border-violet-100 pt-4">
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Phase</span>
|
||||
<strong class="truncate text-sm text-violet-800">{{ selectedSeason.currentPhase }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Kategorien</span>
|
||||
<strong class="text-sm text-slate-900">{{ selectedSeason.categoryCount }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl bg-violet-50/70 px-4 py-3">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Sichtbarkeit</span>
|
||||
<strong class="text-sm text-slate-900">{{ selectedSeason.isCurrent ? 'Public aktiv' : 'Intern' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-end text-xs text-slate-500">
|
||||
<span v-if="switchingSeasonId === selectedSeason.id" class="inline-flex items-center gap-1 font-bold text-violet-600">
|
||||
<LoaderCircle class="h-3.5 w-3.5 animate-spin" />
|
||||
lädt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr wechseln</span>
|
||||
<NativeSelect v-model="selectedSeasonSelectValue" :options="seasonOptions" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div v-else class="px-5 py-8 text-center">
|
||||
<CalendarPlus class="mx-auto h-7 w-7 text-violet-300" />
|
||||
<p class="mt-3 text-sm text-slate-500">Noch keine Award-Jahre aus der API geladen.</p>
|
||||
<Button class="mt-5 gap-2" size="sm" @click="openCreateModal">
|
||||
<PlusCircle class="h-4 w-4" />
|
||||
Erstes Jahr anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { CalendarPlus, LoaderCircle, PlusCircle } from '@lucide/vue'
|
||||
|
||||
import type { AdminSeasonListItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
seasons: AdminSeasonListItem[]
|
||||
selectedSeasonId: number | null
|
||||
openCreateModal: () => void
|
||||
loadSeasonDetail: (seasonId: number) => Promise<void>
|
||||
}>()
|
||||
|
||||
const switchingSeasonId = ref<number | null>(null)
|
||||
const selectedSeason = computed(() =>
|
||||
props.seasons.find((season) => season.id === props.selectedSeasonId) ?? props.seasons[0] ?? null,
|
||||
)
|
||||
const seasonOptions = computed(() =>
|
||||
props.seasons.map((season) => ({
|
||||
label: `${season.year} · ${season.name}`,
|
||||
value: season.id,
|
||||
})),
|
||||
)
|
||||
const selectedSeasonSelectValue = computed({
|
||||
get: () => props.selectedSeasonId,
|
||||
set: (value) => {
|
||||
if (typeof value === 'number') {
|
||||
void selectSeason(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function selectSeason(seasonId: number) {
|
||||
if (seasonId === props.selectedSeasonId || switchingSeasonId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
switchingSeasonId.value = seasonId
|
||||
try {
|
||||
await props.loadSeasonDetail(seasonId)
|
||||
} finally {
|
||||
switchingSeasonId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Modal :open="!!candidate" title="Kandidat löschen?" @close="$emit('close')">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-500">
|
||||
<TriangleAlert class="h-6 w-6" />
|
||||
</span>
|
||||
<p class="text-sm leading-7 text-slate-600">
|
||||
„<strong class="text-slate-800">{{ candidate?.displayName }}</strong>" wird endgültig aus diesem Award-Jahr entfernt.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="deleting" @click="$emit('confirm')">
|
||||
{{ deleting ? 'Löscht …' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlert } from '@lucide/vue'
|
||||
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
defineProps<{
|
||||
candidate: AdminCandidateItem | null
|
||||
deleting: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<Modal :open="open" :title="title" subtitle="Anzeigename und Handle sind Pflicht." @close="$emit('close')">
|
||||
<div class="space-y-4">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kategorie</span>
|
||||
<NativeSelect :model-value="form.categoryId" :options="categoryOptions" @update:model-value="$emit('update:categoryId', Number($event) || 0)" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input :value="form.displayName" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Jayuhime" @input="$emit('update:displayName', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input :value="form.channelSlug" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="@channel" @input="$emit('update:channelSlug', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<select :value="selectedPlatformValue" class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" @change="$emit('platform-selection', $event)">
|
||||
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
<option value="custom">Eigene Plattform</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedPlatformValue === 'custom'" class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||
<input :value="form.platform" type="text" class="h-11 w-full rounded-2xl border border-violet-200 px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z. B. Cake, Booth, neue Plattform" @input="$emit('update:platform', ($event.target as HTMLInputElement).value)" />
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="$emit('close')">Abbrechen</Button>
|
||||
<Button :disabled="saving || !canSave" @click="$emit('save')">{{ saving ? 'Speichert …' : 'Speichern' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SocialIconOption } from '../../lib/socialIcons'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
form: {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}
|
||||
categoryOptions: Array<{ label: string; value: number }>
|
||||
candidatePlatformOptions: SocialIconOption[]
|
||||
selectedPlatformValue: string
|
||||
canSave: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
save: []
|
||||
'update:categoryId': [value: number]
|
||||
'update:displayName': [value: string]
|
||||
'update:channelSlug': [value: string]
|
||||
'update:platform': [value: string]
|
||||
'platform-selection': [event: Event]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 border-b border-violet-100 p-5 lg:flex-row lg:items-center">
|
||||
<label class="relative block flex-1">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
:value="search"
|
||||
type="text"
|
||||
class="h-11 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Name, Handle oder Plattform suchen …"
|
||||
@input="$emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<NativeSelect
|
||||
:model-value="categoryFilter"
|
||||
:options="categoryFilterOptions"
|
||||
@update:model-value="$emit('update:categoryFilter', $event === null ? null : Number($event))"
|
||||
/>
|
||||
<Button v-if="search || categoryFilter" variant="ghost" class="gap-1" @click="$emit('clear-filters')">
|
||||
<X class="h-4 w-4" /> Filter
|
||||
</Button>
|
||||
<Button class="gap-2" @click="$emit('open-create')">
|
||||
<UserPlus class="h-4 w-4" /> Kandidat anlegen
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Search, UserPlus, X } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
|
||||
defineProps<{
|
||||
search: string
|
||||
categoryFilter: number | null
|
||||
categoryFilterOptions: Array<{ label: string; value: number | null }>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:search': [value: string]
|
||||
'update:categoryFilter': [value: number | null]
|
||||
'clear-filters': []
|
||||
'open-create': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="hidden grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] gap-4 border-b border-violet-100 bg-violet-50/40 px-6 py-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-violet-500 lg:grid">
|
||||
<span>Kandidat</span>
|
||||
<span>Kategorie</span>
|
||||
<span>Plattform</span>
|
||||
<span class="text-right">Aktionen</span>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="candidate in pagedCandidates"
|
||||
:key="candidate.id"
|
||||
class="grid grid-cols-1 gap-3 px-6 py-4 transition hover:bg-violet-50/40 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1.2fr)_120px_96px] lg:items-center lg:gap-4"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-[linear-gradient(135deg,#c4b5fd,#f5a9d6)] text-sm font-semibold text-white">
|
||||
{{ candidate.displayName.charAt(0) }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">{{ candidate.displayName }}</p>
|
||||
<p class="truncate text-xs text-slate-500">{{ candidate.channelSlug }}</p>
|
||||
<p v-if="isDuplicate(candidate)" class="mt-1 text-xs font-semibold text-amber-700">
|
||||
Mögliches Duplikat in dieser Kategorie
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="inline-block max-w-full truncate rounded-full border border-violet-100 bg-violet-50/70 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
{{ categoryLabelMap[candidate.categoryId] || 'Ohne Kategorie' }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700">{{ candidate.platform }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 lg:justify-end">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50"
|
||||
title="Bearbeiten"
|
||||
@click="$emit('edit', candidate)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-rose-200 text-rose-500 transition hover:bg-rose-50"
|
||||
title="Löschen"
|
||||
@click="$emit('delete', candidate)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCount === 0" class="px-6 py-12 text-center">
|
||||
<p class="text-sm text-slate-500">
|
||||
{{ totalCount === 0 ? 'Noch keine Kandidaten in diesem Jahr.' : 'Keine Treffer für den aktuellen Filter.' }}
|
||||
</p>
|
||||
<Button class="mt-4 gap-2" @click="$emit('open-create')"><UserPlus class="h-4 w-4" /> Ersten Kandidaten anlegen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCount > 0" class="flex items-center justify-between gap-4 border-t border-violet-100 px-6 py-4 text-sm text-slate-500">
|
||||
<span><strong class="text-violet-800">{{ rangeStart }}–{{ rangeEnd }}</strong> von {{ filteredCount }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page <= 1"
|
||||
@click="$emit('update:page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<span class="min-w-[72px] text-center font-semibold text-slate-700">Seite {{ page }}/{{ totalPages }}</span>
|
||||
<button
|
||||
class="grid h-9 w-9 place-items-center rounded-full border border-violet-200 text-violet-600 transition hover:bg-violet-50 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
:disabled="page >= totalPages"
|
||||
@click="$emit('update:page', page + 1)"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Pencil, Trash2, UserPlus } from '@lucide/vue'
|
||||
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
pagedCandidates: AdminCandidateItem[]
|
||||
totalCount: number
|
||||
filteredCount: number
|
||||
page: number
|
||||
totalPages: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
categoryLabelMap: Record<number, string>
|
||||
duplicateCandidateKeys: Map<string, number>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
edit: [candidate: AdminCandidateItem]
|
||||
delete: [candidate: AdminCandidateItem]
|
||||
'update:page': [page: number]
|
||||
'open-create': []
|
||||
}>()
|
||||
|
||||
function isDuplicate(candidate: AdminCandidateItem) {
|
||||
return (props.duplicateCandidateKeys.get(`${candidate.categoryId}:name:${candidate.displayName.trim().toLowerCase()}`) ?? 0) > 1
|
||||
|| (props.duplicateCandidateKeys.get(`${candidate.categoryId}:slug:${candidate.channelSlug.trim().toLowerCase()}`) ?? 0) > 1
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<Card id="content-basics" class="overflow-hidden p-0">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-r from-violet-50 via-white to-amber-50/60 px-6 py-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Basis</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Host & Hero-Informationen</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Diese Werte erscheinen direkt im Hero-Hostblock und im Community-Bereich.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||
<Globe2 class="h-6 w-6 text-violet-500" />
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Basis speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 p-6 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Name</span>
|
||||
<input v-model="form.hostDisplayName" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Host Tagline</span>
|
||||
<input v-model="form.hostTagline" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 md:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Newsletter URL</span>
|
||||
<input v-model="form.newsletterUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Globe2, Save } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Basis & Host')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Card id="content-faq" class="p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Support</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">FAQ verwalten</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Fragen und Antworten erscheinen im FAQ-Abschnitt der Landingpage.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addFaqItem">
|
||||
<Plus class="h-4 w-4" />
|
||||
FAQ hinzufügen
|
||||
</Button>
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'FAQ speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-3">
|
||||
<div v-for="(item, index) in form.faq" :key="`faq-${index}`" class="space-y-3 rounded-[22px] border border-violet-100 bg-white/80 p-4">
|
||||
<input v-model="item.question" type="text" class="h-11 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Frage" />
|
||||
<textarea v-model="item.answer" rows="3" class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Antwort" />
|
||||
<div class="flex justify-end">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-rose-100 bg-rose-50 px-4 text-rose-600 shadow-none hover:bg-rose-100" @click="removeFaqItem(index)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Save, Trash2 } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
addFaqItem: () => void
|
||||
removeFaqItem: (index: number) => void
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('FAQ')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<Card id="content-links" class="p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Footer & Kontakt</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Rechtliche Links und Kontaktwege</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs werden im Footer und in den öffentlichen Kontaktflächen ausgespielt.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||
<Link2 class="h-6 w-6 text-violet-500" />
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Links speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz E-Mail</span>
|
||||
<input v-model="form.privacyEmail" type="email" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Kontakt URL</span>
|
||||
<input v-model="form.contactUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Impressum URL</span>
|
||||
<input v-model="form.imprintUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Sponsoren & Partner URL</span>
|
||||
<input v-model="form.sponsorsUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Link2, Save } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Footer & Kontakt')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-[#25123f]/70 px-4 py-8 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="max-h-[88vh] w-full max-w-4xl overflow-hidden rounded-[34px] border border-violet-100 bg-white shadow-[0_34px_100px_rgba(38,18,63,0.35)]">
|
||||
<div class="flex items-start justify-between gap-4 border-b border-violet-100 bg-gradient-to-r from-[#f8f2ff] via-white to-[#fff1f8] px-7 py-6">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Rechtliches Preview</p>
|
||||
<h3 class="mt-2 text-xl font-bold text-slate-900">Datenschutzerklärung</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">Stand: {{ updatedLabel }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
|
||||
aria-label="Preview schließen"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="max-h-[calc(88vh-140px)] space-y-4 overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
|
||||
<p
|
||||
v-for="(block, index) in blocks"
|
||||
:key="`privacy-modal-block-${index}`"
|
||||
class="whitespace-pre-wrap rounded-[18px] border border-violet-50 bg-white/86 px-4 py-3 shadow-sm"
|
||||
>
|
||||
{{ block }}
|
||||
</p>
|
||||
<p v-if="!blocks.length" class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
|
||||
Noch kein Datenschutztext eingetragen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
blocks: string[]
|
||||
updatedLabel: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<section id="content-privacy">
|
||||
<Card class="p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Rechtstexte</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Datenschutzerklärung</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Großes Textfeld für den kompletten Inhalt. Die Landingpage übernimmt denselben Text.</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-3">
|
||||
<ShieldCheck class="h-6 w-6 text-violet-500" />
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="$emit('open-preview')">
|
||||
<Eye class="h-4 w-4" />
|
||||
Preview öffnen
|
||||
</Button>
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Datenschutz speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-[24px] border border-violet-100 bg-gradient-to-br from-violet-50 via-white to-[#f8eefb] p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Aktualisiert von</p>
|
||||
<p class="mt-2 text-lg font-semibold text-violet-900">{{ updatedBy || 'Unbekannt' }}</p>
|
||||
</div>
|
||||
<div class="rounded-[24px] border border-violet-100 bg-white p-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Zeitpunkt</p>
|
||||
<p class="mt-2 text-lg font-semibold text-slate-900">{{ updatedLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="mt-6 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Datenschutz Inhalt</span>
|
||||
<textarea
|
||||
v-model="form.privacyPolicyContent"
|
||||
rows="24"
|
||||
class="w-full rounded-[28px] border border-violet-200 bg-[#fcfbff] px-5 py-4 text-sm leading-7 text-slate-700 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Datenschutztext..."
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Eye, Save, ShieldCheck } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
updatedBy: string | null
|
||||
updatedLabel: string
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'open-preview': []
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Datenschutzerklärung')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Bereiche</p>
|
||||
<div class="mt-4 grid gap-2">
|
||||
<a
|
||||
v-for="section in sections"
|
||||
:key="section.href"
|
||||
:href="section.href"
|
||||
:class="section.primary ? primaryClasses : secondaryClasses"
|
||||
>
|
||||
{{ section.label }}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
type SectionLink = {
|
||||
href: string
|
||||
label: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
sections: SectionLink[]
|
||||
}>()
|
||||
|
||||
const primaryClasses =
|
||||
'rounded-2xl border border-violet-100 bg-violet-50/70 px-4 py-3 text-sm font-semibold text-violet-800 no-underline transition hover:bg-violet-100'
|
||||
const secondaryClasses =
|
||||
'rounded-2xl border border-violet-100 bg-white px-4 py-3 text-sm font-semibold text-slate-700 no-underline transition hover:bg-violet-50'
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Card id="content-socials" class="p-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Community</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Social Links</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">Plattform, Label und URL werden auf der Landingpage als Social Icons angezeigt.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" @click="addSocialLink">
|
||||
<Plus class="h-4 w-4" />
|
||||
Link hinzufügen
|
||||
</Button>
|
||||
<Button :disabled="saving" size="sm" class="gap-2 rounded-2xl bg-gradient-to-r from-violet-600 to-fuchsia-500 shadow-violet-500/20" @click="onSave">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Socials speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-4">
|
||||
<p v-if="iconUploadError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ iconUploadError }}</p>
|
||||
<div v-for="(social, index) in form.socialLinks" :key="`${social.platform}-${index}`" class="rounded-[28px] border border-violet-100 bg-gradient-to-br from-white via-violet-50/40 to-white p-5 shadow-[0_16px_42px_rgba(124,92,255,0.08)]">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-sm font-black text-violet-700">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-violet-500">Social Link</p>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ social.label || 'Neuer Link' }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="h-10 w-10 rounded-2xl border border-rose-100 bg-rose-50 p-0 text-rose-600 shadow-none hover:bg-rose-100" aria-label="Social Link entfernen" @click="removeSocialLink(index)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)_300px]">
|
||||
<label class="space-y-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input v-model="social.label" type="text" class="h-11 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="Twitch" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform auswählen</span>
|
||||
<select :value="selectedSocialIconValue(social)" class="h-11 min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" @change="handleSocialIconSelection($event, index)">
|
||||
<optgroup v-for="group in SOCIAL_ICON_OPTION_GROUPS" :key="group.label" :label="group.label">
|
||||
<option v-for="option in group.options" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<option value="custom">Andere Plattform / eigenes Icon</option>
|
||||
</select>
|
||||
<span class="block text-xs text-slate-400">Bekannte Plattformen nutzen automatisch Bibliotheks-Icons. Neue oder eingestellte Plattformen bleiben über Custom-Key plus Upload möglich.</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Custom Icon</span>
|
||||
<label class="group flex h-11 min-w-0 cursor-pointer items-center justify-between gap-3 rounded-2xl border border-dashed border-violet-300 bg-white px-3 text-sm font-semibold text-violet-700 transition hover:border-violet-500 hover:bg-violet-50">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-xl bg-violet-100 text-violet-600">
|
||||
<Upload class="h-4 w-4" />
|
||||
</span>
|
||||
<span class="truncate">{{ isUploadedIcon(social.icon) ? 'Icon ersetzen' : 'Icon hochladen' }}</span>
|
||||
</span>
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="sr-only" @change="handleSocialIconUpload($event, index)" />
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedSocialIconValue(social) === 'custom'" class="space-y-2 xl:col-span-3">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Andere Plattform / eigener Key</span>
|
||||
<input v-model="social.platform" type="text" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="z.B. cake, booth, neue-plattform" />
|
||||
<span class="block text-xs text-slate-400">Für neue oder nicht mehr gepflegte Plattformen: Key speichern, eigenes Icon hochladen oder Stern-Fallback nutzen.</span>
|
||||
</label>
|
||||
<label class="space-y-2 xl:col-span-3">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">URL</span>
|
||||
<input v-model="social.url" type="url" class="h-11 w-full min-w-0 rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" placeholder="https://..." />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="hasSocialIconPreview(social)" class="mt-4 grid gap-4 rounded-[22px] border border-violet-100 bg-white/82 p-4">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Icon Preview</p>
|
||||
<p class="mt-1 text-xs text-slate-500">Aktives Icon: {{ socialIconModeLabel(social) }}. Unbekannte Plattformen bleiben verlinkt und nutzen ein Fallback, bis du ein Custom Icon hochlädst.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="flex items-center justify-between gap-4 rounded-[18px] border border-violet-100 bg-violet-50/60 p-3">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-[11px] bg-[#f1ecfb]">
|
||||
<img v-if="isUploadedIcon(social.icon)" :src="social.icon" :alt="`${social.label || social.platform} Host Icon Preview`" class="h-[22px] w-[22px] object-contain" />
|
||||
<svg v-else-if="socialSimpleIconPath(social)" class="h-[22px] w-[22px]" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social)"><path :d="socialSimpleIconPath(social)" /></svg>
|
||||
<span v-else class="text-lg font-black text-violet-600">✦</span>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-700">Host Größe</span>
|
||||
</span>
|
||||
<input v-model="social.showOnHost" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-4 rounded-[18px] border border-violet-100 bg-white p-3">
|
||||
<span class="flex items-center gap-3">
|
||||
<span class="grid h-[46px] w-[46px] place-items-center rounded-[13px] border border-[#e9e0f8] bg-white shadow-[0_4px_12px_rgba(124,86,196,0.08)]">
|
||||
<img v-if="isUploadedIcon(social.icon)" :src="social.icon" :alt="`${social.label || social.platform} Community Icon Preview`" class="h-6 w-6 object-contain" />
|
||||
<svg v-else-if="socialSimpleIconPath(social)" class="h-6 w-6" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social)"><path :d="socialSimpleIconPath(social)" /></svg>
|
||||
<span v-else class="text-xl font-black text-violet-600">✦</span>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-slate-700">Community Größe</span>
|
||||
</span>
|
||||
<input v-model="social.showOnCommunity" type="checkbox" class="h-4 w-4 rounded border-violet-300 text-violet-600 focus:ring-violet-300" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500 lg:col-span-4">
|
||||
<span>PNG, JPG, WebP oder SVG · max. 256 KB · max. 512 x 512 px</span>
|
||||
<button v-if="social.icon" type="button" class="font-semibold text-rose-500 transition hover:text-rose-700" @click="clearSocialIcon(index)">Icon entfernen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Save, Trash2, Upload } from '@lucide/vue'
|
||||
|
||||
import { SOCIAL_ICON_OPTION_GROUPS } from '../../lib/socialIcons'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
form: AdminContentForm
|
||||
saving: boolean
|
||||
iconUploadError: string
|
||||
addSocialLink: () => void
|
||||
removeSocialLink: (index: number) => void
|
||||
isUploadedIcon: (icon: string) => boolean
|
||||
selectedSocialIconValue: (social: SocialLinkForm) => string
|
||||
handleSocialIconSelection: (event: Event, index: number) => void
|
||||
hasSocialIconPreview: (social: SocialLinkForm) => boolean
|
||||
socialIconModeLabel: (social: SocialLinkForm) => string
|
||||
socialSimpleIconPath: (social: SocialLinkForm) => string
|
||||
socialSimpleIconColor: (social: SocialLinkForm) => string
|
||||
handleSocialIconUpload: (event: Event, index: number) => Promise<void>
|
||||
clearSocialIcon: (index: number) => void
|
||||
saveSiteSettings: (sectionLabel?: string) => Promise<void>
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
return props.saveSiteSettings('Social Links')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
activities: Array<{ label: string; age: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Aktivitäten</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Was gerade passiert ist</h2>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">Audit-nahe Ereignisse, komprimiert für den schnellen Blick.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div
|
||||
v-for="activity in activities"
|
||||
:key="activity.label"
|
||||
class="rounded-[24px] 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-2 text-sm text-slate-500">{{ activity.age }}</p>
|
||||
</div>
|
||||
<p v-if="activities.length === 0" class="rounded-[26px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine aktuellen Audit-Aktivitäten vorhanden.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
operationChecks: Array<{ label: string; value: number; to: string; state: string; note: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4 lg:grid-cols-3">
|
||||
<RouterLink
|
||||
v-for="check in operationChecks"
|
||||
:key="check.label"
|
||||
:to="check.to"
|
||||
class="rounded-[24px] border bg-white/85 p-5 shadow-[0_16px_42px_rgba(168,145,214,0.08)] transition hover:-translate-y-0.5 hover:bg-violet-50/50"
|
||||
:class="{
|
||||
'border-emerald-100': check.state === 'ok',
|
||||
'border-amber-100': check.state === 'warn',
|
||||
'border-rose-100': check.state === 'danger',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">{{ check.label }}</p>
|
||||
<strong class="mt-3 block text-3xl" :class="check.state === 'danger' ? 'text-rose-700' : check.state === 'warn' ? 'text-amber-700' : 'text-emerald-700'">
|
||||
{{ check.value }}
|
||||
</strong>
|
||||
<p class="mt-2 text-sm leading-5 text-slate-500">{{ check.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
metricCards: Array<{ label: string; value: number; note: string; source: string; icon: unknown }>
|
||||
liveSummary: string
|
||||
liveStatusBadge: string
|
||||
openRiskCount: number
|
||||
openReviewCount: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-white/80 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-violet-500">Live-Lage</p>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-5 text-slate-600">
|
||||
{{ liveSummary }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex shrink-0 items-center rounded-xl border px-3 py-2 text-xs font-bold"
|
||||
:class="openRiskCount > 0 ? 'border-rose-200 bg-rose-50 text-rose-700' : openReviewCount > 0 ? 'border-amber-200 bg-amber-50 text-amber-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'"
|
||||
>
|
||||
{{ liveStatusBadge }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="metric in metricCards"
|
||||
:key="metric.label"
|
||||
class="rounded-2xl border border-violet-100 bg-white/90 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500">{{ metric.label }}</p>
|
||||
<strong class="mt-2 block text-2xl text-violet-900">{{ metric.value.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-700">
|
||||
<component :is="metric.icon" class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-violet-100 bg-violet-50/50 px-3 py-2">
|
||||
<p class="text-xs font-semibold leading-5 text-slate-700">{{ metric.note }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-slate-500">Quelle: {{ metric.source }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { Clock3 } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
priorityActions: Array<{ label: string; value: number; to: string; hint: string; icon: unknown; tone: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Schnellzugriffe</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Was zuerst?</h2>
|
||||
</div>
|
||||
<Clock3 class="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-3">
|
||||
<RouterLink
|
||||
v-for="item in priorityActions"
|
||||
:key="item.label"
|
||||
:to="item.to"
|
||||
class="group block rounded-[22px] border border-violet-100 bg-white/85 p-4 transition hover:-translate-y-0.5 hover:border-violet-200 hover:bg-violet-50/70"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl"
|
||||
:class="{
|
||||
'bg-violet-100 text-violet-700': item.tone === 'violet',
|
||||
'bg-rose-100 text-rose-700': item.tone === 'rose',
|
||||
'bg-amber-100 text-amber-700': item.tone === 'amber',
|
||||
'bg-emerald-100 text-emerald-700': item.tone === 'emerald',
|
||||
}"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-semibold text-slate-800">{{ item.label }}</p>
|
||||
<p class="truncate text-sm text-slate-500">{{ item.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<strong class="rounded-full border border-violet-100 bg-white px-3 py-1 text-violet-800">{{ item.value }}</strong>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
totalCategoryVotes: number
|
||||
maxCategoryVotes: number
|
||||
topCategories: Array<{ category: string; votes: number }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-7">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Kategorie-Performance</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Top Kategorien nach Stimmen</h2>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">{{ totalCategoryVotes.toLocaleString('de-DE') }} Stimmen in den Top-Kategorien</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div
|
||||
v-for="(category, index) in topCategories"
|
||||
:key="category.category"
|
||||
class="rounded-[24px] border border-violet-100 bg-white/90 p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">#{{ index + 1 }}</p>
|
||||
<h3 class="mt-1 font-semibold text-slate-800">{{ category.category }}</h3>
|
||||
</div>
|
||||
<strong class="text-lg text-violet-800">{{ Number(category.votes).toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="mt-4 h-3 rounded-full bg-[#f7f2ff]">
|
||||
<div
|
||||
class="h-3 rounded-full bg-gradient-to-r from-[#c4b5fd] to-[#7c5cff]"
|
||||
:style="{ width: `${(category.votes / maxCategoryVotes) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { BarChart3 } from '@lucide/vue'
|
||||
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
year: number
|
||||
yearTotals: Array<{ label: string; value: number; note: string; icon: unknown }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-7">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.26em] text-violet-500">Jahreszahlen</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Gesamtmetriken {{ year }}</h2>
|
||||
</div>
|
||||
<BarChart3 class="h-6 w-6 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="item in yearTotals"
|
||||
:key="item.label"
|
||||
class="rounded-[22px] border border-violet-100 bg-white/90 p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-violet-500">{{ item.label }}</p>
|
||||
<strong class="mt-2 block text-3xl text-violet-900">{{ item.value.toLocaleString('de-DE') }}</strong>
|
||||
</div>
|
||||
<div class="grid h-9 w-9 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-5 text-slate-500">{{ item.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { Sparkles, X } from '@lucide/vue'
|
||||
|
||||
import AdminReviewDecisionPanel from './AdminReviewDecisionPanel.vue'
|
||||
import AdminReviewsHistorySection from './AdminReviewsHistorySection.vue'
|
||||
import AdminReviewsQueueHeader from './AdminReviewsQueueHeader.vue'
|
||||
import AdminReviewsQueueList from './AdminReviewsQueueList.vue'
|
||||
import { useAdminReviewsManager } from './useAdminReviewsManager'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const {
|
||||
reviewSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
} = useAdminReviewsManager()
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && props.open) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
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="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="absolute inset-0 bg-violet-950/35 backdrop-blur-sm" @click="closeModal" />
|
||||
|
||||
<section class="relative z-10 flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-[28px] border border-violet-200/80 bg-white shadow-[0_40px_90px_rgba(76,40,160,0.28)]">
|
||||
<header class="flex items-start justify-between gap-4 border-b border-violet-100 bg-[linear-gradient(135deg,#f7f2ff,#fff7ed)] px-5 py-4 sm:px-6">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<Sparkles class="h-4.5 w-4.5" />
|
||||
</span>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Review-Fokus</p>
|
||||
</div>
|
||||
<h2 class="mt-3 text-2xl font-bold text-slate-950">Nominierung entscheiden</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Die Nominierungsliste bleibt scanbar; Entscheidungen laufen hier konzentriert mit Queue, Prüfung und Kandidatenübernahme.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 shrink-0 place-items-center rounded-full text-slate-400 transition hover:bg-white/80 hover:text-violet-700"
|
||||
aria-label="Review-Dialog schließen"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 overflow-y-auto bg-white">
|
||||
<AdminReviewsQueueHeader
|
||||
v-model:review-filter="reviewFilter"
|
||||
v-model:category-filter="categoryFilter"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:visible-count="filteredNominations.length"
|
||||
:review-stats="reviewStats"
|
||||
:category-options="categoryOptions"
|
||||
/>
|
||||
|
||||
<div class="space-y-4 p-4 sm:p-6">
|
||||
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{{ adminMessage }}</p>
|
||||
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{{ adminError }}</p>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(300px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<AdminReviewsQueueList
|
||||
:nominations="filteredNominations"
|
||||
:total-pending="seasonDetail.pendingNominations.length"
|
||||
:selected-nomination-id="selectedNominationId"
|
||||
@select="selectedNominationId = $event"
|
||||
/>
|
||||
|
||||
<AdminReviewDecisionPanel
|
||||
:nomination="selectedNomination"
|
||||
:review-saving="reviewSaving"
|
||||
:review-form="selectedNomination ? reviewForms[selectedNomination.id] : null"
|
||||
:candidate-platform-options="candidatePlatformOptions"
|
||||
:selected-candidate-collision="selectedCandidateCollision"
|
||||
:selected-related-pending-nominations="selectedRelatedPendingNominations"
|
||||
:signal-summary="selectedNominationSignalSummary"
|
||||
:stream-url="selectedNomination ? extractNominationStreamUrl(selectedNomination) : ''"
|
||||
:can-approve-selected="canApproveSelected"
|
||||
:selected-platform-value="selectedPlatformValue"
|
||||
@platform-change="handlePlatformSelection"
|
||||
@approve="approveNomination"
|
||||
@reject="rejectNomination"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details class="rounded-[24px] border border-violet-100 bg-violet-50/40">
|
||||
<summary class="cursor-pointer px-5 py-4 text-sm font-semibold text-violet-900">
|
||||
Letzte Entscheidungen anzeigen
|
||||
</summary>
|
||||
<div class="border-t border-violet-100 p-5">
|
||||
<AdminReviewsHistorySection
|
||||
:reviewed-nominations="reviewedNominations"
|
||||
:reviewed-total="seasonDetail.reviewedNominations.length"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, Loader2, LockKeyhole, Save, ShieldCheck, Wrench } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||
import AdminSettingsToggle from './AdminSettingsToggle.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminOperationalSettingsForm
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
error: string
|
||||
success: string
|
||||
demoPassword: string
|
||||
demoPasswordHint: string
|
||||
demoPasswordSet: boolean
|
||||
demoManagedByDatabase: boolean
|
||||
demoCredentialsComplete: boolean
|
||||
summary: AdminSettingsStatusSummary[]
|
||||
dirty: boolean
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
'update:demoPassword': [value: string]
|
||||
}>()
|
||||
|
||||
function readInput(event: Event) {
|
||||
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
|
||||
}
|
||||
|
||||
function toneClasses(tone: AdminSettingsTone) {
|
||||
if (tone === 'good') {
|
||||
return 'border-emerald-100 bg-emerald-50/55 text-emerald-800'
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return 'border-amber-100 bg-amber-50/70 text-amber-800'
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return 'border-rose-100 bg-rose-50/70 text-rose-800'
|
||||
}
|
||||
|
||||
return 'border-violet-100 bg-violet-50/60 text-violet-800'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/60 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Demo & Wartung</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Öffentlichen Zugriff steuern</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Demo-Schutz und Wartungsmodus sind echte Betriebs-Schalter. Änderungen werden in der Datenbank gespeichert und sofort für die Public-App verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<Button :disabled="loading || saving || !canManage || !dirty" class="gap-2 rounded-2xl px-5" @click="emit('save')">
|
||||
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
|
||||
<LockKeyhole v-else-if="!canManage" class="h-4 w-4" />
|
||||
<Save v-else class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : !canManage ? 'Nur Owner' : dirty ? 'Zugriff speichern' : 'Gespeichert' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-3 p-6 text-sm font-semibold text-violet-700">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Lade Demo- und Wartungseinstellungen ...
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5 p-5">
|
||||
<p
|
||||
v-if="!canManage"
|
||||
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
|
||||
>
|
||||
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
|
||||
Demo-Zugang und Wartungsmodus sind Owner-Controls. Du kannst den Status ansehen, aber nicht bearbeiten.
|
||||
</p>
|
||||
<p
|
||||
v-else-if="dirty"
|
||||
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div
|
||||
v-for="item in summary"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="toneClasses(item.tone)"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">{{ item.label }}</p>
|
||||
<strong class="mt-1 block text-lg text-slate-950">{{ item.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5">{{ item.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-2">
|
||||
<section class="rounded-[26px] border border-violet-100 bg-white/85 p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<KeyRound class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Demo Login</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Landingpage temporär sperren</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Für Demo-Deployments oder nicht öffentliche Tests. Danach kann der Schutz hier wieder aus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
v-model="form.demoLoginEnabled"
|
||||
label="Demo Login aktivieren"
|
||||
:disabled="saving || !canManage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="form.demoLoginEnabled && !demoCredentialsComplete"
|
||||
class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
Zum Aktivieren fehlen noch Login, Twitch-ID, Anzeigename oder ein gesetztes Passwort.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Login</span>
|
||||
<input v-model="form.demoLoginIdentifier" :disabled="saving || !canManage" type="text" autocomplete="username" placeholder="E-Mail oder Username" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||
<span class="mt-2 block text-xs leading-5 text-slate-500">E-Mail, Username oder dieselbe Kennung wie die Twitch-ID.</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Passwort setzen</span>
|
||||
<input :value="demoPassword" :disabled="saving || !canManage" type="password" autocomplete="new-password" placeholder="Leer lassen = behalten" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" @input="emit('update:demoPassword', readInput($event))" />
|
||||
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Twitch-ID</span>
|
||||
<input v-model="form.demoLoginTwitchUserId" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input v-model="form.demoLoginDisplayName" :disabled="saving || !canManage" type="text" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs font-semibold">
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">{{ demoManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}</span>
|
||||
<span class="rounded-full px-3 py-1" :class="demoPasswordSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
{{ demoPasswordSet ? 'Passwort vorhanden' : 'Passwort fehlt' }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[26px] border p-5 shadow-[0_18px_46px_rgba(168,145,214,0.08)]" :class="form.maintenanceModeEnabled ? 'border-amber-200 bg-amber-50/70' : 'border-violet-100 bg-white/85'">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="flex gap-3">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-2xl" :class="form.maintenanceModeEnabled ? 'bg-amber-100 text-amber-700' : 'bg-violet-100 text-violet-700'">
|
||||
<Wrench class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em]" :class="form.maintenanceModeEnabled ? 'text-amber-600' : 'text-violet-500'">Wartungsmodus</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">Sternenpause anzeigen</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Besucher landen auf der Wartungsseite. Login und Admin bleiben erreichbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSettingsToggle
|
||||
v-model="form.maintenanceModeEnabled"
|
||||
label="Wartungsmodus aktivieren"
|
||||
tone="amber"
|
||||
:disabled="saving || !canManage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Titel der Wartungsseite</span>
|
||||
<input v-model="form.maintenanceTitle" :disabled="saving || !canManage" type="text" maxlength="120" class="mt-2 h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Hinweistext</span>
|
||||
<textarea v-model="form.maintenanceMessage" :disabled="saving || !canManage" rows="4" maxlength="600" class="mt-2 w-full resize-none rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm font-semibold leading-6 text-slate-800 outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"></textarea>
|
||||
</label>
|
||||
<RouterLink to="/maintenance?preview=true" class="inline-flex h-11 items-center gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-sm font-semibold text-violet-700 transition hover:bg-violet-50">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
Wartungsseite ansehen
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="error || success" class="space-y-3">
|
||||
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
|
||||
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -1,50 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
eyebrow?: string
|
||||
title: string
|
||||
description: string
|
||||
/** Dekoratives Icon oben rechts – analog zu den Frontend-Bannern */
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.eyebrow ?? props.title ?? 'Admin')
|
||||
</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-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>
|
||||
|
||||
<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' }}
|
||||
<section
|
||||
class="rounded-2xl border border-violet-100 bg-white/85 px-4 py-3 shadow-[0_12px_32px_rgba(124,92,255,0.07)]"
|
||||
:aria-label="label"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<span
|
||||
v-if="icon"
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600"
|
||||
>
|
||||
<component :is="icon" class="h-4 w-4" />
|
||||
</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 class="min-w-0">
|
||||
<p class="truncate text-[11px] font-bold uppercase tracking-[0.18em] text-violet-600">
|
||||
{{ label }}
|
||||
</p>
|
||||
<p v-if="description" class="mt-1 max-w-4xl text-sm leading-5 text-slate-500">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, Trash2 } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nomination: AdminNominationReviewItem | null
|
||||
reviewSaving: number | null
|
||||
reviewForm: {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
reviewNote: string
|
||||
} | null
|
||||
candidatePlatformOptions: Array<{ key: string; label: string }>
|
||||
selectedCandidateCollision: AdminCandidateItem | null
|
||||
selectedRelatedPendingNominations?: AdminNominationReviewItem[]
|
||||
signalSummary?: {
|
||||
submissions: number
|
||||
uniqueSubmitters: number
|
||||
platforms: string[]
|
||||
} | null
|
||||
streamUrl?: string
|
||||
canApproveSelected: boolean
|
||||
selectedPlatformValue: (platform: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'platform-change': [event: Event]
|
||||
approve: [nominationId: number]
|
||||
reject: [nominationId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="nomination && reviewForm" class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">{{ nomination.categoryName }}</p>
|
||||
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
Eingereicht 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 font-semibold text-violet-800">
|
||||
ID {{ nomination.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="streamUrl" class="mt-5 rounded-2xl border border-sky-100 bg-sky-50/70 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700">Eingereichter Stream-Link</p>
|
||||
<a
|
||||
:href="streamUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mt-2 inline-flex max-w-full items-center rounded-full border border-sky-200 bg-white px-3 py-1.5 text-sm font-semibold text-sky-800 underline-offset-4 hover:underline"
|
||||
>
|
||||
<span class="truncate">{{ streamUrl }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="signalSummary" class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Einreichungen</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.submissions }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Eindeutige User</p>
|
||||
<strong class="mt-1 block text-xl text-violet-900">{{ signalSummary.uniqueSubmitters }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white px-4 py-3">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Plattformen</p>
|
||||
<strong class="mt-1 block truncate text-sm text-violet-900">{{ signalSummary.platforms.join(', ') || 'keine' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-violet-100 bg-violet-50/50 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Als Kandidat übernehmen</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Anzeigename</span>
|
||||
<input
|
||||
v-model="reviewForm.displayName"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Anzeigename"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Handle</span>
|
||||
<input
|
||||
v-model="reviewForm.channelSlug"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="channel"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Plattform</span>
|
||||
<select
|
||||
:value="selectedPlatformValue(reviewForm.platform)"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
@change="emit('platform-change', $event)"
|
||||
>
|
||||
<option v-for="option in candidatePlatformOptions" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
<option value="custom">Eigene Plattform</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="selectedPlatformValue(reviewForm.platform) === 'custom'" class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Eigene Plattform</span>
|
||||
<input
|
||||
v-model="reviewForm.platform"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="z. B. Cake, Booth, neue Plattform"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="selectedCandidateCollision" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Mögliches Duplikat: {{ selectedCandidateCollision.displayName }} ist in dieser Kategorie bereits vorhanden. Übernimm nur, wenn es wirklich ein separater Kandidat ist; Alias-/Merge-Pflege gehört danach in Kandidaten.
|
||||
</p>
|
||||
<div v-if="selectedRelatedPendingNominations?.length" class="mt-3 rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p class="font-semibold">Weitere offene Einreichungen für denselben Namen oder Link:</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
<li v-for="related in selectedRelatedPendingNominations" :key="related.id">
|
||||
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.reviewNote || related.streamUrl || 'ohne Notiz' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-if="!canApproveSelected" class="mt-3 rounded-2xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
Anzeigename, Handle und Plattform sind Pflicht, damit der Kandidat später im Voting eindeutig erscheint.
|
||||
</p>
|
||||
<label class="mt-3 block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Moderationsnotiz</span>
|
||||
<textarea
|
||||
v-model="reviewForm.reviewNote"
|
||||
rows="3"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Warum wurde übernommen oder verworfen?"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button :disabled="reviewSaving === nomination.id" variant="secondary" @click="emit('reject', nomination.id)">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button :disabled="reviewSaving === nomination.id || !canApproveSelected" @click="emit('approve', nomination.id)">
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ reviewSaving === nomination.id ? 'Speichert ...' : 'Als Kandidat übernehmen' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
reviewedTotal: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[26px] border border-violet-100 bg-violet-50/50 p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Letzte Entscheidungen</p>
|
||||
<h3 class="mt-1 text-lg font-bold text-slate-900">Review-Historie</h3>
|
||||
</div>
|
||||
<span class="rounded-full border border-violet-100 bg-white px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{{ reviewedTotal }} entschieden
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-for="nomination in reviewedNominations" :key="`reviewed-${nomination.id}`" class="rounded-2xl border border-violet-100 bg-white/90 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-900">{{ nomination.candidateText }}</p>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
|
||||
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="nomination.status === 'approved' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'">
|
||||
{{ nomination.status === 'approved' ? 'übernommen' : 'verworfen' }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="nomination.reviewNote" class="mt-3 text-sm leading-6 text-slate-600">{{ nomination.reviewNote }}</p>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
{{ nomination.reviewedByTwitchId || 'Admin' }} · {{ nomination.reviewedAt ? new Date(nomination.reviewedAt).toLocaleString('de-DE') : 'ohne Zeitstempel' }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="reviewedNominations.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-4 py-6 text-sm text-slate-500">
|
||||
Noch keine gespeicherten Review-Entscheidungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Search } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
reviewFilter: string
|
||||
categoryFilter: number | null
|
||||
totalPending: number
|
||||
visibleCount: number
|
||||
reviewStats: Array<{ label: string; value: number }>
|
||||
categoryOptions: Array<{ id: number; label: string; count: number }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:reviewFilter': [value: string]
|
||||
'update:categoryFilter': [value: number | null]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-violet-100 bg-white/75 p-6">
|
||||
<div class="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Review Queue</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Offene Nominierungen</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Kompakte Liste für viele Freitext-Fälle. Wähle links einen Fall aus und entscheide rechts, ob daraus ein Kandidat wird.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-3 xl:min-w-[360px]">
|
||||
<div v-for="stat in reviewStats" :key="stat.label" class="rounded-2xl border border-violet-50 bg-violet-50/50 px-3 py-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
:value="reviewFilter"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white/90 pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Nach Kategorie, Kandidat oder Nutzer suchen"
|
||||
@input="emit('update:reviewFilter', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm text-slate-600">
|
||||
{{ visibleCount }} / {{ totalPending }} sichtbar
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="categoryFilter === null ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="emit('update:categoryFilter', null)"
|
||||
>
|
||||
Alle Kategorien
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-1.5 text-xs font-semibold transition"
|
||||
:class="categoryFilter === category.id ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="emit('update:categoryFilter', category.id)"
|
||||
>
|
||||
{{ category.label }} · {{ category.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
defineProps<{
|
||||
nominations: AdminNominationReviewItem[]
|
||||
totalPending: number
|
||||
selectedNominationId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [nominationId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||
<button
|
||||
v-for="nomination in nominations"
|
||||
:key="nomination.id"
|
||||
type="button"
|
||||
class="w-full rounded-2xl border p-3 text-left transition"
|
||||
:class="selectedNominationId === nomination.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||
@click="emit('select', nomination.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full bg-violet-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-violet-700">
|
||||
{{ nomination.categoryName }}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-50 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-600">
|
||||
ID {{ nomination.id }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
|
||||
<p class="mt-1 truncate text-sm text-slate-500">
|
||||
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<p v-if="totalPending === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Review-Fälle im aktuell gewählten Award-Jahr.
|
||||
</p>
|
||||
<p v-else-if="nominations.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Review-Fälle passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, ExternalLink, Trash2 } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminRiskFlag } from '../../types/awards'
|
||||
import type { RiskMetadataItem, RiskResolutionStatus } from './useAdminRiskManager'
|
||||
import { riskAgeLabel, riskSeverityClass } from './useAdminRiskManager'
|
||||
|
||||
defineProps<{
|
||||
riskFlag: AdminRiskFlag | null
|
||||
metadataItems: RiskMetadataItem[]
|
||||
decisionNote: string
|
||||
decisionNoteLength: number
|
||||
decisionReady: boolean
|
||||
saving: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:decisionNote': [value: string]
|
||||
decide: [riskFlagId: number, status: RiskResolutionStatus]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="riskFlag"
|
||||
class="rounded-[26px] border border-violet-100 bg-white/90 p-5 shadow-[0_14px_36px_rgba(168,145,214,0.08)]"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="riskSeverityClass(riskFlag.severity)">
|
||||
{{ riskFlag.severity }}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
{{ riskAgeLabel(riskFlag) }}
|
||||
</span>
|
||||
<span class="rounded-full bg-violet-50 px-3 py-1 text-xs font-semibold text-violet-700">
|
||||
{{ riskFlag.source }} · {{ riskFlag.type }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="mt-3 text-xl font-bold text-slate-900">{{ riskFlag.summary }}</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{ riskFlag.twitchUserId || 'unbekannter User' }} · {{ riskFlag.createdFromIp }} · {{ new Date(riskFlag.createdAt).toLocaleString('de-DE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm font-semibold text-violet-800">
|
||||
Risk Flag #{{ riskFlag.id }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-2xl border border-violet-100 bg-violet-50/45 p-4">
|
||||
<p class="text-xs font-semibold text-slate-500">Metadaten</p>
|
||||
<dl v-if="metadataItems.length" class="mt-3 space-y-2">
|
||||
<div
|
||||
v-for="item in metadataItems"
|
||||
:key="item.key"
|
||||
class="rounded-xl bg-white/80 px-3 py-2"
|
||||
>
|
||||
<dt class="text-xs font-semibold text-slate-400">{{ item.key }}</dt>
|
||||
<dd class="mt-1 break-words text-sm text-slate-700">{{ item.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-else class="mt-3 rounded-xl border border-dashed border-violet-100 px-3 py-4 text-sm text-slate-500">
|
||||
Keine zusätzlichen Metadaten hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 rounded-2xl border border-violet-100 bg-white p-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-slate-500">Betroffene Bereiche</p>
|
||||
<div v-if="riskFlag.entityLinks.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="link in riskFlag.entityLinks"
|
||||
:key="`${link.entityType}-${link.entityId}-${link.to}`"
|
||||
:to="link.to"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-sm font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" />
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-else class="mt-3 rounded-xl border border-dashed border-violet-100 px-3 py-4 text-sm text-slate-500">
|
||||
Kein direkter Zielbereich hinterlegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold text-slate-500">Entscheidungsnotiz</span>
|
||||
<textarea
|
||||
:value="decisionNote"
|
||||
rows="7"
|
||||
class="w-full rounded-2xl border border-violet-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Kurz festhalten, was geprüft wurde und warum du so entscheidest."
|
||||
@input="emit('update:decisionNote', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<p class="mt-2 text-xs" :class="decisionReady ? 'text-slate-500' : 'text-rose-600'">
|
||||
{{ decisionNoteLength }}/500 Zeichen · Mindestens 3 Zeichen für erledigt/verworfen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-3">
|
||||
<Button
|
||||
:disabled="saving === riskFlag.id || !decisionReady"
|
||||
variant="secondary"
|
||||
@click="emit('decide', riskFlag.id, 'dismissed')"
|
||||
>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
{{ saving === riskFlag.id ? 'Speichert ...' : 'Verwerfen' }}
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="saving === riskFlag.id || !decisionReady"
|
||||
@click="emit('decide', riskFlag.id, 'resolved')"
|
||||
>
|
||||
<CheckCircle2 class="mr-2 h-4 w-4" />
|
||||
{{ saving === riskFlag.id ? 'Speichert ...' : 'Erledigt markieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="rounded-[26px] border border-dashed border-violet-100 bg-white/70 p-8 text-sm text-slate-500">
|
||||
Keine offene Auswahl. Sobald ein Risiko-Hinweis auftaucht, erscheint hier die Detailprüfung.
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, RotateCcw } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import type { AdminRiskFlag } from '../../types/awards'
|
||||
import type { RiskResolutionStatus } from './useAdminRiskManager'
|
||||
import { riskSeverityClass, riskStatusLabel } from './useAdminRiskManager'
|
||||
|
||||
defineProps<{
|
||||
riskHistory: AdminRiskFlag[]
|
||||
totalHistory: number
|
||||
historyStatusFilter: 'all' | 'resolved' | 'dismissed'
|
||||
historyStatusFilters: Array<{ key: 'all' | 'resolved' | 'dismissed'; label: string; count: number }>
|
||||
historyStats: Array<{ label: string; value: number }>
|
||||
saving: number | null
|
||||
page: number
|
||||
pageLabel: string
|
||||
hasPrevious: boolean
|
||||
hasMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:historyStatusFilter': [value: 'all' | 'resolved' | 'dismissed']
|
||||
decide: [riskFlagId: number, status: RiskResolutionStatus]
|
||||
page: [page: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-violet-100 bg-violet-50/25 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Entscheidungsprotokoll</h2>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Geprüfte Hinweise mit Notiz, Reviewer und Reopen-Möglichkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in historyStatusFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-2 text-xs font-semibold transition"
|
||||
:class="historyStatusFilter === filter.key ? 'border-violet-200 bg-white text-violet-800' : 'border-violet-100 bg-violet-50 text-slate-600 hover:bg-white'"
|
||||
@click="emit('update:historyStatusFilter', filter.key)"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div v-for="stat in historyStats" :key="stat.label" class="rounded-2xl border border-violet-100 bg-white/80 px-3 py-2">
|
||||
<p class="text-xs font-semibold text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-lg text-violet-800">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 xl:grid-cols-2">
|
||||
<article
|
||||
v-for="flag in riskHistory"
|
||||
:key="flag.id"
|
||||
class="rounded-[22px] border border-violet-100 bg-white/90 p-4 shadow-sm shadow-violet-100/40"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="flag.status === 'resolved' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'"
|
||||
>
|
||||
{{ riskStatusLabel(flag.status) }}
|
||||
</span>
|
||||
<span class="rounded-full border px-3 py-1 text-xs font-semibold" :class="riskSeverityClass(flag.severity)">
|
||||
{{ flag.severity }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-3 text-base font-semibold text-slate-950">{{ flag.summary }}</h3>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{ flag.source }} · {{ flag.type }} · {{ flag.twitchUserId || 'unbekannter User' }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-3 text-violet-700 shadow-none hover:bg-violet-100"
|
||||
:disabled="saving === flag.id"
|
||||
@click="emit('decide', flag.id, 'open')"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
{{ saving === flag.id ? 'Öffnet ...' : 'Wieder öffnen' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-xs text-slate-500 sm:grid-cols-2">
|
||||
<p>
|
||||
<span class="block font-semibold text-slate-400">Entschieden von</span>
|
||||
{{ flag.reviewedByTwitchId || 'nicht protokolliert' }}
|
||||
</p>
|
||||
<p>
|
||||
<span class="block font-semibold text-slate-400">Zeitpunkt</span>
|
||||
{{ flag.reviewedAt ? new Date(flag.reviewedAt).toLocaleString('de-DE') : 'nicht protokolliert' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-4 rounded-2xl border border-violet-100 bg-violet-50/50 px-4 py-3 text-sm text-slate-600">
|
||||
{{ flag.reviewNote || 'Keine Review-Notiz gespeichert.' }}
|
||||
</p>
|
||||
<div v-if="flag.entityLinks.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<RouterLink
|
||||
v-for="link in flag.entityLinks"
|
||||
:key="`${flag.id}-${link.entityType}-${link.entityId}-${link.to}`"
|
||||
:to="link.to"
|
||||
class="inline-flex items-center gap-2 rounded-xl border border-violet-100 bg-violet-50 px-3 py-2 text-xs font-semibold text-violet-800 transition hover:border-violet-200 hover:bg-violet-100"
|
||||
>
|
||||
<ExternalLink class="h-3.5 w-3.5" />
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="totalHistory === 0" class="rounded-[22px] border border-dashed border-violet-100 bg-white/70 px-5 py-6 text-sm text-slate-500">
|
||||
Noch keine entschiedenen Risikohinweise vorhanden.
|
||||
</p>
|
||||
<p v-else-if="riskHistory.length === 0" class="rounded-[22px] border border-dashed border-violet-100 bg-white/70 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Historien-Einträge passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-white/75 px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasPrevious"
|
||||
@click="emit('page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<span class="text-sm font-semibold text-slate-500">{{ pageLabel }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasMore"
|
||||
@click="emit('page', page + 1)"
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { RefreshCw, Search } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
riskFilter: string
|
||||
severityFilter: 'all' | 'high' | 'medium' | 'low'
|
||||
filteredCount: number
|
||||
totalOpen: number
|
||||
loadedLabel: string
|
||||
loading: boolean
|
||||
message: string
|
||||
error: string
|
||||
stats: Array<{ label: string; value: number; tone: string }>
|
||||
severityFilters: Array<{ key: 'all' | 'high' | 'medium' | 'low'; label: string; count: number }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:riskFilter': [value: string]
|
||||
'update:severityFilter': [value: 'all' | 'high' | 'medium' | 'low']
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
function statToneClass(tone: string) {
|
||||
if (tone === 'danger') return 'border-rose-100 bg-rose-50 text-rose-800'
|
||||
if (tone === 'warning') return 'border-amber-100 bg-amber-50 text-amber-800'
|
||||
if (tone === 'ok') return 'border-emerald-100 bg-emerald-50 text-emerald-800'
|
||||
return 'border-violet-100 bg-violet-50 text-violet-800'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-b border-violet-100 bg-white/70 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-900">Risiko-Zentrale</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
Offene Hinweise priorisieren, Entscheidungsnotizen setzen und geprüfte Fälle nachvollziehbar protokollieren.
|
||||
</p>
|
||||
<p class="mt-1 text-xs font-semibold text-slate-400">
|
||||
Geladen: {{ loadedLabel }} · Quelle: /api/admin/risk-flags
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="rounded-2xl border border-violet-100 bg-violet-50 px-4 py-2 text-sm font-semibold text-violet-800">
|
||||
{{ filteredCount }} / {{ totalOpen }} sichtbar
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100"
|
||||
:disabled="loading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
|
||||
{{ loading ? 'Lädt ...' : 'Aktualisieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="statToneClass(stat.tone)"
|
||||
>
|
||||
<p class="text-xs font-semibold text-slate-500">{{ stat.label }}</p>
|
||||
<strong class="mt-1 block text-2xl">{{ stat.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<label class="relative block">
|
||||
<Search class="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-400" />
|
||||
<input
|
||||
:value="riskFilter"
|
||||
type="text"
|
||||
class="h-12 w-full rounded-2xl border border-violet-200 bg-white pl-11 pr-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Typ, User, IP, Summary oder Metadaten filtern"
|
||||
@input="emit('update:riskFilter', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="filter in severityFilters"
|
||||
:key="filter.key"
|
||||
type="button"
|
||||
class="rounded-full border px-3 py-2 text-xs font-semibold transition"
|
||||
:class="severityFilter === filter.key ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="emit('update:severityFilter', filter.key)"
|
||||
>
|
||||
{{ filter.label }} · {{ filter.count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ message }}
|
||||
</p>
|
||||
<p v-if="error" class="mt-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ error }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, Clock3, ListChecks, Trash2, X } from '@lucide/vue'
|
||||
|
||||
import type { AdminRiskFlag } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { riskAgeLabel, riskSeverityClass } from './useAdminRiskManager'
|
||||
|
||||
defineProps<{
|
||||
riskFlags: AdminRiskFlag[]
|
||||
totalOpen: number
|
||||
selectedRiskFlagId: number | null
|
||||
page: number
|
||||
pageLabel: string
|
||||
hasPrevious: boolean
|
||||
hasMore: boolean
|
||||
selectedBulkRiskFlagIds: number[]
|
||||
bulkReviewNote: string
|
||||
bulkSaving: boolean
|
||||
canBulkResolve: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [riskFlagId: number]
|
||||
page: [page: number]
|
||||
toggleBulk: [riskFlagId: number]
|
||||
selectVisibleLow: []
|
||||
clearBulk: []
|
||||
'update:bulkReviewNote': [value: string]
|
||||
bulkDecide: [status: 'resolved' | 'dismissed']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Offene Queue</h2>
|
||||
<p class="text-sm text-slate-500">Nach Severity und Alter prüfen.</p>
|
||||
</div>
|
||||
<span class="rounded-2xl border border-violet-100 bg-violet-50 px-3 py-1.5 text-sm font-semibold text-violet-800">
|
||||
{{ pageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-violet-100 bg-white/85 p-3">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-slate-400">Bulk Low Severity</p>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{{ selectedBulkRiskFlagIds.length }} ausgewählt · nur offene Low-Hinweise
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 sm:flex-row lg:max-w-2xl">
|
||||
<input
|
||||
:value="bulkReviewNote"
|
||||
class="min-h-10 flex-1 rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
placeholder="Bulk-Notiz für niedrige Severity"
|
||||
@input="emit('update:bulkReviewNote', ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" class="gap-2 border border-violet-100" @click="emit('selectVisibleLow')">
|
||||
<ListChecks class="h-4 w-4" />
|
||||
Seite
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" class="gap-2 border border-violet-100" @click="emit('clearBulk')">
|
||||
<X class="h-4 w-4" />
|
||||
Leeren
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" class="gap-2" :disabled="!canBulkResolve || bulkSaving" @click="emit('bulkDecide', 'dismissed')">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Verwerfen
|
||||
</Button>
|
||||
<Button size="sm" class="gap-2" :disabled="!canBulkResolve || bulkSaving" @click="emit('bulkDecide', 'resolved')">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
Erledigt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 xl:max-h-[680px] xl:overflow-y-auto xl:pr-2">
|
||||
<article
|
||||
v-for="flag in riskFlags"
|
||||
:key="flag.id"
|
||||
class="w-full rounded-2xl border p-4 transition"
|
||||
:class="selectedRiskFlagId === flag.id ? 'border-violet-200 bg-violet-50/80 shadow-[0_12px_30px_rgba(168,145,214,0.12)]' : 'border-violet-100 bg-white/85 hover:border-violet-200 hover:bg-violet-50/50'"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<label
|
||||
class="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-violet-100 bg-white"
|
||||
:class="flag.severity.toLowerCase() === 'low' ? 'cursor-pointer' : 'opacity-35'"
|
||||
:title="flag.severity.toLowerCase() === 'low' ? 'Für Bulk auswählen' : 'Bulk ist nur für Low-Severity aktiv'"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-violet-600"
|
||||
:disabled="flag.severity.toLowerCase() !== 'low'"
|
||||
:checked="selectedBulkRiskFlagIds.includes(flag.id)"
|
||||
@change="emit('toggleBulk', flag.id)"
|
||||
>
|
||||
</label>
|
||||
<div class="min-w-0">
|
||||
<button type="button" class="block w-full text-left" @click="emit('select', flag.id)">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold" :class="riskSeverityClass(flag.severity)">
|
||||
<AlertTriangle class="h-3.5 w-3.5" />
|
||||
{{ flag.severity }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-slate-50 px-2.5 py-1 text-xs font-semibold text-slate-600">
|
||||
<Clock3 class="h-3.5 w-3.5" />
|
||||
{{ riskAgeLabel(flag) }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-3 line-clamp-2 text-base font-semibold text-slate-900">{{ flag.summary }}</h3>
|
||||
<p class="mt-2 truncate text-sm text-slate-500">
|
||||
{{ flag.source }} · {{ flag.type }} · {{ flag.twitchUserId || 'unbekannter User' }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-xl bg-white px-2.5 py-1 text-xs font-semibold text-slate-500">
|
||||
ID {{ flag.id }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="totalOpen === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Risikohinweise vorhanden.
|
||||
</p>
|
||||
<p v-else-if="riskFlags.length === 0" class="rounded-[22px] border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine offenen Hinweise passen zum aktuellen Filter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-white/75 px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasPrevious"
|
||||
@click="emit('page', page - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
Zurück
|
||||
</Button>
|
||||
<span class="text-sm font-semibold text-slate-500">{{ pageLabel }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-2 border border-violet-100"
|
||||
:disabled="!hasMore"
|
||||
@click="emit('page', page + 1)"
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, SlidersHorizontal } from '@lucide/vue'
|
||||
|
||||
import type { AdminRiskRule } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
rules: AdminRiskRule[]
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateRule: [ruleKey: string, patch: Partial<AdminRiskRule>]
|
||||
save: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-violet-100 bg-white p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SlidersHorizontal class="h-5 w-5 text-violet-700" />
|
||||
<h2 class="text-lg font-bold text-slate-900">Regel-Editor</h2>
|
||||
</div>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">
|
||||
Thresholds, Zeitfenster und Severity ohne Code-Deploy anpassen.
|
||||
</p>
|
||||
</div>
|
||||
<Button class="gap-2" :disabled="loading || saving || rules.length === 0" @click="emit('save')">
|
||||
<Save class="h-4 w-4" />
|
||||
{{ saving ? 'Speichert ...' : 'Regeln speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-5 rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Risk-Regeln werden geladen.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 grid gap-3">
|
||||
<article
|
||||
v-for="rule in rules"
|
||||
:key="rule.key"
|
||||
class="rounded-2xl border border-violet-100 bg-violet-50/35 p-4"
|
||||
>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_120px_140px_150px_110px] lg:items-end">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-slate-900">{{ rule.label }}</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-slate-500">{{ rule.description }}</p>
|
||||
</div>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Threshold</span>
|
||||
<input
|
||||
:value="rule.threshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@input="emit('updateRule', rule.key, { threshold: Number(($event.target as HTMLInputElement).value) })"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Fenster Min.</span>
|
||||
<input
|
||||
:value="rule.windowMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@input="emit('updateRule', rule.key, { windowMinutes: Number(($event.target as HTMLInputElement).value) })"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-semibold text-slate-500">Severity</span>
|
||||
<select
|
||||
:value="rule.severity"
|
||||
class="h-10 w-full rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
|
||||
@change="emit('updateRule', rule.key, { severity: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex h-10 items-center gap-2 rounded-xl border border-violet-100 bg-white px-3 text-sm font-semibold text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 accent-violet-600"
|
||||
:checked="rule.enabled"
|
||||
@change="emit('updateRule', rule.key, { enabled: ($event.target as HTMLInputElement).checked })"
|
||||
>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="rules.length === 0" class="rounded-2xl border border-dashed border-violet-100 px-5 py-6 text-sm text-slate-500">
|
||||
Keine Risk-Regeln verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
import type { AdminSeasonCopyOption, AdminSeasonCreateForm } from './adminSeasonTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
createForm: AdminSeasonCreateForm
|
||||
creating: boolean
|
||||
canCreate: boolean
|
||||
copySourceOptions: AdminSeasonCopyOption[]
|
||||
createPublicReadinessIssues: string[]
|
||||
phasePresets: string[]
|
||||
onClose: () => void
|
||||
onCreate: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="props.open" title="Neues Award-Jahr anlegen" @close="props.onClose">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
|
||||
<input v-model.number="props.createForm.year" type="number" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input v-model="props.createForm.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
|
||||
<input v-model="props.createForm.showStreamUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Vorjahr kopieren</span>
|
||||
<NativeSelect v-model="props.createForm.copyStructureFromSeasonId" :options="props.copySourceOptions" />
|
||||
<span class="block text-sm leading-5 text-slate-500">
|
||||
Kopiert Kategorien, Gruppen, Slugs, Beschreibung, Sortierung und Nominierungs-Limits. Kandidaten und Gewinner bleiben leer.
|
||||
</span>
|
||||
</label>
|
||||
<fieldset class="grid gap-2 sm:col-span-2 sm:grid-cols-2">
|
||||
<legend class="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Startphase</legend>
|
||||
<button
|
||||
v-for="phase in props.phasePresets"
|
||||
:key="phase"
|
||||
type="button"
|
||||
class="rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition"
|
||||
:class="props.createForm.currentPhase === phase ? 'border-violet-200 bg-violet-100 text-violet-800' : 'border-violet-100 bg-white text-slate-600 hover:bg-violet-50'"
|
||||
@click="props.createForm.currentPhase = phase"
|
||||
>
|
||||
{{ phase }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierung startet</span>
|
||||
<input v-model="props.createForm.nominationStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Nominierung endet</span>
|
||||
<input v-model="props.createForm.nominationEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Voting startet</span>
|
||||
<input v-model="props.createForm.votingStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Voting endet</span>
|
||||
<input v-model="props.createForm.votingEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review startet</span>
|
||||
<input v-model="props.createForm.reviewStartsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Review endet</span>
|
||||
<input v-model="props.createForm.reviewEndsAt" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Show-Datum</span>
|
||||
<input v-model="props.createForm.showDate" type="date" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
<label class="space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Stream startet um</span>
|
||||
<input v-model="props.createForm.showStartsAt" type="time" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 space-y-3">
|
||||
<label class="flex cursor-pointer gap-4 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
|
||||
<input v-model="props.createForm.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
|
||||
<span class="text-sm leading-6 text-slate-600">
|
||||
Community-only aktivieren
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer gap-4 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4">
|
||||
<input v-model="props.createForm.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" />
|
||||
<span class="text-sm leading-6 text-slate-600">
|
||||
Dieses Jahr direkt als Public-Kontext markieren
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="props.createPublicReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
|
||||
<strong class="block text-rose-800">Direkte Public-Aktivierung blockiert.</strong>
|
||||
{{ props.createPublicReadinessIssues.join(' ') }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="props.onClose">Abbrechen</Button>
|
||||
<Button :disabled="props.creating || !props.canCreate" @click="props.onCreate">
|
||||
{{ props.creating ? 'Legt an ...' : 'Award-Jahr anlegen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Modal from '../ui/Modal.vue'
|
||||
|
||||
interface SeasonDeleteTarget {
|
||||
id: number
|
||||
year: number
|
||||
name: string
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
seasonToDelete: SeasonDeleteTarget | null
|
||||
deleting: boolean
|
||||
onClose: () => void
|
||||
onConfirmDelete: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="!!props.seasonToDelete" title="Award-Jahr löschen" @close="props.onClose">
|
||||
<div v-if="props.seasonToDelete" class="space-y-4">
|
||||
<div class="flex gap-4 rounded-[24px] border border-rose-100 bg-rose-50 p-4 text-rose-800">
|
||||
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p class="font-semibold">Diese Aktion löscht das komplette Award-Jahr {{ props.seasonToDelete.year }}.</p>
|
||||
<p class="mt-2 text-sm leading-6">
|
||||
Dazu gehören Kategorien, Kandidaten, Nominierungen, Votes, Gewinner, Clips und zugehörige Risiko-Hinweise dieser Season.
|
||||
Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-[22px] border border-violet-100 bg-white/80 p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Ausgewählt</p>
|
||||
<p class="mt-2 font-semibold text-slate-900">{{ props.seasonToDelete.name }}</p>
|
||||
</div>
|
||||
<p v-if="props.seasonToDelete.isCurrent" class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Dieses Jahr ist öffentlich aktiv und kann nicht gelöscht werden.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="props.onClose">Abbrechen</Button>
|
||||
<Button class="!bg-rose-600 hover:!bg-rose-500" :disabled="props.deleting || !props.seasonToDelete || props.seasonToDelete.isCurrent" @click="props.onConfirmDelete">
|
||||
{{ props.deleting ? 'Löscht ...' : 'Endgültig löschen' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="flex flex-col gap-4 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Phasenwechsel</p>
|
||||
<h2 class="mt-1 truncate text-xl font-bold leading-tight text-slate-900 lg:text-2xl">
|
||||
{{ props.form.name || props.seasonName || 'Kein Award-Jahr gewählt' }}
|
||||
</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
|
||||
Wechsle die öffentliche Award-Phase bewusst. Die Datumsanalyse hilft beim Abgleich, speichert aber nichts automatisch.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Aktiv</span>
|
||||
<span class="rounded-full border border-violet-200 bg-violet-100 px-4 py-2 text-sm font-bold text-violet-800">
|
||||
{{ props.form.currentPhase || 'Nicht gesetzt' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="autoMismatch" class="flex flex-col gap-3 border-b border-amber-100 bg-amber-50/70 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-2xl bg-amber-100 text-amber-700">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-bold text-amber-900">Zeitplan empfiehlt {{ autoPhase }}</p>
|
||||
<p class="mt-1 text-sm leading-6 text-amber-800/80">
|
||||
Laut heutigen Daten liegt das Jahr in einer anderen Phase als gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="border border-amber-200 bg-white text-amber-700 hover:bg-amber-100"
|
||||
:disabled="props.saving || !props.selectedSeasonId || !autoPhase || autoPhaseIsCompleted"
|
||||
@click="autoPhase && !autoPhaseIsCompleted && props.activatePhase(autoPhase)"
|
||||
>
|
||||
{{ autoPhaseIsCompleted ? 'Über Beenden abschließen' : 'Automatisch aktivieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<article
|
||||
v-for="phase in phaseCards"
|
||||
:key="phase.key"
|
||||
class="flex min-h-[178px] flex-col rounded-[20px] border p-4 transition"
|
||||
:class="phase.cardClass"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full" :class="phase.dotClass"></span>
|
||||
<span class="text-xs font-bold text-slate-400">{{ phase.ordinal }}</span>
|
||||
</div>
|
||||
<span class="rounded-full border px-3 py-1 text-[11px] font-bold uppercase tracking-[0.08em]" :class="phase.badgeClass">
|
||||
{{ phase.statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold leading-snug text-slate-900">{{ phase.title }}</h3>
|
||||
<p class="mt-1 flex-1 text-xs leading-5 text-slate-500">{{ phase.dateRange }}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="phase.active ? 'bg-violet-600 text-white' : phase.finalLocked ? 'border border-slate-200 bg-slate-50 text-slate-500' : 'border border-violet-100 bg-white text-violet-700 hover:bg-violet-50'"
|
||||
:disabled="props.saving || !props.selectedSeasonId || phase.active || phase.finalLocked"
|
||||
:title="phase.actionTitle"
|
||||
@click="props.activatePhase(phase.title)"
|
||||
>
|
||||
<CheckCircle2 v-if="phase.active" class="h-4 w-4" />
|
||||
<LockKeyhole v-else-if="phase.finalLocked" class="h-4 w-4" />
|
||||
<WandSparkles v-else class="h-4 w-4" />
|
||||
{{ phase.actionLabel }}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, CheckCircle2, LockKeyhole, WandSparkles } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
hasShowDayPassed,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
type SeasonTimelineForm,
|
||||
} from './adminSeasonTimeline'
|
||||
|
||||
const props = defineProps<{
|
||||
form: SeasonTimelineForm & { name?: string }
|
||||
seasonName: string
|
||||
saving: boolean
|
||||
selectedSeasonId: number | null
|
||||
activatePhase: (phase: string) => Promise<void> | void
|
||||
}>()
|
||||
|
||||
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
|
||||
const autoPhase = computed(() => resolveAutoPhase(props.form))
|
||||
const autoPhaseIsCompleted = computed(() => normalizePhaseKey(autoPhase.value) === 'completed')
|
||||
const autoMismatch = computed(() => Boolean(autoPhase.value && normalizePhaseKey(autoPhase.value) !== currentPhaseKey.value))
|
||||
const phaseCards = computed(() =>
|
||||
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, null).map((phase, index) => {
|
||||
const isAutoActive = autoPhase.value === phase.title && !phase.active
|
||||
const isDone = phase.statusLabel === 'Erledigt'
|
||||
const isCompletedPhase = phase.key === 'completed'
|
||||
const finalLocked = isCompletedPhase && !phase.active
|
||||
const showHasPassed = hasShowDayPassed(props.form)
|
||||
|
||||
return {
|
||||
...phase,
|
||||
ordinal: String(index + 1).padStart(2, '0'),
|
||||
finalLocked,
|
||||
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
|
||||
actionTitle: isCompletedPhase
|
||||
? showHasPassed
|
||||
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
|
||||
: 'Abschluss ist erst nach der Award Show möglich.'
|
||||
: `Phase ${phase.title} aktivieren`,
|
||||
cardClass: phase.active
|
||||
? 'border-violet-200 bg-violet-50 shadow-[0_18px_42px_rgba(139,108,219,0.14)]'
|
||||
: isAutoActive
|
||||
? 'border-amber-200 bg-amber-50/70'
|
||||
: 'border-violet-100 bg-white/90 hover:border-violet-200 hover:bg-violet-50/40',
|
||||
dotClass: phase.active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : isDone ? 'bg-emerald-500' : 'bg-slate-300',
|
||||
badgeClass: phase.active
|
||||
? 'border-violet-200 bg-violet-100 text-violet-800'
|
||||
: isAutoActive
|
||||
? 'border-amber-200 bg-white text-amber-700'
|
||||
: isDone
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
}
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<Card class="flex h-[684px] flex-col overflow-hidden">
|
||||
<div class="shrink-0 border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Grunddaten</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ seasonName || 'Kein Jahr gewählt' }}</h2>
|
||||
<p class="mt-2 max-w-xl text-sm leading-6 text-slate-500">
|
||||
Die wichtigsten Stammdaten, der Public-Kontext und der finale Stream-Link auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border px-4 py-3 text-sm font-semibold" :class="form.isCurrent ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : 'border-slate-100 bg-slate-50 text-slate-500'">
|
||||
{{ form.isCurrent ? 'Öffentlich aktiv' : 'Intern vorbereitet' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid min-h-0 flex-1 gap-5 overflow-y-auto p-5 min-[1400px]:grid-cols-[minmax(0,1fr)_minmax(300px,0.78fr)]">
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-[140px_minmax(0,1fr)]">
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Jahr</span>
|
||||
<input v-model.number="form.year" type="number" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="2027" :disabled="!selectedSeasonId || saving" />
|
||||
</label>
|
||||
<label class="block space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Name</span>
|
||||
<input v-model="form.name" type="text" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="VTuber Star Awards 2027" :disabled="!selectedSeasonId || saving" />
|
||||
</label>
|
||||
<label class="block space-y-2 sm:col-span-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finaler Stream-Link</span>
|
||||
<input v-model="form.showStreamUrl" type="url" class="h-12 w-full rounded-2xl border border-violet-200 bg-white px-4 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-400" placeholder="https://twitch.tv/jayuhime" :disabled="!selectedSeasonId || saving" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
|
||||
<input v-model="form.isCommunityOnly" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || saving" />
|
||||
<span>
|
||||
<span class="block font-semibold text-slate-800">Community-only</span>
|
||||
<span class="mt-1 block text-sm leading-5 text-slate-500">Voting und Teilnahme laufen über die Community.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex min-h-[108px] cursor-pointer gap-3 rounded-[22px] border border-violet-100 bg-violet-50/50 p-4 transition hover:bg-violet-50 has-disabled:cursor-not-allowed has-disabled:opacity-70">
|
||||
<input v-model="form.isCurrent" type="checkbox" class="mt-1 h-4 w-4 shrink-0 accent-violet-600" :disabled="!selectedSeasonId || saving || (!form.isCurrent && !canActivatePublic)" />
|
||||
<span>
|
||||
<span class="block font-semibold text-slate-800">Public-Kontext</span>
|
||||
<span class="mt-1 block text-sm leading-5 text-slate-500">
|
||||
{{ canActivatePublic || form.isCurrent ? 'Nur ein Jahr sollte öffentlich sichtbar sein.' : 'Erst die Readiness-Blocker unten loesen.' }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="adminMessage" class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" role="status">{{ adminMessage }}</p>
|
||||
<p v-if="adminError" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">{{ adminError }}</p>
|
||||
|
||||
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-3">
|
||||
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Löschen
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-full gap-2 border border-amber-100 bg-amber-50 text-amber-700 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="completing || !selectedSeasonId || !canCompleteSelectedSeason" @click="completeSeason">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
{{ completing ? 'Schliesst ...' : 'Beenden' }}
|
||||
</Button>
|
||||
<Button class="w-full" :disabled="saving || !selectedSeasonId" @click="saveSeason">
|
||||
{{ saving ? 'Speichert ...' : 'Speichern' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-if="selectedSeasonIsCurrent" class="text-xs leading-5 text-slate-500">
|
||||
Das öffentliche aktive Jahr kann nicht gelöscht werden. Schalte zuerst ein anderes Jahr öffentlich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="min-h-0 space-y-4 lg:max-h-[432px] lg:overflow-y-auto lg:pr-1">
|
||||
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-violet-100 text-violet-700">
|
||||
<History class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Letzte Admin-Aktion</p>
|
||||
<p class="mt-1 text-sm font-semibold leading-5 text-slate-800">
|
||||
{{ loadingSeasonAudit ? 'Laedt Audit ...' : latestSeasonAuditSummary }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-500">{{ latestSeasonAuditMeta }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[22px] border border-violet-100 bg-white p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">Public-Readiness</p>
|
||||
<p class="mt-1 text-sm leading-5 text-slate-500">Blocker für Public auf einen Blick.</p>
|
||||
</div>
|
||||
<span class="shrink-0 rounded-full border px-3 py-1 text-xs font-semibold" :class="canActivatePublic ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-rose-200 bg-rose-50 text-rose-700'">
|
||||
{{ canActivatePublic ? 'Bereit' : 'Blockiert' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-2">
|
||||
<RouterLink
|
||||
v-for="item in readinessItems"
|
||||
:key="item.label"
|
||||
:to="item.to"
|
||||
class="flex min-h-[58px] items-start gap-3 rounded-2xl border px-3 py-2.5 transition hover:bg-violet-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
:class="item.complete ? 'border-emerald-100 bg-emerald-50/60' : item.blocking ? 'border-rose-200 bg-rose-50/70' : 'border-amber-100 bg-amber-50/60'"
|
||||
>
|
||||
<CheckCircle2 v-if="item.complete" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-700" />
|
||||
<AlertTriangle v-else class="mt-0.5 h-4 w-4 shrink-0" :class="item.blocking ? 'text-rose-700' : 'text-amber-700'" />
|
||||
<span class="min-w-0">
|
||||
<span class="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-800">
|
||||
{{ item.label }}
|
||||
<span v-if="item.blocking && !item.complete" class="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] text-rose-700">Blocker</span>
|
||||
</span>
|
||||
<span class="mt-0.5 block text-xs leading-5 text-slate-500">{{ item.note }}</span>
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="archiveReadinessIssues.length > 0" class="rounded-[22px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700" role="alert">
|
||||
<strong class="block text-rose-800">Abschluss noch blockiert.</strong>
|
||||
{{ archiveReadinessIssues.join(' ') }}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CheckCircle2, History, Trash2 } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
form: AdminSeasonForm
|
||||
seasonName: string
|
||||
saving: boolean
|
||||
completing: boolean
|
||||
adminMessage: string
|
||||
adminError: string
|
||||
readinessItems: AdminSeasonReadinessItem[]
|
||||
archiveReadinessIssues: string[]
|
||||
canActivatePublic: boolean
|
||||
loadingSeasonAudit: boolean
|
||||
latestSeasonAuditSummary: string
|
||||
latestSeasonAuditMeta: string
|
||||
selectedSeasonId: number | null
|
||||
canDeleteSelectedSeason: boolean
|
||||
canCompleteSelectedSeason: boolean
|
||||
selectedSeasonIsCurrent: boolean
|
||||
openDeleteSeasonModal: () => void
|
||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||
completeSeason: () => Promise<void>
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-[#f7eef8] p-5">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">{{ props.form.year || '-' }} · Timeline</p>
|
||||
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen bearbeiten</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-5 text-slate-500">
|
||||
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Änderungen werden direkt im gewählten Award-Jahr gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
<span class="w-fit rounded-full border border-violet-200 bg-white px-4 py-2 text-sm font-bold text-violet-700">
|
||||
{{ props.form.currentPhase || 'Keine Phase gesetzt' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="timelineError" class="border-b border-rose-100 bg-rose-50 px-6 py-3 text-sm font-semibold text-rose-700" role="alert">{{ timelineError }}</p>
|
||||
|
||||
<div class="hidden grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] gap-4 border-b border-violet-100 bg-violet-50/50 px-5 py-3 text-[11px] font-bold uppercase tracking-[0.16em] text-violet-500 lg:grid">
|
||||
<span></span>
|
||||
<span>Phase</span>
|
||||
<span>Zeitraum</span>
|
||||
<span>Status</span>
|
||||
<span class="text-right">Bearbeiten</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[390px] overflow-y-auto divide-y divide-violet-50">
|
||||
<div
|
||||
v-for="row in timelineRows"
|
||||
:key="row.key"
|
||||
class="grid gap-4 px-5 py-3.5 lg:grid-cols-[34px_minmax(0,1fr)_240px_128px_110px] lg:items-center"
|
||||
:class="row.isEditing ? 'bg-violet-50/50' : 'bg-white/70'"
|
||||
>
|
||||
<span class="mt-2 h-2.5 w-2.5 rounded-full lg:mx-auto lg:mt-0" :class="row.dotClass"></span>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-slate-900">{{ row.title }}</p>
|
||||
<p class="mt-1 text-sm leading-5 text-slate-500">{{ row.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="row.isEditing" class="grid gap-2">
|
||||
<template v-if="row.key === 'show'">
|
||||
<input v-model="draft.start" type="date" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="draft.showStartsAt" type="time" class="h-10 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
|
||||
<input v-model="draft.start" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
<input v-model="draft.end" type="date" class="h-10 min-w-0 rounded-xl border border-violet-200 bg-white px-3 text-sm outline-none transition focus:border-violet-400 focus:ring-4 focus:ring-violet-100" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p v-else class="text-sm font-semibold text-slate-600">{{ row.dateRange }}</p>
|
||||
|
||||
<span class="w-fit rounded-full border px-3 py-1 text-xs font-bold" :class="row.statusClass">
|
||||
{{ row.statusLabel }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
<template v-if="row.isEditing">
|
||||
<Button size="sm" :disabled="props.saving" @click="saveTimelineEdit(row)">OK</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-slate-500 transition hover:bg-violet-50"
|
||||
:disabled="props.saving"
|
||||
title="Bearbeitung abbrechen"
|
||||
@click="cancelTimelineEdit"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="row.editable"
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-xl border border-violet-100 bg-white text-violet-700 transition hover:bg-violet-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="props.saving || !props.selectedSeasonId"
|
||||
title="Zeitraum bearbeiten"
|
||||
@click="startTimelineEdit(row)"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</button>
|
||||
<span v-else class="text-xs font-semibold text-slate-400">fest</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-violet-100 bg-violet-50/30 px-5 py-3">
|
||||
<p class="text-xs leading-5 text-slate-500">
|
||||
Hinweis: Die Phasen sind als Systemphasen fest verdrahtet, damit Nominierung, Voting, Review und Show sicher mit Public-API und Rate-Limits zusammenspielen.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Pencil, X } from '@lucide/vue'
|
||||
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
import {
|
||||
createSeasonTimelineRows,
|
||||
normalizePhaseKey,
|
||||
resolveAutoPhase,
|
||||
type PhaseKey,
|
||||
type PhaseRowConfig,
|
||||
type SeasonTimelineForm,
|
||||
} from './adminSeasonTimeline'
|
||||
|
||||
const props = defineProps<{
|
||||
form: SeasonTimelineForm
|
||||
saving: boolean
|
||||
selectedSeasonId: number | null
|
||||
saveSeason: () => Promise<boolean | void> | boolean | void
|
||||
}>()
|
||||
|
||||
const editingPhase = ref<PhaseKey | null>(null)
|
||||
const timelineError = ref('')
|
||||
const draft = reactive({
|
||||
start: '',
|
||||
end: '',
|
||||
showStartsAt: '20:00',
|
||||
})
|
||||
|
||||
const currentPhaseKey = computed(() => normalizePhaseKey(props.form.currentPhase))
|
||||
const autoPhase = computed(() => resolveAutoPhase(props.form))
|
||||
const timelineRows = computed(() =>
|
||||
createSeasonTimelineRows(props.form, currentPhaseKey.value, autoPhase.value, editingPhase.value),
|
||||
)
|
||||
|
||||
function startTimelineEdit(row: PhaseRowConfig) {
|
||||
if (!row.editable) return
|
||||
timelineError.value = ''
|
||||
editingPhase.value = row.key
|
||||
draft.start = row.start ? props.form[row.start] : ''
|
||||
draft.end = row.end ? props.form[row.end] : ''
|
||||
draft.showStartsAt = props.form.showStartsAt || '20:00'
|
||||
}
|
||||
|
||||
function cancelTimelineEdit() {
|
||||
editingPhase.value = null
|
||||
timelineError.value = ''
|
||||
}
|
||||
|
||||
async function saveTimelineEdit(row: PhaseRowConfig) {
|
||||
timelineError.value = ''
|
||||
if (!row.start || !row.end) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!draft.start || !draft.end) {
|
||||
timelineError.value = 'Bitte fülle Start und Ende der Phase aus.'
|
||||
return
|
||||
}
|
||||
|
||||
if (row.key !== 'show' && draft.start > draft.end) {
|
||||
timelineError.value = 'Das Startdatum darf nicht nach dem Enddatum liegen.'
|
||||
return
|
||||
}
|
||||
|
||||
props.form[row.start] = draft.start
|
||||
props.form[row.end] = row.key === 'show' ? draft.start : draft.end
|
||||
if (row.key === 'show') {
|
||||
props.form.showStartsAt = draft.showStartsAt || '20:00'
|
||||
}
|
||||
|
||||
const saved = await props.saveSeason()
|
||||
if (saved !== false) {
|
||||
editingPhase.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import Select from 'primevue/select'
|
||||
import { CalendarCog, CheckCircle2, Clock3, Sparkles, Tags, Users } from '@lucide/vue'
|
||||
|
||||
import NativeSelect from '../ui/NativeSelect.vue'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const store = useAwardsStore()
|
||||
@@ -61,12 +61,9 @@ const yearStats = computed(() => [
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Jahr wechseln</label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
v-model="selectedSeasonId"
|
||||
:options="seasonOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertTriangle, CheckCircle2, Database, Loader2, RefreshCw } from '@lucide/vue'
|
||||
|
||||
import type { DatabaseHealthResponse } from '../../types/awards'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
defineProps<{
|
||||
healthLoading: boolean
|
||||
healthError: string
|
||||
databaseHealth: DatabaseHealthResponse
|
||||
pendingMigrationCount: number
|
||||
healthLoadedLabel: string
|
||||
refreshDatabaseHealth: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-5">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-2xl" :class="databaseHealth.canConnect ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
|
||||
<Database class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Live-Systemstatus</p>
|
||||
<h2 class="mt-1 text-lg font-bold text-slate-900">Datenbank-Healthcheck</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Zuletzt geprüft: {{ healthLoadedLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-violet-50 px-4 text-violet-700 shadow-none hover:bg-violet-100" :disabled="healthLoading" @click="refreshDatabaseHealth">
|
||||
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': healthLoading }" />
|
||||
{{ healthLoading ? 'Prüft ...' : 'Aktualisieren' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-if="healthError" class="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ healthError }}</p>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider</p>
|
||||
<strong class="mt-1 block text-sm text-slate-900">{{ databaseHealth.provider }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-violet-100 bg-white/80 px-4 py-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Connection</p>
|
||||
<strong class="mt-1 block text-sm text-slate-900">{{ databaseHealth.configuredConnection.source }}</strong>
|
||||
</div>
|
||||
<div class="rounded-2xl border px-4 py-3" :class="pendingMigrationCount > 0 ? 'border-amber-100 bg-amber-50/70' : 'border-emerald-100 bg-emerald-50/60'">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Migrationen</p>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<Loader2 v-if="healthLoading" class="h-4 w-4 animate-spin text-violet-600" />
|
||||
<AlertTriangle v-else-if="pendingMigrationCount > 0" class="h-4 w-4 text-amber-700" />
|
||||
<CheckCircle2 v-else class="h-4 w-4 text-emerald-700" />
|
||||
<strong class="block text-sm text-slate-900">{{ pendingMigrationCount > 0 ? `${pendingMigrationCount} offen` : 'Aktuell' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingMigrationCount > 0" class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
<p class="font-semibold">Ausstehende Migrationen</p>
|
||||
<p class="mt-1">{{ databaseHealth.pendingMigrations.join(', ') }}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { CheckCircle2, CircleDashed, ExternalLink } from '@lucide/vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import type { AdminSettingsCheckItem, AdminSettingsGateItem, AdminSettingsStatusSummary, AdminSettingsTone } from './adminSettingsTypes'
|
||||
import Card from '../ui/Card.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
checks: AdminSettingsCheckItem[]
|
||||
gates: AdminSettingsGateItem[]
|
||||
contentChecks: AdminSettingsCheckItem[]
|
||||
contentCompletion: number
|
||||
}>()
|
||||
|
||||
const readyChecks = computed(() => props.checks.filter((check) => check.value).length)
|
||||
const activeGates = computed(() => props.gates.filter((gate) => gate.state).length)
|
||||
const incompleteContentCount = computed(() => props.contentChecks.length - props.contentCompletion)
|
||||
|
||||
const summaryItems = computed<AdminSettingsStatusSummary[]>(() => [
|
||||
{
|
||||
label: 'Systemchecks',
|
||||
value: `${readyChecks.value}/${props.checks.length}`,
|
||||
note: readyChecks.value === props.checks.length ? 'Alles grün.' : 'Mindestens ein Check braucht Aufmerksamkeit.',
|
||||
tone: readyChecks.value === props.checks.length ? 'good' : 'warning',
|
||||
},
|
||||
{
|
||||
label: 'Betriebszustände',
|
||||
value: `${activeGates.value}/${props.gates.length}`,
|
||||
note: 'Aktive Phasen und offene Moderationspunkte.',
|
||||
tone: activeGates.value > 0 ? 'neutral' : 'warning',
|
||||
},
|
||||
{
|
||||
label: 'Landingpage',
|
||||
value: `${props.contentCompletion}/${props.contentChecks.length}`,
|
||||
note: incompleteContentCount.value === 0 ? 'Public-Inhalte vollständig.' : `${incompleteContentCount.value} Bereiche fehlen.`,
|
||||
tone: incompleteContentCount.value === 0 ? 'good' : 'warning',
|
||||
},
|
||||
])
|
||||
|
||||
function toneClasses(tone: AdminSettingsTone) {
|
||||
if (tone === 'good') {
|
||||
return {
|
||||
panel: 'border-emerald-100 bg-emerald-50/55',
|
||||
text: 'text-emerald-700',
|
||||
icon: 'bg-emerald-100 text-emerald-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return {
|
||||
panel: 'border-amber-100 bg-amber-50/65',
|
||||
text: 'text-amber-700',
|
||||
icon: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return {
|
||||
panel: 'border-rose-100 bg-rose-50/65',
|
||||
text: 'text-rose-700',
|
||||
icon: 'bg-rose-100 text-rose-700',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
panel: 'border-violet-100 bg-violet-50/55',
|
||||
text: 'text-violet-700',
|
||||
icon: 'bg-violet-100 text-violet-700',
|
||||
}
|
||||
}
|
||||
|
||||
function booleanTone(value: boolean): AdminSettingsTone {
|
||||
return value ? 'good' : 'warning'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<div class="border-b border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-amber-50/50 p-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-violet-500">Betriebsübersicht</p>
|
||||
<h2 class="mt-1 text-xl font-bold text-slate-900">Alles Wichtige auf einen Blick</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
Systemchecks, aktive Phasen und Landingpage-Readiness stehen jetzt zusammen, ohne dieselben Inhalte noch einmal editierbar zu duplizieren.
|
||||
</p>
|
||||
</div>
|
||||
<RouterLink to="/admin/content" class="inline-flex h-11 items-center justify-center gap-2 rounded-2xl bg-violet-600 px-5 text-sm font-semibold !text-white shadow-lg shadow-violet-500/20 transition hover:bg-violet-500" style="color:#fff;">
|
||||
<ExternalLink class="h-4 w-4 !text-white" />
|
||||
Content Hub
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 p-5 md:grid-cols-3">
|
||||
<div
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="rounded-2xl border px-4 py-3"
|
||||
:class="toneClasses(item.tone).panel"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{{ item.label }}</p>
|
||||
<strong class="mt-1 block text-2xl" :class="toneClasses(item.tone).text">{{ item.value }}</strong>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-600">{{ item.note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid border-t border-violet-100 lg:grid-cols-3">
|
||||
<section class="border-b border-violet-100 p-5 lg:border-b-0 lg:border-r">
|
||||
<h3 class="text-sm font-bold text-slate-900">Systemchecks</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<component
|
||||
:is="check.to ? RouterLink : 'div'"
|
||||
v-for="check in checks"
|
||||
:key="check.label"
|
||||
:to="check.to ?? undefined"
|
||||
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition"
|
||||
:class="[toneClasses(booleanTone(check.value)).panel, check.to ? 'hover:bg-violet-50/60' : '']"
|
||||
>
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(booleanTone(check.value)).icon">
|
||||
<component :is="check.icon" class="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ check.label }}</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ check.note }}</p>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-b border-violet-100 p-5 lg:border-b-0 lg:border-r">
|
||||
<h3 class="text-sm font-bold text-slate-900">Betriebszustände</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<RouterLink
|
||||
v-for="gate in gates"
|
||||
:key="gate.label"
|
||||
:to="gate.to"
|
||||
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:bg-violet-50/60"
|
||||
:class="toneClasses(gate.state ? 'good' : 'neutral').panel"
|
||||
>
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(gate.state ? 'good' : 'neutral').icon">
|
||||
<CheckCircle2 v-if="gate.state" class="h-4.5 w-4.5" />
|
||||
<CircleDashed v-else class="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ gate.label }}</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ gate.note }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="p-5">
|
||||
<h3 class="text-sm font-bold text-slate-900">Landingpage-Readiness</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<RouterLink
|
||||
v-for="check in contentChecks"
|
||||
:key="check.label"
|
||||
to="/admin/content"
|
||||
class="flex items-start gap-3 rounded-2xl border px-4 py-3 transition hover:bg-violet-50/60"
|
||||
:class="toneClasses(booleanTone(check.value)).panel"
|
||||
>
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-xl" :class="toneClasses(booleanTone(check.value)).icon">
|
||||
<component :is="check.icon" class="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-900">{{ check.label }}</p>
|
||||
<p class="mt-0.5 text-xs leading-5 text-slate-500">{{ check.note }}</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
label: string
|
||||
activeLabel?: string
|
||||
inactiveLabel?: string
|
||||
disabled?: boolean
|
||||
tone?: 'violet' | 'amber'
|
||||
}>(), {
|
||||
activeLabel: 'Aktiv',
|
||||
inactiveLabel: 'Inaktiv',
|
||||
disabled: false,
|
||||
tone: 'violet',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function toggle() {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
class="inline-flex items-center gap-3 rounded-2xl border px-4 py-3 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="tone === 'amber' && modelValue ? 'border-amber-200 bg-white/75 text-amber-800' : 'border-violet-100 bg-violet-50/60 text-violet-800'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span>{{ modelValue ? activeLabel : inactiveLabel }}</span>
|
||||
<span
|
||||
class="relative h-7 w-12 rounded-full transition"
|
||||
:class="modelValue ? tone === 'amber' ? 'bg-amber-500' : 'bg-violet-600' : 'bg-slate-200'"
|
||||
>
|
||||
<span class="absolute left-1 top-1 h-5 w-5 rounded-full bg-white shadow transition" :class="{ 'translate-x-5': modelValue }"></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
export type SocialLinkForm = {
|
||||
label: string
|
||||
platform: string
|
||||
icon: string
|
||||
url: string
|
||||
showOnHost: boolean
|
||||
showOnCommunity: boolean
|
||||
}
|
||||
|
||||
export type FaqFormItem = {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export type AdminContentForm = {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
newsletterUrl: string
|
||||
privacyEmail: string
|
||||
privacyPolicyContent: string
|
||||
imprintUrl: string
|
||||
contactUrl: string
|
||||
sponsorsUrl: string
|
||||
socialLinks: SocialLinkForm[]
|
||||
faq: FaqFormItem[]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AdminCandidateItem, AdminNominationReviewItem } from '../../types/awards'
|
||||
|
||||
export interface ReviewFormState {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
reviewNote: string
|
||||
}
|
||||
|
||||
export interface ReviewStatItem {
|
||||
label: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface ReviewCategoryOption {
|
||||
id: number
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ReviewNominationCollision extends AdminCandidateItem {}
|
||||
|
||||
export interface ReviewManagerState {
|
||||
reviewSaving: number | null
|
||||
adminMessage: string
|
||||
adminError: string
|
||||
reviewFilter: string
|
||||
categoryFilter: number | null
|
||||
selectedNominationId: number | null
|
||||
reviewForms: Record<number, ReviewFormState>
|
||||
filteredNominations: AdminNominationReviewItem[]
|
||||
selectedNomination: AdminNominationReviewItem | null
|
||||
reviewStats: ReviewStatItem[]
|
||||
reviewedNominations: AdminNominationReviewItem[]
|
||||
categoryOptions: ReviewCategoryOption[]
|
||||
selectedCandidateCollision: ReviewNominationCollision | null
|
||||
canApproveSelected: boolean
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
export type PhaseKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
||||
|
||||
export type SeasonDateField =
|
||||
| 'nominationStartsAt'
|
||||
| 'nominationEndsAt'
|
||||
| 'votingStartsAt'
|
||||
| 'votingEndsAt'
|
||||
| 'reviewStartsAt'
|
||||
| 'reviewEndsAt'
|
||||
| 'showDate'
|
||||
|
||||
export interface SeasonTimelineForm {
|
||||
year: number
|
||||
currentPhase: string
|
||||
nominationStartsAt: string
|
||||
nominationEndsAt: string
|
||||
votingStartsAt: string
|
||||
votingEndsAt: string
|
||||
reviewStartsAt: string
|
||||
reviewEndsAt: string
|
||||
showDate: string
|
||||
showStartsAt: string
|
||||
}
|
||||
|
||||
export interface PhaseRowConfig {
|
||||
key: PhaseKey
|
||||
title: string
|
||||
description: string
|
||||
start: SeasonDateField | null
|
||||
end: SeasonDateField | null
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface SeasonTimelineRow extends PhaseRowConfig {
|
||||
active: boolean
|
||||
isEditing: boolean
|
||||
dateRange: string
|
||||
statusLabel: string
|
||||
dotClass: string
|
||||
statusClass: string
|
||||
}
|
||||
|
||||
export const SEASON_PHASES: PhaseRowConfig[] = [
|
||||
{
|
||||
key: 'nomination',
|
||||
title: 'Nominierung',
|
||||
description: 'Community schlägt Creator und Clips vor.',
|
||||
start: 'nominationStartsAt',
|
||||
end: 'nominationEndsAt',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'voting',
|
||||
title: 'Community Voting',
|
||||
description: 'Finale Kandidat:innen sind sichtbar und wählbar.',
|
||||
start: 'votingStartsAt',
|
||||
end: 'votingEndsAt',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
title: 'Review & Auswertung',
|
||||
description: 'Team prüft Votes, Clips und Ergebnisse.',
|
||||
start: 'reviewStartsAt',
|
||||
end: 'reviewEndsAt',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'show',
|
||||
title: 'Award Show',
|
||||
description: 'Finale Live-Show inklusive Stream-Countdown.',
|
||||
start: 'showDate',
|
||||
end: 'showDate',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
title: 'Abgeschlossen',
|
||||
description: 'Award-Jahr ist archiviert und Public-Aktionen sind gesperrt.',
|
||||
start: null,
|
||||
end: null,
|
||||
editable: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function createSeasonTimelineRows(
|
||||
form: SeasonTimelineForm,
|
||||
currentPhaseKey: PhaseKey | string,
|
||||
autoPhase: string,
|
||||
editingPhase: PhaseKey | null,
|
||||
): SeasonTimelineRow[] {
|
||||
return SEASON_PHASES.map((phase) => {
|
||||
const active = phase.key === currentPhaseKey
|
||||
const isAutoActive = autoPhase === phase.title
|
||||
const state = active ? 'active' : resolvePhaseState(form, phase)
|
||||
|
||||
return {
|
||||
...phase,
|
||||
active,
|
||||
isEditing: editingPhase === phase.key,
|
||||
dateRange: formatPhaseRange(form, phase),
|
||||
statusLabel: active ? 'Aktiv' : isAutoActive ? 'Auto' : state === 'done' ? 'Erledigt' : 'Geplant',
|
||||
dotClass: active ? 'bg-violet-600' : isAutoActive ? 'bg-amber-500' : state === 'done' ? 'bg-emerald-500' : 'bg-slate-300',
|
||||
statusClass: active
|
||||
? 'border-violet-200 bg-violet-100 text-violet-800'
|
||||
: isAutoActive
|
||||
? 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
: state === 'done'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizePhaseKey(value: string): PhaseKey | string {
|
||||
const phase = value.trim().toLowerCase()
|
||||
if (phase.includes('abgeschlossen') || phase.includes('archiv') || phase.includes('complete') || phase.includes('ended')) return 'completed'
|
||||
if (phase.includes('show')) return 'show'
|
||||
if (phase.includes('review') || phase.includes('auswert')) return 'review'
|
||||
if (phase.includes('vot')) return 'voting'
|
||||
if (phase.includes('nomin')) return 'nomination'
|
||||
return phase
|
||||
}
|
||||
|
||||
export function resolveAutoPhase(form: SeasonTimelineForm) {
|
||||
const today = startOfDay(new Date())
|
||||
for (const phase of SEASON_PHASES.slice(0, 4)) {
|
||||
const start = phase.start ? parseDate(form[phase.start]) : null
|
||||
const end = phase.end ? parseDate(form[phase.end]) : null
|
||||
if (start && end && start <= today && today <= end) {
|
||||
return phase.title
|
||||
}
|
||||
}
|
||||
|
||||
return hasShowDayPassed(form) ? 'Abgeschlossen' : ''
|
||||
}
|
||||
|
||||
function resolvePhaseState(form: SeasonTimelineForm, phase: PhaseRowConfig) {
|
||||
const today = startOfDay(new Date())
|
||||
if (phase.key === 'completed') {
|
||||
return hasShowDayPassed(form) ? 'done' : 'upcoming'
|
||||
}
|
||||
|
||||
const end = phase.end ? parseDate(form[phase.end]) : null
|
||||
return end && end < today ? 'done' : 'upcoming'
|
||||
}
|
||||
|
||||
function formatPhaseRange(form: SeasonTimelineForm, phase: PhaseRowConfig) {
|
||||
if (phase.key === 'completed') {
|
||||
return form.showDate ? `nach ${formatDate(form.showDate)}` : 'nach der Show'
|
||||
}
|
||||
|
||||
const start = phase.start ? form[phase.start] : ''
|
||||
const end = phase.end ? form[phase.end] : ''
|
||||
if (!start && !end) return 'Noch nicht terminiert'
|
||||
if (phase.key === 'show') return `${formatDate(start)} · ${form.showStartsAt || '20:00'} Uhr`
|
||||
if (start === end) return formatDate(start)
|
||||
return `${formatDate(start)} - ${formatDate(end)}`
|
||||
}
|
||||
|
||||
function parseDate(value: string) {
|
||||
if (!value) return null
|
||||
const date = startOfDay(new Date(`${value}T00:00:00`))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function hasShowDayPassed(form: Pick<SeasonTimelineForm, 'showDate'>) {
|
||||
const showDate = parseDate(form.showDate)
|
||||
return Boolean(showDate && startOfDay(new Date()) > showDate)
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
const next = new Date(date)
|
||||
next.setHours(0, 0, 0, 0)
|
||||
return next
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
if (!value) return 'offen'
|
||||
const date = parseDate(value)
|
||||
return date
|
||||
? date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: value
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface AdminSeasonForm {
|
||||
year: number
|
||||
name: string
|
||||
showStreamUrl: string
|
||||
currentPhase: string
|
||||
isCurrent: boolean
|
||||
isCommunityOnly: boolean
|
||||
nominationStartsAt: string
|
||||
nominationEndsAt: string
|
||||
votingStartsAt: string
|
||||
votingEndsAt: string
|
||||
reviewStartsAt: string
|
||||
reviewEndsAt: string
|
||||
showDate: string
|
||||
showStartsAt: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonCreateForm extends AdminSeasonForm {
|
||||
copyStructureFromSeasonId: number | null
|
||||
}
|
||||
|
||||
export interface AdminSeasonReadinessItem {
|
||||
label: string
|
||||
note: string
|
||||
complete: boolean
|
||||
blocking: boolean
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AdminSeasonCopyOption {
|
||||
label: string
|
||||
value: number | null
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type AdminSettingsTone = 'good' | 'warning' | 'danger' | 'neutral'
|
||||
|
||||
export interface AdminOperationalSettingsForm {
|
||||
demoLoginEnabled: boolean
|
||||
demoLoginIdentifier: string
|
||||
demoLoginTwitchUserId: string
|
||||
demoLoginDisplayName: string
|
||||
maintenanceModeEnabled: boolean
|
||||
maintenanceTitle: string
|
||||
maintenanceMessage: string
|
||||
}
|
||||
|
||||
export interface AdminSettingsCheckItem {
|
||||
label: string
|
||||
value: boolean
|
||||
note: string
|
||||
icon: Component
|
||||
to: string | null
|
||||
}
|
||||
|
||||
export interface AdminSettingsGateItem {
|
||||
label: string
|
||||
state: boolean
|
||||
note: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AdminSettingsStatusSummary {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
tone: AdminSettingsTone
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { computed } from 'vue'
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Sparkles, Tags, Trophy, Users, Vote } from '@lucide/vue'
|
||||
|
||||
import { getVoteMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
export function useAdminAnalyticsManager() {
|
||||
const store = useAwardsStore()
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const totalVotes = computed(() => getVoteMetricValue(store.admin.metrics))
|
||||
const totalNominations = computed(() => store.admin.metrics.find((metric) => metric.label === 'Nominierungen')?.value ?? 0)
|
||||
const maxVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
||||
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
||||
const voteMap = computed(() => new Map(topCategories.value.map((category) => [category.category, category.votes])))
|
||||
|
||||
const categoryHealth = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => {
|
||||
const candidates = seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length
|
||||
const reviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
|
||||
const hasWinner = resultMap.value.has(category.id)
|
||||
const votes = voteMap.value.get(category.name) ?? 0
|
||||
const status = candidates === 0 ? 'Leer' : reviews > 0 ? 'Review offen' : hasWinner ? 'Gewinner gesetzt' : 'Bereit'
|
||||
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
groupName: category.groupName,
|
||||
candidates,
|
||||
reviews,
|
||||
hasWinner,
|
||||
votes,
|
||||
status,
|
||||
statusClass: candidates === 0
|
||||
? 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
: reviews > 0
|
||||
? 'border-amber-100 bg-amber-50 text-amber-700'
|
||||
: hasWinner
|
||||
? 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
: 'border-sky-100 bg-sky-50 text-sky-700',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.reviews - a.reviews || a.candidates - b.candidates || b.votes - a.votes),
|
||||
)
|
||||
|
||||
const emptyCategories = computed(() => categoryHealth.value.filter((category) => category.candidates === 0))
|
||||
const categoriesWithReviews = computed(() => categoryHealth.value.filter((category) => category.reviews > 0))
|
||||
const categoriesWithoutWinner = computed(() => categoryHealth.value.filter((category) => !category.hasWinner))
|
||||
const pendingClipCount = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
||||
const winnerCoveragePct = computed(() => {
|
||||
const categoryCount = seasonDetail.value.categories.length
|
||||
if (categoryCount === 0) return 0
|
||||
return Math.round((seasonDetail.value.results.length / categoryCount) * 100)
|
||||
})
|
||||
|
||||
const metricCards = computed(() => [
|
||||
{ label: 'Nominierungen', value: totalNominations.value, note: 'eingereicht im Jahr', icon: Sparkles, tone: 'text-fuchsia-700 bg-fuchsia-50 border-fuchsia-100' },
|
||||
{ label: 'Stimmen', value: totalVotes.value, note: 'gezählte Votes', icon: Vote, tone: 'text-violet-700 bg-violet-50 border-violet-100' },
|
||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length, note: 'freigegeben in Kategorien', icon: Users, tone: 'text-cyan-700 bg-cyan-50 border-cyan-100' },
|
||||
{ label: 'Review-Backlog', value: seasonDetail.value.pendingNominations.length, note: 'offene Entscheidungen', icon: Clock3, tone: 'text-amber-700 bg-amber-50 border-amber-100' },
|
||||
])
|
||||
|
||||
const readinessCards = computed(() => [
|
||||
{
|
||||
label: 'Kategorie-Abdeckung',
|
||||
value: `${seasonDetail.value.categories.length - emptyCategories.value.length}/${seasonDetail.value.categories.length}`,
|
||||
note: emptyCategories.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${emptyCategories.value.length} Kategorien sind noch leer.`,
|
||||
icon: Tags,
|
||||
to: emptyCategories.value.length > 0 ? '/admin/candidates' : '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Gewinnerstatus',
|
||||
value: `${winnerCoveragePct.value}%`,
|
||||
note: `${seasonDetail.value.results.length} von ${seasonDetail.value.categories.length} Kategorien final gesetzt.`,
|
||||
icon: Trophy,
|
||||
to: '/admin/winners',
|
||||
},
|
||||
{
|
||||
label: 'Freigabe-Risiko',
|
||||
value: String(categoriesWithReviews.value.length + pendingClipCount.value),
|
||||
note: `${categoriesWithReviews.value.length} Kategorien mit Reviews, ${pendingClipCount.value} Clips offen.`,
|
||||
icon: AlertTriangle,
|
||||
to: categoriesWithReviews.value.length > 0 ? '/admin/nominations?review=1' : '/admin/clips',
|
||||
},
|
||||
])
|
||||
|
||||
const insightCards = computed(() => {
|
||||
const votesPerCandidate = seasonDetail.value.candidates.length === 0 ? 0 : Math.round(totalVotes.value / seasonDetail.value.candidates.length)
|
||||
const busiestReviewCategory = categoriesWithReviews.value[0]
|
||||
const strongestCategory = topCategories.value[0]
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Votes pro Kandidat',
|
||||
value: votesPerCandidate.toLocaleString('de-DE'),
|
||||
note: 'Zeigt, ob die Kandidatenbasis breit genug fuer die aktuelle Vote-Menge ist.',
|
||||
icon: Vote,
|
||||
},
|
||||
{
|
||||
label: 'Staerkste Kategorie',
|
||||
value: strongestCategory?.votes.toLocaleString('de-DE') ?? '0',
|
||||
note: strongestCategory ? strongestCategory.category : 'Noch keine Vote-Verteilung vorhanden.',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
label: 'Review-Hotspot',
|
||||
value: String(busiestReviewCategory?.reviews ?? 0),
|
||||
note: busiestReviewCategory ? busiestReviewCategory.name : 'Keine offenen Review-Hotspots.',
|
||||
icon: Clock3,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const attentionItems = computed(() => [
|
||||
{
|
||||
key: 'empty-categories',
|
||||
label: 'Leere Kategorien',
|
||||
value: emptyCategories.value.length,
|
||||
note: emptyCategories.value.length === 0 ? 'Keine Luecke in der Kandidatenbasis.' : emptyCategories.value.slice(0, 3).map((category) => category.name).join(', '),
|
||||
to: '/admin/candidates',
|
||||
tone: emptyCategories.value.length > 0 ? 'border-rose-100 bg-rose-50 text-rose-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
key: 'pending-reviews',
|
||||
label: 'Offene Reviews',
|
||||
value: seasonDetail.value.pendingNominations.length,
|
||||
note: categoriesWithReviews.value.length === 0 ? 'Review-Queue ist leer.' : `${categoriesWithReviews.value.length} Kategorien betroffen.`,
|
||||
to: '/admin/nominations?review=1',
|
||||
tone: seasonDetail.value.pendingNominations.length > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
key: 'missing-winners',
|
||||
label: 'Gewinner fehlen',
|
||||
value: categoriesWithoutWinner.value.length,
|
||||
note: categoriesWithoutWinner.value.length === 0 ? 'Alle Gewinner sind gesetzt.' : 'Finalisierung auf der Gewinner-Seite abschliessen.',
|
||||
to: '/admin/winners',
|
||||
tone: categoriesWithoutWinner.value.length > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
categoryHealth,
|
||||
metricCards,
|
||||
readinessCards,
|
||||
insightCards,
|
||||
attentionItems,
|
||||
topCategories,
|
||||
maxVotes,
|
||||
winnerCoveragePct,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminAuditEntry, AdminAuditQueryOptions } from '../../types/awards'
|
||||
|
||||
const auditPageLimit = 80
|
||||
const allFilter = 'all'
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
'candidate.create': 'Kandidat angelegt',
|
||||
'candidate.delete': 'Kandidat gelöscht',
|
||||
'candidate.update': 'Kandidat aktualisiert',
|
||||
'category.create': 'Kategorie angelegt',
|
||||
'category.delete': 'Kategorie gelöscht',
|
||||
'category.update': 'Kategorie aktualisiert',
|
||||
'clip.delete': 'Clip gelöscht',
|
||||
'clip.status.update': 'Clip-Status geändert',
|
||||
'nomination.approve': 'Nominierung übernommen',
|
||||
'nomination.reject': 'Nominierung verworfen',
|
||||
'operational-settings.update': 'Betriebseinstellungen geändert',
|
||||
'result.delete': 'Winner entfernt',
|
||||
'result.set': 'Winner gesetzt',
|
||||
'risk.resolve': 'Risk Flag entschieden',
|
||||
'season.create': 'Jahr angelegt',
|
||||
'season.delete': 'Jahr gelöscht',
|
||||
'season.phase.update': 'Phase geändert',
|
||||
'season.public.update': 'Public-Kontext geändert',
|
||||
'season.update': 'Jahr aktualisiert',
|
||||
'seed.initialize': 'Seed-Daten initialisiert',
|
||||
'site-settings.update': 'Seiteneinstellungen geändert',
|
||||
}
|
||||
|
||||
const groupLabels: Record<string, string> = {
|
||||
candidate: 'Kandidaten',
|
||||
category: 'Kategorien',
|
||||
clip: 'Clips',
|
||||
nomination: 'Reviews',
|
||||
result: 'Gewinner',
|
||||
risk: 'Risiko',
|
||||
seed: 'System',
|
||||
season: 'Jahre',
|
||||
site: 'Seite',
|
||||
'site-settings': 'Seite',
|
||||
'operational-settings': 'Betrieb',
|
||||
}
|
||||
|
||||
const relatedRoutes: Record<string, { label: string; to: string }> = {
|
||||
candidate: { label: 'Kandidaten öffnen', to: '/admin/candidates' },
|
||||
category: { label: 'Kategorien öffnen', to: '/admin/categories' },
|
||||
clip: { label: 'Clips öffnen', to: '/admin/clips' },
|
||||
nomination: { label: 'Review-Fokus öffnen', to: '/admin/nominations?review=1' },
|
||||
result: { label: 'Gewinner öffnen', to: '/admin/winners' },
|
||||
'risk-flag': { label: 'Risiko öffnen', to: '/admin/risk' },
|
||||
season: { label: 'Jahre öffnen', to: '/admin/years' },
|
||||
'site-settings': { label: 'Landingpage öffnen', to: '/admin/content' },
|
||||
'operational-settings': { label: 'Einstellungen öffnen', to: '/admin/settings' },
|
||||
}
|
||||
|
||||
export interface AuditStat {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface AuditCountItem {
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AuditFocusCard {
|
||||
label: string
|
||||
value: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface AuditMetadataItem {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface AuditChangeItem {
|
||||
field: string
|
||||
label: string
|
||||
from: string
|
||||
to: string
|
||||
sensitive: boolean
|
||||
}
|
||||
|
||||
export interface AuditRelatedLink {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface AuditFilterPreset {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
filters: {
|
||||
query?: string
|
||||
action?: string
|
||||
entityType?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditEntityOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AuditLogRow extends AdminAuditEntry {
|
||||
actionLabel: string
|
||||
actionGroup: string
|
||||
actionToneClass: string
|
||||
dotClass: string
|
||||
entityLabel: string
|
||||
createdLabel: string
|
||||
ageLabel: string
|
||||
metadataItems: AuditMetadataItem[]
|
||||
changeItems: AuditChangeItem[]
|
||||
requestContextItems: AuditMetadataItem[]
|
||||
relatedLink: AuditRelatedLink | null
|
||||
rawMetadataJson: string
|
||||
}
|
||||
|
||||
const entityOptions: AuditEntityOption[] = [
|
||||
{ value: allFilter, label: 'Alle Objekte' },
|
||||
{ value: 'season', label: 'Jahre' },
|
||||
{ value: 'category', label: 'Kategorien' },
|
||||
{ value: 'candidate', label: 'Kandidaten' },
|
||||
{ value: 'nomination', label: 'Reviews' },
|
||||
{ value: 'clip', label: 'Clips' },
|
||||
{ value: 'risk-flag', label: 'Risiko' },
|
||||
{ value: 'result', label: 'Gewinner' },
|
||||
{ value: 'site-settings', label: 'Landingpage' },
|
||||
{ value: 'operational-settings', label: 'Betrieb' },
|
||||
{ value: 'seed', label: 'System' },
|
||||
]
|
||||
|
||||
const filterPresets: AuditFilterPreset[] = [
|
||||
{
|
||||
key: 'risk-security',
|
||||
label: 'Risk & Security',
|
||||
description: 'Entscheidungen mit Sicherheits- oder Missbrauchskontext.',
|
||||
filters: { action: 'risk.resolve' },
|
||||
},
|
||||
{
|
||||
key: 'public-context',
|
||||
label: 'Public-Kontext',
|
||||
description: 'Jahreswechsel, Phasen und sichtbare Public-Änderungen.',
|
||||
filters: { entityType: 'season' },
|
||||
},
|
||||
{
|
||||
key: 'content-privacy',
|
||||
label: 'Content & Privacy',
|
||||
description: 'Landingpage, Datenschutz und Content-Konfiguration.',
|
||||
filters: { action: 'site-settings.update' },
|
||||
},
|
||||
{
|
||||
key: 'destructive',
|
||||
label: 'Löschungen',
|
||||
description: 'Entfernende Aktionen aus Kategorien, Kandidaten, Clips und Jahren.',
|
||||
filters: { query: 'delete' },
|
||||
},
|
||||
]
|
||||
|
||||
function countBy<T>(items: T[], getKey: (item: T) => string) {
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const key = getKey(item).trim() || 'unbekannt'
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([key, count]) => ({ key, label: key, count }))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
function humanizeAction(action: string) {
|
||||
if (actionLabels[action]) return actionLabels[action]
|
||||
return action
|
||||
.split('.')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).replaceAll('_', ' '))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function getActionGroup(action: string) {
|
||||
const group = action.split('.')[0] ?? ''
|
||||
return groupLabels[group] ?? 'System'
|
||||
}
|
||||
|
||||
function getActionToneClass(action: string) {
|
||||
if (action.startsWith('risk')) return 'border-rose-100 bg-rose-50 text-rose-700'
|
||||
if (action.startsWith('clip') || action.startsWith('nomination')) return 'border-amber-100 bg-amber-50 text-amber-700'
|
||||
if (action.startsWith('result')) return 'border-emerald-100 bg-emerald-50 text-emerald-700'
|
||||
if (action.startsWith('season')) return 'border-sky-100 bg-sky-50 text-sky-700'
|
||||
if (action.startsWith('site') || action.startsWith('operational')) return 'border-indigo-100 bg-indigo-50 text-indigo-700'
|
||||
if (action.includes('delete')) return 'border-slate-200 bg-slate-100 text-slate-700'
|
||||
return 'border-cyan-100 bg-cyan-50 text-cyan-700'
|
||||
}
|
||||
|
||||
function getDotClass(action: string) {
|
||||
if (action.startsWith('risk')) return 'bg-rose-400'
|
||||
if (action.startsWith('clip') || action.startsWith('nomination')) return 'bg-amber-400'
|
||||
if (action.startsWith('result')) return 'bg-emerald-400'
|
||||
if (action.startsWith('season')) return 'bg-sky-400'
|
||||
if (action.startsWith('site') || action.startsWith('operational')) return 'bg-indigo-400'
|
||||
if (action.includes('delete')) return 'bg-slate-400'
|
||||
return 'bg-cyan-400'
|
||||
}
|
||||
|
||||
function stringifyMetadataValue(entryValue: unknown) {
|
||||
if (typeof entryValue === 'object') return JSON.stringify(entryValue)
|
||||
return String(entryValue)
|
||||
}
|
||||
|
||||
function parseMetadata(metadataJson: string | undefined) {
|
||||
if (!metadataJson) return []
|
||||
|
||||
try {
|
||||
const value = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return []
|
||||
|
||||
return Object.entries(value)
|
||||
.filter(([key]) => key !== 'changes' && key !== 'Changes')
|
||||
.filter(([, entryValue]) => entryValue !== null && entryValue !== undefined && entryValue !== '')
|
||||
.map(([key, entryValue]) => ({
|
||||
key,
|
||||
value: stringifyMetadataValue(entryValue),
|
||||
}))
|
||||
} catch {
|
||||
return [{ key: 'metadata', value: metadataJson }]
|
||||
}
|
||||
}
|
||||
|
||||
function parseChangeItems(metadataJson: string | undefined): AuditChangeItem[] {
|
||||
if (!metadataJson) return []
|
||||
|
||||
try {
|
||||
const value = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
const changes = value.changes ?? value.Changes
|
||||
if (!Array.isArray(changes)) return []
|
||||
|
||||
return changes
|
||||
.map((change) => {
|
||||
if (!change || typeof change !== 'object' || Array.isArray(change)) return null
|
||||
|
||||
const item = change as Record<string, unknown>
|
||||
return {
|
||||
field: String(item.field ?? item.Field ?? ''),
|
||||
label: String(item.label ?? item.Label ?? item.field ?? item.Field ?? 'Änderung'),
|
||||
from: String(item.from ?? item.From ?? ''),
|
||||
to: String(item.to ?? item.To ?? ''),
|
||||
sensitive: Boolean(item.sensitive ?? item.Sensitive),
|
||||
}
|
||||
})
|
||||
.filter((change): change is AuditChangeItem => Boolean(change?.field || change?.label))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
function formatAge(value: string) {
|
||||
const timestamp = new Date(value).getTime()
|
||||
if (Number.isNaN(timestamp)) return 'Zeitpunkt unbekannt'
|
||||
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000))
|
||||
if (minutes < 2) return 'gerade eben'
|
||||
if (minutes < 60) return `vor ${minutes} Min.`
|
||||
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 48) return `vor ${hours} Std.`
|
||||
|
||||
return `vor ${Math.round(hours / 24)} Tagen`
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: unknown) {
|
||||
const text = String(value ?? '')
|
||||
if (!/[",\n\r;]/.test(text)) return text
|
||||
return `"${text.replaceAll('"', '""')}"`
|
||||
}
|
||||
|
||||
function getDateBoundary(value: string, isEndOfDay: boolean) {
|
||||
if (!value) return undefined
|
||||
const suffix = isEndOfDay ? 'T23:59:59.999' : 'T00:00:00.000'
|
||||
const date = new Date(`${value}${suffix}`)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||
}
|
||||
|
||||
function buildRequestContextItems(entry: AdminAuditEntry) {
|
||||
return [
|
||||
{ key: 'IP', value: entry.createdFromIp || 'nicht erfasst' },
|
||||
{ key: 'User-Agent', value: entry.userAgent || 'nicht erfasst' },
|
||||
]
|
||||
}
|
||||
|
||||
function buildRelatedLink(entry: AdminAuditEntry) {
|
||||
const route = relatedRoutes[entry.entityType]
|
||||
if (!route) return null
|
||||
|
||||
if (entry.entityType === 'nomination' && entry.entityId) {
|
||||
return { label: route.label, to: `${route.to}?nominationId=${encodeURIComponent(entry.entityId)}` }
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
function createAuditRow(entry: AdminAuditEntry): AuditLogRow {
|
||||
return {
|
||||
...entry,
|
||||
actionLabel: humanizeAction(entry.actionType),
|
||||
actionGroup: getActionGroup(entry.actionType),
|
||||
actionToneClass: getActionToneClass(entry.actionType),
|
||||
dotClass: getDotClass(entry.actionType),
|
||||
entityLabel: `${entry.entityType} ${entry.entityId}`.trim(),
|
||||
createdLabel: formatDate(entry.createdAt),
|
||||
ageLabel: formatAge(entry.createdAt),
|
||||
metadataItems: parseMetadata(entry.metadataJson),
|
||||
changeItems: parseChangeItems(entry.metadataJson),
|
||||
requestContextItems: buildRequestContextItems(entry),
|
||||
relatedLink: buildRelatedLink(entry),
|
||||
rawMetadataJson: entry.metadataJson || '{}',
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCsv(entries: AdminAuditEntry[]) {
|
||||
const rows = [
|
||||
[
|
||||
'Id',
|
||||
'Admin',
|
||||
'Aktion',
|
||||
'Objekt-Typ',
|
||||
'Objekt-Id',
|
||||
'Zusammenfassung',
|
||||
'Zeitpunkt',
|
||||
'IP',
|
||||
'User-Agent',
|
||||
'Metadaten',
|
||||
],
|
||||
...entries.map((entry) => [
|
||||
entry.id,
|
||||
entry.adminTwitchUserId,
|
||||
entry.actionType,
|
||||
entry.entityType,
|
||||
entry.entityId,
|
||||
entry.summary,
|
||||
new Date(entry.createdAt).toISOString(),
|
||||
entry.createdFromIp,
|
||||
entry.userAgent,
|
||||
entry.metadataJson,
|
||||
]),
|
||||
]
|
||||
|
||||
const csv = rows.map((row) => row.map(escapeCsvCell).join(';')).join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `vtuber-star-awards-audit-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function useAdminAuditManager() {
|
||||
const store = useAwardsStore()
|
||||
const query = ref('')
|
||||
const selectedAdmin = ref(allFilter)
|
||||
const selectedAction = ref(allFilter)
|
||||
const entityFilter = ref(allFilter)
|
||||
const fromDate = ref('')
|
||||
const toDate = ref('')
|
||||
const loadingAudit = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const auditError = ref('')
|
||||
const exportMessage = ref('')
|
||||
const lastLoadedAt = ref<Date | null>(null)
|
||||
const auditEntries = ref<AdminAuditEntry[]>([])
|
||||
const totalCount = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const selectedEntry = ref<AuditLogRow | null>(null)
|
||||
const appliedPresetKey = ref('')
|
||||
let searchTimer: ReturnType<typeof window.setTimeout> | null = null
|
||||
let exportMessageTimer: ReturnType<typeof window.setTimeout> | null = null
|
||||
let suppressFilterWatch = false
|
||||
|
||||
const adminCounts = computed(() => countBy(auditEntries.value, (entry) => entry.adminTwitchUserId))
|
||||
const actionCounts = computed(() =>
|
||||
countBy(auditEntries.value, (entry) => entry.actionType).map((item) => ({
|
||||
...item,
|
||||
label: humanizeAction(item.key),
|
||||
})),
|
||||
)
|
||||
const entityCounts = computed(() =>
|
||||
countBy(auditEntries.value, (entry) => entry.entityType).map((item) => ({
|
||||
...item,
|
||||
label: entityOptions.find((option) => option.value === item.key)?.label ?? item.key,
|
||||
})),
|
||||
)
|
||||
const auditRows = computed<AuditLogRow[]>(() => auditEntries.value.map(createAuditRow))
|
||||
const metadataCount = computed(() => auditEntries.value.filter((entry) => parseMetadata(entry.metadataJson).length > 0).length)
|
||||
const requestContextCount = computed(() => auditEntries.value.filter((entry) => entry.createdFromIp || entry.userAgent).length)
|
||||
const recentDayCount = computed(() => {
|
||||
const minTimestamp = Date.now() - 24 * 60 * 60 * 1000
|
||||
return auditEntries.value.filter((entry) => new Date(entry.createdAt).getTime() >= minTimestamp).length
|
||||
})
|
||||
const hasMore = computed(() => Boolean(nextCursor.value))
|
||||
const loadedCountLabel = computed(() => auditEntries.value.length.toLocaleString('de-DE'))
|
||||
const totalCountLabel = computed(() => totalCount.value.toLocaleString('de-DE'))
|
||||
const logStats = computed<AuditStat[]>(() => [
|
||||
{ label: 'Geladen', value: loadedCountLabel.value, note: `Page ${auditPageLimit}` },
|
||||
{ label: 'Treffer', value: totalCountLabel.value, note: 'serverseitig gefiltert' },
|
||||
{ label: '24h', value: recentDayCount.value.toLocaleString('de-DE'), note: 'neue Aktionen' },
|
||||
{ label: 'Kontext', value: requestContextCount.value.toLocaleString('de-DE'), note: 'mit IP oder User-Agent' },
|
||||
])
|
||||
const focusCards = computed<AuditFocusCard[]>(() => {
|
||||
const topAdmin = adminCounts.value[0]
|
||||
const topAction = actionCounts.value[0]
|
||||
const topEntity = entityCounts.value[0]
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Top Admin',
|
||||
value: topAdmin?.label ?? 'Keine Daten',
|
||||
note: topAdmin ? `${topAdmin.count} Aktionen in den geladenen Treffern` : 'Noch kein Admin aktiv',
|
||||
},
|
||||
{
|
||||
label: 'Top Aktion',
|
||||
value: topAction?.label ?? 'Keine Daten',
|
||||
note: topAction ? `${topAction.count} Einträge` : 'Noch kein Aktionstyp vorhanden',
|
||||
},
|
||||
{
|
||||
label: 'Objekt-Fokus',
|
||||
value: topEntity?.label ?? 'Keine Daten',
|
||||
note: topEntity ? `${topEntity.count} Einträge` : 'Noch kein Objekttyp vorhanden',
|
||||
},
|
||||
{
|
||||
label: 'Details',
|
||||
value: metadataCount.value.toLocaleString('de-DE'),
|
||||
note: 'Einträge mit sichtbaren Metadaten',
|
||||
},
|
||||
]
|
||||
})
|
||||
const activeFilterCount = computed(() =>
|
||||
(query.value.trim() ? 1 : 0) +
|
||||
(selectedAdmin.value !== allFilter ? 1 : 0) +
|
||||
(selectedAction.value !== allFilter ? 1 : 0) +
|
||||
(entityFilter.value !== allFilter ? 1 : 0) +
|
||||
(fromDate.value ? 1 : 0) +
|
||||
(toDate.value ? 1 : 0),
|
||||
)
|
||||
const lastLoadedLabel = computed(() =>
|
||||
lastLoadedAt.value ? lastLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
|
||||
)
|
||||
const emptyStateText = computed(() =>
|
||||
activeFilterCount.value > 0
|
||||
? 'Keine Log-Einträge passen zu den aktiven Filtern.'
|
||||
: 'Noch keine Audit-Einträge vorhanden.',
|
||||
)
|
||||
const pageSummaryLabel = computed(() =>
|
||||
`${loadedCountLabel.value} von ${totalCountLabel.value} Treffern geladen`,
|
||||
)
|
||||
|
||||
function buildAuditOptions(cursor: string | null = null): AdminAuditQueryOptions {
|
||||
return {
|
||||
limit: auditPageLimit,
|
||||
query: query.value,
|
||||
admin: selectedAdmin.value === allFilter ? undefined : selectedAdmin.value,
|
||||
action: selectedAction.value === allFilter ? undefined : selectedAction.value,
|
||||
entityType: entityFilter.value === allFilter ? undefined : entityFilter.value,
|
||||
from: getDateBoundary(fromDate.value, false),
|
||||
to: getDateBoundary(toDate.value, true),
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLoad() {
|
||||
clearTimers()
|
||||
searchTimer = window.setTimeout(() => {
|
||||
void loadAuditEntries()
|
||||
}, 350)
|
||||
}
|
||||
|
||||
async function loadAuditEntries(options: { append?: boolean } = {}) {
|
||||
const append = options.append ?? false
|
||||
if (append && !nextCursor.value) return
|
||||
|
||||
if (append) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loadingAudit.value = true
|
||||
selectedEntry.value = null
|
||||
}
|
||||
|
||||
auditError.value = ''
|
||||
|
||||
try {
|
||||
const response = await store.loadAdminAuditEntriesPage(
|
||||
buildAuditOptions(append ? nextCursor.value : null),
|
||||
append,
|
||||
)
|
||||
auditEntries.value = append ? [...auditEntries.value, ...response.items] : response.items
|
||||
totalCount.value = response.totalCount
|
||||
nextCursor.value = response.nextCursor
|
||||
lastLoadedAt.value = new Date()
|
||||
} catch (error) {
|
||||
auditError.value = error instanceof Error ? error.message : 'Audit-Logs konnten nicht geladen werden.'
|
||||
} finally {
|
||||
loadingAudit.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNextPage() {
|
||||
await loadAuditEntries({ append: true })
|
||||
}
|
||||
|
||||
function exportAuditCsv() {
|
||||
if (auditEntries.value.length === 0) return
|
||||
downloadCsv(auditEntries.value)
|
||||
exportMessage.value = `CSV mit ${auditEntries.value.length.toLocaleString('de-DE')} geladenen Einträgen erstellt.`
|
||||
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
||||
exportMessageTimer = window.setTimeout(() => {
|
||||
exportMessage.value = ''
|
||||
}, 3500)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
clearTimers()
|
||||
suppressFilterWatch = true
|
||||
query.value = ''
|
||||
selectedAdmin.value = allFilter
|
||||
selectedAction.value = allFilter
|
||||
entityFilter.value = allFilter
|
||||
fromDate.value = ''
|
||||
toDate.value = ''
|
||||
appliedPresetKey.value = ''
|
||||
suppressFilterWatch = false
|
||||
await loadAuditEntries()
|
||||
}
|
||||
|
||||
async function applyPreset(presetKey: string) {
|
||||
const preset = filterPresets.find((item) => item.key === presetKey)
|
||||
if (!preset) return
|
||||
|
||||
clearTimers()
|
||||
suppressFilterWatch = true
|
||||
query.value = preset.filters.query ?? ''
|
||||
selectedAdmin.value = allFilter
|
||||
selectedAction.value = preset.filters.action ?? allFilter
|
||||
entityFilter.value = preset.filters.entityType ?? allFilter
|
||||
fromDate.value = ''
|
||||
toDate.value = ''
|
||||
appliedPresetKey.value = preset.key
|
||||
suppressFilterWatch = false
|
||||
await loadAuditEntries()
|
||||
}
|
||||
|
||||
function openAuditEntry(entry: AuditLogRow) {
|
||||
selectedEntry.value = entry
|
||||
}
|
||||
|
||||
function closeAuditEntry() {
|
||||
selectedEntry.value = null
|
||||
}
|
||||
|
||||
watch([query, selectedAdmin, selectedAction, entityFilter, fromDate, toDate], () => {
|
||||
if (suppressFilterWatch) return
|
||||
appliedPresetKey.value = ''
|
||||
scheduleLoad()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
auditEntries.value = store.admin.auditEntries
|
||||
totalCount.value = store.admin.auditEntries.length
|
||||
void loadAuditEntries()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
if (exportMessageTimer) window.clearTimeout(exportMessageTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
query,
|
||||
selectedAdmin,
|
||||
selectedAction,
|
||||
entityFilter,
|
||||
fromDate,
|
||||
toDate,
|
||||
loadingAudit,
|
||||
loadingMore,
|
||||
auditError,
|
||||
exportMessage,
|
||||
selectedEntry,
|
||||
auditRows,
|
||||
adminCounts,
|
||||
actionCounts,
|
||||
entityCounts,
|
||||
logStats,
|
||||
focusCards,
|
||||
activeFilterCount,
|
||||
lastLoadedLabel,
|
||||
emptyStateText,
|
||||
pageSummaryLabel,
|
||||
hasMore,
|
||||
filterPresets,
|
||||
appliedPresetKey,
|
||||
entityOptions,
|
||||
loadAuditEntries,
|
||||
loadNextPage,
|
||||
exportAuditCsv,
|
||||
clearFilters,
|
||||
applyPreset,
|
||||
openAuditEntry,
|
||||
closeAuditEntry,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCandidateItem } from '../../types/awards'
|
||||
|
||||
interface CandidateForm {
|
||||
categoryId: number
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
}
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
export function useAdminCandidateManager() {
|
||||
const store = useAwardsStore()
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const search = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const page = ref(1)
|
||||
const modalOpen = ref(false)
|
||||
const editingId = ref<number | 'new' | null>(null)
|
||||
const candidateToDelete = ref<AdminCandidateItem | null>(null)
|
||||
const form = reactive<CandidateForm>({ categoryId: 0, displayName: '', channelSlug: '', platform: 'Twitch' })
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories.map((category) => ({ label: `${category.groupName} · ${category.name}`, value: category.id })),
|
||||
)
|
||||
const categoryFilterOptions = computed(() => [{ label: 'Alle Kategorien', value: null }, ...categoryOptions.value])
|
||||
const categoryLabelMap = computed<Record<number, string>>(() =>
|
||||
Object.fromEntries(seasonDetail.value.categories.map((category) => [category.id, `${category.groupName} · ${category.name}`])),
|
||||
)
|
||||
const duplicateCandidateKeys = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const candidate of seasonDetail.value.candidates) {
|
||||
const nameKey = createCandidateDuplicateKey(candidate, 'name')
|
||||
const slugKey = createCandidateDuplicateKey(candidate, 'slug')
|
||||
counts.set(nameKey, (counts.get(nameKey) ?? 0) + 1)
|
||||
if (candidate.channelSlug.trim()) {
|
||||
counts.set(slugKey, (counts.get(slugKey) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const duplicateCandidateCount = computed(() =>
|
||||
seasonDetail.value.candidates.filter((candidate) => hasDuplicateCandidateKey(candidate, duplicateCandidateKeys.value)).length,
|
||||
)
|
||||
const filteredCandidates = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
let list = seasonDetail.value.candidates
|
||||
|
||||
if (categoryFilter.value) {
|
||||
list = list.filter((candidate) => candidate.categoryId === categoryFilter.value)
|
||||
}
|
||||
|
||||
if (query) {
|
||||
list = list.filter((candidate) =>
|
||||
[candidate.displayName, candidate.channelSlug, candidate.platform, categoryLabelMap.value[candidate.categoryId] ?? '']
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCandidates.value.length / pageSize)))
|
||||
const pagedCandidates = computed(() => {
|
||||
const start = (page.value - 1) * pageSize
|
||||
return filteredCandidates.value.slice(start, start + pageSize)
|
||||
})
|
||||
const rangeStart = computed(() => (filteredCandidates.value.length === 0 ? 0 : (page.value - 1) * pageSize + 1))
|
||||
const rangeEnd = computed(() => Math.min(page.value * pageSize, filteredCandidates.value.length))
|
||||
const modalTitle = computed(() => (editingId.value === 'new' ? 'Kandidat anlegen' : 'Kandidat bearbeiten'))
|
||||
const canSave = computed(() =>
|
||||
Boolean(selectedSeasonId.value && form.categoryId && form.displayName.trim() && form.channelSlug.trim() && form.platform.trim()),
|
||||
)
|
||||
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||
const selectedPlatformValue = computed(() => socialIconOptionForValue(form.platform)?.key ?? 'custom')
|
||||
|
||||
watch([search, categoryFilter, () => seasonDetail.value.candidates.length], () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch(totalPages, (max) => {
|
||||
if (page.value > max) {
|
||||
page.value = max
|
||||
}
|
||||
})
|
||||
|
||||
function clearFilters() {
|
||||
search.value = ''
|
||||
categoryFilter.value = null
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
editingId.value = 'new'
|
||||
form.categoryId = categoryFilter.value ?? seasonDetail.value.categories[0]?.id ?? 0
|
||||
form.displayName = ''
|
||||
form.channelSlug = ''
|
||||
form.platform = 'Twitch'
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(candidate: AdminCandidateItem) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
editingId.value = candidate.id
|
||||
form.categoryId = candidate.categoryId
|
||||
form.displayName = candidate.displayName
|
||||
form.channelSlug = candidate.channelSlug
|
||||
form.platform = candidate.platform
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
function handlePlatformSelection(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (value === 'custom') {
|
||||
if (socialIconOptionForValue(form.platform)) {
|
||||
form.platform = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const option = socialIconOptionForValue(value)
|
||||
form.platform = option?.label ?? value
|
||||
}
|
||||
|
||||
async function saveModal() {
|
||||
if (!canSave.value || !selectedSeasonId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
if (editingId.value === 'new') {
|
||||
await store.createAdminCandidate(selectedSeasonId.value, { ...form })
|
||||
adminMessage.value = `„${form.displayName}" wurde angelegt.`
|
||||
} else if (typeof editingId.value === 'number') {
|
||||
await store.updateAdminCandidate(editingId.value, selectedSeasonId.value, { ...form })
|
||||
adminMessage.value = `„${form.displayName}" wurde gespeichert.`
|
||||
}
|
||||
modalOpen.value = false
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!candidateToDelete.value || !selectedSeasonId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.deleteAdminCandidate(candidateToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = `„${candidateToDelete.value.displayName}" wurde gelöscht.`
|
||||
candidateToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
seasonDetail,
|
||||
saving,
|
||||
deleting,
|
||||
adminMessage,
|
||||
adminError,
|
||||
search,
|
||||
categoryFilter,
|
||||
page,
|
||||
categoryOptions,
|
||||
categoryFilterOptions,
|
||||
categoryLabelMap,
|
||||
duplicateCandidateKeys,
|
||||
duplicateCandidateCount,
|
||||
filteredCandidates,
|
||||
pagedCandidates,
|
||||
totalPages,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
modalOpen,
|
||||
form,
|
||||
modalTitle,
|
||||
canSave,
|
||||
candidatePlatformOptions,
|
||||
selectedPlatformValue,
|
||||
candidateToDelete,
|
||||
clearFilters,
|
||||
openCreate,
|
||||
openEdit,
|
||||
handlePlatformSelection,
|
||||
saveModal,
|
||||
confirmDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function createCandidateDuplicateKey(candidate: AdminCandidateItem, field: 'name' | 'slug') {
|
||||
const value = field === 'name' ? candidate.displayName : candidate.channelSlug
|
||||
return `${candidate.categoryId}:${field}:${value.trim().toLowerCase()}`
|
||||
}
|
||||
|
||||
function hasDuplicateCandidateKey(candidate: AdminCandidateItem, candidateKeys: Map<string, number>) {
|
||||
return (candidateKeys.get(createCandidateDuplicateKey(candidate, 'name')) ?? 0) > 1
|
||||
|| (candidateKeys.get(createCandidateDuplicateKey(candidate, 'slug')) ?? 0) > 1
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminCategoryItem } from '../../types/awards'
|
||||
|
||||
type CategoryStatusFilter = 'all' | 'empty' | 'reviews' | 'thin'
|
||||
|
||||
interface CategoryForm {
|
||||
groupName: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
sortOrder: number
|
||||
maxNomineesPerUser: number
|
||||
}
|
||||
|
||||
export function useAdminCategoryManager() {
|
||||
const store = useAwardsStore()
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const query = ref('')
|
||||
const statusFilter = ref<CategoryStatusFilter>('all')
|
||||
const selectedCategoryId = ref<number | null>(null)
|
||||
const saving = ref<number | 'new' | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const categoryToDelete = ref<AdminCategoryItem | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const editForms = reactive<Record<number, CategoryForm>>({})
|
||||
const newCategoryForm = reactive(createEmptyCategoryForm())
|
||||
|
||||
const categoriesWithState = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
pending: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
candidates: seasonDetail.value.candidates.filter((candidate) => candidate.categoryId === category.id).length,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
const filteredCategories = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return categoriesWithState.value.filter((category) => {
|
||||
const matchesStatus =
|
||||
statusFilter.value === 'all' ||
|
||||
(statusFilter.value === 'empty' && category.candidates === 0) ||
|
||||
(statusFilter.value === 'reviews' && category.pending > 0) ||
|
||||
(statusFilter.value === 'thin' && category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser))
|
||||
const matchesSearch = !search || [category.groupName, category.name, category.slug, category.description]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
return matchesStatus && matchesSearch
|
||||
})
|
||||
})
|
||||
const selectedCategory = computed(() =>
|
||||
filteredCategories.value.find((category) => category.id === selectedCategoryId.value) ?? filteredCategories.value[0] ?? null,
|
||||
)
|
||||
const categoryStats = computed(() => [
|
||||
{ label: 'Kategorien', value: seasonDetail.value.categories.length },
|
||||
{ label: 'Kandidaten', value: seasonDetail.value.candidates.length },
|
||||
{ label: 'Reviews', value: seasonDetail.value.pendingNominations.length },
|
||||
])
|
||||
const statusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: categoriesWithState.value.length },
|
||||
{ key: 'empty' as const, label: 'Ohne Kandidaten', count: categoriesWithState.value.filter((category) => category.candidates === 0).length },
|
||||
{ key: 'reviews' as const, label: 'Mit Reviews', count: categoriesWithState.value.filter((category) => category.pending > 0).length },
|
||||
{ key: 'thin' as const, label: 'Dünn besetzt', count: categoriesWithState.value.filter((category) => category.candidates > 0 && category.candidates < Math.max(2, category.maxNomineesPerUser)).length },
|
||||
])
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const category of detail.categories) {
|
||||
editForms[category.id] = createCategoryForm(category)
|
||||
}
|
||||
newCategoryForm.sortOrder = detail.categories.length + 1
|
||||
if (!detail.categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = detail.categories[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(filteredCategories, (categories) => {
|
||||
if (!categories.some((category) => category.id === selectedCategoryId.value)) {
|
||||
selectedCategoryId.value = categories[0]?.id ?? null
|
||||
}
|
||||
})
|
||||
|
||||
async function saveCategory(categoryId: number) {
|
||||
if (!selectedSeasonId.value) return
|
||||
saving.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 {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
if (!selectedSeasonId.value) return
|
||||
saving.value = 'new'
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.createAdminCategory(selectedSeasonId.value, newCategoryForm)
|
||||
adminMessage.value = 'Kategorie angelegt.'
|
||||
Object.assign(newCategoryForm, createEmptyCategoryForm(seasonDetail.value.categories.length + 1))
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Kategorie konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function fillNewSlug() {
|
||||
newCategoryForm.slug = slugify(newCategoryForm.name)
|
||||
}
|
||||
|
||||
async function confirmDeleteCategory() {
|
||||
if (!categoryToDelete.value || !selectedSeasonId.value) return
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminCategory(categoryToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = `Kategorie „${categoryToDelete.value.name}" wurde gelöscht.`
|
||||
categoryToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedSeasonId,
|
||||
query,
|
||||
statusFilter,
|
||||
selectedCategoryId,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
editForms,
|
||||
newCategoryForm,
|
||||
filteredCategories,
|
||||
selectedCategory,
|
||||
categoryStats,
|
||||
statusFilters,
|
||||
categoryToDelete,
|
||||
deleting,
|
||||
saveCategory,
|
||||
createCategory,
|
||||
fillNewSlug,
|
||||
confirmDeleteCategory,
|
||||
}
|
||||
}
|
||||
|
||||
function createCategoryForm(category: AdminCategoryItem): CategoryForm
|
||||
function createCategoryForm(category?: AdminCategoryItem, sortOrder?: number): CategoryForm
|
||||
function createCategoryForm(category?: AdminCategoryItem, sortOrder = 1): CategoryForm {
|
||||
return {
|
||||
groupName: category?.groupName ?? '',
|
||||
name: category?.name ?? '',
|
||||
slug: category?.slug ?? '',
|
||||
description: category?.description ?? '',
|
||||
sortOrder: category?.sortOrder ?? sortOrder,
|
||||
maxNomineesPerUser: category?.maxNomineesPerUser ?? 3,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCategoryForm(sortOrder = 1): CategoryForm {
|
||||
return createCategoryForm(undefined, sortOrder)
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, Film, Layers3, PlayCircle } from '@lucide/vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminClipSubmissionItem } from '../../types/awards'
|
||||
|
||||
type ClipReviewStatus = 'pending' | 'approved' | 'rejected'
|
||||
type ClipStatusFilter = 'all' | 'pending' | 'reviewed'
|
||||
type ClipPlatformFilter = 'all' | string
|
||||
|
||||
export function useAdminClipManager() {
|
||||
const store = useAwardsStore()
|
||||
const query = ref('')
|
||||
const statusFilter = ref<ClipStatusFilter>('all')
|
||||
const platformFilter = ref<ClipPlatformFilter>('all')
|
||||
const categoryFilter = ref('all')
|
||||
const deleting = ref(false)
|
||||
const statusSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const clipToDelete = ref<AdminClipSubmissionItem | null>(null)
|
||||
const reviewNotes = reactive<Record<number, string>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const submissions = computed(() => seasonDetail.value.clipSubmissions ?? [])
|
||||
const categories = computed(() => seasonDetail.value.categories ?? [])
|
||||
const categoryName = computed(() =>
|
||||
Object.fromEntries(categories.value.map((category) => [category.id, category.name])),
|
||||
)
|
||||
const clips = computed(() => {
|
||||
const search = query.value.trim().toLowerCase()
|
||||
return submissions.value.filter((clip) =>
|
||||
matchesStatusFilter(clip.status, statusFilter.value) &&
|
||||
(platformFilter.value === 'all' || clip.platform === platformFilter.value) &&
|
||||
(categoryFilter.value === 'all' || String(clip.categoryId) === categoryFilter.value) &&
|
||||
(!search || [clip.title, clip.creator, clip.platform, clip.submittedByTwitchId, clip.clipUrl].join(' ').toLowerCase().includes(search)),
|
||||
)
|
||||
})
|
||||
const duplicateUrls = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const clip of submissions.value) {
|
||||
const key = normalizeClipUrl(clip.clipUrl)
|
||||
if (!key) continue
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const creatorClipGroups = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const clip of submissions.value) {
|
||||
const key = creatorClipKey(clip)
|
||||
if (!key) continue
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const stats = computed(() => [
|
||||
{ label: 'Einreichungen', value: submissions.value.length, icon: Film },
|
||||
{ label: 'Offen', value: submissions.value.filter((clip) => clip.status === 'pending').length, icon: PlayCircle },
|
||||
{ label: 'Freigegeben', value: submissions.value.filter((clip) => clip.status === 'approved').length, icon: CheckCircle2 },
|
||||
{ label: 'Duplikate', value: [...duplicateUrls.value.values()].filter((count) => count > 1).length, icon: Layers3 },
|
||||
])
|
||||
const statusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: submissions.value.length },
|
||||
{ key: 'pending' as const, label: 'Offen', count: submissions.value.filter((clip) => clip.status === 'pending').length },
|
||||
{ key: 'reviewed' as const, label: 'Geprüft', count: submissions.value.filter((clip) => clip.status !== 'pending').length },
|
||||
])
|
||||
const platformFilters = computed(() => [
|
||||
{ key: 'all', label: 'Alle Plattformen', count: submissions.value.length },
|
||||
...[...new Set(submissions.value.map((clip) => clip.platform).filter(Boolean))]
|
||||
.sort()
|
||||
.map((platform) => ({
|
||||
key: platform,
|
||||
label: platform,
|
||||
count: submissions.value.filter((clip) => clip.platform === platform).length,
|
||||
})),
|
||||
])
|
||||
const categoryFilters = computed(() => [
|
||||
{ id: 'all' as const, label: 'Alle Kategorien', count: submissions.value.length },
|
||||
...categories.value
|
||||
.filter((category) => submissions.value.some((clip) => clip.categoryId === category.id))
|
||||
.map((category) => ({
|
||||
id: String(category.id),
|
||||
label: category.name,
|
||||
count: submissions.value.filter((clip) => clip.categoryId === category.id).length,
|
||||
})),
|
||||
])
|
||||
|
||||
watch(submissions, (items) => {
|
||||
for (const clip of items) {
|
||||
if (reviewNotes[clip.id] === undefined) {
|
||||
reviewNotes[clip.id] = clip.reviewNote ?? ''
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function updateClipStatus(clip: AdminClipSubmissionItem, status: ClipReviewStatus) {
|
||||
if (!selectedSeasonId.value) return
|
||||
statusSaving.value = clip.id
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.updateAdminClipStatus(clip.id, selectedSeasonId.value, {
|
||||
status,
|
||||
reviewNote: reviewNotes[clip.id]?.trim() || undefined,
|
||||
})
|
||||
adminMessage.value = `Clip ${clip.id} wurde auf „${statusLabel(status)}“ gesetzt.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Clip-Status konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
statusSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!clipToDelete.value || !selectedSeasonId.value) return
|
||||
deleting.value = true
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminClip(clipToDelete.value.id, selectedSeasonId.value)
|
||||
adminMessage.value = 'Clip-Einreichung wurde entfernt.'
|
||||
clipToDelete.value = null
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateUrlCount(clipUrl: string) {
|
||||
return duplicateUrls.value.get(normalizeClipUrl(clipUrl)) ?? 0
|
||||
}
|
||||
|
||||
function creatorClipCount(clip: AdminClipSubmissionItem) {
|
||||
return creatorClipGroups.value.get(creatorClipKey(clip)) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
statusFilter,
|
||||
platformFilter,
|
||||
categoryFilter,
|
||||
deleting,
|
||||
statusSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
clipToDelete,
|
||||
reviewNotes,
|
||||
submissions,
|
||||
categoryName,
|
||||
clips,
|
||||
stats,
|
||||
statusFilters,
|
||||
platformFilters,
|
||||
categoryFilters,
|
||||
platformClass,
|
||||
statusClass,
|
||||
statusLabel,
|
||||
duplicateUrlCount,
|
||||
creatorClipCount,
|
||||
updateClipStatus,
|
||||
confirmDelete,
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStatusFilter(status: string, filter: ClipStatusFilter) {
|
||||
return filter === 'all' || status === filter || (filter === 'reviewed' && status !== 'pending')
|
||||
}
|
||||
|
||||
function normalizeClipUrl(clipUrl: string) {
|
||||
return clipUrl.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function creatorClipKey(clip: AdminClipSubmissionItem) {
|
||||
if (clip.candidateId) return `candidate:${clip.candidateId}`
|
||||
const creator = clip.creator.trim().toLowerCase()
|
||||
const categoryId = clip.categoryId ?? 'global'
|
||||
return creator ? `creator:${categoryId}:${creator}` : ''
|
||||
}
|
||||
|
||||
function platformClass(platform: string) {
|
||||
if (platform === 'Twitch') return 'border-violet-200 bg-violet-50 text-violet-700'
|
||||
if (platform === 'YouTube') return 'border-rose-200 bg-rose-50 text-rose-600'
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
}
|
||||
|
||||
function statusClass(status: string) {
|
||||
if (status === 'approved') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
if (status === 'rejected') return 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
return 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'approved') return 'freigegeben'
|
||||
if (status === 'rejected') return 'abgelehnt'
|
||||
return 'offen'
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
normalizeSocialIconKey,
|
||||
simpleIconForKey,
|
||||
socialIconOptionForKey,
|
||||
} from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
|
||||
|
||||
function createEmptyForm(): AdminContentForm {
|
||||
return {
|
||||
hostDisplayName: '',
|
||||
hostTagline: '',
|
||||
newsletterUrl: '',
|
||||
privacyEmail: '',
|
||||
privacyPolicyContent: '',
|
||||
imprintUrl: '',
|
||||
contactUrl: '',
|
||||
sponsorsUrl: '',
|
||||
socialLinks: [],
|
||||
faq: [],
|
||||
}
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(new Error('Icon konnte nicht gelesen werden.'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function validateRasterDimensions(file: File) {
|
||||
if (file.type === 'image/svg+xml') {
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
if (image.naturalWidth > 512 || image.naturalHeight > 512) {
|
||||
reject(new Error('Icon ist zu groß. Maximal erlaubt sind 512 x 512 px.'))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
image.onerror = () => reject(new Error('Icon-Abmessungen konnten nicht geprüft werden.'))
|
||||
image.src = objectUrl
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminContentManager() {
|
||||
const store = useAwardsStore()
|
||||
const form = reactive(createEmptyForm())
|
||||
const saving = ref(false)
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref('')
|
||||
const privacyPreviewOpen = ref(false)
|
||||
const iconUploadError = ref('')
|
||||
|
||||
watch(
|
||||
() => store.adminSiteSettings,
|
||||
(settings) => {
|
||||
form.hostDisplayName = settings.hostDisplayName
|
||||
form.hostTagline = settings.hostTagline
|
||||
form.newsletterUrl = settings.newsletterUrl
|
||||
form.privacyEmail = settings.privacyEmail
|
||||
form.privacyPolicyContent = settings.privacyPolicyContent
|
||||
form.imprintUrl = settings.imprintUrl
|
||||
form.contactUrl = settings.contactUrl
|
||||
form.sponsorsUrl = settings.sponsorsUrl
|
||||
form.socialLinks = settings.socialLinks.map((item) => ({
|
||||
label: item.label ?? '',
|
||||
platform: item.platform ?? '',
|
||||
icon: item.icon ?? '',
|
||||
url: item.url ?? '',
|
||||
showOnHost: item.showOnHost ?? true,
|
||||
showOnCommunity: item.showOnCommunity ?? true,
|
||||
}))
|
||||
form.faq = settings.faq.map((item) => ({
|
||||
question: item.question ?? '',
|
||||
answer: item.answer ?? '',
|
||||
}))
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
const privacyPreviewBlocks = computed(() =>
|
||||
form.privacyPolicyContent
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const privacyUpdatedLabel = computed(() => {
|
||||
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
|
||||
if (!updatedAt) {
|
||||
return 'Noch keine Änderung gespeichert'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(updatedAt))
|
||||
})
|
||||
|
||||
function addSocialLink() {
|
||||
form.socialLinks.push({
|
||||
label: 'Neu',
|
||||
platform: 'website',
|
||||
icon: '',
|
||||
url: '',
|
||||
showOnHost: true,
|
||||
showOnCommunity: true,
|
||||
})
|
||||
}
|
||||
|
||||
function removeSocialLink(index: number) {
|
||||
form.socialLinks.splice(index, 1)
|
||||
}
|
||||
|
||||
function addFaqItem() {
|
||||
form.faq.push({ question: '', answer: '' })
|
||||
}
|
||||
|
||||
function removeFaqItem(index: number) {
|
||||
form.faq.splice(index, 1)
|
||||
}
|
||||
|
||||
function isUploadedIcon(icon: string) {
|
||||
return icon.startsWith('data:image/')
|
||||
}
|
||||
|
||||
function socialIconKey(social: SocialLinkForm) {
|
||||
return normalizeSocialIconKey(social.icon || social.platform)
|
||||
}
|
||||
|
||||
function selectedSocialIconValue(social: SocialLinkForm) {
|
||||
return socialIconOptionForKey(social.platform)
|
||||
? normalizeSocialIconKey(social.platform)
|
||||
: 'custom'
|
||||
}
|
||||
|
||||
function handleSocialIconSelection(event: Event, index: number) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
const social = form.socialLinks[index]
|
||||
if (!social) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === 'custom') {
|
||||
if (socialIconOptionForKey(social.platform)) {
|
||||
social.platform = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const option = socialIconOptionForKey(value)
|
||||
social.platform = value
|
||||
if (!social.label || social.label === 'Neu' || social.label === 'Website / Fallback') {
|
||||
social.label = option?.label ?? value
|
||||
}
|
||||
if (social.icon && !isUploadedIcon(social.icon)) {
|
||||
social.icon = ''
|
||||
}
|
||||
}
|
||||
|
||||
function hasSocialIconPreview(social: SocialLinkForm) {
|
||||
return Boolean(social.label || social.platform || social.icon)
|
||||
}
|
||||
|
||||
function socialIconModeLabel(social: SocialLinkForm) {
|
||||
if (isUploadedIcon(social.icon)) {
|
||||
return 'hochgeladenes Custom Icon'
|
||||
}
|
||||
|
||||
const icon = simpleIconForKey(socialIconKey(social))
|
||||
if (icon) {
|
||||
return `eingebautes ${icon.title} Icon`
|
||||
}
|
||||
|
||||
return 'generisches Fallback Icon'
|
||||
}
|
||||
|
||||
function socialSimpleIconPath(social: SocialLinkForm) {
|
||||
return simpleIconForKey(socialIconKey(social))?.path ?? ''
|
||||
}
|
||||
|
||||
function socialSimpleIconColor(social: SocialLinkForm) {
|
||||
return `#${simpleIconForKey(socialIconKey(social))?.hex ?? '8b5cf6'}`
|
||||
}
|
||||
|
||||
async function handleSocialIconUpload(event: Event, index: number) {
|
||||
iconUploadError.value = ''
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
iconUploadError.value = 'Bitte PNG, JPG, WebP oder SVG hochladen.'
|
||||
return
|
||||
}
|
||||
|
||||
const maxBytes = 256 * 1024
|
||||
if (file.size > maxBytes) {
|
||||
iconUploadError.value = 'Icon ist zu groß. Maximal erlaubt sind 256 KB.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await validateRasterDimensions(file)
|
||||
form.socialLinks[index].icon = await readFileAsDataUrl(file)
|
||||
} catch (error) {
|
||||
iconUploadError.value = error instanceof Error ? error.message : 'Icon konnte nicht hochgeladen werden.'
|
||||
}
|
||||
}
|
||||
|
||||
function clearSocialIcon(index: number) {
|
||||
form.socialLinks[index].icon = ''
|
||||
}
|
||||
|
||||
async function saveSiteSettings(sectionLabel = 'Landingpage-Inhalte') {
|
||||
saving.value = true
|
||||
saveMessage.value = ''
|
||||
saveError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSiteSettings({
|
||||
hostDisplayName: form.hostDisplayName,
|
||||
hostTagline: form.hostTagline,
|
||||
newsletterUrl: form.newsletterUrl,
|
||||
privacyEmail: form.privacyEmail,
|
||||
privacyPolicyContent: form.privacyPolicyContent,
|
||||
imprintUrl: form.imprintUrl,
|
||||
contactUrl: form.contactUrl,
|
||||
sponsorsUrl: form.sponsorsUrl,
|
||||
socialLinks: form.socialLinks
|
||||
.map((item) => ({
|
||||
label: item.label.trim(),
|
||||
platform: item.platform.trim(),
|
||||
icon: item.icon.trim(),
|
||||
url: item.url.trim(),
|
||||
showOnHost: item.showOnHost,
|
||||
showOnCommunity: item.showOnCommunity,
|
||||
}))
|
||||
.filter((item) => item.label && item.platform && item.url),
|
||||
faq: form.faq
|
||||
.map((item) => ({
|
||||
question: item.question.trim(),
|
||||
answer: item.answer.trim(),
|
||||
}))
|
||||
.filter((item) => item.question && item.answer),
|
||||
})
|
||||
saveMessage.value = `${sectionLabel} gespeichert und auf der Landingpage aktualisiert.`
|
||||
} catch (error) {
|
||||
saveError.value = error instanceof Error ? error.message : 'Inhalte konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
saving,
|
||||
saveMessage,
|
||||
saveError,
|
||||
privacyPreviewOpen,
|
||||
iconUploadError,
|
||||
privacyPreviewBlocks,
|
||||
privacyUpdatedLabel,
|
||||
addSocialLink,
|
||||
removeSocialLink,
|
||||
addFaqItem,
|
||||
removeFaqItem,
|
||||
isUploadedIcon,
|
||||
selectedSocialIconValue,
|
||||
handleSocialIconSelection,
|
||||
hasSocialIconPreview,
|
||||
socialIconModeLabel,
|
||||
socialSimpleIconPath,
|
||||
socialSimpleIconColor,
|
||||
handleSocialIconUpload,
|
||||
clearSocialIcon,
|
||||
saveSiteSettings,
|
||||
adminSiteSettings: computed(() => store.adminSiteSettings),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { computed } from 'vue'
|
||||
import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/vue'
|
||||
|
||||
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const metricToneMap = {
|
||||
Nominierungen: {
|
||||
icon: Sparkles,
|
||||
source: 'Nomination-Tabelle',
|
||||
},
|
||||
Stimmen: {
|
||||
icon: BarChart3,
|
||||
source: 'VoteEntries-Tabelle',
|
||||
},
|
||||
Kategorien: {
|
||||
icon: Tags,
|
||||
source: 'Categories-Tabelle',
|
||||
},
|
||||
'Reviews offen': {
|
||||
icon: Clock3,
|
||||
source: 'Nominations-Review-Status',
|
||||
},
|
||||
Risikohinweise: {
|
||||
icon: ShieldAlert,
|
||||
source: 'RiskFlags-Tabelle',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function useAdminDashboardOverview() {
|
||||
const store = useAwardsStore()
|
||||
|
||||
const metrics = computed(() => store.admin.metrics)
|
||||
const activities = computed(() => store.admin.activities)
|
||||
const topCategories = computed(() => store.admin.topCategories)
|
||||
const metricCards = computed(() =>
|
||||
metrics.value.map((metric) => ({
|
||||
...metric,
|
||||
...(metricToneMap[metric.label as keyof typeof metricToneMap] ?? {
|
||||
icon: BarChart3,
|
||||
source: 'Backend-Metrik',
|
||||
}),
|
||||
})),
|
||||
)
|
||||
const openReviewCount = computed(() => store.adminSeasonDetail.pendingNominations.length)
|
||||
const openRiskCount = computed(() => getRiskMetricValue(metrics.value))
|
||||
const liveSummary = computed(() => {
|
||||
const phase = store.adminSeasonDetail.currentPhase || 'nicht gesetzt'
|
||||
const reviewText = openReviewCount.value === 1 ? '1 offene Review' : `${openReviewCount.value} offene Reviews`
|
||||
const riskText = openRiskCount.value === 1 ? '1 Risikohinweis' : `${openRiskCount.value} Risikohinweise`
|
||||
const categoryText = store.adminSeasonDetail.categories.length === 1
|
||||
? '1 Kategorie'
|
||||
: `${store.adminSeasonDetail.categories.length} Kategorien`
|
||||
const candidateText = store.adminSeasonDetail.candidates.length === 1
|
||||
? '1 Kandidat'
|
||||
: `${store.adminSeasonDetail.candidates.length} Kandidaten`
|
||||
|
||||
return `Aktuelle Phase: ${phase}. ${categoryText}, ${candidateText}, ${reviewText} und ${riskText} kommen direkt aus der Admin-API.`
|
||||
})
|
||||
const liveStatusBadge = computed(() => {
|
||||
if (openRiskCount.value > 0) {
|
||||
return `${openRiskCount.value} Risiko offen`
|
||||
}
|
||||
|
||||
if (openReviewCount.value > 0) {
|
||||
return `${openReviewCount.value} Review offen`
|
||||
}
|
||||
|
||||
return 'Betrieb wirkt sauber'
|
||||
})
|
||||
const maxCategoryVotes = computed(() => Math.max(...topCategories.value.map((category) => category.votes), 1))
|
||||
const totalCategoryVotes = computed(() => topCategories.value.reduce((sum, category) => sum + category.votes, 0))
|
||||
const yearTotals = computed(() => [
|
||||
{
|
||||
label: 'Nominierungen gesamt',
|
||||
value: metrics.value.find((metric) => metric.label === 'Nominierungen')?.value ?? 0,
|
||||
note: `im Award-Jahr ${store.adminSeasonDetail.year}`,
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
label: 'Stimmen gesamt',
|
||||
value: getVoteMetricValue(metrics.value),
|
||||
note: 'alle abgegebenen Votes',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
label: 'Kandidaten',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
note: 'für Voting und Archiv gepflegt',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Kategorien',
|
||||
value: store.adminSeasonDetail.categories.length,
|
||||
note: 'aktive Award-Kategorien',
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
label: 'Offene Reviews',
|
||||
value: store.adminSeasonDetail.pendingNominations.length,
|
||||
note: 'brauchen Team-Entscheidung',
|
||||
icon: Clock3,
|
||||
},
|
||||
{
|
||||
label: 'Risikohinweise',
|
||||
value: openRiskCount.value,
|
||||
note: 'aktuell offen',
|
||||
icon: ShieldAlert,
|
||||
},
|
||||
])
|
||||
const priorityActions = computed(() => [
|
||||
{
|
||||
label: 'Reviews bearbeiten',
|
||||
value: store.adminSeasonDetail.pendingNominations.length,
|
||||
to: '/admin/nominations?review=1',
|
||||
hint: 'Freitext-Nominierungen warten auf Entscheidung',
|
||||
icon: Sparkles,
|
||||
tone: 'violet',
|
||||
},
|
||||
{
|
||||
label: 'Risiko prüfen',
|
||||
value: openRiskCount.value,
|
||||
to: '/admin/risk',
|
||||
hint: 'Auffällige Muster brauchen Sichtung',
|
||||
icon: ShieldAlert,
|
||||
tone: 'rose',
|
||||
},
|
||||
{
|
||||
label: 'Kategorien pflegen',
|
||||
value: store.adminSeasonDetail.categories.length,
|
||||
to: '/admin/categories',
|
||||
hint: 'Texte, Limits und Reihenfolge aktuell halten',
|
||||
icon: Tags,
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
label: 'Kandidatenbasis',
|
||||
value: store.adminSeasonDetail.candidates.length,
|
||||
to: '/admin/candidates',
|
||||
hint: 'Kandidaten und Plattformen schnell prüfen',
|
||||
icon: Users,
|
||||
tone: 'emerald',
|
||||
},
|
||||
])
|
||||
const operationChecks = computed(() => {
|
||||
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
|
||||
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||
)
|
||||
const categoriesWithReviews = store.adminSeasonDetail.categories.filter((category) =>
|
||||
store.adminSeasonDetail.pendingNominations.some((nomination) => nomination.categoryId === category.id),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Kategorien ohne Kandidaten',
|
||||
value: categoriesWithoutCandidates.length,
|
||||
to: '/admin/categories',
|
||||
state: categoriesWithoutCandidates.length === 0 ? 'ok' : 'warn',
|
||||
note: categoriesWithoutCandidates.length === 0 ? 'Alle Kategorien sind besetzt.' : 'Vor Voting-Endspurt prüfen.',
|
||||
},
|
||||
{
|
||||
label: 'Review-Backlog verteilt',
|
||||
value: categoriesWithReviews.length,
|
||||
to: '/admin/nominations',
|
||||
state: categoriesWithReviews.length <= 1 ? 'ok' : 'warn',
|
||||
note: categoriesWithReviews.length <= 1 ? 'Backlog ist fokussiert.' : 'Mehrere Kategorien brauchen Sichtung.',
|
||||
},
|
||||
{
|
||||
label: 'Risk Flags offen',
|
||||
value: openRiskCount.value,
|
||||
to: '/admin/risk',
|
||||
state: openRiskCount.value === 0 ? 'ok' : 'danger',
|
||||
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
store,
|
||||
activities,
|
||||
topCategories,
|
||||
metricCards,
|
||||
openReviewCount,
|
||||
openRiskCount,
|
||||
liveSummary,
|
||||
liveStatusBadge,
|
||||
maxCategoryVotes,
|
||||
totalCategoryVotes,
|
||||
yearTotals,
|
||||
priorityActions,
|
||||
operationChecks,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { ApiRequestError, api } from '../../lib/api'
|
||||
import { clearSiteStatusCache } from '../../lib/siteStatus'
|
||||
import type { AdminOperationalSettingsResponse } from '../../types/awards'
|
||||
import type { AdminOperationalSettingsForm, AdminSettingsStatusSummary } from './adminSettingsTypes'
|
||||
|
||||
const fallbackMaintenanceTitle = 'Sternenpause'
|
||||
const fallbackMaintenanceMessage = 'Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei.'
|
||||
|
||||
export function useAdminOperationalSettings() {
|
||||
const operationalLoading = ref(true)
|
||||
const operationalSaving = ref(false)
|
||||
const operationalError = ref('')
|
||||
const operationalSuccess = ref('')
|
||||
const demoPasswordSet = ref(false)
|
||||
const demoManagedByDatabase = ref(false)
|
||||
const demoPasswordInput = ref('')
|
||||
const savedOperationalSnapshot = ref('')
|
||||
|
||||
const operationalForm = reactive<AdminOperationalSettingsForm>({
|
||||
demoLoginEnabled: false,
|
||||
demoLoginIdentifier: '',
|
||||
demoLoginTwitchUserId: '',
|
||||
demoLoginDisplayName: '',
|
||||
maintenanceModeEnabled: false,
|
||||
maintenanceTitle: fallbackMaintenanceTitle,
|
||||
maintenanceMessage: fallbackMaintenanceMessage,
|
||||
})
|
||||
|
||||
const demoPasswordHint = computed(() => {
|
||||
if (demoPasswordInput.value.trim()) {
|
||||
return 'Dieses neue Passwort wird beim Speichern gesetzt.'
|
||||
}
|
||||
|
||||
if (demoPasswordSet.value) {
|
||||
return demoManagedByDatabase.value
|
||||
? 'Passwort ist gesetzt. Leer lassen, wenn es bleiben soll.'
|
||||
: 'Aktuell kommt das Passwort aus der Demo-Config. Beim Speichern wird es in die DB übernommen.'
|
||||
}
|
||||
|
||||
return 'Noch kein Demo-Passwort gesetzt. Zum Aktivieren bitte ein Passwort vergeben.'
|
||||
})
|
||||
|
||||
const demoCredentialsComplete = computed(() =>
|
||||
!operationalForm.demoLoginEnabled
|
||||
|| (
|
||||
Boolean(operationalForm.demoLoginIdentifier.trim())
|
||||
&& Boolean(operationalForm.demoLoginTwitchUserId.trim())
|
||||
&& Boolean(operationalForm.demoLoginDisplayName.trim())
|
||||
&& (demoPasswordSet.value || Boolean(demoPasswordInput.value.trim()))
|
||||
),
|
||||
)
|
||||
|
||||
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
|
||||
{
|
||||
label: 'Demo Login',
|
||||
value: operationalForm.demoLoginEnabled ? 'Aktiv' : 'Aus',
|
||||
note: operationalForm.demoLoginEnabled
|
||||
? demoCredentialsComplete.value
|
||||
? 'Öffentlicher Zugriff ist geschützt.'
|
||||
: 'Aktivierung braucht vollständige Zugangsdaten.'
|
||||
: 'Landingpage ist öffentlich erreichbar.',
|
||||
tone: operationalForm.demoLoginEnabled
|
||||
? demoCredentialsComplete.value ? 'warning' : 'danger'
|
||||
: 'good',
|
||||
},
|
||||
{
|
||||
label: 'Wartungsmodus',
|
||||
value: operationalForm.maintenanceModeEnabled ? 'Aktiv' : 'Aus',
|
||||
note: operationalForm.maintenanceModeEnabled
|
||||
? 'Besucher sehen die Wartungsseite.'
|
||||
: 'Öffentliche Seiten werden normal ausgeliefert.',
|
||||
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
|
||||
},
|
||||
{
|
||||
label: 'Passwort',
|
||||
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
|
||||
note: demoManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration',
|
||||
tone: demoPasswordSet.value ? 'good' : 'warning',
|
||||
},
|
||||
])
|
||||
|
||||
const hasUnsavedOperationalChanges = computed(() =>
|
||||
Boolean(savedOperationalSnapshot.value) &&
|
||||
buildOperationalSnapshot() !== savedOperationalSnapshot.value,
|
||||
)
|
||||
|
||||
function applyOperationalSettings(response: AdminOperationalSettingsResponse) {
|
||||
operationalForm.demoLoginEnabled = response.demoLoginEnabled
|
||||
operationalForm.demoLoginIdentifier = response.demoLoginEmail
|
||||
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
|
||||
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
|
||||
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
|
||||
operationalForm.maintenanceTitle = response.maintenanceTitle
|
||||
operationalForm.maintenanceMessage = response.maintenanceMessage
|
||||
demoPasswordSet.value = response.demoLoginPasswordSet
|
||||
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
|
||||
demoPasswordInput.value = ''
|
||||
rememberSavedOperationalSettings()
|
||||
}
|
||||
|
||||
function buildOperationalSnapshot() {
|
||||
return JSON.stringify({
|
||||
demoLoginEnabled: operationalForm.demoLoginEnabled,
|
||||
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
|
||||
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
|
||||
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
|
||||
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
|
||||
maintenanceTitle: operationalForm.maintenanceTitle,
|
||||
maintenanceMessage: operationalForm.maintenanceMessage,
|
||||
demoPasswordInput: demoPasswordInput.value,
|
||||
})
|
||||
}
|
||||
|
||||
function rememberSavedOperationalSettings() {
|
||||
savedOperationalSnapshot.value = buildOperationalSnapshot()
|
||||
}
|
||||
|
||||
async function loadOperationalSettings(options: { silent?: boolean } = {}) {
|
||||
if (!options.silent) {
|
||||
operationalLoading.value = true
|
||||
operationalError.value = ''
|
||||
}
|
||||
|
||||
try {
|
||||
applyOperationalSettings(await api.getAdminOperationalSettings())
|
||||
} catch (error) {
|
||||
operationalError.value = error instanceof ApiRequestError
|
||||
? error.message
|
||||
: 'Operational Settings konnten nicht geladen werden.'
|
||||
} finally {
|
||||
if (!options.silent) {
|
||||
operationalLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateOperationalSettings() {
|
||||
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
|
||||
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
|
||||
return false
|
||||
}
|
||||
|
||||
if (operationalForm.demoLoginEnabled && !demoCredentialsComplete.value) {
|
||||
operationalError.value = 'Demo-Login braucht Login, Twitch-ID, Anzeigenamen und ein gesetztes Passwort.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveOperationalSettings() {
|
||||
operationalError.value = ''
|
||||
operationalSuccess.value = ''
|
||||
|
||||
if (!validateOperationalSettings()) {
|
||||
return
|
||||
}
|
||||
|
||||
operationalSaving.value = true
|
||||
try {
|
||||
const result = await api.updateAdminOperationalSettings({
|
||||
...operationalForm,
|
||||
demoLoginEmail: operationalForm.demoLoginIdentifier,
|
||||
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
|
||||
})
|
||||
|
||||
demoPasswordSet.value = result.demoLoginPasswordSet
|
||||
demoManagedByDatabase.value = true
|
||||
demoPasswordInput.value = ''
|
||||
await loadOperationalSettings({ silent: true })
|
||||
clearSiteStatusCache()
|
||||
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
|
||||
} catch (error) {
|
||||
operationalError.value = error instanceof ApiRequestError
|
||||
? error.message
|
||||
: 'Operational Settings konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
operationalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
operationalForm.demoLoginEnabled,
|
||||
operationalForm.demoLoginIdentifier,
|
||||
operationalForm.demoLoginTwitchUserId,
|
||||
operationalForm.demoLoginDisplayName,
|
||||
operationalForm.maintenanceModeEnabled,
|
||||
operationalForm.maintenanceTitle,
|
||||
operationalForm.maintenanceMessage,
|
||||
demoPasswordInput.value,
|
||||
],
|
||||
() => {
|
||||
if (operationalLoading.value || operationalSaving.value) return
|
||||
operationalError.value = ''
|
||||
operationalSuccess.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(loadOperationalSettings)
|
||||
|
||||
return {
|
||||
operationalLoading,
|
||||
operationalSaving,
|
||||
operationalError,
|
||||
operationalSuccess,
|
||||
operationalForm,
|
||||
demoPasswordSet,
|
||||
demoManagedByDatabase,
|
||||
demoPasswordInput,
|
||||
demoPasswordHint,
|
||||
demoCredentialsComplete,
|
||||
operationalSummary,
|
||||
hasUnsavedOperationalChanges,
|
||||
loadOperationalSettings,
|
||||
saveOperationalSettings,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SOCIAL_ICON_OPTIONS, socialIconOptionForValue } from '../../lib/socialIcons'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
export function useAdminReviewsManager() {
|
||||
const store = useAwardsStore()
|
||||
const route = useRoute()
|
||||
const reviewSaving = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const reviewFilter = ref('')
|
||||
const categoryFilter = ref<number | null>(null)
|
||||
const selectedNominationId = ref<number | null>(null)
|
||||
const reviewForms = reactive<Record<number, {
|
||||
displayName: string
|
||||
channelSlug: string
|
||||
platform: string
|
||||
reviewNote: string
|
||||
}>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const candidatePlatformOptions = computed(() => SOCIAL_ICON_OPTIONS.filter((option) => option.key !== 'website'))
|
||||
const filteredNominations = computed(() => {
|
||||
const query = reviewFilter.value.trim().toLowerCase()
|
||||
return seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||
(!categoryFilter.value || nomination.categoryId === categoryFilter.value) &&
|
||||
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)),
|
||||
)
|
||||
})
|
||||
const selectedNomination = computed(() =>
|
||||
filteredNominations.value.find((nomination) => nomination.id === selectedNominationId.value) ?? filteredNominations.value[0] ?? null,
|
||||
)
|
||||
const reviewStats = computed(() => [
|
||||
{ label: 'Offen', value: seasonDetail.value.pendingNominations.length },
|
||||
{ label: 'Entschieden', value: seasonDetail.value.reviewedNominations.length },
|
||||
{ label: 'Sichtbar', value: filteredNominations.value.length },
|
||||
{ label: 'Kategorien', value: new Set(seasonDetail.value.pendingNominations.map((nomination) => nomination.categoryName)).size },
|
||||
])
|
||||
const reviewedNominations = computed(() => seasonDetail.value.reviewedNominations.slice(0, 12))
|
||||
const categoryOptions = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.filter((category) => seasonDetail.value.pendingNominations.some((nomination) => nomination.categoryId === category.id))
|
||||
.map((category) => ({
|
||||
id: category.id,
|
||||
label: category.name,
|
||||
count: seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length,
|
||||
})),
|
||||
)
|
||||
const selectedCandidateCollision = computed(() => {
|
||||
if (!selectedNomination.value) return null
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
if (!form) return null
|
||||
const normalizedName = form.displayName.trim().toLowerCase()
|
||||
const normalizedSlug = form.channelSlug.trim().toLowerCase()
|
||||
return seasonDetail.value.candidates.find((candidate) =>
|
||||
candidate.categoryId === selectedNomination.value?.categoryId &&
|
||||
(candidate.displayName.trim().toLowerCase() === normalizedName || (!!normalizedSlug && candidate.channelSlug.trim().toLowerCase() === normalizedSlug)),
|
||||
) ?? null
|
||||
})
|
||||
const selectedRelatedPendingNominations = computed(() => {
|
||||
if (!selectedNomination.value) return []
|
||||
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
|
||||
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
|
||||
|
||||
return seasonDetail.value.pendingNominations
|
||||
.filter((nomination) => {
|
||||
if (nomination.id === selectedNomination.value?.id || nomination.categoryId !== selectedNomination.value?.categoryId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sameName = nomination.candidateText.trim().toLowerCase() === selectedName
|
||||
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
|
||||
return sameName || sameStreamUrl
|
||||
})
|
||||
.slice(0, 5)
|
||||
})
|
||||
const selectedNominationSignalSummary = computed(() => {
|
||||
if (!selectedNomination.value) return null
|
||||
|
||||
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
|
||||
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
|
||||
nomination.categoryId === selectedNomination.value?.categoryId &&
|
||||
nomination.candidateText.trim().toLowerCase() === selectedName,
|
||||
)
|
||||
const submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
|
||||
const platforms = new Set(
|
||||
relatedNominations
|
||||
.map((nomination) => resolveStreamIdentity(extractNominationStreamUrl(nomination)).platform)
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
return {
|
||||
submissions: relatedNominations.length,
|
||||
uniqueSubmitters: submitters.size,
|
||||
platforms: [...platforms].sort(),
|
||||
}
|
||||
})
|
||||
const canApproveSelected = computed(() => {
|
||||
if (!selectedNomination.value) return false
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
return Boolean(form?.displayName.trim() && form.channelSlug.trim() && form.platform.trim())
|
||||
})
|
||||
|
||||
function focusNominationFromRoute() {
|
||||
const rawNominationId = Array.isArray(route.query.nominationId) ? route.query.nominationId[0] : route.query.nominationId
|
||||
const nominationId = Number(rawNominationId)
|
||||
if (!Number.isFinite(nominationId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nomination = seasonDetail.value.pendingNominations.find((item) => item.id === nominationId)
|
||||
if (!nomination) {
|
||||
return false
|
||||
}
|
||||
|
||||
reviewFilter.value = ''
|
||||
categoryFilter.value = nomination.categoryId
|
||||
selectedNominationId.value = nomination.id
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
for (const nomination of detail.pendingNominations) {
|
||||
const streamIdentity = resolveStreamIdentity(extractNominationStreamUrl(nomination))
|
||||
reviewForms[nomination.id] = {
|
||||
displayName: nomination.candidateText,
|
||||
channelSlug: streamIdentity.channelSlug,
|
||||
platform: streamIdentity.platform,
|
||||
reviewNote: nomination.reviewNote ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
if (focusNominationFromRoute()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!detail.pendingNominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||
selectedNominationId.value = detail.pendingNominations[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.nominationId,
|
||||
() => {
|
||||
focusNominationFromRoute()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
filteredNominations,
|
||||
(nominations) => {
|
||||
if (!nominations.some((nomination) => nomination.id === selectedNominationId.value)) {
|
||||
selectedNominationId.value = nominations[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
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 übernommen.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht übernommen 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, {
|
||||
reviewNote: reviewForms[nominationId]?.reviewNote || undefined,
|
||||
})
|
||||
adminMessage.value = 'Nominierung wurde aus der Review-Liste entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Nominierung konnte nicht verworfen werden.'
|
||||
} finally {
|
||||
reviewSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectedPlatformValue(platform: string) {
|
||||
return socialIconOptionForValue(platform)?.key ?? 'custom'
|
||||
}
|
||||
|
||||
function setPlatform(platform: string) {
|
||||
if (!selectedNomination.value) return
|
||||
const form = reviewForms[selectedNomination.value.id]
|
||||
if (!form) return
|
||||
|
||||
if (platform === 'custom') {
|
||||
if (socialIconOptionForValue(form.platform)) {
|
||||
form.platform = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
form.platform = socialIconOptionForValue(platform)?.label ?? platform
|
||||
}
|
||||
|
||||
function handlePlatformSelection(event: Event) {
|
||||
setPlatform((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
return {
|
||||
reviewSaving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
reviewForms,
|
||||
seasonDetail,
|
||||
reviewFilter,
|
||||
categoryFilter,
|
||||
selectedNominationId,
|
||||
candidatePlatformOptions,
|
||||
filteredNominations,
|
||||
selectedNomination,
|
||||
reviewStats,
|
||||
reviewedNominations,
|
||||
categoryOptions,
|
||||
selectedCandidateCollision,
|
||||
selectedRelatedPendingNominations,
|
||||
selectedNominationSignalSummary,
|
||||
canApproveSelected,
|
||||
approveNomination,
|
||||
rejectNomination,
|
||||
selectedPlatformValue,
|
||||
handlePlatformSelection,
|
||||
extractNominationStreamUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function extractNominationStreamUrl(nomination: { streamUrl?: string | null; reviewNote?: string | null }) {
|
||||
if (nomination.streamUrl?.trim()) {
|
||||
return nomination.streamUrl.trim()
|
||||
}
|
||||
|
||||
const noteMatch = nomination.reviewNote?.match(/Stream-Link:\s*(https?:\/\/\S+)/i)
|
||||
return noteMatch?.[1]?.trim() ?? ''
|
||||
}
|
||||
|
||||
function resolveStreamIdentity(streamUrl: string) {
|
||||
const fallback = { platform: 'Twitch', channelSlug: '' }
|
||||
if (!streamUrl) return fallback
|
||||
|
||||
try {
|
||||
const url = new URL(streamUrl)
|
||||
const host = url.hostname.replace(/^www\./, '').toLowerCase()
|
||||
const firstPathPart = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.find((part) => !['c', 'channel', 'user', 'live'].includes(part.toLowerCase()))
|
||||
?.replace(/^@/, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '') ?? ''
|
||||
|
||||
if (host.includes('twitch.tv')) {
|
||||
return { platform: 'Twitch', channelSlug: firstPathPart }
|
||||
}
|
||||
|
||||
if (host.includes('kick.com')) {
|
||||
return { platform: 'Kick', channelSlug: firstPathPart }
|
||||
}
|
||||
|
||||
if (host.includes('youtube.com') || host.includes('youtu.be')) {
|
||||
return { platform: 'YouTube', channelSlug: firstPathPart }
|
||||
}
|
||||
|
||||
const platform = host.split('.')[0]
|
||||
return {
|
||||
platform: platform ? `${platform.charAt(0).toUpperCase()}${platform.slice(1)}` : fallback.platform,
|
||||
channelSlug: firstPathPart,
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminRiskFlag, AdminRiskRule } from '../../types/awards'
|
||||
|
||||
export type RiskResolutionStatus = 'open' | 'resolved' | 'dismissed'
|
||||
|
||||
export interface RiskMetadataItem {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type SeverityFilter = 'all' | 'high' | 'medium' | 'low'
|
||||
type HistoryStatusFilter = 'all' | 'resolved' | 'dismissed'
|
||||
const QUEUE_PAGE_SIZE = 12
|
||||
const HISTORY_PAGE_SIZE = 10
|
||||
|
||||
export function riskSeverityClass(severity: string) {
|
||||
const normalized = severity.toLowerCase()
|
||||
if (normalized === 'high') return 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
if (normalized === 'medium') return 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
if (normalized === 'low') return 'border-sky-200 bg-sky-50 text-sky-700'
|
||||
return 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
}
|
||||
|
||||
export function riskStatusLabel(status: string) {
|
||||
if (status === 'resolved') return 'Erledigt'
|
||||
if (status === 'dismissed') return 'Verworfen'
|
||||
if (status === 'open') return 'Offen'
|
||||
return status
|
||||
}
|
||||
|
||||
export function riskAgeLabel(flag: AdminRiskFlag) {
|
||||
const createdAt = new Date(flag.createdAt)
|
||||
if (Number.isNaN(createdAt.getTime())) return 'unbekannt'
|
||||
|
||||
const diffMinutes = Math.max(0, Math.round((Date.now() - createdAt.getTime()) / 60000))
|
||||
if (diffMinutes < 60) return `${diffMinutes} min`
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
if (diffHours < 48) return `${diffHours} Std.`
|
||||
|
||||
return `${Math.round(diffHours / 24)} Tage`
|
||||
}
|
||||
|
||||
export function useAdminRiskManager() {
|
||||
const store = useAwardsStore()
|
||||
const riskSaving = ref<number | null>(null)
|
||||
const riskLoading = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const riskFilter = ref('')
|
||||
const severityFilter = ref<SeverityFilter>('all')
|
||||
const historyStatusFilter = ref<HistoryStatusFilter>('all')
|
||||
const selectedRiskFlagId = ref<number | null>(null)
|
||||
const selectedDecisionNote = ref('')
|
||||
const queuePage = ref(1)
|
||||
const historyPage = ref(1)
|
||||
const selectedBulkRiskFlagIds = ref<number[]>([])
|
||||
const bulkReviewNote = ref('')
|
||||
const bulkSaving = ref(false)
|
||||
const riskRules = ref<AdminRiskRule[]>([])
|
||||
const riskRulesLoading = ref(false)
|
||||
const riskRulesSaving = ref(false)
|
||||
const queuePageSize = QUEUE_PAGE_SIZE
|
||||
const historyPageSize = HISTORY_PAGE_SIZE
|
||||
|
||||
const riskFlags = computed(() => store.admin.riskFlags)
|
||||
const riskHistory = computed(() => store.adminRiskHistory)
|
||||
const riskLoadedLabel = computed(() => {
|
||||
const total = store.adminRiskFlagsPage.totalCount
|
||||
return total > 0 ? createPageLabel(store.adminRiskFlagsPage.offset, store.adminRiskFlagsPage.returnedCount, total) : 'keine offenen Hinweise'
|
||||
})
|
||||
const queuePageLabel = computed(() =>
|
||||
createPageLabel(store.adminRiskFlagsPage.offset, store.adminRiskFlagsPage.returnedCount, store.adminRiskFlagsPage.totalCount),
|
||||
)
|
||||
const queueHasPrevious = computed(() => queuePage.value > 1)
|
||||
const queueHasMore = computed(() => store.adminRiskFlagsPage.hasMore)
|
||||
const historyPageLabel = computed(() =>
|
||||
createPageLabel(store.adminRiskHistoryPage.offset, store.adminRiskHistoryPage.returnedCount, store.adminRiskHistoryPage.totalCount),
|
||||
)
|
||||
const historyHasPrevious = computed(() => historyPage.value > 1)
|
||||
const historyHasMore = computed(() => store.adminRiskHistoryPage.hasMore)
|
||||
|
||||
const filteredRiskFlags = computed(() => riskFlags.value)
|
||||
const selectedRiskFlag = computed(() =>
|
||||
filteredRiskFlags.value.find((flag) => flag.id === selectedRiskFlagId.value) ?? filteredRiskFlags.value[0] ?? null,
|
||||
)
|
||||
const selectedRiskMetadata = computed<RiskMetadataItem[]>(() => parseRiskMetadata(selectedRiskFlag.value?.metadataJson))
|
||||
const selectedDecisionNoteLength = computed(() => selectedDecisionNote.value.trim().length)
|
||||
const selectedDecisionReady = computed(() => selectedDecisionNoteLength.value >= 3 && selectedDecisionNoteLength.value <= 500)
|
||||
const canBulkResolve = computed(() =>
|
||||
selectedBulkRiskFlagIds.value.length > 0 &&
|
||||
bulkReviewNote.value.trim().length >= 3 &&
|
||||
bulkReviewNote.value.trim().length <= 500,
|
||||
)
|
||||
|
||||
const riskStats = computed(() => {
|
||||
const highCount = countFor(store.adminRiskFlagsPage.severityCounts, 'high')
|
||||
const uniqueUsers = new Set(riskFlags.value.map((flag) => flag.twitchUserId).filter(Boolean)).size
|
||||
return [
|
||||
{ label: 'Offen', value: store.adminRiskFlagsPage.totalCount, tone: store.adminRiskFlagsPage.totalCount > 0 ? 'warning' : 'ok' },
|
||||
{ label: 'High', value: highCount, tone: highCount > 0 ? 'danger' : 'ok' },
|
||||
{ label: 'Medium', value: countFor(store.adminRiskFlagsPage.severityCounts, 'medium'), tone: 'warning' },
|
||||
{ label: 'User betroffen', value: uniqueUsers, tone: 'neutral' },
|
||||
]
|
||||
})
|
||||
const severityFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: store.adminRiskFlagsPage.totalCount },
|
||||
{ key: 'high' as const, label: 'High', count: countFor(store.adminRiskFlagsPage.severityCounts, 'high') },
|
||||
{ key: 'medium' as const, label: 'Medium', count: countFor(store.adminRiskFlagsPage.severityCounts, 'medium') },
|
||||
{ key: 'low' as const, label: 'Low', count: countFor(store.adminRiskFlagsPage.severityCounts, 'low') },
|
||||
])
|
||||
|
||||
const recentRiskHistory = computed(() => riskHistory.value)
|
||||
const riskHistoryStats = computed(() => [
|
||||
{ label: 'Entschieden', value: store.adminRiskHistoryPage.totalCount },
|
||||
{ label: 'Erledigt', value: countFor(store.adminRiskHistoryPage.statusCounts, 'resolved') },
|
||||
{ label: 'Verworfen', value: countFor(store.adminRiskHistoryPage.statusCounts, 'dismissed') },
|
||||
])
|
||||
const historyStatusFilters = computed(() => [
|
||||
{ key: 'all' as const, label: 'Alle', count: store.adminRiskHistoryPage.totalCount },
|
||||
{ key: 'resolved' as const, label: 'Erledigt', count: countFor(store.adminRiskHistoryPage.statusCounts, 'resolved') },
|
||||
{ key: 'dismissed' as const, label: 'Verworfen', count: countFor(store.adminRiskHistoryPage.statusCounts, 'dismissed') },
|
||||
])
|
||||
|
||||
async function loadRiskFlags() {
|
||||
riskLoading.value = true
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const query = riskFilter.value.trim()
|
||||
await Promise.all([
|
||||
store.loadAdminRiskFlagsPage({
|
||||
limit: queuePageSize,
|
||||
offset: (queuePage.value - 1) * queuePageSize,
|
||||
status: 'open',
|
||||
severity: severityFilter.value === 'all' ? undefined : severityFilter.value,
|
||||
query,
|
||||
}),
|
||||
store.loadAdminRiskHistoryPage({
|
||||
limit: historyPageSize,
|
||||
offset: (historyPage.value - 1) * historyPageSize,
|
||||
status: historyStatusFilter.value,
|
||||
reviewedOnly: true,
|
||||
query,
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risikohinweise konnten nicht geladen werden.'
|
||||
} finally {
|
||||
riskLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setQueuePage(page: number) {
|
||||
queuePage.value = Math.max(1, page)
|
||||
selectedBulkRiskFlagIds.value = []
|
||||
await loadRiskFlags()
|
||||
}
|
||||
|
||||
async function setHistoryPage(page: number) {
|
||||
historyPage.value = Math.max(1, page)
|
||||
await loadRiskFlags()
|
||||
}
|
||||
|
||||
async function updateRiskFlagStatus(riskFlagId: number, status: RiskResolutionStatus) {
|
||||
if (riskSaving.value) return
|
||||
if (status !== 'open' && !selectedDecisionReady.value) {
|
||||
adminError.value = 'Bitte notiere kurz, warum du diesen Hinweis entscheidest.'
|
||||
return
|
||||
}
|
||||
|
||||
riskSaving.value = riskFlagId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const reviewNote = status === 'open' ? 'Wieder geöffnet.' : selectedDecisionNote.value.trim()
|
||||
await store.resolveRiskFlag(riskFlagId, { status, reviewNote })
|
||||
selectedDecisionNote.value = ''
|
||||
await loadRiskFlags()
|
||||
adminMessage.value = `Risikohinweis ${riskFlagId} wurde aktualisiert.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risikohinweis konnte nicht aktualisiert werden.'
|
||||
} finally {
|
||||
riskSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBulkRiskFlag(riskFlagId: number) {
|
||||
const flag = filteredRiskFlags.value.find((item) => item.id === riskFlagId)
|
||||
if (!flag || flag.severity.toLowerCase() !== 'low') return
|
||||
|
||||
selectedBulkRiskFlagIds.value = selectedBulkRiskFlagIds.value.includes(riskFlagId)
|
||||
? selectedBulkRiskFlagIds.value.filter((id) => id !== riskFlagId)
|
||||
: [...selectedBulkRiskFlagIds.value, riskFlagId]
|
||||
}
|
||||
|
||||
function selectVisibleLowRiskFlags() {
|
||||
selectedBulkRiskFlagIds.value = filteredRiskFlags.value
|
||||
.filter((flag) => flag.severity.toLowerCase() === 'low')
|
||||
.map((flag) => flag.id)
|
||||
}
|
||||
|
||||
function clearBulkSelection() {
|
||||
selectedBulkRiskFlagIds.value = []
|
||||
bulkReviewNote.value = ''
|
||||
}
|
||||
|
||||
async function bulkResolveRiskFlags(status: Exclude<RiskResolutionStatus, 'open'>) {
|
||||
if (!canBulkResolve.value || bulkSaving.value) return
|
||||
|
||||
bulkSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.bulkResolveRiskFlags({
|
||||
riskFlagIds: selectedBulkRiskFlagIds.value,
|
||||
status,
|
||||
reviewNote: bulkReviewNote.value.trim(),
|
||||
})
|
||||
adminMessage.value = `${selectedBulkRiskFlagIds.value.length} Risikohinweise wurden aktualisiert.`
|
||||
clearBulkSelection()
|
||||
await loadRiskFlags()
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Bulk-Entscheidung konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
bulkSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateRiskRule(ruleKey: string, patch: Partial<AdminRiskRule>) {
|
||||
riskRules.value = riskRules.value.map((rule) =>
|
||||
rule.key === ruleKey ? { ...rule, ...patch } : rule,
|
||||
)
|
||||
}
|
||||
|
||||
async function saveRiskRules() {
|
||||
riskRulesSaving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const response = await store.updateAdminRiskRules({ rules: riskRules.value })
|
||||
riskRules.value = response.rules
|
||||
adminMessage.value = 'Risk-Regeln wurden gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risk-Regeln konnten nicht gespeichert werden.'
|
||||
} finally {
|
||||
riskRulesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRiskRules() {
|
||||
riskRulesLoading.value = true
|
||||
try {
|
||||
const response = await store.loadAdminRiskRules()
|
||||
riskRules.value = response.rules
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Risk-Regeln konnten nicht geladen werden.'
|
||||
} finally {
|
||||
riskRulesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredRiskFlags, (flags) => {
|
||||
if (!flags.some((flag) => flag.id === selectedRiskFlagId.value)) {
|
||||
selectedRiskFlagId.value = flags[0]?.id ?? null
|
||||
}
|
||||
}, { immediate: true })
|
||||
watch(selectedRiskFlag, (flag) => {
|
||||
selectedDecisionNote.value = flag?.reviewNote ?? ''
|
||||
})
|
||||
let filterReloadTimer: number | undefined
|
||||
watch([riskFilter, severityFilter, historyStatusFilter], () => {
|
||||
queuePage.value = 1
|
||||
historyPage.value = 1
|
||||
window.clearTimeout(filterReloadTimer)
|
||||
filterReloadTimer = window.setTimeout(() => {
|
||||
void loadRiskFlags()
|
||||
}, 250)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadRiskFlags()
|
||||
void loadRiskRules()
|
||||
})
|
||||
|
||||
return {
|
||||
riskSaving,
|
||||
riskLoading,
|
||||
adminMessage,
|
||||
adminError,
|
||||
riskFilter,
|
||||
severityFilter,
|
||||
historyStatusFilter,
|
||||
selectedRiskFlagId,
|
||||
selectedDecisionNote,
|
||||
selectedDecisionNoteLength,
|
||||
selectedDecisionReady,
|
||||
queuePage,
|
||||
queuePageLabel,
|
||||
queueHasPrevious,
|
||||
queueHasMore,
|
||||
historyPage,
|
||||
historyPageLabel,
|
||||
historyHasPrevious,
|
||||
historyHasMore,
|
||||
selectedBulkRiskFlagIds,
|
||||
bulkReviewNote,
|
||||
bulkSaving,
|
||||
canBulkResolve,
|
||||
riskRules,
|
||||
riskRulesLoading,
|
||||
riskRulesSaving,
|
||||
riskFlags,
|
||||
riskHistory,
|
||||
filteredRiskFlags,
|
||||
selectedRiskFlag,
|
||||
selectedRiskMetadata,
|
||||
riskStats,
|
||||
riskHistoryStats,
|
||||
recentRiskHistory,
|
||||
severityFilters,
|
||||
historyStatusFilters,
|
||||
riskLoadedLabel,
|
||||
loadRiskFlags,
|
||||
updateRiskFlagStatus,
|
||||
setQueuePage,
|
||||
setHistoryPage,
|
||||
toggleBulkRiskFlag,
|
||||
selectVisibleLowRiskFlags,
|
||||
clearBulkSelection,
|
||||
bulkResolveRiskFlags,
|
||||
updateRiskRule,
|
||||
saveRiskRules,
|
||||
}
|
||||
}
|
||||
|
||||
function countFor(counts: Array<{ key: string; count: number }>, key: string) {
|
||||
return counts.find((item) => item.key === key)?.count ?? 0
|
||||
}
|
||||
|
||||
function createPageLabel(offset: number, returnedCount: number, totalCount: number) {
|
||||
if (totalCount === 0) return '0 Treffer'
|
||||
|
||||
const start = offset + 1
|
||||
const end = offset + returnedCount
|
||||
return `${start}-${end} / ${totalCount}`
|
||||
}
|
||||
|
||||
function parseRiskMetadata(metadataJson: string | undefined) {
|
||||
if (!metadataJson) return []
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
return Object.entries(parsed)
|
||||
.filter(([key]) => key !== 'entityLinks')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value : JSON.stringify(value),
|
||||
}))
|
||||
} catch {
|
||||
return [{ key: 'raw', value: metadataJson }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { api } from '../../lib/api'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminAuditEntry, AdminSeasonDetailResponse } from '../../types/awards'
|
||||
import { hasShowDayPassed, normalizePhaseKey } from './adminSeasonTimeline'
|
||||
import type {
|
||||
AdminSeasonCopyOption,
|
||||
AdminSeasonCreateForm,
|
||||
AdminSeasonForm,
|
||||
AdminSeasonReadinessItem,
|
||||
} from './adminSeasonTypes'
|
||||
|
||||
function createEmptySeasonForm(): AdminSeasonForm {
|
||||
return {
|
||||
year: new Date().getFullYear(),
|
||||
name: '',
|
||||
showStreamUrl: '',
|
||||
currentPhase: '',
|
||||
isCurrent: false,
|
||||
isCommunityOnly: true,
|
||||
nominationStartsAt: '',
|
||||
nominationEndsAt: '',
|
||||
votingStartsAt: '',
|
||||
votingEndsAt: '',
|
||||
reviewStartsAt: '',
|
||||
reviewEndsAt: '',
|
||||
showDate: '',
|
||||
showStartsAt: '20:00',
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCreateForm(): AdminSeasonCreateForm {
|
||||
return {
|
||||
year: new Date().getFullYear() + 1,
|
||||
name: '',
|
||||
showStreamUrl: 'https://twitch.tv/jayuhime',
|
||||
currentPhase: 'Nominierung',
|
||||
isCurrent: false,
|
||||
isCommunityOnly: true,
|
||||
nominationStartsAt: '',
|
||||
nominationEndsAt: '',
|
||||
votingStartsAt: '',
|
||||
votingEndsAt: '',
|
||||
reviewStartsAt: '',
|
||||
reviewEndsAt: '',
|
||||
showDate: '',
|
||||
showStartsAt: '20:00',
|
||||
copyStructureFromSeasonId: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminSeasonManager() {
|
||||
const store = useAwardsStore()
|
||||
const saving = ref(false)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const createModalOpen = ref(false)
|
||||
const creating = ref(false)
|
||||
const seasonToDelete = ref<{ id: number; year: number; name: string; isCurrent: boolean } | null>(null)
|
||||
const deleting = ref(false)
|
||||
const completing = ref(false)
|
||||
const seasonAuditEntries = ref<AdminAuditEntry[]>([])
|
||||
const loadingSeasonAudit = ref(false)
|
||||
let seasonAuditRequestId = 0
|
||||
|
||||
const form = reactive(createEmptySeasonForm())
|
||||
const createForm = reactive(createEmptyCreateForm())
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const selectedSeasonId = computed(() => store.adminSelectedSeasonId)
|
||||
const selectedSeason = computed(() =>
|
||||
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
|
||||
)
|
||||
const phasePresets = ['Nominierung', 'Community Voting', 'Review & Auswertung', 'Award Show', 'Abgeschlossen']
|
||||
const readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
|
||||
const publicReadinessIssues = computed(() =>
|
||||
readinessItems.value
|
||||
.filter((item) => item.blocking && !item.complete)
|
||||
.map((item) => item.note),
|
||||
)
|
||||
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
|
||||
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
|
||||
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
|
||||
const canCompleteSelectedSeason = computed(() =>
|
||||
Boolean(
|
||||
selectedSeason.value &&
|
||||
normalizePhaseKey(form.currentPhase) !== 'completed' &&
|
||||
archiveReadinessIssues.value.length === 0,
|
||||
),
|
||||
)
|
||||
const canDeleteSelectedSeason = computed(() => Boolean(selectedSeason.value && !selectedSeason.value.isCurrent))
|
||||
const copySourceOptions = computed<AdminSeasonCopyOption[]>(() => [
|
||||
{ label: 'Keine Struktur kopieren', value: null },
|
||||
...store.adminSeasons.map((season) => ({
|
||||
label: `${season.year} · ${season.name} (${season.categoryCount} Kategorien)`,
|
||||
value: season.id,
|
||||
})),
|
||||
])
|
||||
const canCreate = computed(() =>
|
||||
Boolean(
|
||||
createForm.year &&
|
||||
createForm.name.trim() &&
|
||||
createForm.showStreamUrl.trim() &&
|
||||
createForm.currentPhase.trim() &&
|
||||
createForm.nominationStartsAt &&
|
||||
createForm.nominationEndsAt &&
|
||||
createForm.votingStartsAt &&
|
||||
createForm.votingEndsAt &&
|
||||
createForm.reviewStartsAt &&
|
||||
createForm.reviewEndsAt &&
|
||||
createForm.showDate &&
|
||||
createForm.showStartsAt &&
|
||||
createPublicReadinessIssues.value.length === 0,
|
||||
),
|
||||
)
|
||||
const latestSeasonAuditEntry = computed(() => seasonAuditEntries.value[0] ?? null)
|
||||
const latestSeasonAuditSummary = computed(() =>
|
||||
latestSeasonAuditEntry.value?.summary ?? 'Noch keine Aktion fuer dieses Jahr gefunden.',
|
||||
)
|
||||
const latestSeasonAuditMeta = computed(() => {
|
||||
const entry = latestSeasonAuditEntry.value
|
||||
return entry ? `${formatAuditDate(entry.createdAt)} · ${entry.adminTwitchUserId}` : 'Audit wird nach der ersten Aenderung angezeigt.'
|
||||
})
|
||||
|
||||
watch(
|
||||
seasonDetail,
|
||||
(detail) => {
|
||||
form.year = detail.year
|
||||
form.name = detail.name
|
||||
form.showStreamUrl = detail.showStreamUrl
|
||||
form.currentPhase = detail.currentPhase
|
||||
form.isCurrent = detail.isCurrent
|
||||
form.isCommunityOnly = detail.isCommunityOnly
|
||||
form.nominationStartsAt = detail.nominationStartsAt
|
||||
form.nominationEndsAt = detail.nominationEndsAt
|
||||
form.votingStartsAt = detail.votingStartsAt
|
||||
form.votingEndsAt = detail.votingEndsAt
|
||||
form.reviewStartsAt = detail.reviewStartsAt
|
||||
form.reviewEndsAt = detail.reviewEndsAt
|
||||
form.showDate = detail.showDate
|
||||
form.showStartsAt = normalizeTimeInput(detail.showStartsAt)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => createForm.year,
|
||||
(year) => {
|
||||
if (year >= 2020 && (!createForm.name || createForm.name.startsWith('VTuber Star Awards '))) {
|
||||
createForm.name = `VTuber Star Awards ${year}`
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [selectedSeasonId.value, seasonDetail.value.year, seasonDetail.value.name] as const,
|
||||
() => {
|
||||
void loadSeasonAuditEntries()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function fillCreateDefaults(year = new Date().getFullYear() + 1) {
|
||||
createForm.year = year
|
||||
createForm.name = `VTuber Star Awards ${year}`
|
||||
createForm.showStreamUrl = 'https://twitch.tv/jayuhime'
|
||||
createForm.currentPhase = 'Nominierung'
|
||||
createForm.isCurrent = false
|
||||
createForm.isCommunityOnly = true
|
||||
createForm.nominationStartsAt = `${year}-08-01`
|
||||
createForm.nominationEndsAt = `${year}-08-24`
|
||||
createForm.votingStartsAt = `${year}-08-25`
|
||||
createForm.votingEndsAt = `${year}-09-11`
|
||||
createForm.reviewStartsAt = `${year}-09-12`
|
||||
createForm.reviewEndsAt = `${year}-09-15`
|
||||
createForm.showDate = `${year}-09-20`
|
||||
createForm.showStartsAt = '20:00'
|
||||
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
fillCreateDefaults(
|
||||
Math.max(new Date().getFullYear(), (store.adminSeasons[0]?.year ?? new Date().getFullYear()) + 1),
|
||||
)
|
||||
createModalOpen.value = true
|
||||
}
|
||||
|
||||
async function persistSeason(successMessage: string) {
|
||||
if (!selectedSeasonId.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const completesSeasonNow =
|
||||
normalizePhaseKey(form.currentPhase) === 'completed' &&
|
||||
normalizePhaseKey(seasonDetail.value.currentPhase) !== 'completed'
|
||||
if (completesSeasonNow && archiveReadinessIssues.value.length > 0) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = `Abschluss blockiert: ${archiveReadinessIssues.value.join(' ')}`
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.isCurrent && publicReadinessIssues.value.length > 0) {
|
||||
adminMessage.value = ''
|
||||
adminError.value = `Public-Aktivierung blockiert: ${publicReadinessIssues.value.join(' ')}`
|
||||
return false
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.updateAdminSeason(selectedSeasonId.value, normalizeSeasonPayload(form))
|
||||
await loadSeasonAuditEntries()
|
||||
adminMessage.value = successMessage
|
||||
return true
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Jahr konnte nicht gespeichert werden.'
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeason() {
|
||||
return persistSeason('Jahresstatus gespeichert.')
|
||||
}
|
||||
|
||||
async function activatePhase(phase: string) {
|
||||
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousPhase = form.currentPhase
|
||||
form.currentPhase = phase
|
||||
const saved = await persistSeason(`Phase „${phase}“ wurde aktiviert.`)
|
||||
if (!saved) {
|
||||
form.currentPhase = previousPhase
|
||||
}
|
||||
}
|
||||
|
||||
async function completeSeason() {
|
||||
if (!selectedSeasonId.value || completing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
completing.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
if (archiveReadinessIssues.value.length > 0) {
|
||||
adminError.value = `Abschluss blockiert: ${archiveReadinessIssues.value.join(' ')}`
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...form,
|
||||
currentPhase: 'Abgeschlossen',
|
||||
}
|
||||
await store.updateAdminSeason(selectedSeasonId.value, normalizeSeasonPayload(payload))
|
||||
await loadSeasonAuditEntries()
|
||||
form.currentPhase = 'Abgeschlossen'
|
||||
adminMessage.value = `Award-Jahr ${form.year} wurde abgeschlossen.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht abgeschlossen werden.'
|
||||
} finally {
|
||||
completing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSeason() {
|
||||
if (!canCreate.value) {
|
||||
if (createPublicReadinessIssues.value.length > 0) {
|
||||
adminError.value = `Direkte Public-Aktivierung blockiert: ${createPublicReadinessIssues.value.join(' ')}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
await store.createAdminSeason(normalizeSeasonPayload(createForm))
|
||||
createModalOpen.value = false
|
||||
adminMessage.value = createForm.copyStructureFromSeasonId
|
||||
? `Award-Jahr ${createForm.year} wurde mit kopierter Struktur angelegt.`
|
||||
: `Award-Jahr ${createForm.year} wurde angelegt.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht angelegt werden.'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteSeasonModal() {
|
||||
if (!selectedSeason.value) {
|
||||
return
|
||||
}
|
||||
|
||||
seasonToDelete.value = {
|
||||
id: selectedSeason.value.id,
|
||||
year: selectedSeason.value.year,
|
||||
name: selectedSeason.value.name,
|
||||
isCurrent: selectedSeason.value.isCurrent,
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSeason() {
|
||||
if (!seasonToDelete.value || seasonToDelete.value.isCurrent) {
|
||||
return
|
||||
}
|
||||
|
||||
deleting.value = true
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
|
||||
try {
|
||||
const deletedYear = seasonToDelete.value.year
|
||||
await store.deleteAdminSeason(seasonToDelete.value.id)
|
||||
await loadSeasonAuditEntries()
|
||||
seasonToDelete.value = null
|
||||
adminMessage.value = `Award-Jahr ${deletedYear} wurde gelöscht.`
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Award-Jahr konnte nicht gelöscht werden.'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function findCopySourceForYear(year: number) {
|
||||
const previousSeason = [...store.adminSeasons]
|
||||
.filter((season) => season.year < year)
|
||||
.sort((left, right) => right.year - left.year)[0]
|
||||
|
||||
return previousSeason?.id ?? store.adminSeasons[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function loadSeasonAuditEntries() {
|
||||
const detail = seasonDetail.value
|
||||
if (!detail.id) {
|
||||
seasonAuditEntries.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++seasonAuditRequestId
|
||||
loadingSeasonAudit.value = true
|
||||
|
||||
try {
|
||||
const entries = await api.getAdminAuditEntries(100, 'season')
|
||||
if (requestId !== seasonAuditRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
const knownEntityIds = new Set([String(detail.id), String(detail.year)])
|
||||
seasonAuditEntries.value = entries
|
||||
.filter((entry) =>
|
||||
entry.entityType === 'season' &&
|
||||
knownEntityIds.has(entry.entityId) &&
|
||||
entry.actionType.startsWith('season.'),
|
||||
)
|
||||
.slice(0, 5)
|
||||
} catch {
|
||||
if (requestId === seasonAuditRequestId) {
|
||||
seasonAuditEntries.value = []
|
||||
}
|
||||
} finally {
|
||||
if (requestId === seasonAuditRequestId) {
|
||||
loadingSeasonAudit.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
store,
|
||||
form,
|
||||
createForm,
|
||||
saving,
|
||||
adminMessage,
|
||||
adminError,
|
||||
createModalOpen,
|
||||
creating,
|
||||
completing,
|
||||
seasonToDelete,
|
||||
deleting,
|
||||
seasonDetail,
|
||||
selectedSeasonId,
|
||||
selectedSeason,
|
||||
readinessItems,
|
||||
publicReadinessIssues,
|
||||
archiveReadinessIssues,
|
||||
createPublicReadinessIssues,
|
||||
canActivatePublic,
|
||||
phasePresets,
|
||||
canDeleteSelectedSeason,
|
||||
canCompleteSelectedSeason,
|
||||
copySourceOptions,
|
||||
loadingSeasonAudit,
|
||||
latestSeasonAuditSummary,
|
||||
latestSeasonAuditMeta,
|
||||
canCreate,
|
||||
activatePhase,
|
||||
openCreateModal,
|
||||
saveSeason,
|
||||
completeSeason,
|
||||
createSeason,
|
||||
openDeleteSeasonModal,
|
||||
confirmDeleteSeason,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTimeInput(value: string) {
|
||||
return value ? value.slice(0, 5) : '20:00'
|
||||
}
|
||||
|
||||
function normalizeTimeForApi(value: string) {
|
||||
const normalized = normalizeTimeInput(value)
|
||||
return normalized.length === 5 ? `${normalized}:00` : normalized
|
||||
}
|
||||
|
||||
function normalizeSeasonPayload<T extends { showStartsAt: string }>(payload: T): T {
|
||||
return {
|
||||
...payload,
|
||||
showStartsAt: normalizeTimeForApi(payload.showStartsAt),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSeasonFacts(detail: AdminSeasonDetailResponse) {
|
||||
const categoryIdsWithCandidates = new Set(detail.candidates.map((candidate) => candidate.categoryId))
|
||||
const categoryIdsWithResults = new Set(detail.results.map((result) => result.categoryId))
|
||||
const emptyCategories = detail.categories.filter((category) => !categoryIdsWithCandidates.has(category.id)).length
|
||||
const missingResults = detail.categories.filter((category) => !categoryIdsWithResults.has(category.id)).length
|
||||
const pendingClips = detail.clipSubmissions.filter((clip) => clip.status === 'pending').length
|
||||
|
||||
return {
|
||||
emptyCategories,
|
||||
missingResults,
|
||||
pendingClips,
|
||||
}
|
||||
}
|
||||
|
||||
function buildReadinessItems(
|
||||
detail: AdminSeasonDetailResponse,
|
||||
currentPhase: string,
|
||||
): AdminSeasonReadinessItem[] {
|
||||
const facts = resolveSeasonFacts(detail)
|
||||
const phaseKey = normalizePhaseKey(currentPhase)
|
||||
const candidateBlocking = phaseKey !== 'nomination'
|
||||
const winnerBlocking = phaseKey === 'completed'
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Kategorien',
|
||||
note: detail.categories.length > 0
|
||||
? `${detail.categories.length} Kategorien vorbereitet`
|
||||
: 'Mindestens eine Kategorie ist erforderlich.',
|
||||
complete: detail.categories.length > 0,
|
||||
blocking: true,
|
||||
to: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Kandidatenbasis',
|
||||
note: facts.emptyCategories === 0 && detail.candidates.length > 0
|
||||
? 'Alle Kategorien haben Kandidaten.'
|
||||
: candidateBlocking
|
||||
? `${facts.emptyCategories} Kategorien brauchen noch Kandidaten.`
|
||||
: 'In der Nominierung duerfen Kategorien noch leer sein.',
|
||||
complete: detail.categories.length > 0 && facts.emptyCategories === 0 && detail.candidates.length > 0,
|
||||
blocking: candidateBlocking,
|
||||
to: '/admin/candidates',
|
||||
},
|
||||
{
|
||||
label: 'Reviews',
|
||||
note: detail.pendingNominations.length === 0
|
||||
? 'Keine offenen Nominierungsreviews.'
|
||||
: `${detail.pendingNominations.length} Reviews sollten vor Voting-Freeze entschieden werden.`,
|
||||
complete: detail.pendingNominations.length === 0,
|
||||
blocking: false,
|
||||
to: '/admin/nominations?review=1',
|
||||
},
|
||||
{
|
||||
label: 'Clips',
|
||||
note: facts.pendingClips === 0
|
||||
? 'Keine offenen Clip-Pruefungen.'
|
||||
: `${facts.pendingClips} Clips warten noch auf Pruefung.`,
|
||||
complete: facts.pendingClips === 0,
|
||||
blocking: false,
|
||||
to: '/admin/clips',
|
||||
},
|
||||
{
|
||||
label: 'Gewinner / Archiv',
|
||||
note: detail.categories.length > 0 && facts.missingResults === 0
|
||||
? 'Alle Gewinner sind vergeben.'
|
||||
: winnerBlocking
|
||||
? `${facts.missingResults} Kategorien brauchen vor Abschluss einen Gewinner.`
|
||||
: 'Vor Abschluss muessen alle Gewinner gesetzt sein.',
|
||||
complete: detail.categories.length > 0 && facts.missingResults === 0,
|
||||
blocking: winnerBlocking,
|
||||
to: '/admin/analytics',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildArchiveReadinessIssues(detail: AdminSeasonDetailResponse) {
|
||||
const facts = resolveSeasonFacts(detail)
|
||||
const issues: string[] = []
|
||||
|
||||
if (!hasShowDayPassed(detail)) {
|
||||
issues.push(
|
||||
detail.showDate
|
||||
? 'Die Award Show liegt noch nicht in der Vergangenheit.'
|
||||
: 'Lege zuerst ein Datum für die Award Show fest.',
|
||||
)
|
||||
}
|
||||
|
||||
if (detail.categories.length === 0) {
|
||||
issues.push('Mindestens eine Kategorie ist erforderlich.')
|
||||
}
|
||||
|
||||
if (facts.emptyCategories > 0) {
|
||||
issues.push(`${facts.emptyCategories} Kategorien haben noch keine Kandidaten.`)
|
||||
}
|
||||
|
||||
if (facts.missingResults > 0) {
|
||||
issues.push(`${facts.missingResults} Kategorien haben noch keinen Gewinner.`)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function buildCreatePublicReadinessIssues(createForm: AdminSeasonCreateForm) {
|
||||
if (!createForm.isCurrent) {
|
||||
return []
|
||||
}
|
||||
|
||||
const phaseKey = normalizePhaseKey(createForm.currentPhase)
|
||||
const issues: string[] = []
|
||||
if (!createForm.copyStructureFromSeasonId) {
|
||||
issues.push('Kopiere erst eine Vorjahres-Struktur oder lege Kategorien nach dem Erstellen manuell an.')
|
||||
}
|
||||
|
||||
if (phaseKey !== 'nomination') {
|
||||
issues.push('Direkt public ist fuer neue Jahre nur in der Nominierungsphase sinnvoll, weil Kandidaten noch fehlen.')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function formatAuditDate(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { CheckCircle2, Database, FileText, Link2, ShieldCheck, Tags, UserRound } from '@lucide/vue'
|
||||
|
||||
import { getRiskMetricValue } from '../../lib/adminMetrics'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { AdminSettingsCheckItem, AdminSettingsGateItem } from './adminSettingsTypes'
|
||||
|
||||
export function useAdminSettingsOverview() {
|
||||
const store = useAwardsStore()
|
||||
const healthLoading = ref(false)
|
||||
const healthError = ref('')
|
||||
const healthLoadedAt = ref<Date | null>(null)
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const databaseHealth = computed(() => store.databaseHealth)
|
||||
const hasVotingPhase = computed(() => seasonDetail.value.currentPhase.toLowerCase().includes('voting'))
|
||||
const categoriesWithoutCandidates = computed(() =>
|
||||
seasonDetail.value.categories.filter((category) =>
|
||||
!seasonDetail.value.candidates.some((candidate) => candidate.categoryId === category.id),
|
||||
),
|
||||
)
|
||||
const pendingClips = computed(() => seasonDetail.value.clipSubmissions.filter((clip) => clip.status === 'pending').length)
|
||||
const pendingMigrationCount = computed(() => databaseHealth.value.pendingMigrations.length)
|
||||
const openRiskCount = computed(() => getRiskMetricValue(store.admin.metrics))
|
||||
const healthLoadedLabel = computed(() =>
|
||||
healthLoadedAt.value ? healthLoadedAt.value.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : 'Noch nicht aktualisiert',
|
||||
)
|
||||
const siteSettings = computed(() => store.adminSiteSettings)
|
||||
const configuredFooterLinks = computed(() => [
|
||||
siteSettings.value.imprintUrl,
|
||||
siteSettings.value.contactUrl,
|
||||
siteSettings.value.sponsorsUrl,
|
||||
siteSettings.value.newsletterUrl,
|
||||
].filter((url) => url.trim()).length)
|
||||
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
|
||||
{
|
||||
label: 'Host',
|
||||
value: Boolean(siteSettings.value.hostDisplayName.trim() && siteSettings.value.hostTagline.trim()),
|
||||
note: siteSettings.value.hostDisplayName.trim()
|
||||
? siteSettings.value.hostTagline.trim() ? siteSettings.value.hostDisplayName.trim() : 'Host-Tagline fehlt'
|
||||
: 'Kein Host-Name hinterlegt',
|
||||
icon: UserRound,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'Social Links',
|
||||
value: siteSettings.value.socialLinks.length > 0,
|
||||
note: `${siteSettings.value.socialLinks.length} Links gespeichert`,
|
||||
icon: Link2,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'FAQ',
|
||||
value: siteSettings.value.faq.length > 0,
|
||||
note: `${siteSettings.value.faq.length} Fragen gespeichert`,
|
||||
icon: FileText,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'Footer Links',
|
||||
value: configuredFooterLinks.value === 4,
|
||||
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
|
||||
icon: Link2,
|
||||
to: '/admin/content',
|
||||
},
|
||||
{
|
||||
label: 'Datenschutz',
|
||||
value: Boolean(siteSettings.value.privacyPolicyContent.trim() && siteSettings.value.privacyEmail.trim()),
|
||||
note: siteSettings.value.privacyPolicyUpdatedAt
|
||||
? `Zuletzt aktualisiert: ${new Date(siteSettings.value.privacyPolicyUpdatedAt).toLocaleString('de-DE')}`
|
||||
: 'Datenschutztext oder Datenschutz-Mail fehlt',
|
||||
icon: ShieldCheck,
|
||||
to: '/admin/content',
|
||||
},
|
||||
])
|
||||
const contentCompletion = computed(() => contentChecks.value.filter((check) => check.value).length)
|
||||
|
||||
const checks = computed<AdminSettingsCheckItem[]>(() => [
|
||||
{
|
||||
label: 'Backend verbunden',
|
||||
value: store.apiMode === 'api',
|
||||
note: store.apiMode === 'api' ? 'Admin-Daten kommen aus der API.' : 'Fallback-Daten aktiv oder API nicht erreichbar.',
|
||||
icon: Database,
|
||||
to: null,
|
||||
},
|
||||
{
|
||||
label: 'Postgres verbunden',
|
||||
value: databaseHealth.value.canConnect,
|
||||
note: databaseHealth.value.canConnect
|
||||
? `${databaseHealth.value.provider} erreichbar · Quelle: ${databaseHealth.value.configuredConnection.source}.`
|
||||
: databaseHealth.value.error || 'Datenbank ist nicht erreichbar.',
|
||||
icon: Database,
|
||||
to: null,
|
||||
},
|
||||
{
|
||||
label: 'Migrationen aktuell',
|
||||
value: databaseHealth.value.canConnect && pendingMigrationCount.value === 0,
|
||||
note: pendingMigrationCount.value === 0
|
||||
? 'Keine ausstehenden Migrationen.'
|
||||
: `${pendingMigrationCount.value} Migrationen warten auf Anwendung.`,
|
||||
icon: CheckCircle2,
|
||||
to: null,
|
||||
},
|
||||
{
|
||||
label: 'Public-Jahr gesetzt',
|
||||
value: seasonDetail.value.isCurrent,
|
||||
note: seasonDetail.value.isCurrent ? `${seasonDetail.value.year} ist öffentlich markiert.` : 'Das gewählte Jahr ist aktuell intern.',
|
||||
icon: CheckCircle2,
|
||||
to: '/admin/years',
|
||||
},
|
||||
{
|
||||
label: 'Voting-Basis vollständig',
|
||||
value: categoriesWithoutCandidates.value.length === 0 && seasonDetail.value.categories.length > 0,
|
||||
note: categoriesWithoutCandidates.value.length === 0 ? 'Alle Kategorien haben Kandidaten.' : `${categoriesWithoutCandidates.value.length} Kategorien brauchen Kandidaten.`,
|
||||
icon: Tags,
|
||||
to: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Risiko-Queue leer',
|
||||
value: openRiskCount.value === 0,
|
||||
note: `${openRiskCount.value} offene Risikohinweise im Admin-Kontext.`,
|
||||
icon: ShieldCheck,
|
||||
to: '/admin/risk',
|
||||
},
|
||||
])
|
||||
const gates = computed<AdminSettingsGateItem[]>(() => [
|
||||
{
|
||||
label: 'Nominierungsfenster',
|
||||
state: seasonDetail.value.currentPhase.toLowerCase().includes('nomin'),
|
||||
note: seasonDetail.value.currentPhase.toLowerCase().includes('nomin')
|
||||
? 'Öffentliche Nominierung ist aktiv.'
|
||||
: 'Die aktuelle Phase erlaubt keine neuen Nominierungen.',
|
||||
to: '/admin/years',
|
||||
},
|
||||
{
|
||||
label: 'Voting freigeschaltet',
|
||||
state: hasVotingPhase.value && categoriesWithoutCandidates.value.length === 0,
|
||||
note: hasVotingPhase.value ? 'Phase ist Voting; Kategorie-Readiness entscheidet.' : 'Phase ist nicht Voting.',
|
||||
to: '/admin/categories',
|
||||
},
|
||||
{
|
||||
label: 'Clip-Reviews offen',
|
||||
state: pendingClips.value > 0,
|
||||
note: pendingClips.value > 0 ? `${pendingClips.value} Clip-Einreichungen offen.` : 'Keine offenen Clip-Einreichungen.',
|
||||
to: '/admin/clips',
|
||||
},
|
||||
{
|
||||
label: 'Freitext-Reviews geklärt',
|
||||
state: seasonDetail.value.pendingNominations.length === 0,
|
||||
note: `${seasonDetail.value.pendingNominations.length} offene Freitext-Reviews.`,
|
||||
to: '/admin/nominations?review=1',
|
||||
},
|
||||
])
|
||||
|
||||
async function refreshDatabaseHealth() {
|
||||
healthLoading.value = true
|
||||
healthError.value = ''
|
||||
try {
|
||||
await store.loadDatabaseHealth()
|
||||
healthLoadedAt.value = new Date()
|
||||
} catch (error) {
|
||||
healthError.value = error instanceof Error ? error.message : 'Datenbank-Healthcheck konnte nicht geladen werden.'
|
||||
} finally {
|
||||
healthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshDatabaseHealth)
|
||||
|
||||
return {
|
||||
healthLoading,
|
||||
healthError,
|
||||
databaseHealth,
|
||||
pendingMigrationCount,
|
||||
healthLoadedLabel,
|
||||
contentChecks,
|
||||
contentCompletion,
|
||||
checks,
|
||||
gates,
|
||||
refreshDatabaseHealth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, Clock3, ListChecks, Trophy } from '@lucide/vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
const allFilter = 'all'
|
||||
|
||||
export function useAdminWinnersManager() {
|
||||
const store = useAwardsStore()
|
||||
const savingResultForCategory = ref<number | null>(null)
|
||||
const deletingResultId = ref<number | null>(null)
|
||||
const adminMessage = ref('')
|
||||
const adminError = ref('')
|
||||
const query = ref('')
|
||||
const statusFilter = ref(allFilter)
|
||||
const winnerSelections = reactive<Record<number, string>>({})
|
||||
|
||||
const seasonDetail = computed(() => store.adminSeasonDetail)
|
||||
const resultMap = computed(() => new Map(seasonDetail.value.results.map((result) => [result.categoryId, result])))
|
||||
|
||||
const resultRows = computed(() =>
|
||||
seasonDetail.value.categories
|
||||
.map((category) => {
|
||||
const candidates = seasonDetail.value.candidates
|
||||
.filter((candidate) => candidate.categoryId === category.id)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
const existing = resultMap.value.get(category.id) ?? null
|
||||
const openReviews = seasonDetail.value.pendingNominations.filter((nomination) => nomination.categoryId === category.id).length
|
||||
const approvedClips = seasonDetail.value.clipSubmissions.filter((clip) => clip.categoryId === category.id && clip.status === 'approved').length
|
||||
|
||||
return {
|
||||
category,
|
||||
candidates,
|
||||
existing,
|
||||
openReviews,
|
||||
approvedClips,
|
||||
hasPendingReviews: openReviews > 0,
|
||||
isEmpty: candidates.length === 0,
|
||||
isComplete: Boolean(existing),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.category.sortOrder - b.category.sortOrder),
|
||||
)
|
||||
|
||||
const completedCount = computed(() => resultRows.value.filter((row) => row.isComplete).length)
|
||||
const missingCount = computed(() => Math.max(0, resultRows.value.length - completedCount.value))
|
||||
const reviewWarningCount = computed(() => resultRows.value.filter((row) => row.hasPendingReviews).length)
|
||||
const emptyCount = computed(() => resultRows.value.filter((row) => row.isEmpty).length)
|
||||
const completionPct = computed(() => resultRows.value.length === 0 ? 0 : Math.round((completedCount.value / resultRows.value.length) * 100))
|
||||
|
||||
const statusFilters = computed(() => [
|
||||
{ value: allFilter, label: 'Alle', count: resultRows.value.length },
|
||||
{ value: 'open', label: 'Offen', count: missingCount.value },
|
||||
{ value: 'set', label: 'Gesetzt', count: completedCount.value },
|
||||
{ value: 'review', label: 'Reviews offen', count: reviewWarningCount.value },
|
||||
{ value: 'empty', label: 'Ohne Kandidaten', count: emptyCount.value },
|
||||
])
|
||||
|
||||
const visibleResultRows = computed(() => {
|
||||
const normalizedQuery = query.value.trim().toLowerCase()
|
||||
|
||||
return resultRows.value.filter((row) => {
|
||||
const matchesStatus = statusFilter.value === allFilter
|
||||
|| (statusFilter.value === 'open' && !row.isComplete)
|
||||
|| (statusFilter.value === 'set' && row.isComplete)
|
||||
|| (statusFilter.value === 'review' && row.hasPendingReviews)
|
||||
|| (statusFilter.value === 'empty' && row.isEmpty)
|
||||
|
||||
if (!matchesStatus) return false
|
||||
if (!normalizedQuery) return true
|
||||
|
||||
return [
|
||||
row.category.name,
|
||||
row.category.groupName,
|
||||
row.existing?.candidateDisplayName ?? '',
|
||||
...row.candidates.map((candidate) => `${candidate.displayName} ${candidate.channelSlug} ${candidate.platform}`),
|
||||
].some((value) => value.toLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
})
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{
|
||||
label: 'Fortschritt',
|
||||
value: `${completionPct.value}%`,
|
||||
note: `${completedCount.value} von ${resultRows.value.length} Kategorien final.`,
|
||||
icon: Trophy,
|
||||
tone: 'border-violet-100 bg-violet-50 text-violet-800',
|
||||
},
|
||||
{
|
||||
label: 'Offen',
|
||||
value: String(missingCount.value),
|
||||
note: missingCount.value === 0 ? 'Keine Gewinner fehlen.' : 'Diese Kategorien brauchen eine Auswahl.',
|
||||
icon: ListChecks,
|
||||
tone: missingCount.value > 0 ? 'border-sky-100 bg-sky-50 text-sky-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
label: 'Reviews',
|
||||
value: String(reviewWarningCount.value),
|
||||
note: reviewWarningCount.value === 0 ? 'Keine Review-Warnungen.' : 'Vor finaler Freigabe pruefen.',
|
||||
icon: Clock3,
|
||||
tone: reviewWarningCount.value > 0 ? 'border-amber-100 bg-amber-50 text-amber-800' : 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
{
|
||||
label: 'Gesetzt',
|
||||
value: String(completedCount.value),
|
||||
note: 'Speist Archiv und Gewinneransicht.',
|
||||
icon: CheckCircle2,
|
||||
tone: 'border-emerald-100 bg-emerald-50 text-emerald-800',
|
||||
},
|
||||
])
|
||||
|
||||
watch(resultRows, (rows) => {
|
||||
const knownCategoryIds = new Set(rows.map((row) => row.category.id))
|
||||
for (const key of Object.keys(winnerSelections)) {
|
||||
if (!knownCategoryIds.has(Number(key))) {
|
||||
delete winnerSelections[Number(key)]
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
winnerSelections[row.category.id] = row.existing?.candidateId ? String(row.existing.candidateId) : ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function saveWinner(categoryId: number) {
|
||||
const candidateId = Number(winnerSelections[categoryId])
|
||||
if (!candidateId || !seasonDetail.value.id) return
|
||||
savingResultForCategory.value = categoryId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.setAdminResult(seasonDetail.value.id, { categoryId, candidateId })
|
||||
adminMessage.value = 'Gewinner wurde gespeichert.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht gespeichert werden.'
|
||||
} finally {
|
||||
savingResultForCategory.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function clearWinner(resultId: number) {
|
||||
if (!seasonDetail.value.id) return
|
||||
deletingResultId.value = resultId
|
||||
adminMessage.value = ''
|
||||
adminError.value = ''
|
||||
try {
|
||||
await store.deleteAdminResult(resultId, seasonDetail.value.id)
|
||||
adminMessage.value = 'Gewinner wurde entfernt.'
|
||||
} catch (error) {
|
||||
adminError.value = error instanceof Error ? error.message : 'Gewinner konnte nicht entfernt werden.'
|
||||
} finally {
|
||||
deletingResultId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminError,
|
||||
adminMessage,
|
||||
completionPct,
|
||||
deletingResultId,
|
||||
query,
|
||||
savingResultForCategory,
|
||||
statusFilter,
|
||||
statusFilters,
|
||||
summaryCards,
|
||||
visibleResultRows,
|
||||
winnerSelections,
|
||||
clearWinner,
|
||||
saveWinner,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
.loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 50% -10%, rgba(255, 255, 255, 0.22), transparent 36%),
|
||||
linear-gradient(180deg, #160a26 0%, #25103c 38%, #38205f 68%, #1c0e2e 100%);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.loader__nebula {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(48px);
|
||||
opacity: 0.88;
|
||||
mix-blend-mode: screen;
|
||||
animation: nebulaDrift 16s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader__nebula--violet {
|
||||
top: -6%;
|
||||
left: -4%;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
background: radial-gradient(circle, rgba(120, 88, 231, 0.72) 0%, rgba(120, 88, 231, 0.08) 46%, transparent 72%);
|
||||
}
|
||||
|
||||
.loader__nebula--rose {
|
||||
right: -8%;
|
||||
top: 14%;
|
||||
width: 440px;
|
||||
height: 440px;
|
||||
background: radial-gradient(circle, rgba(255, 162, 221, 0.56) 0%, rgba(255, 162, 221, 0.08) 48%, transparent 74%);
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.loader__nebula--gold {
|
||||
left: 24%;
|
||||
bottom: -18%;
|
||||
width: 520px;
|
||||
height: 420px;
|
||||
background: radial-gradient(circle, rgba(238, 190, 98, 0.34) 0%, rgba(238, 190, 98, 0.06) 42%, transparent 72%);
|
||||
animation-delay: -9s;
|
||||
}
|
||||
|
||||
.loader__vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle at 50% 42%, transparent 0%, rgba(17, 7, 30, 0.08) 42%, rgba(12, 3, 20, 0.5) 100%);
|
||||
}
|
||||
|
||||
.loader__starfield {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background-repeat: repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loader__starfield--far {
|
||||
opacity: 0.38;
|
||||
background-image:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.88) 0 1px, transparent 1.6px),
|
||||
radial-gradient(circle, rgba(211, 194, 255, 0.66) 0 1px, transparent 1.8px);
|
||||
background-size: 180px 180px, 240px 240px;
|
||||
background-position: 0 0, 60px 100px;
|
||||
animation: starDriftFar 44s linear infinite;
|
||||
}
|
||||
|
||||
.loader__starfield--mid {
|
||||
opacity: 0.56;
|
||||
background-image:
|
||||
radial-gradient(circle, rgba(255, 247, 232, 0.9) 0 1.3px, transparent 2px),
|
||||
radial-gradient(circle, rgba(193, 167, 255, 0.9) 0 1px, transparent 1.9px);
|
||||
background-size: 130px 130px, 190px 190px;
|
||||
background-position: 20px 30px, 90px 120px;
|
||||
animation: starDriftMid 28s linear infinite;
|
||||
}
|
||||
|
||||
.loader__starfield--near {
|
||||
opacity: 0.72;
|
||||
background-image:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.98) 0 1.6px, transparent 2.3px),
|
||||
radial-gradient(circle, rgba(255, 216, 165, 0.92) 0 1.2px, transparent 2px);
|
||||
background-size: 110px 110px, 170px 170px;
|
||||
background-position: 0 0, 55px 85px;
|
||||
animation: starDriftNear 18s linear infinite;
|
||||
}
|
||||
|
||||
.loader__constellation {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.constellation-star {
|
||||
position: absolute;
|
||||
color: #fbe9ff;
|
||||
text-shadow:
|
||||
0 0 10px rgba(255, 240, 255, 0.65),
|
||||
0 0 28px rgba(184, 143, 255, 0.42);
|
||||
animation: constellationTwinkle 3.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.constellation-star--1 { top: 15%; left: 22%; font-size: 18px; animation-delay: 0s; }
|
||||
.constellation-star--2 { top: 24%; right: 24%; font-size: 24px; animation-delay: .6s; }
|
||||
.constellation-star--3 { top: 32%; left: 72%; font-size: 15px; animation-delay: 1.1s; }
|
||||
.constellation-star--4 { bottom: 23%; left: 18%; font-size: 20px; animation-delay: 1.5s; }
|
||||
.constellation-star--5 { bottom: 18%; right: 21%; font-size: 17px; animation-delay: 2s; }
|
||||
.constellation-star--6 { top: 48%; left: 11%; font-size: 13px; animation-delay: 2.4s; }
|
||||
|
||||
.loader__scene {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: min(640px, calc(100% - 40px));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loader__tableau {
|
||||
position: relative;
|
||||
width: 340px;
|
||||
height: 230px;
|
||||
margin-bottom: 26px;
|
||||
animation: tableauFloat 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader__aurora {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
width: 210px;
|
||||
height: 118px;
|
||||
border-radius: 999px;
|
||||
filter: blur(22px);
|
||||
opacity: 0.82;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.loader__aurora--violet {
|
||||
left: 24px;
|
||||
background: linear-gradient(90deg, rgba(148, 118, 248, 0.08) 0%, rgba(196, 160, 255, 0.6) 46%, rgba(255, 205, 233, 0.3) 100%);
|
||||
animation: auroraDrift 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader__aurora--peach {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 56px;
|
||||
width: 190px;
|
||||
background: linear-gradient(90deg, rgba(255, 216, 179, 0.08) 0%, rgba(255, 212, 171, 0.42) 42%, rgba(255, 170, 218, 0.28) 100%);
|
||||
animation: auroraDrift 7s ease-in-out infinite -2.4s;
|
||||
}
|
||||
|
||||
.loader__hanger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 58px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: hangerSwing 4.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader__hanger--left {
|
||||
left: 62px;
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
|
||||
.loader__hanger--center {
|
||||
left: 142px;
|
||||
}
|
||||
|
||||
.loader__hanger--right {
|
||||
right: 58px;
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
.loader__thread {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 72px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.32) 0%, rgba(215, 193, 255, 0.62) 100%);
|
||||
}
|
||||
|
||||
.loader__charm {
|
||||
position: absolute;
|
||||
top: 62px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 239, 255, 0.98) 100%);
|
||||
color: #fffdf7;
|
||||
font-size: 23px;
|
||||
box-shadow:
|
||||
0 0 18px rgba(255, 233, 195, 0.32),
|
||||
0 14px 28px rgba(26, 10, 45, 0.18);
|
||||
}
|
||||
|
||||
.loader__charm--star {
|
||||
color: #d39aff;
|
||||
}
|
||||
|
||||
.loader__charm--moon {
|
||||
color: #f6c56d;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.loader__charm--spark {
|
||||
color: #ffd8ec;
|
||||
}
|
||||
|
||||
.loader__mist {
|
||||
position: absolute;
|
||||
bottom: 38px;
|
||||
height: 66px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 239, 255, 0.98) 72%, rgba(232, 214, 255, 0.96) 100%);
|
||||
box-shadow:
|
||||
inset 0 -10px 14px rgba(190, 166, 237, 0.22),
|
||||
0 18px 34px rgba(18, 7, 33, 0.2);
|
||||
}
|
||||
|
||||
.loader__mist::before,
|
||||
.loader__mist::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.loader__mist--left {
|
||||
left: 22px;
|
||||
width: 126px;
|
||||
}
|
||||
|
||||
.loader__mist--left::before {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
left: 14px;
|
||||
top: -18px;
|
||||
}
|
||||
|
||||
.loader__mist--left::after {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
right: 12px;
|
||||
top: -26px;
|
||||
}
|
||||
|
||||
.loader__mist--center {
|
||||
left: 90px;
|
||||
bottom: 24px;
|
||||
width: 164px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.loader__mist--center::before {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
left: 16px;
|
||||
top: -34px;
|
||||
}
|
||||
|
||||
.loader__mist--center::after {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
right: 18px;
|
||||
top: -24px;
|
||||
}
|
||||
|
||||
.loader__mist--right {
|
||||
right: 18px;
|
||||
width: 118px;
|
||||
}
|
||||
|
||||
.loader__mist--right::before {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
left: 10px;
|
||||
top: -18px;
|
||||
}
|
||||
|
||||
.loader__mist--right::after {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
right: 12px;
|
||||
top: -12px;
|
||||
}
|
||||
|
||||
.loader__dissolve {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loader__dissolve-dot {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loader__dissolve-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loader__dissolve-line {
|
||||
opacity: 0;
|
||||
stroke: rgba(255, 245, 226, 0.92);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 10px rgba(255, 233, 202, 0.45));
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.loader__dissolve-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 248, 236, 0.96);
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 238, 206, 0.95),
|
||||
0 0 34px rgba(198, 166, 255, 0.55);
|
||||
transform: scale(0.4);
|
||||
}
|
||||
|
||||
.loader__dissolve-dot--one { left: 50px; top: 58px; }
|
||||
.loader__dissolve-dot--two { left: 155px; top: 35px; }
|
||||
.loader__dissolve-dot--three { left: 285px; top: 75px; }
|
||||
.loader__dissolve-dot--four { left: 82px; top: 150px; }
|
||||
.loader__dissolve-dot--five { left: 190px; top: 132px; }
|
||||
.loader__dissolve-dot--six { left: 292px; top: 188px; }
|
||||
.loader__dissolve-dot--seven { left: 218px; top: 208px; }
|
||||
.loader__dissolve-dot--eight { left: 36px; top: 200px; }
|
||||
|
||||
.loader__copy {
|
||||
max-width: 620px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.loader__eyebrow {
|
||||
margin-bottom: 10px;
|
||||
color: #b693ff;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.loader__title {
|
||||
max-width: 600px;
|
||||
margin: -4px auto 12px;
|
||||
padding: 6px 0 10px;
|
||||
color: #fff8fd;
|
||||
font-family: 'Cormorant Garamond', serif;
|
||||
font-size: clamp(42px, 5.4vw, 66px);
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
overflow: visible;
|
||||
text-wrap: balance;
|
||||
text-shadow: 0 8px 34px rgba(19, 7, 37, 0.52);
|
||||
}
|
||||
|
||||
.loader__text {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
color: rgba(240, 231, 255, 0.82);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
text-shadow: 0 4px 18px rgba(15, 5, 28, 0.28);
|
||||
}
|
||||
|
||||
.loader__outro-text {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
color: rgba(255, 248, 235, 0.96);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.7;
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
text-shadow: 0 2px 10px rgba(15, 5, 28, 0.24);
|
||||
}
|
||||
|
||||
@keyframes nebulaDrift {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(26px, -20px, 0) scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes starDriftFar {
|
||||
from { transform: translate3d(0, 0, 0); }
|
||||
to { transform: translate3d(-120px, 80px, 0); }
|
||||
}
|
||||
|
||||
@keyframes starDriftMid {
|
||||
from { transform: translate3d(0, 0, 0); }
|
||||
to { transform: translate3d(-180px, 120px, 0); }
|
||||
}
|
||||
|
||||
@keyframes starDriftNear {
|
||||
from { transform: translate3d(0, 0, 0); }
|
||||
to { transform: translate3d(-220px, 160px, 0); }
|
||||
}
|
||||
|
||||
@keyframes constellationTwinkle {
|
||||
0%, 100% { opacity: 0.26; transform: translateY(0) scale(0.9); }
|
||||
50% { opacity: 1; transform: translateY(-8px) scale(1.16); }
|
||||
}
|
||||
|
||||
@keyframes tableauFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-7px); }
|
||||
}
|
||||
|
||||
@keyframes auroraDrift {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.78; }
|
||||
50% { transform: translate3d(10px, -8px, 0) scale(1.04); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes hangerSwing {
|
||||
0%, 100% { transform: rotate(-3deg); }
|
||||
50% { transform: rotate(3deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.loader__scene {
|
||||
width: min(100%, calc(100% - 24px));
|
||||
}
|
||||
|
||||
.loader__tableau {
|
||||
width: 250px;
|
||||
height: 182px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.loader__aurora {
|
||||
width: 150px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.loader__hanger--left {
|
||||
left: 38px;
|
||||
}
|
||||
|
||||
.loader__hanger--center {
|
||||
left: 104px;
|
||||
}
|
||||
|
||||
.loader__hanger--right {
|
||||
right: 36px;
|
||||
}
|
||||
|
||||
.loader__thread {
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.loader__charm {
|
||||
top: 48px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.loader__charm--moon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.loader__mist {
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.loader__mist--left {
|
||||
left: 12px;
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.loader__mist--center {
|
||||
left: 62px;
|
||||
width: 122px;
|
||||
bottom: 18px;
|
||||
}
|
||||
|
||||
.loader__mist--right {
|
||||
right: 12px;
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.loader__dissolve {
|
||||
transform: scale(0.74);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.loader__text {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.loader__title {
|
||||
font-size: clamp(34px, 9vw, 48px);
|
||||
line-height: 1.1;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
privacyModalOpen: boolean
|
||||
privacyContentBlocks: string[]
|
||||
onClosePrivacy: () => void
|
||||
privacyModalStop: (event: Event) => void
|
||||
accountModalOpen: boolean
|
||||
twitchUser: string
|
||||
role: string
|
||||
deleteNotConfirm: boolean
|
||||
deleteConfirm: boolean
|
||||
accountActionError: string
|
||||
authLoading: boolean
|
||||
onCloseAccount: () => void
|
||||
accountModalStop: (event: Event) => void
|
||||
onOpenPrivacy: () => void
|
||||
onLogout: () => Promise<void> | void
|
||||
onRequestDelete: () => void
|
||||
onCancelDelete: () => void
|
||||
onConfirmDelete: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="props.privacyModalOpen">
|
||||
<div class="home-modal-overlay" @click="props.onClosePrivacy" style="position:fixed;inset:0;z-index:400;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
||||
<div class="home-modal" @click="props.privacyModalStop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 32px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Rechtliches</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">Datenschutzerklärung</h2>
|
||||
</div>
|
||||
<button @click="props.onClosePrivacy" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;">✕</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;padding:28px 32px;flex:1;display:flex;flex-direction:column;gap:14px;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
|
||||
<p
|
||||
v-for="(block, index) in props.privacyContentBlocks"
|
||||
:key="`privacy-block-${index}`"
|
||||
style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;white-space:pre-wrap;"
|
||||
>
|
||||
{{ block }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="props.accountModalOpen">
|
||||
<div class="home-modal-overlay" @click="props.onCloseAccount" style="position:fixed;inset:0;z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.55);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
||||
<div class="home-modal" @click="props.accountModalStop" style="position:relative;width:100%;max-width:480px;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:24px 28px 18px;border-bottom:1px solid #f1ecfb;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#8b6cdb;margin-bottom:4px;">Mein Profil</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:24px;margin:0;color:#3f3556;">@{{ props.twitchUser }}</h2>
|
||||
</div>
|
||||
<button @click="props.onCloseAccount" style="width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;" style-hover="background:#e6dcf6;">✕</button>
|
||||
</div>
|
||||
<div style="padding:24px 28px;display:flex;flex-direction:column;gap:16px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:13px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #ede4fb;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
||||
<span style="font-size:14px;color:#5f44ad;font-weight:500;">Angemeldet über Twitch</span>
|
||||
</div>
|
||||
<div style="padding:16px;border-radius:14px;background:#fafafa;border:1px solid #f0eafc;">
|
||||
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#a99fc0;margin:0 0 10px;">Gespeicherte Daten</p>
|
||||
<div style="display:flex;flex-direction:column;gap:7px;">
|
||||
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Twitch-ID</span><span style="font-weight:600;color:#3f3556;">@{{ props.twitchUser }}</span></div>
|
||||
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Rolle</span><span style="font-weight:700;color:#8b6cdb;text-transform:uppercase;font-size:11px;letter-spacing:1px;">{{ props.role }}</span></div>
|
||||
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Einreichungen</span><span style="font-weight:600;color:#3f3556;">Clips, Votes, Nominierungen</span></div>
|
||||
<div class="home-account-data-row" style="display:flex;justify-content:space-between;font-size:13.5px;"><span style="color:#6f6685;">Löschfrist</span><span style="font-weight:600;color:#059669;">März 2027 (auto.)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="props.onOpenPrivacy" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #ede4fb;background:#f9f6ff;color:#6a4fb8;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#f1ecfb;">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Datenschutzerklärung lesen
|
||||
</button>
|
||||
<button @click="props.onLogout" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #e2e8f0;background:#f8fafc;color:#64748b;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#f1f5f9;">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
Abmelden
|
||||
</button>
|
||||
<template v-if="props.deleteNotConfirm">
|
||||
<button @click="props.onRequestDelete" style="display:flex;align-items:center;gap:8px;padding:12px 16px;border-radius:12px;border:1px solid #fecdd3;background:#fff5f5;color:#e11d48;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:600;cursor:pointer;text-align:left;width:100%;" style-hover="background:#ffe4e6;">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||
Meine Daten löschen (Löschrecht Art. 17 DSGVO)
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="props.deleteConfirm">
|
||||
<div style="padding:16px;border-radius:14px;background:#fff5f5;border:1.5px solid #fecdd3;">
|
||||
<p style="font-size:13.5px;color:#9f1239;font-weight:600;margin:0 0 6px;">Alle deine Daten werden sofort gelöscht.</p>
|
||||
<p style="font-size:12.5px;color:#e11d48;margin:0 0 14px;line-height:1.5;">Das umfasst deine Votes, Nominierungen und Clip-Einreichungen. Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<p v-if="props.accountActionError" style="font-size:12.5px;color:#be123c;margin:0 0 12px;line-height:1.5;font-weight:600;">{{ props.accountActionError }}</p>
|
||||
<div class="home-account-confirm-actions" style="display:flex;gap:8px;">
|
||||
<button :disabled="props.authLoading" @click="props.onCancelDelete" style="flex:1;padding:10px;border-radius:10px;border:1px solid #e6dcf6;background:white;color:#6f6685;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;cursor:pointer;" style-hover="background:#f9f6ff;">Abbrechen</button>
|
||||
<button :disabled="props.authLoading" @click="props.onConfirmDelete" style="flex:1;padding:10px;border-radius:10px;border:none;background:#e11d48;color:white;font-family:'Outfit',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" style-hover="opacity:0.9;">{{ props.authLoading ? 'Löscht …' : 'Jetzt löschen' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { HomeArchiveYearItem, HomeSelectedArchive } from './homeModalTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
archiveModalOpen: boolean
|
||||
archiveYears: HomeArchiveYearItem[]
|
||||
selectedArchive: HomeSelectedArchive
|
||||
onCloseArchive: () => void
|
||||
archiveModalStop: (event: Event) => void
|
||||
setArchiveYear: (year: number) => Promise<void>
|
||||
archiveYearButtonStyle: (active: boolean) => string
|
||||
winnerPlatformStyle: (url: string) => string
|
||||
winnerPlatformKey: (url: string) => string
|
||||
winnerPlatformLabel: (url: string) => string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="props.archiveModalOpen">
|
||||
<div class="home-modal-overlay" @click="props.onCloseArchive" style="position:fixed;inset:0;z-index:260;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(34,18,58,.46);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);">
|
||||
<div class="home-modal" @click="props.archiveModalStop" style="position:relative;width:100%;max-width:1020px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:radial-gradient(62% 80% at 82% 14%,rgba(255,210,236,.58),transparent 55%),radial-gradient(70% 90% at 10% 84%,rgba(206,196,247,.48),transparent 58%),linear-gradient(180deg,#fcf7ff 0%,#f3ebfc 100%);border-radius:30px;box-shadow:0 40px 90px rgba(20,8,40,.28);border:1px solid rgba(255,255,255,.62);">
|
||||
<button @click="props.onCloseArchive" aria-label="Archiv schliessen" style="position:absolute;top:18px;right:18px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:50%;border:none;background:rgba(139,108,219,.12);color:#8b6cdb;font-size:20px;cursor:pointer;" style-hover="background:rgba(139,108,219,.2);">✕</button>
|
||||
<div style="position:relative;padding:30px 34px 22px;border-bottom:1px solid rgba(255,210,122,.14);background:linear-gradient(135deg,#2a1842,#3a2168);overflow:hidden;">
|
||||
<span style="position:absolute;top:16px;left:24px;font-size:14px;color:#ffd27a;animation:twinkle 3s ease-in-out infinite;">✦</span>
|
||||
<span style="position:absolute;top:26px;right:84px;font-size:12px;color:#ffb9d4;animation:twinkle 2.5s ease-in-out .4s infinite;">✧</span>
|
||||
<span style="position:absolute;bottom:18px;left:280px;font-size:13px;color:#c9b1ff;animation:twinkle 3.2s ease-in-out .9s infinite;">✦</span>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#c9b1ff;margin-bottom:8px;position:relative;z-index:1;">Archiv</div>
|
||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:38px;line-height:1;margin:0;color:#fff6fb;position:relative;z-index:1;">Gewinner vergangener Jahre</h3>
|
||||
</div>
|
||||
<div class="home-archive-modal__body" style="display:grid;grid-template-columns:220px minmax(0,1fr);min-height:0;flex:1;">
|
||||
<aside class="home-archive-modal__years" style="padding:24px 18px;border-right:1px solid rgba(139,108,219,.12);background:rgba(255,255,255,.34);overflow-y:auto;">
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">
|
||||
<button
|
||||
v-for="year in props.archiveYears"
|
||||
:key="year.year"
|
||||
@click="props.setArchiveYear(year.year)"
|
||||
:style="props.archiveYearButtonStyle(year.active)"
|
||||
>
|
||||
<span>{{ year.label }}</span>
|
||||
<span :style="year.active ? 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#fff;color:#8b6cdb;font-size:11px;font-weight:800;box-shadow:0 6px 14px rgba(139,108,219,.12);' : 'display:inline-flex;align-items:center;justify-content:center;min-width:28px;height:28px;border-radius:999px;background:#f1ecfb;color:#7355c8;font-size:11px;font-weight:800;'">{{ year.winners.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div style="padding:24px 24px 28px;overflow-y:auto;">
|
||||
<div style="display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:18px;padding:18px 20px;border-radius:20px;background:linear-gradient(135deg,#2a1842,#3a2168);box-shadow:0 18px 40px rgba(34,18,58,.2);">
|
||||
<div>
|
||||
<div style="font-size:12px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:6px;">Award Year</div>
|
||||
<h4 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:30px;line-height:1;margin:0;color:#fff6fb;">{{ props.selectedArchive.year }}</h4>
|
||||
</div>
|
||||
<div style="font-size:13px;color:rgba(255,246,251,.78);">{{ props.selectedArchive.winners.length }} Kategorien archiviert</div>
|
||||
</div>
|
||||
<div class="home-archive-modal__winners" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;">
|
||||
<article
|
||||
v-for="winner in props.selectedArchive.winners"
|
||||
:key="`${props.selectedArchive.year}-${winner.category}`"
|
||||
style="display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:16px 18px;border-radius:18px;background:rgba(255,255,255,.7);border:1px solid rgba(139,108,219,.12);box-shadow:0 12px 28px rgba(139,108,219,.08);"
|
||||
>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-size:10px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#d9942a;margin-bottom:7px;">{{ winner.category }}</div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:18px;line-height:1.25;color:#3f3556;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ winner.name }}</div>
|
||||
<div style="font-size:13px;color:#8a8398;margin-top:5px;">{{ winner.handle }}</div>
|
||||
</div>
|
||||
<a :href="winner.url" target="_blank" rel="noopener" :style="props.winnerPlatformStyle(winner.url)" style-hover="transform:translateY(-1px);opacity:.88;">
|
||||
<template v-if="props.winnerPlatformKey(winner.url) === 'twitch'">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.winnerPlatformKey(winner.url) === 'youtube'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.5 15.5v-7l6.5 3.5z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.winnerPlatformKey(winner.url) === 'x'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.winnerPlatformKey(winner.url) === 'instagram'">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.winnerPlatformKey(winner.url) === 'cake'">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11h16"/><path d="M6 11V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v3"/><path d="M5 11h14l-1 7a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z"/><path d="M9 6a2 2 0 1 1 4 0"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>
|
||||
</template>
|
||||
{{ props.winnerPlatformLabel(winner.url) }}
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<section id="kategorien" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
||||
<div style="text-align:center;margin-bottom:52px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#ff5fa2);margin-bottom:12px;">✦ Die Awards</div>
|
||||
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;">{{ displayCategories.length }} Kategorien · 1 Sternenhimmel</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#c9b8da);max-width:560px;margin:0 auto;">Von Newcomer bis VTuber des Jahres — für jede Art von Magie gibt es einen Stern zu gewinnen.</p>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:18px;" data-cat-grid>
|
||||
<div v-for="category in displayCategories" :key="category.id" style="padding:26px 22px;border-radius:22px;background:var(--card,#22123a);border:1px solid var(--line,rgba(255,255,255,.12));transition:transform .2s,border-color .2s;" style-hover="transform:translateY(-5px);border-color:var(--accent,#ff5fa2);">
|
||||
<div style="display:inline-flex;align-items:center;justify-content:center;width:50px;height:50px;border-radius:15px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:24px;margin-bottom:16px;box-shadow:0 8px 20px var(--glow,rgba(255,95,162,.4));">{{ category.icon }}</div>
|
||||
<h3 style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:19px;margin:0 0 6px;">{{ category.name }}</h3>
|
||||
<p style="font-size:14px;color:var(--muted,#c9b8da);margin:0;line-height:1.5;">{{ category.candidates.length }} Kandidat:innen aktuell in dieser Award-Kategorie.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type DisplayCategory = {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
candidates: unknown[]
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
displayCategories: DisplayCategory[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<header class="home-hero" style="position:relative;overflow:hidden;min-height:780px;background:radial-gradient(62% 80% at 76% 26%,rgba(255,210,236,.55),transparent 60%),radial-gradient(70% 90% at 18% 84%,rgba(206,196,247,.5),transparent 62%),linear-gradient(180deg,#f8f3fe 0%,#f1ebfc 100%);">
|
||||
<div style="position:absolute;top:-140px;right:6%;width:520px;height:520px;border-radius:50%;background:radial-gradient(circle,rgba(255,196,228,.55),transparent 68%);filter:blur(18px);pointer-events:none;animation:pulseGlow 7s ease-in-out infinite;"></div>
|
||||
<div style="position:absolute;bottom:-160px;left:-60px;width:480px;height:480px;border-radius:50%;background:radial-gradient(circle,rgba(190,176,240,.5),transparent 70%);filter:blur(22px);pointer-events:none;animation:pulseGlow 9s ease-in-out infinite;"></div>
|
||||
<div style="position:absolute;right:max(-120px,calc(50% - 640px));top:50%;transform:translateY(-50%);width:720px;height:720px;border-radius:50%;background:radial-gradient(circle,rgba(255,255,255,.85),rgba(244,236,255,.4) 55%,transparent 72%);pointer-events:none;"></div>
|
||||
<span style="position:absolute;top:64px;left:9%;font-size:20px;color:#e7b13e;animation:twinkle 3s ease-in-out infinite;">✦</span>
|
||||
<span style="position:absolute;top:150px;left:40%;font-size:13px;color:#b79be8;animation:twinkle 2.4s ease-in-out .4s infinite;">✦</span>
|
||||
<span style="position:absolute;top:300px;left:33%;font-size:12px;color:#f3a9cb;animation:twinkle 2.8s ease-in-out .8s infinite;">✧</span>
|
||||
<span style="position:absolute;top:90px;left:53%;font-size:15px;color:#cdb6f0;animation:twinkle 3.4s ease-in-out 1s infinite;">✦</span>
|
||||
<span style="position:absolute;top:40px;left:30%;font-size:11px;color:#e7b13e;animation:twinkle 2.6s ease-in-out .2s infinite;">✧</span>
|
||||
<span style="position:absolute;top:120px;left:88%;width:10px;height:10px;border-radius:50%;background:#f3a9cb;opacity:.7;"></span>
|
||||
<span style="position:absolute;top:230px;left:62%;width:8px;height:8px;border-radius:50%;background:#c9b6f0;opacity:.7;"></span>
|
||||
<span style="position:absolute;bottom:120px;left:84%;font-size:14px;color:#e7b13e;animation:twinkle 3.1s ease-in-out 1.4s infinite;">✦</span>
|
||||
|
||||
<img class="home-hero__character" src="/assets/jayu-hero.png" alt="Jayu mit Stern-Pokal" data-hero-char style="position:absolute;top:-10px;right:max(30px,calc(50% - 560px));height:1010px;width:auto;z-index:1;pointer-events:none;filter:drop-shadow(0 26px 50px rgba(120,80,180,.2));-webkit-mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);mask-image:linear-gradient(to bottom,#000 50%,rgba(0,0,0,0) 80%);" />
|
||||
<div class="home-hero__veil" style="position:absolute;inset:0;z-index:2;pointer-events:none;background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.86) 26%,rgba(246,240,254,.3) 46%,transparent 62%);"></div>
|
||||
|
||||
<div class="home-hero__content" style="position:relative;z-index:3;max-width:1200px;margin:0 auto;padding:60px 24px 70px;">
|
||||
<div class="home-hero__copy" style="max-width:560px;">
|
||||
<div class="home-hero__eyebrow" style="display:flex;align-items:center;gap:10px;font-size:13px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:22px;">
|
||||
<span style="color:#e7b13e;animation:spinSlow 9s linear infinite;display:inline-block;">✦</span> Die grosse Community-Auszeichnung
|
||||
</div>
|
||||
<h1 style="margin:0 0 4px;font-family:'Cormorant Garamond',serif;font-weight:700;line-height:.92;">
|
||||
<span style="display:block;font-size:clamp(56px,8.4vw,116px);letter-spacing:3px;color:#5f44ad;">VTUBER</span>
|
||||
<span style="display:block;font-size:clamp(38px,5.6vw,78px);letter-spacing:5px;background:linear-gradient(95deg,#eeb24a,#d9942a);-webkit-background-clip:text;background-clip:text;color:transparent;">STAR AWARDS</span>
|
||||
</h1>
|
||||
<div class="home-hero__presented" style="font-family:'Sacramento',cursive;font-size:clamp(32px,3.8vw,48px);color:#8a6fd0;margin:4px 0 22px;transform:rotate(-3deg);transform-origin:left center;display:inline-block;white-space:nowrap;">Presented by {{ siteContent.hostDisplayName }} <span style="font-family:'Cormorant Garamond',serif;color:#c79be6;">✦</span></div>
|
||||
<p class="home-hero__body" style="font-size:18px;line-height:1.62;color:#6f6685;max-width:430px;margin:0 0 30px;">
|
||||
{{ categoryIntroText }}
|
||||
</p>
|
||||
|
||||
<div class="home-hero__phase-card" style="max-width:430px;background:rgba(255,255,255,.46);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.6);border-radius:22px;padding:24px 24px 26px;box-shadow:0 24px 54px rgba(124,86,196,.16);">
|
||||
<div style="font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#d9942a;margin-bottom:8px;">Aktuelle Phase</div>
|
||||
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-bottom:8px;">
|
||||
<div class="home-hero__phase-title" style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;color:#3f3556;line-height:1.05;white-space:nowrap;">{{ phaseCardTitle }}</div>
|
||||
<span :style="phaseStatusStyle"><span style="width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block;"></span>{{ phaseStatusLabel }}</span>
|
||||
</div>
|
||||
<p style="font-size:14px;line-height:1.55;color:#8a8398;margin:0 0 8px;white-space:pre-line;">{{ phaseCardDescription }}</p>
|
||||
<div style="font-size:12.5px;font-weight:600;color:#a99fc0;margin:0 0 18px;">{{ phaseCardRange }}</div>
|
||||
|
||||
<template v-if="showCountdown">
|
||||
<div
|
||||
data-dc-ref="phaseCountdownLabelRef"
|
||||
style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#a98ddb;margin:0 0 8px;"
|
||||
aria-live="polite"
|
||||
>
|
||||
Phase endet in
|
||||
</div>
|
||||
<div style="display:flex;border:1px solid #eee4fb;border-radius:14px;overflow:hidden;margin-bottom:22px;background:#fbf8ff;">
|
||||
<div style="flex:1;text-align:center;padding:14px 4px;border-right:1px solid #eee4fb;">
|
||||
<div data-dc-ref="dRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#5a4a8a;">00</div>
|
||||
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Tage</div>
|
||||
</div>
|
||||
<div style="flex:1;text-align:center;padding:14px 4px;border-right:1px solid #eee4fb;">
|
||||
<div data-dc-ref="hRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#5a4a8a;">00</div>
|
||||
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Std</div>
|
||||
</div>
|
||||
<div style="flex:1;text-align:center;padding:14px 4px;border-right:1px solid #eee4fb;">
|
||||
<div data-dc-ref="mRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#5a4a8a;">00</div>
|
||||
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Min</div>
|
||||
</div>
|
||||
<div style="flex:1;text-align:center;padding:14px 4px;">
|
||||
<div data-dc-ref="sRef" style="font-family:'Outfit',sans-serif;font-weight:700;font-size:28px;line-height:1;color:#c77ab0;">00</div>
|
||||
<div style="font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#a99fc0;margin-top:5px;">Sek</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<a
|
||||
v-if="showPhase"
|
||||
:href="publicStreamUrl"
|
||||
:style="phasePrimaryActionStyle"
|
||||
style-hover="transform:translateY(-2px);"
|
||||
>
|
||||
{{ phasePrimaryLabel }}
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:disabled="phasePrimaryDisabled"
|
||||
:aria-disabled="phasePrimaryDisabled"
|
||||
:style="phasePrimaryActionStyle"
|
||||
@click="onPrimaryPhaseAction"
|
||||
style-hover="transform:translateY(-2px);"
|
||||
>
|
||||
{{ phasePrimaryLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-hero__host-card" style="position:absolute;z-index:61;right:64px;bottom:96px;background:rgba(255,255,255,.46);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.6);border-radius:18px;padding:18px 24px 20px;box-shadow:0 20px 48px rgba(124,86,196,.18);">
|
||||
<div style="font-size:12px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#a98ddb;margin-bottom:5px;">Host</div>
|
||||
<div class="home-hero__host-name" style="display:flex;align-items:center;gap:9px;font-family:'Outfit',sans-serif;font-weight:700;font-size:27px;letter-spacing:.5px;color:#5f44ad;line-height:1;margin-bottom:5px;">{{ siteContent.hostDisplayName.toUpperCase() }} <span style="font-size:19px;color:#e7b13e;">✦</span></div>
|
||||
<div style="font-size:14px;color:#8a8398;margin-bottom:14px;">{{ siteContent.hostTagline }}</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||
<a
|
||||
v-for="social in hostSocialLinks"
|
||||
:key="`host-${social.platform}`"
|
||||
:href="social.url"
|
||||
:aria-label="social.label"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:11px;background:#f1ecfb;text-decoration:none;"
|
||||
style-hover="transform:translateY(-2px);background:#e8e0f9;"
|
||||
>
|
||||
<template v-if="isUploadedSocialIcon(social.icon)">
|
||||
<img :src="social.icon" :alt="social.label" style="width:22px;height:22px;object-fit:contain;" />
|
||||
</template>
|
||||
<template v-else-if="socialSimpleIconPath(social.platform)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" :fill="socialSimpleIconColor(social.platform)"><path :d="socialSimpleIconPath(social.platform)" /></svg>
|
||||
</template>
|
||||
<template v-else-if="platformKey(social.platform) === 'twitch'">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#9146FF"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="platformKey(social.platform) === 'youtube'">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#FF0000"><path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.5 15.5v-7l6.5 3.5z"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="font-size:16px;font-weight:800;color:#5f44ad;">{{ social.icon || '✦' }}</span>
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;top:0;left:0;right:0;height:4px;z-index:5;background:linear-gradient(90deg,#8b6cdb,#e7b13e,#ff8fc0);"></div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface HomeSocialLink {
|
||||
label: string
|
||||
platform: string
|
||||
icon: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface HomeSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
siteContent: HomeSiteContent
|
||||
hostSocialLinks: HomeSocialLink[]
|
||||
categoryIntroText: string
|
||||
phaseCardTitle: string
|
||||
phaseStatusStyle: string
|
||||
phaseStatusLabel: string
|
||||
phaseCardDescription: string
|
||||
phaseCardRange: string
|
||||
showCountdown: boolean
|
||||
publicStreamUrl: string
|
||||
showPhase: boolean
|
||||
phasePrimaryLabel: string
|
||||
phasePrimaryDisabled: boolean
|
||||
phasePrimaryActionStyle: string
|
||||
onPrimaryPhaseAction: (event?: Event) => void
|
||||
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
|
||||
socialSimpleIconPath: (platform: string | null | undefined) => string
|
||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||
platformKey: (platform: string | null | undefined) => string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import HomeHeroMasthead from './HomeHeroMasthead.vue'
|
||||
import HomeHeroStatsBand from './HomeHeroStatsBand.vue'
|
||||
import HomeHeroStreamBand from './HomeHeroStreamBand.vue'
|
||||
import HomeTopNav from './HomeTopNav.vue'
|
||||
|
||||
interface HomeSocialLink {
|
||||
label: string
|
||||
platform: string
|
||||
icon: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface HomeSiteContent {
|
||||
hostDisplayName: string
|
||||
hostTagline: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
isGuest: boolean
|
||||
isUser: boolean
|
||||
isAdmin: boolean
|
||||
twitchUser: string
|
||||
siteContent: HomeSiteContent
|
||||
hostSocialLinks: HomeSocialLink[]
|
||||
categoryIntroText: string
|
||||
phaseCardTitle: string
|
||||
phaseStatusStyle: string
|
||||
phaseStatusLabel: string
|
||||
phaseCardDescription: string
|
||||
phaseCardRange: string
|
||||
showCountdown: boolean
|
||||
publicStreamUrl: string
|
||||
showPhase: boolean
|
||||
phasePrimaryLabel: string
|
||||
phasePrimaryDisabled: boolean
|
||||
phasePrimaryActionStyle: string
|
||||
streamEyebrow: string
|
||||
streamTitle: string
|
||||
streamMeta: string
|
||||
streamLive: boolean
|
||||
streamLocked: boolean
|
||||
streamLockedLabel: string
|
||||
streamLockedTitle: string
|
||||
statOneValue: string
|
||||
statOneLabel: string
|
||||
statTwoValue: string
|
||||
statThreeValue: string
|
||||
statThreeLabel: string
|
||||
onLogin: () => void
|
||||
onOpenAccount: () => void
|
||||
openAdminPanel: (event?: Event) => void
|
||||
onPrimaryPhaseAction: (event?: Event) => void
|
||||
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
|
||||
socialSimpleIconPath: (platform: string | null | undefined) => string
|
||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||
platformKey: (platform: string | null | undefined) => string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomeTopNav
|
||||
:is-guest="isGuest"
|
||||
:is-user="isUser"
|
||||
:is-admin="isAdmin"
|
||||
:twitch-user="twitchUser"
|
||||
:on-login="onLogin"
|
||||
:on-open-account="onOpenAccount"
|
||||
:open-admin-panel="openAdminPanel"
|
||||
/>
|
||||
|
||||
<HomeHeroMasthead
|
||||
:site-content="siteContent"
|
||||
:host-social-links="hostSocialLinks"
|
||||
:category-intro-text="categoryIntroText"
|
||||
:phase-card-title="phaseCardTitle"
|
||||
:phase-status-style="phaseStatusStyle"
|
||||
:phase-status-label="phaseStatusLabel"
|
||||
:phase-card-description="phaseCardDescription"
|
||||
:phase-card-range="phaseCardRange"
|
||||
:show-countdown="showCountdown"
|
||||
:public-stream-url="publicStreamUrl"
|
||||
:show-phase="showPhase"
|
||||
:phase-primary-label="phasePrimaryLabel"
|
||||
:phase-primary-disabled="phasePrimaryDisabled"
|
||||
:phase-primary-action-style="phasePrimaryActionStyle"
|
||||
:on-primary-phase-action="onPrimaryPhaseAction"
|
||||
:is-uploaded-social-icon="isUploadedSocialIcon"
|
||||
:social-simple-icon-path="socialSimpleIconPath"
|
||||
:social-simple-icon-color="socialSimpleIconColor"
|
||||
:platform-key="platformKey"
|
||||
/>
|
||||
|
||||
<HomeHeroStreamBand
|
||||
:public-stream-url="publicStreamUrl"
|
||||
:stream-eyebrow="streamEyebrow"
|
||||
:stream-title="streamTitle"
|
||||
:stream-meta="streamMeta"
|
||||
:stream-live="streamLive"
|
||||
:stream-locked="streamLocked"
|
||||
:stream-locked-label="streamLockedLabel"
|
||||
:stream-locked-title="streamLockedTitle"
|
||||
/>
|
||||
|
||||
<HomeHeroStatsBand
|
||||
:stat-one-value="statOneValue"
|
||||
:stat-one-label="statOneLabel"
|
||||
:stat-two-value="statTwoValue"
|
||||
:stat-three-value="statThreeValue"
|
||||
:stat-three-label="statThreeLabel"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<section style="border-top:1px solid var(--line,rgba(255,255,255,.12));border-bottom:1px solid var(--line,rgba(255,255,255,.12));background:var(--bg2,#1f0f33);">
|
||||
<div style="max-width:1100px;margin:0 auto;padding:34px 24px;display:grid;grid-template-columns:repeat(4,1fr);gap:24px;text-align:center;" data-stats>
|
||||
<div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">{{ statOneValue }}</div>
|
||||
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">{{ statOneLabel }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">{{ statTwoValue }}</div>
|
||||
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">Kategorien</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">{{ statThreeValue }}</div>
|
||||
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">{{ statThreeLabel }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:38px;line-height:1;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));-webkit-background-clip:text;background-clip:text;color:transparent;">∞</div>
|
||||
<div style="font-size:14px;color:var(--muted,#c9b8da);margin-top:6px;">Chaos & Glitzer</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
statOneValue: string
|
||||
statOneLabel: string
|
||||
statTwoValue: string
|
||||
statThreeValue: string
|
||||
statThreeLabel: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<section class="home-stream-band" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);">
|
||||
<span style="position:absolute;top:18px;left:7%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;">✦</span>
|
||||
<span style="position:absolute;bottom:16px;left:46%;font-size:12px;color:rgba(255,255,255,.3);animation:twinkle 2.6s ease-in-out .5s infinite;">✧</span>
|
||||
<span style="position:absolute;top:24px;right:38%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;">✦</span>
|
||||
<div class="home-stream-band__inner" style="max-width:1200px;margin:0 auto;padding:26px 24px;display:flex;align-items:center;justify-content:space-between;gap:26px;flex-wrap:wrap;">
|
||||
<div class="home-stream-band__lead" style="display:flex;align-items:center;gap:18px;">
|
||||
<div style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:54px;height:54px;border-radius:15px;background:rgba(145,70,255,.22);border:1px solid rgba(255,255,255,.18);">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="#c9b1ff"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2.5px;text-transform:uppercase;color:#c9b1ff;margin-bottom:5px;">{{ streamEyebrow }}</div>
|
||||
<div style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;color:#fff;line-height:1.1;">{{ streamTitle }}</div>
|
||||
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;font-size:14px;color:rgba(255,255,255,.78);margin-top:6px;">
|
||||
<span style="display:inline-flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#c9b1ff" stroke-width="2"><rect x="3" y="4.5" width="18" height="17" rx="2.5"/><path d="M3 9h18M8 2.5v4M16 2.5v4" stroke-linecap="round"/></svg>{{ streamMeta }}</span>
|
||||
<span style="width:4px;height:4px;border-radius:50%;background:rgba(255,255,255,.4);"></span>
|
||||
<span>Countdown bis zur grossen Live-Show</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="streamLive || streamLocked">
|
||||
<div class="home-stream-band__actions" :class="{ 'home-stream-band__actions--locked': streamLocked }" style="display:flex;flex-direction:column;align-items:center;gap:9px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.5px;color:rgba(255,255,255,.7);">
|
||||
<span data-dc-ref="streamCountdownLabelRef">Finale startet in</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-family:'Outfit',sans-serif;font-weight:700;color:#fff;">
|
||||
<span data-dc-ref="bdRef">00</span><span style="opacity:.6;">T</span>
|
||||
<span data-dc-ref="bhRef">00</span><span style="opacity:.6;">:</span>
|
||||
<span data-dc-ref="bmRef">00</span><span style="opacity:.6;">:</span>
|
||||
<span data-dc-ref="bsRef">00</span>
|
||||
</span>
|
||||
</div>
|
||||
<a v-if="streamLive" :href="publicStreamUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:16px 28px;border-radius:14px;background:#ec3b5a;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;box-shadow:0 12px 30px rgba(236,59,90,.45);" style-hover="transform:translateY(-2px);">
|
||||
<span style="width:9px;height:9px;border-radius:50%;background:#fff;display:inline-block;animation:pulseGlow 1.4s ease-in-out infinite;"></span>
|
||||
Jetzt live · Zum Stream
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
|
||||
</a>
|
||||
<div v-else :title="streamLockedTitle" style="display:inline-flex;align-items:center;gap:9px;padding:14px 24px;border-radius:14px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);color:rgba(255,255,255,.62);font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="rgba(255,255,255,.62)"><path d="M17 9V7a5 5 0 0 0-10 0v2H5v13h14V9h-2zM9 7a3 3 0 0 1 6 0v2H9V7zm3 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4z"/></svg>
|
||||
{{ streamLockedLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
publicStreamUrl: string
|
||||
streamEyebrow: string
|
||||
streamTitle: string
|
||||
streamMeta: string
|
||||
streamLive: boolean
|
||||
streamLocked: boolean
|
||||
streamLockedLabel: string
|
||||
streamLockedTitle: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import HomeSelectDropdown from './HomeSelectDropdown.vue'
|
||||
import HomeVotingPickerPane from './HomeVotingPickerPane.vue'
|
||||
import type { HomeCategoryListItem, HomeNomineeListItem, HomeSelectionOption } from './homeModalTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
modalOpen: boolean
|
||||
submitted: boolean
|
||||
notSubmitted: boolean
|
||||
formError: string
|
||||
successTitle: string
|
||||
successText: string
|
||||
closeModal: () => void
|
||||
stop: (event: Event) => void
|
||||
isShow: boolean
|
||||
isPicker: boolean
|
||||
isVote: boolean
|
||||
isClip: boolean
|
||||
nominationPhase: boolean
|
||||
publicStreamUrl: string
|
||||
formatShowDate: () => string
|
||||
submitReminder: () => void
|
||||
pickerTitle: string
|
||||
pickerSubtitle: string
|
||||
catList: HomeCategoryListItem[]
|
||||
activeCatName: string
|
||||
noms: HomeNomineeListItem[]
|
||||
voteCount: number
|
||||
totalCats: number
|
||||
canSubmitVote: boolean
|
||||
submitVote: () => Promise<void> | void
|
||||
submitNomination: () => Promise<void> | void
|
||||
submitting: boolean
|
||||
onClipCatChange: (event: Event) => void
|
||||
catOptions: HomeSelectionOption[]
|
||||
clipNomOptions: HomeSelectionOption[]
|
||||
clipDsgvo: boolean
|
||||
clipDsgvoChange: () => void
|
||||
onOpenPrivacy: () => void
|
||||
canSubmitClip: boolean
|
||||
clipSubmitStyle: string
|
||||
submitClip: () => Promise<void> | void
|
||||
}>()
|
||||
|
||||
const nominationCatValue = ref(0)
|
||||
const clipCatValue = ref(0)
|
||||
const clipNomValue = ref(0)
|
||||
|
||||
watch(
|
||||
() => props.catOptions,
|
||||
(options) => {
|
||||
nominationCatValue.value = resolveOptionValue(nominationCatValue.value, options)
|
||||
const nextClipCat = resolveOptionValue(clipCatValue.value, options)
|
||||
if (nextClipCat !== clipCatValue.value) {
|
||||
clipCatValue.value = nextClipCat
|
||||
notifyClipCategoryChange(nextClipCat)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.clipNomOptions,
|
||||
(options) => {
|
||||
clipNomValue.value = resolveOptionValue(clipNomValue.value, options)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function resolveOptionValue(value: number, options: HomeSelectionOption[]) {
|
||||
return options.some((option) => option.id === value) ? value : options[0]?.id ?? 0
|
||||
}
|
||||
|
||||
function handleClipCategoryChange(value: number) {
|
||||
clipCatValue.value = value
|
||||
clipNomValue.value = 0
|
||||
notifyClipCategoryChange(value)
|
||||
}
|
||||
|
||||
function notifyClipCategoryChange(value: number) {
|
||||
props.onClipCatChange({ target: { value: String(value) } } as unknown as Event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="props.modalOpen">
|
||||
<div class="home-modal-overlay" @click="props.closeModal" style="position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(40,24,70,.5);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);">
|
||||
<div class="home-modal" :class="{ 'home-modal--wide': props.isPicker && (props.isVote || (props.nominationPhase && !props.isVote)) }" @click="props.stop" style="position:relative;width:100%;max-width:680px;max-height:88vh;overflow:hidden;display:flex;flex-direction:column;background:#fff;border-radius:24px;box-shadow:0 40px 90px rgba(40,24,70,.4);">
|
||||
<button @click="props.closeModal" aria-label="Schliessen" style="position:absolute;top:16px;right:16px;z-index:5;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:none;background:#f1ecfb;color:#8b6cdb;font-size:18px;cursor:pointer;" style-hover="background:#e6dcf6;">✕</button>
|
||||
|
||||
<template v-if="props.submitted">
|
||||
<div class="home-modal__success" style="padding:56px 40px;text-align:center;">
|
||||
<div style="width:72px;height:72px;margin:0 auto 22px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 14px 32px rgba(31,157,90,.32);">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 12px;color:#3f3556;">{{ props.successTitle }}</h3>
|
||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;max-width:420px;margin:0 auto 28px;">{{ props.successText }}</p>
|
||||
<button @click="props.closeModal" style="padding:14px 32px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Schliessen</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="props.notSubmitted">
|
||||
<p
|
||||
v-if="props.formError"
|
||||
style="margin:18px 36px 0;padding:12px 14px;border-radius:12px;border:1px solid #fecdd3;background:#fff1f2;color:#be123c;font-size:13.5px;font-weight:600;line-height:1.45;"
|
||||
>
|
||||
{{ props.formError }}
|
||||
</p>
|
||||
|
||||
<template v-if="props.isShow">
|
||||
<div class="home-modal__show" style="padding:38px 40px 40px;">
|
||||
<div style="display:inline-flex;align-items:center;gap:8px;padding:5px 14px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:18px;">Award-Show</div>
|
||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:30px;margin:0 0 10px;color:#3f3556;">Sei live dabei ✦</h3>
|
||||
<p style="font-size:15.5px;line-height:1.6;color:#7d7491;margin:0 0 24px;">Die grosse Live-Show findet am <strong style="color:#5f44ad;">{{ props.formatShowDate() }}</strong> statt. Aktiviere eine Erinnerung, damit du nichts verpasst.</p>
|
||||
<div class="home-modal__reminder-form" style="display:flex;gap:10px;margin-bottom:14px;">
|
||||
<input data-dc-ref="emailRef" type="email" placeholder="deine@email.de" style="flex:1;padding:14px 16px;border-radius:12px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:15px;color:#3f3556;outline:none;" style-focus="border-color:#8b6cdb;" />
|
||||
<button @click="props.submitReminder" style="flex:none;padding:14px 24px;border-radius:12px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 22px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);">Erinnern</button>
|
||||
</div>
|
||||
<a :href="props.publicStreamUrl" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:9px;padding:14px;border-radius:12px;background:#f1ecfb;color:#7a3fd0;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;" style-hover="background:#e8e0f9;"><svg width="17" height="17" viewBox="0 0 24 24" fill="#9146FF"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>Zum Award-Stream</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="props.isPicker">
|
||||
<template v-if="props.nominationPhase && !props.isVote">
|
||||
<div class="home-modal__nomination-submit" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
|
||||
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;flex:none;">
|
||||
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">✦ Nominierungsphase</div>
|
||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
|
||||
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
|
||||
</div>
|
||||
<div class="home-modal__nomination-grid" style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:24px 36px 32px;overflow-y:auto;">
|
||||
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fcfaff;border:1px solid #efe7fb;">
|
||||
<div>
|
||||
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">VTuber oder Streamer nominieren</h4>
|
||||
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Name und Stream-Link gehen direkt in den Admin-Review.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="nominationCatValue"
|
||||
data-ref="nominationCatRef"
|
||||
label="Kategorie fuer Nominierung auswaehlen"
|
||||
:options="props.catOptions"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Name <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="nominationNameRef" type="text" placeholder="Kanalname oder Anzeigename" maxlength="120" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Stream-Link <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://twitch.tv/kanal oder https://kick.com/kanal" maxlength="300" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
<p style="font-size:12.5px;line-height:1.45;color:#8a8398;margin:6px 0 0;">Twitch, Kick, YouTube oder ein anderer offizieller Kanal-Link.</p>
|
||||
</div>
|
||||
<button @click="props.submitNomination" :disabled="props.submitting" :style="props.submitting ? 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;' : 'width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);'" style-hover="transform:translateY(-2px);">
|
||||
{{ props.submitting ? 'Speichert ...' : 'Nominierung einreichen' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section style="display:flex;flex-direction:column;gap:15px;padding:18px;border-radius:18px;background:#fffafd;border:1px solid #f4d8e9;">
|
||||
<div>
|
||||
<h4 style="font-family:'Outfit',sans-serif;font-size:16px;font-weight:800;color:#3f3556;margin:0 0 5px;">Clip einreichen</h4>
|
||||
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Highlight-Clips können separat zur Show-Prüfung eingereicht werden.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
</div>
|
||||
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="clipCatValue"
|
||||
label="Kategorie fuer Clip auswaehlen"
|
||||
:options="props.catOptions"
|
||||
@change="handleClipCategoryChange"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="clipNomValue"
|
||||
data-ref="clipNomRef"
|
||||
label="VTuber fuer Clip auswaehlen"
|
||||
placeholder="Noch keine Kandidat:innen"
|
||||
:options="props.clipNomOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Beschreibung <span style="color:#a99fc0;font-weight:400;">(optional)</span></label>
|
||||
<textarea data-dc-ref="clipDescRef" placeholder="Warum ist dieser Moment so besonders?" rows="3" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;resize:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;"></textarea>
|
||||
</div>
|
||||
<label style="display:flex;align-items:flex-start;gap:11px;cursor:pointer;padding:13px;border-radius:11px;background:#fff;border:1px solid #ede4fb;">
|
||||
<input type="checkbox" :checked="props.clipDsgvo" @change="props.clipDsgvoChange" style="width:17px;height:17px;flex:none;margin-top:2px;accent-color:#8b6cdb;cursor:pointer;" />
|
||||
<span style="font-size:13px;line-height:1.6;color:#6f6685;">Ich stimme der Verarbeitung meiner Daten gemäß der <button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:#8b6cdb;font-weight:600;cursor:pointer;font-size:inherit;font-family:inherit;">Datenschutzerklärung</button> zu.</span>
|
||||
</label>
|
||||
<button @click="props.submitClip" :disabled="props.submitting || !props.canSubmitClip" :style="props.clipSubmitStyle">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
{{ props.submitting ? 'Speichert ...' : 'Clip einreichen' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div style="display:flex;flex-direction:column;min-height:0;">
|
||||
<div class="home-modal__picker-header" style="padding:30px 36px 18px;border-bottom:1px solid #f1ecfb;">
|
||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:27px;margin:0 0 6px;color:#3f3556;">{{ props.pickerTitle }}</h3>
|
||||
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">{{ props.pickerSubtitle }}</p>
|
||||
</div>
|
||||
<HomeVotingPickerPane
|
||||
:cat-list="props.catList"
|
||||
:active-cat-name="props.activeCatName"
|
||||
:noms="props.noms"
|
||||
:vote-count="props.voteCount"
|
||||
:total-cats="props.totalCats"
|
||||
:can-submit-vote="props.canSubmitVote"
|
||||
:submit-vote="props.submitVote"
|
||||
:submitting="props.submitting"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="props.isClip">
|
||||
<div class="home-modal__clip" style="display:flex;flex-direction:column;max-height:calc(88vh - 80px);">
|
||||
<div class="home-modal__clip-header" style="padding:30px 40px 24px;border-bottom:1px solid #f1ecfb;flex:none;">
|
||||
<div style="display:inline-flex;align-items:center;gap:7px;padding:4px 12px;border-radius:999px;background:rgba(247,108,173,.12);color:#c7508a;font-size:11px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:14px;">✦ Nominierungsphase</div>
|
||||
<h3 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:28px;margin:0 0 6px;color:#3f3556;">Clip einreichen</h3>
|
||||
<p style="font-size:14px;line-height:1.5;color:#8a8398;margin:0;">Die besten Clips werden in der Award-Show präsentiert.</p>
|
||||
</div>
|
||||
<div class="home-modal__clip-body" style="padding:24px 40px 32px;overflow-y:auto;display:flex;flex-direction:column;gap:16px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Clip-Link <span style="color:#e11d48;">*</span></label>
|
||||
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://clips.twitch.tv/... oder YouTube · TikTok" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;" />
|
||||
</div>
|
||||
<div class="home-modal__clip-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="clipCatValue"
|
||||
label="Kategorie fuer Clip auswaehlen"
|
||||
:options="props.catOptions"
|
||||
@change="handleClipCategoryChange"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Für welche:n VTuber?</label>
|
||||
<HomeSelectDropdown
|
||||
v-model="clipNomValue"
|
||||
data-ref="clipNomRef"
|
||||
label="VTuber fuer Clip auswaehlen"
|
||||
placeholder="Noch keine Kandidat:innen"
|
||||
:options="props.clipNomOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Beschreibung <span style="color:#a99fc0;font-weight:400;">(optional)</span></label>
|
||||
<textarea data-dc-ref="clipDescRef" placeholder="Warum ist dieser Moment so besonders?" rows="3" style="width:100%;padding:12px 14px;border-radius:11px;border:1.5px solid #e6dcf6;background:#fbf9ff;font-family:'Outfit',sans-serif;font-size:14.5px;color:#3f3556;outline:none;resize:none;box-sizing:border-box;" style-focus="border-color:#8b6cdb;"></textarea>
|
||||
</div>
|
||||
<label style="display:flex;align-items:flex-start;gap:11px;cursor:pointer;padding:13px;border-radius:11px;background:#f9f6ff;border:1px solid #ede4fb;">
|
||||
<input type="checkbox" :checked="props.clipDsgvo" @change="props.clipDsgvoChange" style="width:17px;height:17px;flex:none;margin-top:2px;accent-color:#8b6cdb;cursor:pointer;" />
|
||||
<span style="font-size:13px;line-height:1.6;color:#6f6685;">Ich stimme der Verarbeitung meiner Daten gemäß der <button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:#8b6cdb;font-weight:600;cursor:pointer;font-size:inherit;font-family:inherit;">Datenschutzerklärung</button> zu.</span>
|
||||
</label>
|
||||
<button @click="props.submitClip" :disabled="props.submitting || !props.canSubmitClip" :style="props.clipSubmitStyle">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
{{ props.submitting ? 'Speichert ...' : 'Clip einreichen' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import CinematicStarLoader from '../CinematicStarLoader.vue'
|
||||
import HomeCategoriesSection from './HomeCategoriesSection.vue'
|
||||
import HomeHeroShell from './HomeHeroShell.vue'
|
||||
import HomeLandingModals from './HomeLandingModals.vue'
|
||||
import HomeParticipationSection from './HomeParticipationSection.vue'
|
||||
import HomeSupportFooterSection from './HomeSupportFooterSection.vue'
|
||||
import HomeTimelineSection from './HomeTimelineSection.vue'
|
||||
import HomeWinnerShowcaseSection from './HomeWinnerShowcaseSection.vue'
|
||||
import { useHomeLandingState } from './useHomeLandingState'
|
||||
import { useHomeLandingViewEffects } from './useHomeLandingViewEffects'
|
||||
|
||||
const router = useRouter()
|
||||
const rootStyle = "font-family:'Outfit',sans-serif;min-height:100vh;overflow-x:hidden;line-height:1.5;position:relative;background:#f4eefb;color:#3f3556;--bg:#f4eefb;--bg2:#ebe2f8;--surface:#ffffff;--card:#ffffff;--ink:#3f3556;--muted:#8a8398;--accent:#8b6cdb;--accent2:#b78bff;--gold:#d9942a;--line:rgba(124,86,196,0.16);--glow:rgba(139,108,219,0.3)"
|
||||
const {
|
||||
store,
|
||||
authStore,
|
||||
role,
|
||||
isGuest,
|
||||
isUser,
|
||||
isAdmin,
|
||||
twitchUser,
|
||||
siteContent,
|
||||
hostSocialLinks,
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
faqItems,
|
||||
privacyContentBlocks,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
streamLive,
|
||||
streamLocked,
|
||||
privacyModalOpen,
|
||||
accountModalOpen,
|
||||
archiveModalOpen,
|
||||
modalOpen,
|
||||
isShow,
|
||||
isVote,
|
||||
isPicker,
|
||||
isClip,
|
||||
notSubmitted,
|
||||
deleteNotConfirm,
|
||||
voteCount,
|
||||
totalCats,
|
||||
canSubmitVote,
|
||||
activeCatName,
|
||||
phaseCardTitle,
|
||||
phaseCardDescription,
|
||||
phaseCardRange,
|
||||
phaseStatusLabel,
|
||||
phaseStatusStyle,
|
||||
phasePrimaryLabel,
|
||||
phasePrimaryDisabled,
|
||||
phasePrimaryActionStyle,
|
||||
streamEyebrow,
|
||||
streamTitle,
|
||||
streamMeta,
|
||||
streamLockedLabel,
|
||||
streamLockedTitle,
|
||||
statOneValue,
|
||||
statOneLabel,
|
||||
statTwoValue,
|
||||
statThreeValue,
|
||||
statThreeLabel,
|
||||
categoryIntroText,
|
||||
timelineLineStyle,
|
||||
sectionTitle,
|
||||
sectionText,
|
||||
sectionActionLabel,
|
||||
sectionActionHref,
|
||||
sectionActionDisabled,
|
||||
sectionActionStyle,
|
||||
previewPhase,
|
||||
pickerTitle,
|
||||
pickerSubtitle,
|
||||
successTitle,
|
||||
successText,
|
||||
clipNomOptions,
|
||||
catOptions,
|
||||
canSubmitClip,
|
||||
clipSubmitStyle,
|
||||
archiveYears,
|
||||
selectedArchive,
|
||||
winnerShowcase,
|
||||
submitted,
|
||||
submitting,
|
||||
formError,
|
||||
clipDsgvo,
|
||||
archiveYear,
|
||||
catList,
|
||||
noms,
|
||||
formatTimelineRange,
|
||||
formatShowDate,
|
||||
initialsFor,
|
||||
openVote,
|
||||
openNominate,
|
||||
onLogin,
|
||||
onOpenAccount,
|
||||
openAdminPanel,
|
||||
onPrimaryPhaseAction,
|
||||
isUploadedSocialIcon,
|
||||
socialSimpleIconPath,
|
||||
socialSimpleIconColor,
|
||||
platformKey,
|
||||
onSectionAction,
|
||||
closeModal,
|
||||
stop,
|
||||
submitReminder,
|
||||
submitVote,
|
||||
submitNomination,
|
||||
onClipCatChange,
|
||||
clipDsgvoChange,
|
||||
onOpenPrivacy,
|
||||
submitClip,
|
||||
onCloseArchive,
|
||||
archiveYearButtonStyle,
|
||||
winnerPlatformStyle,
|
||||
winnerPlatformKey,
|
||||
winnerPlatformLabel,
|
||||
privacyModalStop,
|
||||
accountModalStop,
|
||||
archiveModalStop,
|
||||
onClosePrivacy,
|
||||
onCloseAccount,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
onLogout,
|
||||
onRequestDelete,
|
||||
onCancelDelete,
|
||||
onConfirmDelete,
|
||||
onOpenArchive,
|
||||
setArchiveYear,
|
||||
onTimelineFinalAction,
|
||||
setPreviewPhase,
|
||||
initializeHomeInteractions,
|
||||
} = useHomeLandingState()
|
||||
|
||||
const previewPhaseButtons = [
|
||||
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
|
||||
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
|
||||
{ key: 'review', label: 'Review', hint: 'Auswertung' },
|
||||
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
|
||||
] as const
|
||||
|
||||
const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmit } = useHomeLandingViewEffects({
|
||||
router,
|
||||
store,
|
||||
authStore,
|
||||
modalOpen,
|
||||
accountModalOpen,
|
||||
privacyModalOpen,
|
||||
archiveModalOpen,
|
||||
submitted,
|
||||
streamLive,
|
||||
archiveYear,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
completedPhase,
|
||||
initializeHomeInteractions,
|
||||
submitNomination,
|
||||
submitClip,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :ref="setRootEl">
|
||||
<transition name="cinematic-loader">
|
||||
<div
|
||||
v-if="landingLoaderVisible"
|
||||
style="position:fixed;inset:0;z-index:1200;"
|
||||
>
|
||||
<CinematicStarLoader
|
||||
eyebrow="VTuber Star Award"
|
||||
title="Suche nach Sternen im Himmel"
|
||||
outro-text="Sterne am Himmel gefunden und Erfolgreich verknüpft."
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div :style="rootStyle" class="home-landing">
|
||||
<div v-if="isAdmin" class="home-demo-preview" aria-label="Demo Phasen-Vorschau">
|
||||
<div class="home-demo-preview__label">
|
||||
<span>✦</span>
|
||||
<strong>Vorschau</strong>
|
||||
</div>
|
||||
<div class="home-demo-preview__buttons">
|
||||
<button
|
||||
v-for="phaseButton in previewPhaseButtons"
|
||||
:key="phaseButton.key"
|
||||
type="button"
|
||||
class="home-demo-preview__button"
|
||||
:class="{ 'home-demo-preview__button--active': previewPhase === phaseButton.key }"
|
||||
@click="setPreviewPhase(phaseButton.key)"
|
||||
>
|
||||
<span>{{ phaseButton.label }}</span>
|
||||
<small>{{ phaseButton.hint }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeHeroShell
|
||||
:is-guest="isGuest"
|
||||
:is-user="isUser"
|
||||
:is-admin="isAdmin"
|
||||
:twitch-user="twitchUser"
|
||||
:site-content="siteContent"
|
||||
:host-social-links="hostSocialLinks"
|
||||
:category-intro-text="categoryIntroText"
|
||||
:phase-card-title="phaseCardTitle"
|
||||
:phase-status-style="phaseStatusStyle"
|
||||
:phase-status-label="phaseStatusLabel"
|
||||
:phase-card-description="phaseCardDescription"
|
||||
:phase-card-range="phaseCardRange"
|
||||
:show-countdown="showCountdown"
|
||||
:public-stream-url="publicStreamUrl"
|
||||
:show-phase="showPhase"
|
||||
:phase-primary-label="phasePrimaryLabel"
|
||||
:phase-primary-disabled="phasePrimaryDisabled"
|
||||
:phase-primary-action-style="phasePrimaryActionStyle"
|
||||
:stream-eyebrow="streamEyebrow"
|
||||
:stream-title="streamTitle"
|
||||
:stream-meta="streamMeta"
|
||||
:stream-live="streamLive"
|
||||
:stream-locked="streamLocked"
|
||||
:stream-locked-label="streamLockedLabel"
|
||||
:stream-locked-title="streamLockedTitle"
|
||||
:stat-one-value="statOneValue"
|
||||
:stat-one-label="statOneLabel"
|
||||
:stat-two-value="statTwoValue"
|
||||
:stat-three-value="statThreeValue"
|
||||
:stat-three-label="statThreeLabel"
|
||||
:on-login="onLogin"
|
||||
:on-open-account="onOpenAccount"
|
||||
:open-admin-panel="openAdminPanel"
|
||||
:on-primary-phase-action="onPrimaryPhaseAction"
|
||||
:is-uploaded-social-icon="isUploadedSocialIcon"
|
||||
:social-simple-icon-path="socialSimpleIconPath"
|
||||
:social-simple-icon-color="socialSimpleIconColor"
|
||||
:platform-key="platformKey"
|
||||
/>
|
||||
|
||||
<HomeTimelineSection
|
||||
:nomination-phase="nominationPhase"
|
||||
:voting-phase="votingPhase"
|
||||
:review-phase="reviewPhase"
|
||||
:show-phase="showPhase"
|
||||
:completed-phase="completedPhase"
|
||||
:timeline-line-style="timelineLineStyle"
|
||||
:public-stream-url="publicStreamUrl"
|
||||
:format-timeline-range="formatTimelineRange"
|
||||
:open-nominate="openNominate"
|
||||
:open-vote="openVote"
|
||||
:on-timeline-final-action="onTimelineFinalAction"
|
||||
/>
|
||||
|
||||
<HomeCategoriesSection :display-categories="displayCategories" />
|
||||
|
||||
<HomeWinnerShowcaseSection
|
||||
:selected-archive="selectedArchive"
|
||||
:winner-showcase="winnerShowcase"
|
||||
:initials-for="initialsFor"
|
||||
:on-open-archive="onOpenArchive"
|
||||
/>
|
||||
|
||||
<HomeParticipationSection
|
||||
:section-title="sectionTitle"
|
||||
:section-text="sectionText"
|
||||
:section-action-label="sectionActionLabel"
|
||||
:section-action-href="sectionActionHref"
|
||||
:section-action-disabled="sectionActionDisabled"
|
||||
:section-action-style="sectionActionStyle"
|
||||
:nomination-phase="nominationPhase"
|
||||
:review-phase="reviewPhase"
|
||||
:on-section-action="onSectionAction"
|
||||
/>
|
||||
|
||||
<HomeSupportFooterSection
|
||||
:community-social-links="communitySocialLinks"
|
||||
:site-content="siteContent"
|
||||
:faq-items="faqItems"
|
||||
:footer-links="footerLinks"
|
||||
:on-open-privacy="onOpenPrivacy"
|
||||
:is-uploaded-social-icon="isUploadedSocialIcon"
|
||||
:social-simple-icon-path="socialSimpleIconPath"
|
||||
:social-simple-icon-color="socialSimpleIconColor"
|
||||
:platform-key="platformKey"
|
||||
/>
|
||||
|
||||
<HomeLandingModals
|
||||
:modal-open="modalOpen"
|
||||
:submitted="submitted"
|
||||
:not-submitted="notSubmitted"
|
||||
:form-error="formError"
|
||||
:success-title="successTitle"
|
||||
:success-text="successText"
|
||||
:close-modal="closeModal"
|
||||
:stop="stop"
|
||||
:is-show="isShow"
|
||||
:is-picker="isPicker"
|
||||
:is-vote="isVote"
|
||||
:is-clip="isClip"
|
||||
:nomination-phase="nominationPhase"
|
||||
:public-stream-url="publicStreamUrl"
|
||||
:format-show-date="formatShowDate"
|
||||
:submit-reminder="submitReminder"
|
||||
:picker-title="pickerTitle"
|
||||
:picker-subtitle="pickerSubtitle"
|
||||
:cat-list="catList"
|
||||
:active-cat-name="activeCatName"
|
||||
:noms="noms"
|
||||
:vote-count="voteCount"
|
||||
:total-cats="totalCats"
|
||||
:can-submit-vote="canSubmitVote"
|
||||
:submit-vote="submitVote"
|
||||
:submit-nomination="handleNominationSubmit"
|
||||
:submitting="submitting"
|
||||
:on-clip-cat-change="onClipCatChange"
|
||||
:cat-options="catOptions"
|
||||
:clip-nom-options="clipNomOptions"
|
||||
:clip-dsgvo="clipDsgvo"
|
||||
:clip-dsgvo-change="clipDsgvoChange"
|
||||
:on-open-privacy="onOpenPrivacy"
|
||||
:can-submit-clip="canSubmitClip"
|
||||
:clip-submit-style="clipSubmitStyle"
|
||||
:submit-clip="handleClipSubmit"
|
||||
:archive-modal-open="archiveModalOpen"
|
||||
:archive-years="archiveYears"
|
||||
:selected-archive="selectedArchive"
|
||||
:on-close-archive="onCloseArchive"
|
||||
:archive-modal-stop="archiveModalStop"
|
||||
:set-archive-year="setArchiveYear"
|
||||
:archive-year-button-style="archiveYearButtonStyle"
|
||||
:winner-platform-style="winnerPlatformStyle"
|
||||
:winner-platform-key="winnerPlatformKey"
|
||||
:winner-platform-label="winnerPlatformLabel"
|
||||
:privacy-modal-open="privacyModalOpen"
|
||||
:privacy-content-blocks="privacyContentBlocks"
|
||||
:on-close-privacy="onClosePrivacy"
|
||||
:privacy-modal-stop="privacyModalStop"
|
||||
:account-modal-open="accountModalOpen"
|
||||
:twitch-user="twitchUser"
|
||||
:role="role"
|
||||
:delete-not-confirm="deleteNotConfirm"
|
||||
:delete-confirm="deleteConfirm"
|
||||
:account-action-error="accountActionError"
|
||||
:auth-loading="authStore.loading"
|
||||
:on-close-account="onCloseAccount"
|
||||
:account-modal-stop="accountModalStop"
|
||||
:on-logout="onLogout"
|
||||
:on-request-delete="onRequestDelete"
|
||||
:on-cancel-delete="onCancelDelete"
|
||||
:on-confirm-delete="onConfirmDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import './homeLandingExperience.css';
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import HomeAccountAndPrivacyModals from './HomeAccountAndPrivacyModals.vue'
|
||||
import HomeArchiveModal from './HomeArchiveModal.vue'
|
||||
import HomeInteractionModal from './HomeInteractionModal.vue'
|
||||
import type {
|
||||
HomeArchiveYearItem,
|
||||
HomeCategoryListItem,
|
||||
HomeNomineeListItem,
|
||||
HomeSelectedArchive,
|
||||
HomeSelectionOption,
|
||||
} from './homeModalTypes'
|
||||
|
||||
defineProps<{
|
||||
modalOpen: boolean
|
||||
submitted: boolean
|
||||
notSubmitted: boolean
|
||||
formError: string
|
||||
successTitle: string
|
||||
successText: string
|
||||
closeModal: () => void
|
||||
stop: (event: Event) => void
|
||||
isShow: boolean
|
||||
isPicker: boolean
|
||||
isVote: boolean
|
||||
isClip: boolean
|
||||
nominationPhase: boolean
|
||||
publicStreamUrl: string
|
||||
formatShowDate: () => string
|
||||
submitReminder: () => void
|
||||
pickerTitle: string
|
||||
pickerSubtitle: string
|
||||
catList: HomeCategoryListItem[]
|
||||
activeCatName: string
|
||||
noms: HomeNomineeListItem[]
|
||||
voteCount: number
|
||||
totalCats: number
|
||||
canSubmitVote: boolean
|
||||
submitVote: () => Promise<void> | void
|
||||
submitNomination: () => Promise<void> | void
|
||||
submitting: boolean
|
||||
onClipCatChange: (event: Event) => void
|
||||
catOptions: HomeSelectionOption[]
|
||||
clipNomOptions: HomeSelectionOption[]
|
||||
clipDsgvo: boolean
|
||||
clipDsgvoChange: () => void
|
||||
onOpenPrivacy: () => void
|
||||
canSubmitClip: boolean
|
||||
clipSubmitStyle: string
|
||||
submitClip: () => Promise<void> | void
|
||||
archiveModalOpen: boolean
|
||||
archiveYears: HomeArchiveYearItem[]
|
||||
selectedArchive: HomeSelectedArchive
|
||||
onCloseArchive: () => void
|
||||
archiveModalStop: (event: Event) => void
|
||||
setArchiveYear: (year: number) => Promise<void>
|
||||
archiveYearButtonStyle: (active: boolean) => string
|
||||
winnerPlatformStyle: (url: string) => string
|
||||
winnerPlatformKey: (url: string) => string
|
||||
winnerPlatformLabel: (url: string) => string
|
||||
privacyModalOpen: boolean
|
||||
privacyContentBlocks: string[]
|
||||
onClosePrivacy: () => void
|
||||
privacyModalStop: (event: Event) => void
|
||||
accountModalOpen: boolean
|
||||
twitchUser: string
|
||||
role: string
|
||||
deleteNotConfirm: boolean
|
||||
deleteConfirm: boolean
|
||||
accountActionError: string
|
||||
authLoading: boolean
|
||||
onCloseAccount: () => void
|
||||
accountModalStop: (event: Event) => void
|
||||
onLogout: () => Promise<void> | void
|
||||
onRequestDelete: () => void
|
||||
onCancelDelete: () => void
|
||||
onConfirmDelete: () => Promise<void> | void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomeInteractionModal
|
||||
:modal-open="modalOpen"
|
||||
:submitted="submitted"
|
||||
:not-submitted="notSubmitted"
|
||||
:form-error="formError"
|
||||
:success-title="successTitle"
|
||||
:success-text="successText"
|
||||
:close-modal="closeModal"
|
||||
:stop="stop"
|
||||
:is-show="isShow"
|
||||
:is-picker="isPicker"
|
||||
:is-vote="isVote"
|
||||
:is-clip="isClip"
|
||||
:nomination-phase="nominationPhase"
|
||||
:public-stream-url="publicStreamUrl"
|
||||
:format-show-date="formatShowDate"
|
||||
:submit-reminder="submitReminder"
|
||||
:picker-title="pickerTitle"
|
||||
:picker-subtitle="pickerSubtitle"
|
||||
:cat-list="catList"
|
||||
:active-cat-name="activeCatName"
|
||||
:noms="noms"
|
||||
:vote-count="voteCount"
|
||||
:total-cats="totalCats"
|
||||
:can-submit-vote="canSubmitVote"
|
||||
:submit-vote="submitVote"
|
||||
:submit-nomination="submitNomination"
|
||||
:submitting="submitting"
|
||||
:on-clip-cat-change="onClipCatChange"
|
||||
:cat-options="catOptions"
|
||||
:clip-nom-options="clipNomOptions"
|
||||
:clip-dsgvo="clipDsgvo"
|
||||
:clip-dsgvo-change="clipDsgvoChange"
|
||||
:on-open-privacy="onOpenPrivacy"
|
||||
:can-submit-clip="canSubmitClip"
|
||||
:clip-submit-style="clipSubmitStyle"
|
||||
:submit-clip="submitClip"
|
||||
/>
|
||||
|
||||
<HomeArchiveModal
|
||||
:archive-modal-open="archiveModalOpen"
|
||||
:archive-years="archiveYears"
|
||||
:selected-archive="selectedArchive"
|
||||
:on-close-archive="onCloseArchive"
|
||||
:archive-modal-stop="archiveModalStop"
|
||||
:set-archive-year="setArchiveYear"
|
||||
:archive-year-button-style="archiveYearButtonStyle"
|
||||
:winner-platform-style="winnerPlatformStyle"
|
||||
:winner-platform-key="winnerPlatformKey"
|
||||
:winner-platform-label="winnerPlatformLabel"
|
||||
/>
|
||||
|
||||
<HomeAccountAndPrivacyModals
|
||||
:privacy-modal-open="privacyModalOpen"
|
||||
:privacy-content-blocks="privacyContentBlocks"
|
||||
:on-close-privacy="onClosePrivacy"
|
||||
:privacy-modal-stop="privacyModalStop"
|
||||
:account-modal-open="accountModalOpen"
|
||||
:twitch-user="twitchUser"
|
||||
:role="role"
|
||||
:delete-not-confirm="deleteNotConfirm"
|
||||
:delete-confirm="deleteConfirm"
|
||||
:account-action-error="accountActionError"
|
||||
:auth-loading="authLoading"
|
||||
:on-close-account="onCloseAccount"
|
||||
:account-modal-stop="accountModalStop"
|
||||
:on-open-privacy="onOpenPrivacy"
|
||||
:on-logout="onLogout"
|
||||
:on-request-delete="onRequestDelete"
|
||||
:on-cancel-delete="onCancelDelete"
|
||||
:on-confirm-delete="onConfirmDelete"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
sectionTitle: string
|
||||
sectionText: string
|
||||
sectionActionLabel: string
|
||||
sectionActionHref: string
|
||||
sectionActionDisabled: boolean
|
||||
sectionActionStyle: string
|
||||
nominationPhase: boolean
|
||||
reviewPhase: boolean
|
||||
onSectionAction: (event?: Event) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="voting" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
||||
<div class="home-steps-card" style="position:relative;background:#fdfcff;border:1px solid #efe7fb;border-radius:32px;padding:52px 44px;box-shadow:0 22px 60px rgba(124,86,196,.09);margin-bottom:50px;">
|
||||
<div style="display:flex;align-items:center;justify-content:center;gap:16px;margin-bottom:50px;">
|
||||
<span style="font-size:13px;color:#e7b13e;">✦</span>
|
||||
<h2 class="home-steps-heading" style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:clamp(20px,2.4vw,26px);letter-spacing:3px;text-transform:uppercase;color:#6a4fb8;white-space:nowrap;">So funktioniert's</h2>
|
||||
<span style="font-size:13px;color:#b9a3e8;">✦</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:flex-start;gap:10px;" data-steps>
|
||||
<div style="flex:1;">
|
||||
<div style="height:170px;border-radius:18px;background:#f6f1fd;border:1px dashed #d8c9f2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#a98ddb;font-family:monospace;font-size:12px;text-align:center;padding:14px;margin-bottom:22px;"><span style="font-size:30px;">✎</span>Illustration<br>Nominieren</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
||||
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">1</span>
|
||||
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Nominieren</h3>
|
||||
</div>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Nominiere deine Favoriten in jeder Kategorie — pro Kategorie 3 Nominierungen. Du kannst auch Clips deiner Lieblingsmomente einsenden.</p>
|
||||
</div>
|
||||
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<div style="flex:1;">
|
||||
<div style="height:170px;border-radius:18px;background:#f6f1fd;border:1px dashed #d8c9f2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#a98ddb;font-family:monospace;font-size:12px;text-align:center;padding:14px;margin-bottom:22px;"><span style="font-size:30px;">♡</span>Illustration<br>Voten</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
||||
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">2</span>
|
||||
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Voten</h3>
|
||||
</div>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Die Community stimmt über ihre Top-Favoriten ab. Nur 1 Stimme pro Kategorie.</p>
|
||||
</div>
|
||||
<svg data-step-arrow width="58" height="44" viewBox="0 0 58 44" fill="none" style="flex:none;margin-top:64px;"><path d="M3 12 C 22 4, 40 8, 50 26" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/><path d="M50 26 L 39 25 M50 26 L 49 14" stroke="#c2aef0" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<div style="flex:1;">
|
||||
<div style="height:170px;border-radius:18px;background:#f6f1fd;border:1px dashed #d8c9f2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:#a98ddb;font-family:monospace;font-size:12px;text-align:center;padding:14px;margin-bottom:22px;"><span style="font-size:30px;">★</span>Illustration<br>Die Besten</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
|
||||
<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;border:2px solid #c9b6f0;font-family:'Outfit',sans-serif;font-weight:700;font-size:16px;color:#7c5fc8;">3</span>
|
||||
<h3 style="margin:0;font-family:'Outfit',sans-serif;font-weight:700;font-size:19px;letter-spacing:1.5px;text-transform:uppercase;color:#5f44ad;">Die Besten</h3>
|
||||
</div>
|
||||
<p style="margin:0;font-size:15.5px;line-height:1.6;color:#7d7491;">Die Stimmen werden gezählt und die Gewinner live in der Show gekürt!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-cta-card" style="position:relative;overflow:hidden;border-radius:30px;padding:56px 40px;text-align:center;background:linear-gradient(135deg,#8b6cdb,#b78bff);box-shadow:0 30px 70px rgba(124,86,196,.4);">
|
||||
<span style="position:absolute;top:24px;left:8%;font-size:22px;color:#fff;opacity:.6;animation:twinkle 3s ease-in-out infinite;">✦</span>
|
||||
<span style="position:absolute;bottom:30px;right:12%;font-size:18px;color:#fff;opacity:.6;animation:twinkle 2.6s ease-in-out .5s infinite;">✧</span>
|
||||
<span style="position:absolute;top:40%;right:6%;font-size:14px;color:#fff;opacity:.5;animation:twinkle 3.2s ease-in-out 1s infinite;">✦</span>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(30px,3.8vw,44px);color:#fff;margin:0 0 14px;">{{ props.sectionTitle }}</h2>
|
||||
<p style="font-size:18px;color:rgba(255,255,255,.92);max-width:540px;margin:0 auto 28px;">{{ props.sectionText }}</p>
|
||||
<button
|
||||
v-if="props.sectionActionDisabled"
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
:style="props.sectionActionStyle"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="#9B8ABF"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>{{ props.sectionActionLabel }}
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
:href="props.sectionActionHref"
|
||||
@click="props.onSectionAction"
|
||||
:style="props.sectionActionStyle"
|
||||
style-hover="transform:translateY(-2px);"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.reviewPhase ? '#B7791F' : '#9146FF'"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>{{ props.sectionActionLabel }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { HomeSelectionOption } from './homeModalTypes'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: number
|
||||
options: HomeSelectionOption[]
|
||||
dataRef?: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
}>(), {
|
||||
dataRef: undefined,
|
||||
placeholder: 'Auswählen',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
change: [value: number]
|
||||
}>()
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const selectedOption = computed(() =>
|
||||
props.options.find((option) => option.id === props.modelValue) ?? props.options[0] ?? null,
|
||||
)
|
||||
|
||||
const selectedValue = computed(() => selectedOption.value?.id ?? 0)
|
||||
|
||||
const listboxId = computed(() =>
|
||||
`home-select-${props.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
(options) => {
|
||||
const currentIndex = options.findIndex((option) => option.id === props.modelValue)
|
||||
if (currentIndex >= 0) {
|
||||
activeIndex.value = currentIndex
|
||||
return
|
||||
}
|
||||
|
||||
activeIndex.value = 0
|
||||
if (options[0]) {
|
||||
emit('update:modelValue', options[0].id)
|
||||
emit('change', options[0].id)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function toggleDropdown() {
|
||||
if (!props.options.length) return
|
||||
open.value = !open.value
|
||||
if (open.value) {
|
||||
activeIndex.value = Math.max(0, props.options.findIndex((option) => option.id === selectedValue.value))
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function selectOption(option: HomeSelectionOption) {
|
||||
emit('update:modelValue', option.id)
|
||||
emit('change', option.id)
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function selectActiveOption() {
|
||||
const option = props.options[activeIndex.value]
|
||||
if (option) selectOption(option)
|
||||
}
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!props.options.length) return
|
||||
if (!open.value) {
|
||||
open.value = true
|
||||
return
|
||||
}
|
||||
|
||||
activeIndex.value = (activeIndex.value + delta + props.options.length) % props.options.length
|
||||
}
|
||||
|
||||
function onButtonKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
open.value ? selectActiveOption() : toggleDropdown()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event: PointerEvent) {
|
||||
if (!rootEl.value?.contains(event.target as Node)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="home-select" :class="{ 'home-select--open': open }">
|
||||
<select
|
||||
v-if="props.dataRef"
|
||||
class="home-select__native"
|
||||
:data-dc-ref="props.dataRef"
|
||||
:value="selectedValue"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<option v-for="option in props.options" :key="option.id" :value="option.id">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="home-select__trigger"
|
||||
:aria-label="props.label"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="listbox"
|
||||
:aria-controls="listboxId"
|
||||
@click="toggleDropdown"
|
||||
@keydown="onButtonKeydown"
|
||||
>
|
||||
<span class="home-select__value">{{ selectedOption?.label ?? props.placeholder }}</span>
|
||||
<span class="home-select__chevron" aria-hidden="true">
|
||||
<ChevronDown :size="20" :stroke-width="2.8" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="open" :id="listboxId" class="home-select__menu" role="listbox">
|
||||
<button
|
||||
v-for="(option, optionIndex) in props.options"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="home-select__option"
|
||||
:class="{
|
||||
'home-select__option--active': optionIndex === activeIndex,
|
||||
'home-select__option--selected': option.id === selectedValue,
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="option.id === selectedValue"
|
||||
@mouseenter="activeIndex = optionIndex"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="home-select__option-label">{{ option.label }}</span>
|
||||
<Check v-if="option.id === selectedValue" class="home-select__check" :size="18" :stroke-width="3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-select{
|
||||
position:relative;
|
||||
width:100%;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
.home-select--open{
|
||||
z-index:30;
|
||||
}
|
||||
|
||||
.home-select__native{
|
||||
position:absolute;
|
||||
width:1px;
|
||||
height:1px;
|
||||
opacity:0;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.home-select__trigger{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:14px;
|
||||
width:100%;
|
||||
min-height:56px;
|
||||
padding:11px 11px 11px 17px;
|
||||
border:2px solid #e8def8;
|
||||
border-radius:16px;
|
||||
background:linear-gradient(180deg,#fff 0%,#fbf8ff 100%);
|
||||
box-shadow:0 8px 22px rgba(139,108,219,.08), inset 0 1px 0 rgba(255,255,255,.9);
|
||||
color:#3f3556;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:15px;
|
||||
font-weight:700;
|
||||
line-height:1.2;
|
||||
outline:none;
|
||||
text-align:left;
|
||||
transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease;
|
||||
}
|
||||
|
||||
.home-select__trigger:hover,
|
||||
.home-select__trigger:focus-visible,
|
||||
.home-select--open .home-select__trigger{
|
||||
border-color:#9b7ce4;
|
||||
background:#fff;
|
||||
box-shadow:0 12px 30px rgba(139,108,219,.16), 0 0 0 4px rgba(139,108,219,.12);
|
||||
}
|
||||
|
||||
.home-select__value{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.home-select__chevron{
|
||||
display:grid;
|
||||
place-items:center;
|
||||
flex:none;
|
||||
width:34px;
|
||||
height:34px;
|
||||
border-radius:12px;
|
||||
background:#f1eafd;
|
||||
color:#7c5bd2;
|
||||
box-shadow:inset 0 0 0 1px rgba(139,108,219,.09);
|
||||
transition:transform .18s ease, background .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.home-select--open .home-select__chevron{
|
||||
transform:rotate(180deg);
|
||||
background:#835fd8;
|
||||
color:#fff;
|
||||
}
|
||||
|
||||
.home-select__menu{
|
||||
position:absolute;
|
||||
top:calc(100% + 8px);
|
||||
left:0;
|
||||
right:0;
|
||||
display:grid;
|
||||
gap:4px;
|
||||
max-height:286px;
|
||||
padding:8px;
|
||||
overflow:auto;
|
||||
border:1px solid rgba(139,108,219,.18);
|
||||
border-radius:18px;
|
||||
background:rgba(255,255,255,.98);
|
||||
box-shadow:0 24px 54px rgba(63,53,86,.22);
|
||||
backdrop-filter:blur(14px);
|
||||
-webkit-backdrop-filter:blur(14px);
|
||||
}
|
||||
|
||||
.home-select__option{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:12px;
|
||||
width:100%;
|
||||
min-height:46px;
|
||||
padding:10px 12px;
|
||||
border:0;
|
||||
border-radius:13px;
|
||||
background:transparent;
|
||||
color:#514765;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:15px;
|
||||
font-weight:650;
|
||||
line-height:1.2;
|
||||
text-align:left;
|
||||
transition:background .16s ease, color .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.home-select__option:hover,
|
||||
.home-select__option--active{
|
||||
background:#f6f0ff;
|
||||
color:#4f3a8a;
|
||||
}
|
||||
|
||||
.home-select__option--selected{
|
||||
background:linear-gradient(135deg,#efe6ff,#e7dcfb);
|
||||
color:#3f2f70;
|
||||
font-weight:800;
|
||||
}
|
||||
|
||||
.home-select__option-label{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.home-select__check{
|
||||
flex:none;
|
||||
color:#7c5bd2;
|
||||
}
|
||||
|
||||
@media (max-width:760px){
|
||||
.home-select__trigger{
|
||||
min-height:52px;
|
||||
padding-left:14px;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.home-select__menu{
|
||||
max-height:232px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
interface HomeSocialLink {
|
||||
label: string
|
||||
platform: string
|
||||
icon: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface SiteContent {
|
||||
newsletterUrl: string
|
||||
}
|
||||
|
||||
interface FaqItem {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
communitySocialLinks: HomeSocialLink[]
|
||||
siteContent: SiteContent
|
||||
faqItems: FaqItem[]
|
||||
footerLinks: FooterLink[]
|
||||
onOpenPrivacy: () => void
|
||||
isUploadedSocialIcon: (icon: string | null | undefined) => boolean
|
||||
socialSimpleIconPath: (platform: string | null | undefined) => string
|
||||
socialSimpleIconColor: (platform: string | null | undefined) => string
|
||||
platformKey: (platform: string | null | undefined) => string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="host" style="background:linear-gradient(180deg,#ece3fa 0%,#e6dcf6 100%);border-top:1px solid #ddd0f2;border-bottom:1px solid #ddd0f2;padding:84px 0;">
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 24px;">
|
||||
<div style="position:relative;display:grid;grid-template-columns:1.62fr 1fr;gap:26px;align-items:stretch;" data-community-grid>
|
||||
<div class="home-community-card" style="position:relative;overflow:visible;background:#f5f0fc;border:1px solid #e9e0f8;border-radius:28px;padding:46px 46px 42px;min-height:360px;">
|
||||
<div class="home-community-card__content" style="position:relative;z-index:2;max-width:62%;">
|
||||
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(26px,3vw,36px);letter-spacing:.5px;color:#5f44ad;">COMMUNITY & UPDATES</h2>
|
||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 26px;">Tritt unserer Community bei und verpasse keine News, Updates und Behind-the-Scenes!</p>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:26px;">
|
||||
<a
|
||||
v-for="social in props.communitySocialLinks"
|
||||
:key="`community-${social.platform}`"
|
||||
:href="social.url"
|
||||
:aria-label="social.label"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;width:46px;height:46px;border-radius:13px;background:#fff;border:1px solid #e9e0f8;box-shadow:0 4px 12px rgba(124,86,196,.08);text-decoration:none;"
|
||||
style-hover="transform:translateY(-2px);"
|
||||
>
|
||||
<template v-if="props.isUploadedSocialIcon(social.icon)">
|
||||
<img :src="social.icon" :alt="social.label" style="width:24px;height:24px;object-fit:contain;" />
|
||||
</template>
|
||||
<template v-else-if="props.socialSimpleIconPath(social.platform)">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" :fill="props.socialSimpleIconColor(social.platform)"><path :d="props.socialSimpleIconPath(social.platform)" /></svg>
|
||||
</template>
|
||||
<template v-else-if="props.platformKey(social.platform) === 'twitch'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#9146FF"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.platformKey(social.platform) === 'youtube'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#FF0000"><path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.4.6A3 3 0 0 0 .5 6.2 31 31 0 0 0 0 12a31 31 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.6 9.4.6 9.4.6s7.5 0 9.4-.6a3 3 0 0 0 2.1-2.1A31 31 0 0 0 24 12a31 31 0 0 0-.5-5.8zM9.5 15.5v-7l6.5 3.5z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.platformKey(social.platform) === 'instagram'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#E1306C" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="#E1306C" stroke="none"/></svg>
|
||||
</template>
|
||||
<template v-else-if="props.platformKey(social.platform) === 'discord'">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="#5865F2"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="font-size:18px;font-weight:800;color:#5f44ad;">{{ social.icon || '✦' }}</span>
|
||||
</template>
|
||||
</a>
|
||||
</div>
|
||||
<a :href="props.siteContent.newsletterUrl" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:11px;padding:15px 26px;border-radius:14px;background:#fff;border:1px solid #e3d8f5;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 8px 22px rgba(124,86,196,.1);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>Newsletter abonnieren</a>
|
||||
</div>
|
||||
<img class="home-community-card__image" src="/assets/jayu-hero.png" alt="Jayuhime" style="position:absolute;z-index:1;right:-26px;bottom:0;height:430px;width:auto;pointer-events:none;filter:drop-shadow(0 18px 36px rgba(120,80,180,.22));" />
|
||||
</div>
|
||||
<div class="home-share-card" style="background:linear-gradient(160deg,#fdf6f6,#faf3fb);border:1px solid #f0e7f2;border-radius:28px;padding:46px 38px;display:flex;flex-direction:column;">
|
||||
<h2 style="margin:0 0 14px;font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(24px,2.6vw,32px);letter-spacing:.5px;color:#5f44ad;">TEILE DIE AWARDS</h2>
|
||||
<p style="font-size:16px;line-height:1.6;color:#7d7491;margin:0 0 28px;">Supporte deine Favoriten und teile die Awards mit deinen Freunden!</p>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:auto;">
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:#15131c;color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(20,18,28,.2);" style-hover="transform:translateY(-2px);"><svg width="17" height="17" viewBox="0 0 24 24" fill="#fff"><path d="M18.9 1.6h3.5l-7.6 8.7L23.7 22h-7l-5.5-7.2L4.9 22H1.4l8.1-9.3L1 1.6h7.2l4.9 6.5 5.8-6.5zm-1.2 18.3h1.9L7.1 3.6H5z"/></svg>Auf X teilen</a>
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:12px;padding:15px 22px;border-radius:14px;background:linear-gradient(135deg,#7c5fd0,#6d4fd0);color:#fff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 10px 24px rgba(124,86,196,.3);" style-hover="transform:translateY(-2px);"><svg width="19" height="19" viewBox="0 0 24 24" fill="#fff"><path d="M19.5 5.3A16 16 0 0 0 15.5 4l-.25.5a14.6 14.6 0 0 1 3.3 1.05 13 13 0 0 0-11.1 0A14.6 14.6 0 0 1 10.75 4.5L10.5 4A16 16 0 0 0 6.5 5.3 16.6 16.6 0 0 0 3.7 16.5a16.1 16.1 0 0 0 4.9 2.5l.6-.85a10.5 10.5 0 0 1-1.65-.8l.4-.3a11.5 11.5 0 0 0 9.9 0l.4.3a10.5 10.5 0 0 1-1.65.8l.6.85a16.1 16.1 0 0 0 4.9-2.5 16.6 16.6 0 0 0-2.8-11.2zM9.4 14.2c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.76 1.95-1.7 1.95zm5.2 0c-.95 0-1.7-.88-1.7-1.95s.75-1.95 1.7-1.95 1.72.88 1.7 1.95c0 1.07-.75 1.95-1.7 1.95z"/></svg>Auf Discord teilen</a>
|
||||
<a href="#" style="display:flex;align-items:center;justify-content:center;gap:11px;padding:15px 22px;border-radius:14px;background:#fff;border:1px solid #e9e0f8;color:#5f44ad;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:600;font-size:16px;box-shadow:0 6px 16px rgba(124,86,196,.08);" style-hover="transform:translateY(-2px);border-color:#c9b6f0;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b6cdb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1.5 1.5"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1.5-1.5"/></svg>Link kopieren</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="faq" class="home-section" style="max-width:880px;margin:0 auto;padding:90px 24px;">
|
||||
<div style="text-align:center;margin-bottom:46px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;">✧ Häufige Fragen</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:var(--ink,#3f3556);">FAQ</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:520px;margin:0 auto;">Alles, was du über Nominierung, Voting und die Show wissen musst.</p>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||
<details v-for="item in props.faqItems" :key="item.question" style="background:#ffffff;border:1px solid var(--line,rgba(124,86,196,.16));border-radius:16px;padding:4px 22px;box-shadow:0 8px 24px rgba(124,86,196,.07);">
|
||||
<summary style="display:flex;align-items:center;justify-content:space-between;gap:16px;cursor:pointer;list-style:none;padding:18px 0;font-family:'Outfit',sans-serif;font-weight:600;font-size:18px;color:var(--ink,#3f3556);">{{ item.question }}<span style="flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;background:#f1ecfb;color:#8b6cdb;font-size:16px;transition:transform .2s;">+</span></summary>
|
||||
<p style="margin:0 0 18px;font-size:15px;line-height:1.65;color:var(--muted,#8a8398);">{{ item.answer }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer style="border-top:1px solid var(--line,rgba(255,255,255,.12));">
|
||||
<div style="max-width:1200px;margin:0 auto;padding:50px 24px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:17px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:10px;background:linear-gradient(135deg,var(--accent,#ff5fa2),var(--accent2,#a06bff));color:#fff;font-size:15px;">✦</span>
|
||||
VTuber Star Award 2026
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px;font-size:14px;font-weight:500;">
|
||||
<a v-for="link in props.footerLinks" :key="link.label" :href="link.url" target="_blank" rel="noopener" style="color:var(--muted,#8a8398);text-decoration:none;" style-hover="color:var(--accent,#8b6cdb);">{{ link.label }}</a>
|
||||
<button @click="props.onOpenPrivacy" style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;" style-hover="color:var(--accent,#8b6cdb);">Datenschutz</button>
|
||||
</div>
|
||||
<div style="font-size:13px;color:var(--muted,#c9b8da);">© 2026 · Made with ♡ & Chaos</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<section id="ablauf" class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px 70px;">
|
||||
<div style="text-align:center;margin-bottom:56px;">
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent,#8b6cdb);margin-bottom:12px;">✦ Der Ablauf</div>
|
||||
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0 0 14px;letter-spacing:-.4px;color:#3f3556;">In vier Phasen auf die Bühne</h2>
|
||||
<p style="font-size:17px;color:var(--muted,#8a8398);max-width:620px;margin:0 auto;">Von Nominierung und Voting über Review & Auswertung bis ganz zum Schluss zur grossen Show.</p>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;align-items:start;" data-timeline>
|
||||
<div :style="timelineLineStyle"></div>
|
||||
|
||||
<div style="position:relative;z-index:1;text-align:center;">
|
||||
<div :style="nominationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(43,189,110,.18),0 8px 20px rgba(31,157,90,.35);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
|
||||
<template v-if="nominationPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M12 17.3l-6.2 3.3 1.2-7-5.1-5 7.1-1 3-6.4 3 6.4 7.1 1-5.1 5 1.2 7z"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;">
|
||||
<div :style="nominationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ nominationPhase ? 'JETZT AKTIV' : 'ABGESCHLOSSEN' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Nominierung</h3>
|
||||
<div style="font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;">{{ formatTimelineRange('nomination') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die Community reicht ihre Favoriten ein — pro Kategorie bis zu drei Nominierungen.</p>
|
||||
<button type="button" @click="openNominate" style="display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;" style-hover="background:#e8e0f9;">{{ nominationPhase ? 'Jetzt nominieren und Clips einsenden' : 'Nominierungen ansehen' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;z-index:1;text-align:center;">
|
||||
<div :style="votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#2bbd6e,#1f9d5a);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(43,189,110,.18),0 8px 20px rgba(31,157,90,.35);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : nominationPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
|
||||
<template v-if="nominationPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l2.9 6.3 6.9.7-5.1 4.6 1.4 6.8L12 17.8 5.9 20.4l1.4-6.8L2.2 9l6.9-.7z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="votingPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M12 2l2.9 6.3 6.9.7-5.1 4.6 1.4 6.8L12 17.8 5.9 20.4l1.4-6.8L2.2 9l6.9-.7z"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div :style="votingPhase ? 'background:#fff;border:1px solid #c9efd8;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(31,157,90,.14);min-height:320px;' : nominationPhase ? 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;' : 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;'">
|
||||
<div :style="votingPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : nominationPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'"><span v-if="votingPhase" style="width:6px;height:6px;border-radius:50%;background:#1f9d5a;display:inline-block;"></span>{{ votingPhase ? 'JETZT AKTIV' : nominationPhase ? 'ALS NÄCHSTES' : 'ABGESCHLOSSEN' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Voting</h3>
|
||||
<div :style="votingDateStyle(votingPhase, nominationPhase)">{{ formatTimelineRange('voting') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die ganze Szene stimmt ab — eine Stimme pro Kategorie, Login über Twitch.</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="openVote"
|
||||
:disabled="!votingPhase"
|
||||
:aria-disabled="!votingPhase"
|
||||
:style="voteButtonStyle(votingPhase)"
|
||||
style-hover="transform:translateY(-2px);"
|
||||
>
|
||||
★ {{ votingPhase ? 'Jetzt voten' : nominationPhase ? 'Voting folgt' : 'Voting beendet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;z-index:1;text-align:center;">
|
||||
<div :style="nominationPhase || votingPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);' : reviewPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#f7c76a,#b7791f);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(247,199,106,.18),0 8px 20px rgba(183,121,31,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;'">
|
||||
<template v-if="nominationPhase || votingPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</template>
|
||||
<template v-else-if="reviewPhase">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div :style="reviewPhase ? 'background:#fffdf8;border:1px solid #f3ddae;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(183,121,31,.14);min-height:320px;' : showPhase || completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
|
||||
<div :style="reviewPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : showPhase || completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ reviewPhase ? 'IN PRÜFUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Review & Auswertung</h3>
|
||||
<div :style="reviewPhase ? 'font-size:13px;font-weight:600;color:#b7791f;margin-bottom:12px;' : showPhase || completedPhase ? 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('review') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team prüft Fairness, Stimmen und Clips, wertet die Ergebnisse aus und bereitet die Show final vor.</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
:style="reviewButtonStyle(reviewPhase, showPhase, completedPhase)"
|
||||
>
|
||||
✦ {{ reviewPhase ? 'Auswertung läuft' : showPhase || completedPhase ? 'Auswertung abgeschlossen' : 'Review folgt' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:relative;z-index:1;text-align:center;">
|
||||
<div :style="showPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#ec3b5a,#b91c43);display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 6px rgba(236,59,90,.18),0 8px 20px rgba(185,28,67,.28);border:4px solid #f4eefb;animation:pulseGlow 2.2s ease-in-out infinite;' : completedPhase ? 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:linear-gradient(135deg,#8b6cdb,#7355c8);display:flex;align-items:center;justify-content:center;box-shadow:0 8px 20px rgba(124,86,196,.32);border:4px solid #f4eefb;' : 'width:54px;height:54px;margin:0 auto 20px;border-radius:50%;background:#fff;border:4px solid #e2d6f4;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(124,86,196,.1);'">
|
||||
<template v-if="showPhase">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>
|
||||
</template>
|
||||
<template v-else-if="completedPhase">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#b9a9dd" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5v14l11-7z"/></svg>
|
||||
</template>
|
||||
</div>
|
||||
<div :style="showPhase ? 'background:#fff5f7;border:1px solid #ffc7d2;border-radius:20px;padding:24px 20px;box-shadow:0 16px 38px rgba(236,59,90,.14);min-height:320px;' : completedPhase ? 'background:#fff;border:1px solid #efe7fb;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.08);min-height:320px;' : 'background:#fbf9ff;border:1px solid #ede5fa;border-radius:20px;padding:24px 20px;box-shadow:0 12px 30px rgba(124,86,196,.06);min-height:320px;'">
|
||||
<div :style="showPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : completedPhase ? 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;' : 'display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:#f3eefb;color:#a98ddb;font-size:11px;font-weight:700;letter-spacing:.5px;margin-bottom:12px;'">{{ showPhase ? 'JETZT LIVE' : completedPhase ? 'ABGESCHLOSSEN' : 'FINALE' }}</div>
|
||||
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Show</h3>
|
||||
<div :style="showPhase ? 'font-size:13px;font-weight:600;color:#ec3b5a;margin-bottom:12px;' : 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'">{{ formatTimelineRange('show') }}</div>
|
||||
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Die grosse Live-Show auf Twitch: Gewinner werden gekrönt, Clips gezeigt und die Szene feiert gemeinsam.</p>
|
||||
<a
|
||||
v-if="showPhase"
|
||||
:href="publicStreamUrl"
|
||||
:style="finalActionStyle(showPhase)"
|
||||
style-hover="background:#ece2fa;"
|
||||
>
|
||||
Zum Stream
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
:disabled="completedPhase"
|
||||
:aria-disabled="completedPhase"
|
||||
@click="onTimelineFinalAction"
|
||||
:style="finalActionStyle(showPhase, completedPhase)"
|
||||
style-hover="background:#ece2fa;"
|
||||
>
|
||||
{{ completedPhase ? 'Award beendet' : 'Erinnerung aktivieren' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
nominationPhase: boolean
|
||||
votingPhase: boolean
|
||||
reviewPhase: boolean
|
||||
showPhase: boolean
|
||||
completedPhase: boolean
|
||||
timelineLineStyle: string
|
||||
publicStreamUrl: string
|
||||
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
|
||||
openNominate: (event?: Event) => void
|
||||
openVote: (event?: Event) => void
|
||||
onTimelineFinalAction: (event?: Event) => void
|
||||
}>()
|
||||
|
||||
function votingDateStyle(votingPhase: boolean, nominationPhase: boolean) {
|
||||
if (votingPhase) {
|
||||
return 'font-size:13px;font-weight:600;color:#1f9d5a;margin-bottom:12px;'
|
||||
}
|
||||
|
||||
return nominationPhase
|
||||
? 'font-size:13px;font-weight:600;color:#a99fc0;margin-bottom:12px;'
|
||||
: 'font-size:13px;font-weight:600;color:#8b6cdb;margin-bottom:12px;'
|
||||
}
|
||||
|
||||
function voteButtonStyle(votingPhase: boolean) {
|
||||
return votingPhase
|
||||
? "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:0 8px 18px rgba(124,86,196,.3);"
|
||||
: "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:none;"
|
||||
}
|
||||
|
||||
function reviewButtonStyle(reviewPhase: boolean, showPhase: boolean, completedPhase: boolean) {
|
||||
if (reviewPhase) {
|
||||
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#fff1d6;color:#8a5a00;border:1px solid #f3ddae;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
|
||||
}
|
||||
|
||||
if (showPhase || completedPhase) {
|
||||
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f1ecfb;color:#7355c8;border:none;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
|
||||
}
|
||||
|
||||
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;"
|
||||
}
|
||||
|
||||
function finalActionStyle(showPhase: boolean, completedPhase = false) {
|
||||
if (completedPhase) {
|
||||
return "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:not-allowed;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;text-decoration:none;"
|
||||
}
|
||||
|
||||
return showPhase
|
||||
? "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#ec3b5a;color:#fff;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;box-shadow:0 8px 18px rgba(236,59,90,.26);text-decoration:none;"
|
||||
: "display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:11px;background:#f3eefb;color:#a06bd8;border:1px solid #e4d8f6;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:14px;text-decoration:none;"
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<nav class="home-nav" style="position:sticky;top:0;z-index:50;display:flex;align-items:center;justify-content:space-between;gap:20px;padding:14px 28px;background:rgba(248,243,254,.86);border-bottom:1px solid #ece2fa;backdrop-filter:blur(10px);">
|
||||
<div class="home-nav__brand" style="display:flex;align-items:center;gap:10px;font-family:'Fredoka',sans-serif;font-weight:700;font-size:19px;letter-spacing:.3px;">
|
||||
<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:11px;background:linear-gradient(135deg,#8b6cdb,#e7b13e);color:#fff;font-size:18px;box-shadow:0 6px 18px rgba(124,86,196,.35);">✦</span>
|
||||
<span style="color:#3f3556;">VTuber <span style="color:#8b6cdb;">Star</span> Award</span>
|
||||
</div>
|
||||
<div class="home-nav__links" style="display:flex;align-items:center;gap:26px;font-size:15px;font-weight:500;color:#6f6685;" data-nav-links>
|
||||
<a href="#kategorien" style="color:inherit;text-decoration:none;">Kategorien</a>
|
||||
<a href="#ablauf" style="color:inherit;text-decoration:none;">Ablauf</a>
|
||||
<a href="#nominierte" style="color:inherit;text-decoration:none;">Nominierte</a>
|
||||
<a href="#voting" style="color:inherit;text-decoration:none;">Voting</a>
|
||||
<a href="#host" style="color:inherit;text-decoration:none;">Host</a>
|
||||
<a href="#faq" style="color:inherit;text-decoration:none;">FAQ</a>
|
||||
</div>
|
||||
<div class="home-nav__actions" style="display:flex;align-items:center;gap:10px;">
|
||||
<template v-if="isGuest">
|
||||
<button @click="onLogin" style="display:inline-flex;align-items:center;gap:8px;padding:10px 20px;border-radius:999px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Fredoka',sans-serif;font-weight:600;font-size:15px;box-shadow:0 8px 22px rgba(124,86,196,.32);border:none;cursor:pointer;" style-hover="transform:translateY(-2px);">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#fff"><path d="M4 2L2.4 6v14.5h5V23h2.7l2.5-2.5h4L21.6 16V2H4zm15.3 13.1l-2.9 2.9h-4.4l-2.5 2.5v-2.5H6.7V3.7h12.6v11.4z"/><path d="M11.1 7.1h1.7v5h-1.7zM15.7 7.1h1.7v5h-1.7z"/></svg>
|
||||
Anmelden mit Twitch
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="isUser">
|
||||
<button @click="onOpenAccount" style="display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:999px;background:rgba(139,108,219,0.1);border:1.5px solid rgba(139,108,219,0.25);color:#5f44ad;font-family:'Fredoka',sans-serif;font-weight:600;font-size:14px;cursor:pointer;" style-hover="background:rgba(139,108,219,0.18);">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
@{{ twitchUser }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="isAdmin">
|
||||
<button @click="onOpenAccount" style="display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:999px;background:rgba(231,177,62,0.1);border:1.5px solid rgba(231,177,62,0.4);color:#8a5a00;font-family:'Fredoka',sans-serif;font-weight:700;font-size:14px;cursor:pointer;" style-hover="background:rgba(231,177,62,0.18);">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
@{{ twitchUser }}
|
||||
<span style="padding:1px 6px;border-radius:999px;background:rgba(231,177,62,0.25);color:#8a5a00;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;">Admin</span>
|
||||
</button>
|
||||
</template>
|
||||
<button @click="openAdminPanel" style="display:inline-flex;align-items:center;gap:6px;padding:8px 15px;border-radius:999px;background:rgba(231,177,62,0.12);border:1.5px solid rgba(231,177,62,0.3);color:#8a5a00;text-decoration:none;font-family:'Fredoka',sans-serif;font-weight:700;font-size:13px;cursor:pointer;" style-hover="background:rgba(231,177,62,0.22);">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
Admin Panel
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isGuest: boolean
|
||||
isUser: boolean
|
||||
isAdmin: boolean
|
||||
twitchUser: string
|
||||
onLogin: () => void
|
||||
onOpenAccount: () => void
|
||||
openAdminPanel: (event?: Event) => void
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import type { HomeCategoryListItem, HomeNomineeListItem } from './homeModalTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
catList: HomeCategoryListItem[]
|
||||
activeCatName: string
|
||||
noms: HomeNomineeListItem[]
|
||||
voteCount: number
|
||||
totalCats: number
|
||||
canSubmitVote: boolean
|
||||
submitVote: () => Promise<void> | void
|
||||
submitting: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-vote-picker">
|
||||
<aside class="home-vote-picker__rail">
|
||||
<div class="home-vote-picker__rail-label">Kategorien</div>
|
||||
<template v-for="(cat, __idx) in props.catList" :key="cat.idx ?? __idx">
|
||||
<button class="home-vote-picker__category-button" @click="cat.onClick" :style="cat.rowStyle">
|
||||
<span :style="cat.iconStyle">{{ cat.icon }}</span>
|
||||
<span>{{ cat.name }}</span>
|
||||
<span :style="cat.checkStyle">✓</span>
|
||||
</button>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<section class="home-vote-picker__content">
|
||||
<header class="home-vote-picker__category-header">
|
||||
<div>
|
||||
<p class="home-vote-picker__eyebrow">Kategorie</p>
|
||||
<h4>{{ props.activeCatName }}</h4>
|
||||
</div>
|
||||
<span class="home-vote-picker__hint">Nominee ansehen, Clip prüfen, Favorit:in wählen.</span>
|
||||
</header>
|
||||
|
||||
<div class="home-vote-picker__cards">
|
||||
<template v-for="(nom, __idx) in props.noms" :key="nom.idx ?? __idx">
|
||||
<article class="home-vote-card" :class="{ 'home-vote-card--selected': nom.selected, 'home-vote-card--missing-clip': !nom.hasClip }">
|
||||
<div class="home-vote-card__identity">
|
||||
<div class="home-vote-card__avatar">{{ nom.initials || '✦' }}</div>
|
||||
<div class="home-vote-card__name-block">
|
||||
<div class="home-vote-card__name-row">
|
||||
<h5>{{ nom.name }}</h5>
|
||||
<span>{{ nom.platform }}</span>
|
||||
</div>
|
||||
<p>{{ nom.handle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-vote-card__clip">
|
||||
<span class="home-vote-card__clip-platform">{{ nom.clipPlatform }}</span>
|
||||
<a
|
||||
v-if="nom.clipUrl"
|
||||
:href="nom.clipUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
@click.stop
|
||||
>
|
||||
{{ nom.clipTitle }}
|
||||
</a>
|
||||
<span v-else>Kein geprüfter Clip für diese Kategorie hinterlegt.</span>
|
||||
</div>
|
||||
|
||||
<template v-if="nom.showPick">
|
||||
<button class="home-vote-card__pick" :class="{ 'home-vote-card__pick--selected': nom.selected }" @click="nom.onPick">
|
||||
{{ nom.btnLabel }}
|
||||
</button>
|
||||
</template>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<div v-if="props.noms.length === 0" class="home-vote-picker__empty">
|
||||
Für diese Kategorie sind noch keine Kandidat:innen freigegeben.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="home-modal__vote-footer home-vote-picker__footer">
|
||||
<div><span>{{ props.voteCount }}</span> / {{ props.totalCats }} Kategorien gewählt</div>
|
||||
<button
|
||||
@click="props.submitVote"
|
||||
:disabled="props.submitting || !props.canSubmitVote"
|
||||
:class="{ 'home-vote-picker__submit--disabled': !props.canSubmitVote }"
|
||||
>
|
||||
{{ props.submitting ? 'Speichert ...' : props.canSubmitVote ? 'Stimmen absenden ✩' : 'Erst Favorit:in wählen' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<section id="nominierte" style="position:relative;overflow:hidden;background:linear-gradient(115deg,#241640 0%,#3a2168 48%,#5b3aa0 100%);border-top:1px solid rgba(255,255,255,.12);border-bottom:1px solid rgba(255,255,255,.12);">
|
||||
<span style="position:absolute;top:22px;left:6%;font-size:16px;color:rgba(255,255,255,.35);animation:twinkle 3s ease-in-out infinite;">✦</span>
|
||||
<span style="position:absolute;top:18px;left:38%;font-size:12px;color:rgba(255,255,255,.28);animation:twinkle 2.6s ease-in-out .4s infinite;">✧</span>
|
||||
<span style="position:absolute;top:30px;right:22%;font-size:13px;color:rgba(255,255,255,.3);animation:twinkle 3.3s ease-in-out 1s infinite;">✦</span>
|
||||
<span style="position:absolute;top:16px;right:7%;font-size:10px;color:rgba(255,255,255,.25);animation:twinkle 2.8s ease-in-out .7s infinite;">✧</span>
|
||||
<span style="position:absolute;bottom:24px;left:14%;font-size:14px;color:rgba(255,255,255,.3);animation:twinkle 3.1s ease-in-out .3s infinite;">✦</span>
|
||||
<span style="position:absolute;bottom:20px;right:34%;font-size:11px;color:rgba(255,255,255,.25);animation:twinkle 2.5s ease-in-out .9s infinite;">✧</span>
|
||||
<span style="position:absolute;bottom:28px;right:8%;font-size:15px;color:rgba(255,255,255,.3);animation:twinkle 3.4s ease-in-out 1.2s infinite;">✦</span>
|
||||
<div class="home-section" style="max-width:1200px;margin:0 auto;padding:90px 24px;">
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:46px;">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:#ff5fa2;margin-bottom:12px;">★ Die Stars</div>
|
||||
<h2 style="font-family:'Fredoka',sans-serif;font-weight:700;font-size:clamp(32px,4vw,48px);margin:0;letter-spacing:-.4px;color:#fff6fb;">Gewinner {{ selectedArchive.year }}</h2>
|
||||
</div>
|
||||
<button @click="onOpenArchive" style="display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);color:#fff6fb;font-weight:600;font-size:15px;cursor:pointer;" style-hover="transform:translateY(-2px);background:rgba(255,255,255,.09);">Archiv ansehen →</button>
|
||||
</div>
|
||||
<div style="overflow-x:auto;overflow-y:hidden;padding:0 18px 12px 0;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.34) rgba(255,255,255,.06);">
|
||||
<div style="display:flex;gap:22px;width:max-content;">
|
||||
<article v-for="winner in winnerShowcase" :key="`${selectedArchive.year}-${winner.category}`" class="home-winner-card" style="flex:none;width:360px;border-radius:24px;overflow:hidden;background:#22123a;border:1px solid rgba(255,255,255,.12);">
|
||||
<div style="position:relative;padding:24px 24px 20px;background:radial-gradient(circle at 28% 24%,rgba(255,95,162,.22),transparent 28%),radial-gradient(circle at 74% 78%,rgba(160,107,255,.2),transparent 32%),linear-gradient(180deg,#26133d 0%,#201132 100%);">
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-top:28px;margin-bottom:18px;min-height:108px;">
|
||||
<div style="flex:none;width:84px;height:84px;border-radius:24px;background:linear-gradient(135deg,#ffd27a,#e7b13e);display:flex;align-items:center;justify-content:center;color:#2f1b00;font-family:'Fredoka',sans-serif;font-size:28px;font-weight:700;">{{ initialsFor(winner.name) }}</div>
|
||||
<div style="min-width:0;">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:#ffd27a;margin-bottom:8px;">{{ winner.category }}</div>
|
||||
<div style="font-family:'Fredoka',sans-serif;font-weight:600;font-size:24px;line-height:1.15;color:#fff6fb;">{{ winner.name }}</div>
|
||||
<div style="font-size:13px;color:#c9b8da;margin-top:6px;">{{ winner.handle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<a :href="winner.url" target="_blank" rel="noopener" style="display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:13px 18px;border-radius:14px;background:linear-gradient(135deg,#ff5fa2,#a06bff);color:#fff;font-family:'Fredoka',sans-serif;font-weight:600;font-size:14px;text-decoration:none;box-sizing:border-box;">{{ winner.platform }}</a>
|
||||
</div>
|
||||
<div style="padding:0 24px 24px;background:#1b0d2d;">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:1.6px;text-transform:uppercase;color:#ffb9d4;margin-bottom:10px;">Archivierter Gewinner</div>
|
||||
<div style="border-radius:18px;overflow:hidden;background:#12091d;border:1px solid rgba(255,255,255,.08);aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#c9b8da;font-family:'Fredoka',sans-serif;font-size:18px;">{{ winner.platform }} · {{ winner.handle }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type WinnerCard = {
|
||||
category: string
|
||||
name: string
|
||||
handle: string
|
||||
platform: string
|
||||
url: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
selectedArchive: {
|
||||
year: number
|
||||
winners: WinnerCard[]
|
||||
}
|
||||
winnerShowcase: WinnerCard[]
|
||||
initialsFor: (value: string) => string
|
||||
onOpenArchive: () => Promise<void>
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,898 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Fredoka:wght@400;500;600;700&family=Great+Vibes&family=Outfit:wght@300;400;500;600;700&family=Sacramento&display=swap");
|
||||
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;background:#160a26;}
|
||||
@keyframes twinkle{0%,100%{opacity:.25;transform:scale(.7);}50%{opacity:1;transform:scale(1.15);}}
|
||||
@keyframes floaty{0%,100%{transform:translateY(0);}50%{transform:translateY(-16px);}}
|
||||
@keyframes floaty2{0%,100%{transform:translateY(0) rotate(-4deg);}50%{transform:translateY(-22px) rotate(4deg);}}
|
||||
@keyframes pulseGlow{0%,100%{opacity:.55;}50%{opacity:1;}}
|
||||
@keyframes spinSlow{from{transform:rotate(0);}to{transform:rotate(360deg);}}
|
||||
@keyframes shimmer{0%{background-position:0% 50%;}100%{background-position:200% 50%;}}
|
||||
#faq summary::-webkit-details-marker{display:none;}
|
||||
#faq details[open] summary span{transform:rotate(45deg);}
|
||||
|
||||
.home-landing{
|
||||
overflow-x:clip;
|
||||
}
|
||||
|
||||
.home-demo-preview{
|
||||
position:fixed;
|
||||
left:50%;
|
||||
bottom:22px;
|
||||
z-index:95;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
max-width:calc(100vw - 28px);
|
||||
padding:10px 12px;
|
||||
border:1px solid rgba(139,108,219,.22);
|
||||
border-radius:999px;
|
||||
background:rgba(255,255,255,.78);
|
||||
box-shadow:0 18px 52px rgba(63,53,86,.18);
|
||||
backdrop-filter:blur(18px);
|
||||
-webkit-backdrop-filter:blur(18px);
|
||||
transform:translateX(-50%);
|
||||
}
|
||||
|
||||
.home-demo-preview__label{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
padding:0 7px 0 4px;
|
||||
color:#5f44ad;
|
||||
font-family:'Fredoka',sans-serif;
|
||||
font-size:13px;
|
||||
font-weight:800;
|
||||
letter-spacing:.12em;
|
||||
text-transform:uppercase;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.home-demo-preview__label span{
|
||||
display:inline-grid;
|
||||
place-items:center;
|
||||
width:28px;
|
||||
height:28px;
|
||||
border-radius:50%;
|
||||
color:#fff;
|
||||
background:linear-gradient(135deg,#8b6cdb,#e7b13e);
|
||||
box-shadow:0 10px 24px rgba(139,108,219,.26);
|
||||
}
|
||||
|
||||
.home-demo-preview__buttons{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
}
|
||||
|
||||
.home-demo-preview__button{
|
||||
display:grid;
|
||||
gap:1px;
|
||||
min-width:112px;
|
||||
padding:9px 14px;
|
||||
border:1px solid rgba(139,108,219,.18);
|
||||
border-radius:999px;
|
||||
background:rgba(246,240,254,.78);
|
||||
color:#6f6685;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
text-align:left;
|
||||
transition:transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease, color .18s ease;
|
||||
}
|
||||
|
||||
.home-demo-preview__button span{
|
||||
font-size:13px;
|
||||
font-weight:800;
|
||||
line-height:1.1;
|
||||
}
|
||||
|
||||
.home-demo-preview__button small{
|
||||
color:inherit;
|
||||
font-size:10px;
|
||||
font-weight:700;
|
||||
line-height:1.2;
|
||||
opacity:.72;
|
||||
}
|
||||
|
||||
.home-demo-preview__button:hover,
|
||||
.home-demo-preview__button--active{
|
||||
border-color:rgba(231,177,62,.5);
|
||||
background:linear-gradient(135deg,rgba(139,108,219,.95),rgba(231,177,62,.9));
|
||||
color:#fff;
|
||||
box-shadow:0 12px 30px rgba(139,108,219,.24);
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.home-landing img,
|
||||
.home-landing svg{
|
||||
max-width:100%;
|
||||
}
|
||||
|
||||
.home-modal--wide{
|
||||
max-width:1080px!important;
|
||||
}
|
||||
|
||||
.home-vote-picker{
|
||||
display:grid;
|
||||
grid-template-columns:280px minmax(0,1fr);
|
||||
min-height:0;
|
||||
flex:1;
|
||||
background:
|
||||
radial-gradient(circle at 82% 8%,rgba(139,108,219,.13),transparent 32%),
|
||||
#fff;
|
||||
}
|
||||
|
||||
.home-vote-picker__rail{
|
||||
border-right:1px solid #efe7fb;
|
||||
padding:18px 16px;
|
||||
overflow-y:auto;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:8px;
|
||||
background:linear-gradient(180deg,#fcfaff 0%,#f7f2ff 100%);
|
||||
}
|
||||
|
||||
.home-vote-picker__rail-label{
|
||||
margin:0 4px 6px;
|
||||
font-size:11px;
|
||||
font-weight:800;
|
||||
letter-spacing:1.7px;
|
||||
text-transform:uppercase;
|
||||
color:#a98ddb;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-button{
|
||||
min-height:54px;
|
||||
}
|
||||
|
||||
.home-vote-picker__content{
|
||||
min-width:0;
|
||||
padding:22px 26px 24px;
|
||||
overflow-y:auto;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-header{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:16px;
|
||||
margin-bottom:18px;
|
||||
}
|
||||
|
||||
.home-vote-picker__eyebrow{
|
||||
margin:0 0 5px;
|
||||
font-size:12px;
|
||||
font-weight:800;
|
||||
letter-spacing:1.8px;
|
||||
text-transform:uppercase;
|
||||
color:#a98ddb;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-header h4{
|
||||
margin:0;
|
||||
font-family:'Cormorant Garamond',serif;
|
||||
font-size:30px;
|
||||
line-height:1.05;
|
||||
color:#3f3556;
|
||||
}
|
||||
|
||||
.home-vote-picker__hint{
|
||||
max-width:230px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
background:#f4eefd;
|
||||
color:#7d68bd;
|
||||
font-size:12px;
|
||||
font-weight:700;
|
||||
line-height:1.35;
|
||||
}
|
||||
|
||||
.home-vote-picker__cards{
|
||||
display:grid;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.home-vote-card{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(210px,1.1fr) minmax(230px,1fr) auto;
|
||||
align-items:center;
|
||||
gap:16px;
|
||||
padding:16px 18px;
|
||||
border:1.5px solid #eadff8;
|
||||
border-radius:20px;
|
||||
background:rgba(255,255,255,.88);
|
||||
box-shadow:0 14px 34px rgba(82,61,128,.08);
|
||||
transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease,background .16s ease;
|
||||
}
|
||||
|
||||
.home-vote-card:hover{
|
||||
border-color:#d8c7f2;
|
||||
box-shadow:0 18px 40px rgba(82,61,128,.13);
|
||||
transform:translateY(-1px);
|
||||
}
|
||||
|
||||
.home-vote-card--selected{
|
||||
border-color:#8b6cdb;
|
||||
background:linear-gradient(135deg,#fbf8ff 0%,#f2ebff 100%);
|
||||
box-shadow:0 18px 46px rgba(139,108,219,.18);
|
||||
}
|
||||
|
||||
.home-vote-card--missing-clip{
|
||||
background:#fff;
|
||||
}
|
||||
|
||||
.home-vote-card__identity{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.home-vote-card__avatar{
|
||||
flex:none;
|
||||
width:56px;
|
||||
height:56px;
|
||||
border-radius:18px;
|
||||
display:grid;
|
||||
place-items:center;
|
||||
background:
|
||||
linear-gradient(135deg,rgba(139,108,219,.96),rgba(231,177,62,.9)),
|
||||
repeating-linear-gradient(45deg,#f3eefb 0 8px,#ece2fa 8px 16px);
|
||||
color:#fff;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:18px;
|
||||
font-weight:800;
|
||||
box-shadow:0 12px 24px rgba(139,108,219,.22);
|
||||
}
|
||||
|
||||
.home-vote-card__name-block{
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.home-vote-card__name-row{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:9px;
|
||||
min-width:0;
|
||||
}
|
||||
|
||||
.home-vote-card__name-row h5{
|
||||
margin:0;
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:18px;
|
||||
font-weight:800;
|
||||
color:#332b49;
|
||||
}
|
||||
|
||||
.home-vote-card__name-row span,
|
||||
.home-vote-card__clip-platform{
|
||||
flex:none;
|
||||
padding:4px 8px;
|
||||
border-radius:999px;
|
||||
background:#f0e8fb;
|
||||
color:#7d60c6;
|
||||
font-size:11px;
|
||||
font-weight:800;
|
||||
line-height:1;
|
||||
}
|
||||
|
||||
.home-vote-card__name-block p{
|
||||
margin:5px 0 0;
|
||||
color:#8e86a0;
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
}
|
||||
|
||||
.home-vote-card__clip{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
padding:10px 12px;
|
||||
border-radius:16px;
|
||||
background:#fbf8ff;
|
||||
border:1px solid #efe5fb;
|
||||
color:#7d7491;
|
||||
font-size:13px;
|
||||
font-weight:700;
|
||||
}
|
||||
|
||||
.home-vote-card__clip a{
|
||||
min-width:0;
|
||||
overflow:hidden;
|
||||
text-overflow:ellipsis;
|
||||
white-space:nowrap;
|
||||
color:#6d4bc4;
|
||||
text-decoration:none;
|
||||
}
|
||||
|
||||
.home-vote-card__clip a:hover{
|
||||
color:#4f32a2;
|
||||
text-decoration:underline;
|
||||
}
|
||||
|
||||
.home-vote-card--missing-clip .home-vote-card__clip{
|
||||
background:#fffaf0;
|
||||
border-color:#f2dfb6;
|
||||
color:#9b6a16;
|
||||
}
|
||||
|
||||
.home-vote-card__pick{
|
||||
flex:none;
|
||||
min-width:116px;
|
||||
padding:12px 18px;
|
||||
border:none;
|
||||
border-radius:14px;
|
||||
background:#f1ecfb;
|
||||
color:#8b6cdb;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:14px;
|
||||
font-weight:800;
|
||||
transition:transform .16s ease,box-shadow .16s ease,background .16s ease;
|
||||
}
|
||||
|
||||
.home-vote-card__pick:hover{
|
||||
transform:translateY(-1px);
|
||||
box-shadow:0 10px 22px rgba(139,108,219,.18);
|
||||
}
|
||||
|
||||
.home-vote-card__pick--selected{
|
||||
background:linear-gradient(135deg,#8b6cdb,#7355c8);
|
||||
color:#fff;
|
||||
box-shadow:0 12px 26px rgba(139,108,219,.26);
|
||||
}
|
||||
|
||||
.home-vote-picker__empty{
|
||||
padding:32px 18px;
|
||||
border:1px dashed #d8c9f2;
|
||||
border-radius:18px;
|
||||
background:#fcfaff;
|
||||
color:#7d7491;
|
||||
text-align:center;
|
||||
font-size:14px;
|
||||
font-weight:700;
|
||||
}
|
||||
|
||||
.home-vote-picker__footer{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:16px;
|
||||
padding:18px 36px;
|
||||
border-top:1px solid #f1ecfb;
|
||||
background:#fcfaff;
|
||||
}
|
||||
|
||||
.home-vote-picker__footer div{
|
||||
color:#7d7491;
|
||||
font-size:14px;
|
||||
font-weight:700;
|
||||
}
|
||||
|
||||
.home-vote-picker__footer span{
|
||||
color:#8b6cdb;
|
||||
font-weight:900;
|
||||
}
|
||||
|
||||
.home-vote-picker__footer button{
|
||||
padding:13px 28px;
|
||||
border:none;
|
||||
border-radius:14px;
|
||||
background:linear-gradient(135deg,#8b6cdb,#7355c8);
|
||||
color:#fff;
|
||||
cursor:pointer;
|
||||
font-family:'Outfit',sans-serif;
|
||||
font-size:15px;
|
||||
font-weight:800;
|
||||
box-shadow:0 10px 22px rgba(124,86,196,.3);
|
||||
}
|
||||
|
||||
.home-vote-picker__footer button:disabled,
|
||||
.home-vote-picker__submit--disabled{
|
||||
background:#d1c4e9!important;
|
||||
color:#9e8cc5!important;
|
||||
cursor:not-allowed!important;
|
||||
box-shadow:none!important;
|
||||
}
|
||||
|
||||
@media (max-width:1080px){
|
||||
.home-nav{
|
||||
flex-wrap:wrap!important;
|
||||
align-items:flex-start!important;
|
||||
gap:12px!important;
|
||||
padding:12px 18px!important;
|
||||
}
|
||||
|
||||
.home-nav__brand{
|
||||
flex:1 1 260px!important;
|
||||
min-width:0!important;
|
||||
}
|
||||
|
||||
.home-nav__links{
|
||||
order:3!important;
|
||||
width:100%!important;
|
||||
gap:16px!important;
|
||||
overflow-x:auto!important;
|
||||
overflow-y:hidden!important;
|
||||
justify-content:flex-start!important;
|
||||
padding:2px 2px 7px!important;
|
||||
scrollbar-width:none;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
}
|
||||
|
||||
.home-nav__links::-webkit-scrollbar{
|
||||
display:none;
|
||||
}
|
||||
|
||||
.home-nav__links a{
|
||||
flex:0 0 auto!important;
|
||||
}
|
||||
|
||||
.home-nav__actions{
|
||||
flex:0 1 auto!important;
|
||||
min-width:0!important;
|
||||
}
|
||||
|
||||
.home-demo-preview{
|
||||
align-items:flex-start;
|
||||
border-radius:28px;
|
||||
}
|
||||
|
||||
.home-demo-preview__buttons{
|
||||
max-width:70vw;
|
||||
overflow-x:auto;
|
||||
padding-bottom:2px;
|
||||
scrollbar-width:none;
|
||||
}
|
||||
|
||||
.home-demo-preview__buttons::-webkit-scrollbar{
|
||||
display:none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:960px){
|
||||
.home-hero{
|
||||
min-height:auto!important;
|
||||
}
|
||||
|
||||
.home-hero__content{
|
||||
padding:52px 20px 32px!important;
|
||||
}
|
||||
|
||||
.home-hero__copy{
|
||||
max-width:620px!important;
|
||||
}
|
||||
|
||||
.home-hero__character{
|
||||
right:-170px!important;
|
||||
top:76px!important;
|
||||
height:780px!important;
|
||||
opacity:.34!important;
|
||||
}
|
||||
|
||||
.home-hero__veil{
|
||||
background:linear-gradient(100deg,#f6f0fe 0%,rgba(246,240,254,.94) 44%,rgba(246,240,254,.55) 76%,transparent 100%)!important;
|
||||
}
|
||||
|
||||
.home-hero__host-card{
|
||||
position:relative!important;
|
||||
right:auto!important;
|
||||
bottom:auto!important;
|
||||
margin:0 20px 30px!important;
|
||||
max-width:620px!important;
|
||||
z-index:4!important;
|
||||
}
|
||||
|
||||
.home-stream-band__inner{
|
||||
align-items:flex-start!important;
|
||||
}
|
||||
|
||||
[data-timeline]{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr))!important;
|
||||
}
|
||||
|
||||
[data-cat-grid]{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr))!important;
|
||||
}
|
||||
|
||||
[data-community-grid]{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:760px){
|
||||
.home-nav{
|
||||
padding:10px 14px!important;
|
||||
}
|
||||
|
||||
.home-nav__brand{
|
||||
flex-basis:100%!important;
|
||||
font-size:17px!important;
|
||||
}
|
||||
|
||||
.home-nav__actions{
|
||||
order:2!important;
|
||||
width:100%!important;
|
||||
justify-content:flex-start!important;
|
||||
overflow-x:auto!important;
|
||||
padding-bottom:2px!important;
|
||||
scrollbar-width:none;
|
||||
}
|
||||
|
||||
.home-nav__actions::-webkit-scrollbar{
|
||||
display:none;
|
||||
}
|
||||
|
||||
.home-nav__actions > button{
|
||||
flex:1 0 auto!important;
|
||||
justify-content:center!important;
|
||||
padding:10px 12px!important;
|
||||
font-size:13px!important;
|
||||
white-space:nowrap!important;
|
||||
}
|
||||
|
||||
.home-demo-preview{
|
||||
left:12px;
|
||||
right:12px;
|
||||
bottom:12px;
|
||||
max-width:none;
|
||||
transform:none;
|
||||
flex-direction:column;
|
||||
align-items:stretch;
|
||||
border-radius:24px;
|
||||
}
|
||||
|
||||
.home-demo-preview__label{
|
||||
justify-content:center;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
.home-demo-preview__buttons{
|
||||
max-width:none;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
}
|
||||
|
||||
.home-demo-preview__button{
|
||||
min-width:0;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.home-hero__content{
|
||||
padding:40px 16px 24px!important;
|
||||
}
|
||||
|
||||
.home-hero__eyebrow{
|
||||
font-size:11px!important;
|
||||
letter-spacing:1.7px!important;
|
||||
margin-bottom:16px!important;
|
||||
}
|
||||
|
||||
.home-hero__presented{
|
||||
white-space:normal!important;
|
||||
font-size:clamp(28px,8vw,38px)!important;
|
||||
line-height:1.05!important;
|
||||
margin-bottom:18px!important;
|
||||
}
|
||||
|
||||
.home-hero__body{
|
||||
font-size:16px!important;
|
||||
max-width:none!important;
|
||||
}
|
||||
|
||||
.home-hero__phase-card{
|
||||
max-width:none!important;
|
||||
border-radius:18px!important;
|
||||
padding:20px!important;
|
||||
}
|
||||
|
||||
.home-hero__phase-title{
|
||||
white-space:normal!important;
|
||||
font-size:24px!important;
|
||||
}
|
||||
|
||||
.home-hero__host-card{
|
||||
margin:0 16px 24px!important;
|
||||
padding:16px 18px!important;
|
||||
border-radius:16px!important;
|
||||
}
|
||||
|
||||
.home-hero__host-name{
|
||||
font-size:22px!important;
|
||||
flex-wrap:wrap!important;
|
||||
}
|
||||
|
||||
.home-stream-band__inner{
|
||||
padding:22px 16px!important;
|
||||
flex-direction:column!important;
|
||||
gap:18px!important;
|
||||
}
|
||||
|
||||
.home-stream-band__lead{
|
||||
align-items:flex-start!important;
|
||||
gap:14px!important;
|
||||
}
|
||||
|
||||
.home-stream-band__actions{
|
||||
width:100%!important;
|
||||
align-items:stretch!important;
|
||||
justify-content:center!important;
|
||||
}
|
||||
|
||||
.home-stream-band__actions > a,
|
||||
.home-stream-band__actions > div{
|
||||
width:100%!important;
|
||||
justify-content:center!important;
|
||||
}
|
||||
|
||||
[data-stats]{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr))!important;
|
||||
gap:20px 14px!important;
|
||||
padding:28px 16px!important;
|
||||
}
|
||||
|
||||
.home-section{
|
||||
padding-left:16px!important;
|
||||
padding-right:16px!important;
|
||||
}
|
||||
|
||||
[data-timeline]{
|
||||
grid-template-columns:1fr!important;
|
||||
gap:18px!important;
|
||||
}
|
||||
|
||||
[data-timeline] > div:first-child{
|
||||
display:none!important;
|
||||
}
|
||||
|
||||
[data-timeline] > div:not(:first-child){
|
||||
text-align:left!important;
|
||||
}
|
||||
|
||||
[data-timeline] > div:not(:first-child) > div:first-child{
|
||||
margin:0 0 14px!important;
|
||||
}
|
||||
|
||||
[data-timeline] > div:not(:first-child) > div:nth-child(2){
|
||||
min-height:0!important;
|
||||
padding:20px!important;
|
||||
border-radius:18px!important;
|
||||
}
|
||||
|
||||
[data-timeline] button,
|
||||
[data-timeline] a{
|
||||
width:100%!important;
|
||||
}
|
||||
|
||||
[data-cat-grid]{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
[data-cat-grid] > div{
|
||||
border-radius:18px!important;
|
||||
padding:22px 20px!important;
|
||||
}
|
||||
|
||||
.home-winner-card{
|
||||
width:min(84vw,360px)!important;
|
||||
}
|
||||
|
||||
.home-steps-card{
|
||||
border-radius:22px!important;
|
||||
padding:32px 20px!important;
|
||||
}
|
||||
|
||||
.home-steps-heading{
|
||||
white-space:normal!important;
|
||||
letter-spacing:1.8px!important;
|
||||
text-align:center!important;
|
||||
}
|
||||
|
||||
[data-steps]{
|
||||
flex-direction:column!important;
|
||||
gap:24px!important;
|
||||
}
|
||||
|
||||
[data-step-arrow]{
|
||||
display:none!important;
|
||||
}
|
||||
|
||||
.home-cta-card{
|
||||
border-radius:22px!important;
|
||||
padding:38px 20px!important;
|
||||
}
|
||||
|
||||
.home-community-card{
|
||||
padding:32px 22px!important;
|
||||
min-height:0!important;
|
||||
overflow:hidden!important;
|
||||
}
|
||||
|
||||
.home-community-card__content{
|
||||
max-width:100%!important;
|
||||
}
|
||||
|
||||
.home-community-card__image{
|
||||
right:-74px!important;
|
||||
height:280px!important;
|
||||
opacity:.18!important;
|
||||
}
|
||||
|
||||
.home-share-card{
|
||||
padding:32px 22px!important;
|
||||
}
|
||||
|
||||
.home-modal-overlay{
|
||||
align-items:flex-start!important;
|
||||
padding:10px!important;
|
||||
}
|
||||
|
||||
.home-modal{
|
||||
max-height:calc(100dvh - 20px)!important;
|
||||
border-radius:18px!important;
|
||||
}
|
||||
|
||||
.home-modal__success,
|
||||
.home-modal__show,
|
||||
.home-modal__picker-header,
|
||||
.home-modal__clip-header{
|
||||
padding-left:20px!important;
|
||||
padding-right:20px!important;
|
||||
}
|
||||
|
||||
.home-modal__reminder-form{
|
||||
flex-direction:column!important;
|
||||
}
|
||||
|
||||
.home-modal__reminder-form button,
|
||||
.home-modal__reminder-form input{
|
||||
width:100%!important;
|
||||
}
|
||||
|
||||
.home-modal__picker-grid{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
.home-vote-picker{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
.home-modal__nomination-grid{
|
||||
grid-template-columns:1fr!important;
|
||||
padding:20px!important;
|
||||
}
|
||||
|
||||
.home-modal__category-rail{
|
||||
border-right:none!important;
|
||||
border-bottom:1px solid #f1ecfb!important;
|
||||
max-height:210px!important;
|
||||
}
|
||||
|
||||
.home-vote-picker__rail{
|
||||
border-right:none!important;
|
||||
border-bottom:1px solid #f1ecfb!important;
|
||||
max-height:210px!important;
|
||||
}
|
||||
|
||||
.home-modal__nominee-pane{
|
||||
max-height:none!important;
|
||||
}
|
||||
|
||||
.home-vote-picker__content{
|
||||
padding:20px!important;
|
||||
}
|
||||
|
||||
.home-vote-picker__category-header{
|
||||
flex-direction:column!important;
|
||||
}
|
||||
|
||||
.home-vote-picker__hint{
|
||||
max-width:none!important;
|
||||
}
|
||||
|
||||
.home-vote-card{
|
||||
grid-template-columns:1fr!important;
|
||||
align-items:stretch!important;
|
||||
}
|
||||
|
||||
.home-vote-card__clip{
|
||||
align-items:flex-start!important;
|
||||
flex-direction:column!important;
|
||||
}
|
||||
|
||||
.home-vote-card__pick{
|
||||
width:100%!important;
|
||||
}
|
||||
|
||||
.home-modal__vote-footer{
|
||||
flex-direction:column!important;
|
||||
align-items:stretch!important;
|
||||
padding:16px 20px!important;
|
||||
}
|
||||
|
||||
.home-modal__vote-footer button{
|
||||
width:100%!important;
|
||||
}
|
||||
|
||||
.home-modal__clip{
|
||||
max-height:calc(100dvh - 20px)!important;
|
||||
}
|
||||
|
||||
.home-modal__clip-body{
|
||||
padding:20px!important;
|
||||
}
|
||||
|
||||
.home-modal__clip-grid{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
.home-archive-modal__body{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
|
||||
.home-archive-modal__years{
|
||||
border-right:none!important;
|
||||
border-bottom:1px solid rgba(139,108,219,.12)!important;
|
||||
overflow-x:auto!important;
|
||||
overflow-y:hidden!important;
|
||||
}
|
||||
|
||||
.home-archive-modal__years > div{
|
||||
flex-direction:row!important;
|
||||
width:max-content!important;
|
||||
}
|
||||
|
||||
.home-archive-modal__winners{
|
||||
grid-template-columns:1fr!important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:480px){
|
||||
.home-hero__character{
|
||||
right:-220px!important;
|
||||
top:112px!important;
|
||||
height:620px!important;
|
||||
opacity:.22!important;
|
||||
}
|
||||
|
||||
.home-hero__phase-card [data-dc-ref]{
|
||||
font-size:23px!important;
|
||||
}
|
||||
|
||||
[data-stats]{
|
||||
grid-template-columns:repeat(2,minmax(0,1fr))!important;
|
||||
}
|
||||
|
||||
.home-modal__success{
|
||||
padding-top:48px!important;
|
||||
padding-bottom:34px!important;
|
||||
}
|
||||
|
||||
.home-vote-card__avatar{
|
||||
width:48px!important;
|
||||
height:48px!important;
|
||||
border-radius:15px!important;
|
||||
font-size:16px!important;
|
||||
}
|
||||
|
||||
.home-vote-card__name-row{
|
||||
align-items:flex-start!important;
|
||||
flex-direction:column!important;
|
||||
gap:6px!important;
|
||||
}
|
||||
|
||||
.home-account-data-row{
|
||||
align-items:flex-start!important;
|
||||
flex-direction:column!important;
|
||||
gap:3px!important;
|
||||
}
|
||||
|
||||
.home-account-confirm-actions{
|
||||
flex-direction:column!important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CandidateSummary } from '../../types/awards'
|
||||
|
||||
export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip'
|
||||
|
||||
export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
||||
|
||||
export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
|
||||
|
||||
export interface HomeClipSubmitContext {
|
||||
clipUrl: string
|
||||
selectedNomineeIndex: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface HomeNominationSubmitContext {
|
||||
categoryIndex: number
|
||||
name: string
|
||||
streamUrl: string
|
||||
}
|
||||
|
||||
export interface HomeDisplayCategory {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
candidates: CandidateSummary[]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface HomeCategoryListItem {
|
||||
name: string
|
||||
icon: string
|
||||
idx: number
|
||||
done: boolean
|
||||
onClick: () => void
|
||||
rowStyle: string
|
||||
iconStyle: string
|
||||
checkStyle: string
|
||||
}
|
||||
|
||||
export interface HomeNomineeListItem {
|
||||
name: string
|
||||
handle: string
|
||||
platform: string
|
||||
initials: string
|
||||
clipUrl: string | null
|
||||
clipTitle: string
|
||||
clipPlatform: string
|
||||
idx: number
|
||||
selected: boolean
|
||||
hasClip: boolean
|
||||
showPick: boolean
|
||||
onPick: () => void
|
||||
cardStyle: string
|
||||
btnStyle: string
|
||||
btnLabel: string
|
||||
}
|
||||
|
||||
export interface HomeSelectionOption {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface HomeArchiveYearItem {
|
||||
year: number
|
||||
label: string
|
||||
winners: Array<unknown>
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface HomeArchiveWinnerItem {
|
||||
category: string
|
||||
name: string
|
||||
handle: string
|
||||
platform: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface HomeSelectedArchive {
|
||||
year: number
|
||||
winners: HomeArchiveWinnerItem[]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
|
||||
export function useHomeArchivePresentation(store: AwardsStore, archiveYear: Ref<number>) {
|
||||
const archiveYears = computed(() => {
|
||||
const knownYears = new Set<number>(store.overview.winnersPreview.map((entry) => entry.year))
|
||||
if (store.archive.items.length > 0) {
|
||||
knownYears.add(store.archive.year)
|
||||
}
|
||||
|
||||
return [...knownYears]
|
||||
.sort((left, right) => right - left)
|
||||
.map((year) => ({
|
||||
year,
|
||||
label: String(year),
|
||||
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
|
||||
active: archiveYear.value === year,
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedArchive = computed(() => ({
|
||||
year: store.archive.year,
|
||||
winners: store.archive.items.map((winner) => ({
|
||||
category: winner.category,
|
||||
name: winner.winnerName,
|
||||
handle: winner.winnerSlug,
|
||||
platform: winner.winnerPlatform,
|
||||
url: winner.winnerUrl,
|
||||
})),
|
||||
}))
|
||||
|
||||
const winnerShowcase = computed(() => selectedArchive.value.winners.slice(0, 4))
|
||||
|
||||
function archiveYearButtonStyle(active: boolean) {
|
||||
return active
|
||||
? "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.26);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 14px 28px rgba(20,8,40,.24);"
|
||||
: "display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 16px;border-radius:16px;border:1px solid rgba(255,210,122,.16);background:linear-gradient(135deg,#2a1842,#3a2168);color:#fff6fb;font-family:'Outfit',sans-serif;font-size:15px;font-weight:700;cursor:pointer;text-align:left;box-shadow:0 10px 24px rgba(20,8,40,.14);opacity:.9;"
|
||||
}
|
||||
|
||||
function winnerPlatformKey(url: string) {
|
||||
const normalized = url.toLowerCase()
|
||||
if (normalized.includes('twitch.tv')) return 'twitch'
|
||||
if (normalized.includes('youtube.com') || normalized.includes('youtu.be')) return 'youtube'
|
||||
if (normalized.includes('x.com') || normalized.includes('twitter.com')) return 'x'
|
||||
if (normalized.includes('instagram.com')) return 'instagram'
|
||||
if (normalized.includes('cake.gg') || normalized.includes('cake.')) return 'cake'
|
||||
return 'link'
|
||||
}
|
||||
|
||||
function winnerPlatformLabel(url: string) {
|
||||
const key = winnerPlatformKey(url)
|
||||
if (key === 'twitch') return 'Twitch'
|
||||
if (key === 'youtube') return 'YouTube'
|
||||
if (key === 'x') return 'X'
|
||||
if (key === 'instagram') return 'Instagram'
|
||||
if (key === 'cake') return 'Cake'
|
||||
return 'Profil'
|
||||
}
|
||||
|
||||
function winnerPlatformStyle(url: string) {
|
||||
const key = winnerPlatformKey(url)
|
||||
if (key === 'twitch') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#c9b1ff;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
|
||||
if (key === 'youtube') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffb3c1;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
|
||||
if (key === 'x') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#d8d3e6;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
|
||||
if (key === 'instagram') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffb5db;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
|
||||
if (key === 'cake') return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#8b6cdb;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
|
||||
return "flex:none;display:inline-flex;align-items:center;gap:7px;color:#ffd27a;text-decoration:none;font-family:'Outfit',sans-serif;font-weight:700;font-size:12px;"
|
||||
}
|
||||
|
||||
return {
|
||||
archiveYears,
|
||||
selectedArchive,
|
||||
winnerShowcase,
|
||||
archiveYearButtonStyle,
|
||||
winnerPlatformKey,
|
||||
winnerPlatformLabel,
|
||||
winnerPlatformStyle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
|
||||
export function useHomeLandingModalState(
|
||||
store: AwardsStore,
|
||||
archiveYears: ComputedRef<Array<{ year: number }>>,
|
||||
resetInteractionState: () => void,
|
||||
) {
|
||||
const modal = ref<null | 'show' | 'vote' | 'nominate' | 'clip'>(null)
|
||||
const accountModal = ref(false)
|
||||
const deleteConfirm = ref(false)
|
||||
const accountActionError = ref('')
|
||||
const privacyModal = ref(false)
|
||||
const archiveModal = ref(false)
|
||||
const archiveYear = ref(2025)
|
||||
|
||||
const privacyModalOpen = computed(() => privacyModal.value)
|
||||
const accountModalOpen = computed(() => accountModal.value)
|
||||
const archiveModalOpen = computed(() => archiveModal.value)
|
||||
const modalOpen = computed(() => modal.value !== null)
|
||||
const isShow = computed(() => modal.value === 'show')
|
||||
const isVote = computed(() => modal.value === 'vote')
|
||||
const isPicker = computed(() => modal.value === 'vote' || modal.value === 'nominate')
|
||||
const isClip = computed(() => modal.value === 'clip')
|
||||
const deleteNotConfirm = computed(() => !deleteConfirm.value)
|
||||
|
||||
function openModal(kind: 'show' | 'vote' | 'nominate' | 'clip') {
|
||||
modal.value = kind
|
||||
resetInteractionState()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.value = null
|
||||
resetInteractionState()
|
||||
}
|
||||
|
||||
function openVote(event?: Event) {
|
||||
event?.preventDefault()
|
||||
openModal('vote')
|
||||
}
|
||||
|
||||
function openShow(event?: Event) {
|
||||
event?.preventDefault()
|
||||
openModal('show')
|
||||
}
|
||||
|
||||
function openNominate(event?: Event) {
|
||||
event?.preventDefault()
|
||||
openModal('nominate')
|
||||
}
|
||||
|
||||
function openClip(event?: Event) {
|
||||
event?.preventDefault()
|
||||
openModal('clip')
|
||||
}
|
||||
|
||||
function stop(event: Event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
function onOpenAccount() {
|
||||
accountModal.value = true
|
||||
deleteConfirm.value = false
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function onCloseAccount() {
|
||||
accountModal.value = false
|
||||
deleteConfirm.value = false
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function onOpenPrivacy() {
|
||||
privacyModal.value = true
|
||||
}
|
||||
|
||||
function onClosePrivacy() {
|
||||
privacyModal.value = false
|
||||
}
|
||||
|
||||
async function onOpenArchive() {
|
||||
archiveModal.value = true
|
||||
const latestArchiveYear = archiveYears.value[0]?.year ?? store.overview.year - 1
|
||||
archiveYear.value = latestArchiveYear
|
||||
await store.loadArchive(latestArchiveYear)
|
||||
}
|
||||
|
||||
function onCloseArchive() {
|
||||
archiveModal.value = false
|
||||
}
|
||||
|
||||
async function setArchiveYear(year: number) {
|
||||
archiveYear.value = year
|
||||
await store.loadArchive(year)
|
||||
}
|
||||
|
||||
function onRequestDelete() {
|
||||
deleteConfirm.value = true
|
||||
}
|
||||
|
||||
function onCancelDelete() {
|
||||
deleteConfirm.value = false
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
modal,
|
||||
accountModal,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
privacyModal,
|
||||
archiveModal,
|
||||
archiveYear,
|
||||
privacyModalOpen,
|
||||
accountModalOpen,
|
||||
archiveModalOpen,
|
||||
modalOpen,
|
||||
isShow,
|
||||
isVote,
|
||||
isPicker,
|
||||
isClip,
|
||||
deleteNotConfirm,
|
||||
openModal,
|
||||
closeModal,
|
||||
openVote,
|
||||
openShow,
|
||||
openNominate,
|
||||
openClip,
|
||||
stop,
|
||||
onOpenAccount,
|
||||
onCloseAccount,
|
||||
onOpenPrivacy,
|
||||
onClosePrivacy,
|
||||
onOpenArchive,
|
||||
onCloseArchive,
|
||||
setArchiveYear,
|
||||
onRequestDelete,
|
||||
onCancelDelete,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes'
|
||||
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
|
||||
|
||||
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
|
||||
|
||||
export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore: AuthStore) {
|
||||
const role = computed<'guest' | 'user' | 'admin'>(() => {
|
||||
if (!authStore.session) return 'guest'
|
||||
return authStore.isAdmin ? 'admin' : 'user'
|
||||
})
|
||||
const isGuest = computed(() => role.value === 'guest')
|
||||
const isUser = computed(() => role.value === 'user')
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
const twitchUser = computed(() => authStore.session?.twitchUserId ?? 'local_user')
|
||||
const siteContent = computed(() => store.overview.siteContent)
|
||||
const faqItems = computed(() =>
|
||||
(store.overview.faq ?? []).filter((item) => item?.question && item.answer),
|
||||
)
|
||||
const publicStreamUrl = computed(() => store.overview.showStreamUrl || 'https://twitch.tv/jayuhime')
|
||||
const showDate = computed(() => store.overview.showDate)
|
||||
const showStartsAt = computed(() => store.overview.showStartsAt || '20:00:00')
|
||||
const currentYear = computed(() => store.overview.year ? String(store.overview.year) : '')
|
||||
const displayCategories = computed<HomeDisplayCategory[]>(() =>
|
||||
store.categories.categories.map((category, index) => ({
|
||||
id: String(category.id),
|
||||
name: category.name,
|
||||
icon: CATEGORY_ICONS[index % CATEGORY_ICONS.length] ?? '✦',
|
||||
candidates: category.candidates,
|
||||
})),
|
||||
)
|
||||
const candidateCount = computed(() =>
|
||||
displayCategories.value.reduce((sum, category) => sum + category.candidates.length, 0),
|
||||
)
|
||||
const bootstrapArchiveYears = computed<Array<{ year: number }>>(() =>
|
||||
store.overview.winnersPreview.length > 0
|
||||
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
|
||||
: [{ year: store.overview.year - 1 }],
|
||||
)
|
||||
|
||||
function timelineItem(key: HomeTimelineKey) {
|
||||
return store.overview.timeline.find((entry) => entry.key === key)
|
||||
}
|
||||
|
||||
function formatRange(key: Exclude<HomeTimelineKey, 'show'>) {
|
||||
const item = timelineItem(key)
|
||||
if (!item) return ''
|
||||
return `${formatDateLabel(item.startsAt)} – ${formatDateLabel(item.endsAt)}`
|
||||
}
|
||||
|
||||
function formatTimelineRange(key: HomeTimelineKey) {
|
||||
const item = timelineItem(key)
|
||||
if (!item) return 'Noch offen'
|
||||
return item.startsAt === item.endsAt
|
||||
? formatDateLabel(item.startsAt)
|
||||
: `${formatDateLabel(item.startsAt)} – ${formatDateLabel(item.endsAt)}`
|
||||
}
|
||||
|
||||
function formatShowDate() {
|
||||
return formatDateLabel(store.overview.showDate)
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
isGuest,
|
||||
isUser,
|
||||
isAdmin,
|
||||
twitchUser,
|
||||
siteContent,
|
||||
faqItems,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
displayCategories,
|
||||
candidateCount,
|
||||
bootstrapArchiveYears,
|
||||
formatRange,
|
||||
formatTimelineRange,
|
||||
formatShowDate,
|
||||
initialsFor,
|
||||
}
|
||||
}
|
||||
|
||||
export function useHomeModalCandidatePresentation(params: {
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||
activeCat: Ref<number>
|
||||
modal: Ref<null | HomeInteractionModalKind>
|
||||
votes: Ref<Record<string, number>>
|
||||
activeCategory: ComputedRef<HomeDisplayCategory | null>
|
||||
setCat: (index: number) => void
|
||||
pickNominee: (categoryId: string, index: number) => void
|
||||
}) {
|
||||
const {
|
||||
displayCategories,
|
||||
activeCat,
|
||||
modal,
|
||||
votes,
|
||||
activeCategory,
|
||||
setCat,
|
||||
pickNominee,
|
||||
} = params
|
||||
|
||||
const catList = computed(() => buildCatList(displayCategories, activeCat, votes, setCat))
|
||||
const noms = computed(() => {
|
||||
const cat = activeCategory.value
|
||||
const list = cat?.candidates ?? []
|
||||
const voteMode = modal.value === 'vote'
|
||||
return list.map((candidate, idx) => {
|
||||
const selected = cat ? votes.value[cat.id] === idx : false
|
||||
const clipUrl = candidate.clipUrl?.trim() || null
|
||||
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
|
||||
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
|
||||
return {
|
||||
name: candidate.displayName,
|
||||
handle: candidate.channelSlug,
|
||||
platform: candidate.platform,
|
||||
initials: initialsFor(candidate.displayName),
|
||||
clipUrl,
|
||||
clipTitle,
|
||||
clipPlatform,
|
||||
idx,
|
||||
selected,
|
||||
hasClip: Boolean(clipUrl),
|
||||
showPick: voteMode,
|
||||
onPick: () => cat && pickNominee(cat.id, idx),
|
||||
cardStyle: `display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-radius:13px;transition:all .15s;border:1.5px solid ${selected ? '#8b6cdb;background:#f6f1fd;' : '#ece4f6;background:#fff;'}`,
|
||||
btnStyle: "flex:none;white-space:nowrap;padding:8px 16px;border-radius:9px;border:none;cursor:pointer;font-family:'Outfit',sans-serif;font-weight:600;font-size:13px;transition:all .15s;" + (selected ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#f1ecfb;color:#8b6cdb;'),
|
||||
btnLabel: selected ? '✓ Gewählt' : 'Auswählen',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
catList,
|
||||
noms,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateLabel(value: string) {
|
||||
if (!value) return 'Noch nicht terminiert'
|
||||
const date = new Date(`${value}T00:00:00`)
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function initialsFor(value: string) {
|
||||
return value
|
||||
.split(/[\s&.-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
function buildCatList(
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>,
|
||||
activeCat: Ref<number>,
|
||||
votes: Ref<Record<string, number>>,
|
||||
setCat: (index: number) => void,
|
||||
) {
|
||||
const baseRow = "display:flex;align-items:center;gap:10px;padding:11px 13px;border-radius:11px;cursor:pointer;font-size:14px;font-weight:600;transition:all .15s;border:1px solid transparent;outline:none;-webkit-tap-highlight-color:transparent;text-align:left;width:100%;background:transparent;font-family:'Outfit',sans-serif;"
|
||||
return displayCategories.value.map((category, index) => {
|
||||
const active = index === activeCat.value
|
||||
const done = votes.value[category.id] != null
|
||||
return {
|
||||
name: category.name,
|
||||
icon: category.icon,
|
||||
idx: index,
|
||||
done,
|
||||
onClick: () => setCat(index),
|
||||
rowStyle: baseRow + (active ? 'background:#f1ecfb;border-color:#d8c9f2;color:#5f44ad;' : 'border-color:transparent;color:#6f6685;'),
|
||||
iconStyle: "flex:none;display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;font-size:14px;" + (active ? 'background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;' : 'background:#efe7fb;color:#9a7fce;'),
|
||||
checkStyle: `flex:none;margin-left:auto;color:#1f9d5a;font-size:14px;font-weight:700;display:${done ? 'inline' : 'none'};`,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import { useHomeArchivePresentation } from './useHomeArchivePresentation'
|
||||
import type { HomePreviewPhase } from './homeLandingTypes'
|
||||
import { useHomeLandingModalState } from './useHomeLandingModalState'
|
||||
import {
|
||||
useHomeLandingOverviewPresentation,
|
||||
useHomeModalCandidatePresentation,
|
||||
} from './useHomeLandingPresentation'
|
||||
import { useHomePhasePresentation } from './useHomePhasePresentation'
|
||||
import { useHomeParticipationState } from './useHomeParticipationState'
|
||||
import { useHomeSocialPresentation } from './useHomeSocialPresentation'
|
||||
|
||||
export function useHomeLandingState() {
|
||||
const store = useAwardsStore()
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const activeCat = ref(0)
|
||||
const previewPhase = ref<HomePreviewPhase>('voting')
|
||||
|
||||
const {
|
||||
role,
|
||||
isGuest,
|
||||
isUser,
|
||||
isAdmin,
|
||||
twitchUser,
|
||||
siteContent,
|
||||
faqItems,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
displayCategories,
|
||||
candidateCount,
|
||||
bootstrapArchiveYears,
|
||||
formatRange,
|
||||
formatTimelineRange,
|
||||
formatShowDate,
|
||||
initialsFor,
|
||||
} = useHomeLandingOverviewPresentation(store, authStore)
|
||||
const {
|
||||
hostSocialLinks,
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
privacyContentBlocks,
|
||||
platformKey,
|
||||
isUploadedSocialIcon,
|
||||
socialSimpleIconPath,
|
||||
socialSimpleIconColor,
|
||||
} = useHomeSocialPresentation(siteContent)
|
||||
const {
|
||||
modal,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
archiveYear,
|
||||
privacyModalOpen,
|
||||
accountModalOpen,
|
||||
archiveModalOpen,
|
||||
modalOpen,
|
||||
isShow,
|
||||
isVote,
|
||||
isPicker,
|
||||
isClip,
|
||||
deleteNotConfirm,
|
||||
openModal,
|
||||
closeModal,
|
||||
openVote,
|
||||
openNominate,
|
||||
openClip,
|
||||
stop,
|
||||
onOpenAccount,
|
||||
onCloseAccount,
|
||||
onOpenPrivacy,
|
||||
onClosePrivacy,
|
||||
onOpenArchive,
|
||||
onCloseArchive,
|
||||
setArchiveYear,
|
||||
onRequestDelete,
|
||||
onCancelDelete,
|
||||
} = useHomeLandingModalState(store, bootstrapArchiveYears, () => {
|
||||
submitted.value = false
|
||||
formError.value = ''
|
||||
successKind.value = null
|
||||
if (modal.value === 'clip') {
|
||||
clipDsgvo.value = false
|
||||
}
|
||||
})
|
||||
const {
|
||||
archiveYears,
|
||||
selectedArchive,
|
||||
winnerShowcase,
|
||||
archiveYearButtonStyle,
|
||||
winnerPlatformKey,
|
||||
winnerPlatformLabel,
|
||||
winnerPlatformStyle,
|
||||
} = useHomeArchivePresentation(store, archiveYear)
|
||||
const {
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
streamLive,
|
||||
streamLocked,
|
||||
phaseCardTitle,
|
||||
phaseCardDescription,
|
||||
phaseCardRange,
|
||||
phaseStatusLabel,
|
||||
phaseStatusStyle,
|
||||
phasePrimaryLabel,
|
||||
phasePrimaryDisabled,
|
||||
phasePrimaryActionStyle,
|
||||
streamEyebrow,
|
||||
streamTitle,
|
||||
streamMeta,
|
||||
streamLockedLabel,
|
||||
streamLockedTitle,
|
||||
statOneValue,
|
||||
statOneLabel,
|
||||
statTwoValue,
|
||||
statThreeValue,
|
||||
statThreeLabel,
|
||||
categoryIntroText,
|
||||
timelineLineStyle,
|
||||
sectionTitle,
|
||||
sectionText,
|
||||
sectionActionLabel,
|
||||
sectionActionHref,
|
||||
sectionActionDisabled,
|
||||
sectionActionStyle,
|
||||
} = useHomePhasePresentation({
|
||||
previewPhase,
|
||||
displayCategories,
|
||||
candidateCount,
|
||||
publicStreamUrl,
|
||||
showDate,
|
||||
showStartsAt,
|
||||
currentYear,
|
||||
formatRange,
|
||||
formatShowDate,
|
||||
})
|
||||
const {
|
||||
votes,
|
||||
submitted,
|
||||
submitting,
|
||||
formError,
|
||||
successKind,
|
||||
clipDsgvo,
|
||||
notSubmitted,
|
||||
voteCount,
|
||||
totalCats,
|
||||
canSubmitVote,
|
||||
activeCategory,
|
||||
activeCatName,
|
||||
pickerTitle,
|
||||
pickerSubtitle,
|
||||
successTitle,
|
||||
successText,
|
||||
clipNomOptions,
|
||||
catOptions,
|
||||
canSubmitClip,
|
||||
clipSubmitStyle,
|
||||
initializeHomeInteractions,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onConfirmDelete,
|
||||
openAdminPanel,
|
||||
setCat,
|
||||
pickNominee,
|
||||
submitVote,
|
||||
submitReminder,
|
||||
submitNomination,
|
||||
clipDsgvoChange,
|
||||
onClipCatChange,
|
||||
submitClip,
|
||||
onPrimaryPhaseAction,
|
||||
onSectionAction,
|
||||
onTimelineFinalAction,
|
||||
syncPreviewPhaseFromOverview,
|
||||
} = useHomeParticipationState({
|
||||
store,
|
||||
authStore,
|
||||
routerPush: async (path) => {
|
||||
await router.push(path)
|
||||
},
|
||||
twitchUser,
|
||||
displayCategories,
|
||||
archiveYears,
|
||||
archiveYear,
|
||||
modal,
|
||||
previewPhase,
|
||||
activeCat,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
openModal,
|
||||
closeAccountModal: onCloseAccount,
|
||||
})
|
||||
const { catList, noms } = useHomeModalCandidatePresentation({
|
||||
displayCategories,
|
||||
activeCat,
|
||||
modal,
|
||||
votes,
|
||||
activeCategory,
|
||||
setCat,
|
||||
pickNominee,
|
||||
})
|
||||
|
||||
function setPreviewPhase(phase: HomePreviewPhase) {
|
||||
previewPhase.value = phase
|
||||
closeModal()
|
||||
activeCat.value = 0
|
||||
submitted.value = false
|
||||
formError.value = ''
|
||||
successKind.value = null
|
||||
clipDsgvo.value = false
|
||||
}
|
||||
|
||||
function openVoteForPhase(event?: Event) {
|
||||
if (!votingPhase.value) {
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
openVote(event)
|
||||
}
|
||||
|
||||
function openClipForPhase(event?: Event) {
|
||||
if (!nominationPhase.value) {
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
openClip(event)
|
||||
}
|
||||
|
||||
watch(previewPhase, (phase) => {
|
||||
if ((phase !== 'voting' && modal.value === 'vote') || (phase !== 'nomination' && modal.value === 'clip')) {
|
||||
closeModal()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
store,
|
||||
authStore,
|
||||
role,
|
||||
isGuest,
|
||||
isUser,
|
||||
isAdmin,
|
||||
twitchUser,
|
||||
siteContent,
|
||||
hostSocialLinks,
|
||||
communitySocialLinks,
|
||||
footerLinks,
|
||||
faqItems,
|
||||
privacyContentBlocks,
|
||||
publicStreamUrl,
|
||||
displayCategories,
|
||||
candidateCount,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
showPhase,
|
||||
completedPhase,
|
||||
showCountdown,
|
||||
streamLive,
|
||||
streamLocked,
|
||||
privacyModalOpen,
|
||||
accountModalOpen,
|
||||
archiveModalOpen,
|
||||
modalOpen,
|
||||
isShow,
|
||||
isVote,
|
||||
isPicker,
|
||||
isClip,
|
||||
notSubmitted,
|
||||
deleteNotConfirm,
|
||||
voteCount,
|
||||
totalCats,
|
||||
canSubmitVote,
|
||||
activeCatName,
|
||||
phaseCardTitle,
|
||||
phaseCardDescription,
|
||||
phaseCardRange,
|
||||
phaseStatusLabel,
|
||||
phaseStatusStyle,
|
||||
phasePrimaryLabel,
|
||||
phasePrimaryDisabled,
|
||||
phasePrimaryActionStyle,
|
||||
streamEyebrow,
|
||||
streamTitle,
|
||||
streamMeta,
|
||||
streamLockedLabel,
|
||||
streamLockedTitle,
|
||||
statOneValue,
|
||||
statOneLabel,
|
||||
statTwoValue,
|
||||
statThreeValue,
|
||||
statThreeLabel,
|
||||
categoryIntroText,
|
||||
timelineLineStyle,
|
||||
sectionTitle,
|
||||
sectionText,
|
||||
sectionActionLabel,
|
||||
sectionActionHref,
|
||||
sectionActionDisabled,
|
||||
sectionActionStyle,
|
||||
previewPhase,
|
||||
pickerTitle,
|
||||
pickerSubtitle,
|
||||
successTitle,
|
||||
successText,
|
||||
clipNomOptions,
|
||||
catOptions,
|
||||
canSubmitClip,
|
||||
clipSubmitStyle,
|
||||
archiveYears,
|
||||
selectedArchive,
|
||||
winnerShowcase,
|
||||
activeCat,
|
||||
submitted,
|
||||
submitting,
|
||||
formError,
|
||||
clipDsgvo,
|
||||
archiveYear,
|
||||
modal,
|
||||
catList,
|
||||
noms,
|
||||
formatTimelineRange,
|
||||
formatShowDate,
|
||||
initialsFor,
|
||||
openClip: openClipForPhase,
|
||||
openVote: openVoteForPhase,
|
||||
openNominate,
|
||||
onLogin,
|
||||
onOpenAccount,
|
||||
openAdminPanel,
|
||||
onPrimaryPhaseAction,
|
||||
isUploadedSocialIcon,
|
||||
socialSimpleIconPath,
|
||||
socialSimpleIconColor,
|
||||
platformKey,
|
||||
onSectionAction,
|
||||
closeModal,
|
||||
stop,
|
||||
submitReminder,
|
||||
submitVote,
|
||||
submitNomination,
|
||||
onClipCatChange,
|
||||
clipDsgvoChange,
|
||||
onOpenPrivacy,
|
||||
submitClip,
|
||||
onCloseArchive,
|
||||
archiveYearButtonStyle,
|
||||
winnerPlatformStyle,
|
||||
winnerPlatformKey,
|
||||
winnerPlatformLabel,
|
||||
privacyModalStop: stop,
|
||||
accountModalStop: stop,
|
||||
archiveModalStop: stop,
|
||||
onClosePrivacy,
|
||||
onCloseAccount,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
onLogout,
|
||||
onRequestDelete,
|
||||
onCancelDelete,
|
||||
onConfirmDelete,
|
||||
onOpenArchive,
|
||||
setArchiveYear,
|
||||
onTimelineFinalAction,
|
||||
setPreviewPhase,
|
||||
syncPreviewPhaseFromOverview,
|
||||
initializeHomeInteractions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch, type ComponentPublicInstance, type Ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAwardsStore } from '../../stores/awards'
|
||||
import type { HomeNominationSubmitContext } from './homeLandingTypes'
|
||||
|
||||
type AuthStore = ReturnType<typeof useAuthStore>
|
||||
type AwardsStore = ReturnType<typeof useAwardsStore>
|
||||
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
|
||||
|
||||
interface PhaseCountdownTarget {
|
||||
label: string
|
||||
target: number
|
||||
direction?: 'until' | 'since'
|
||||
}
|
||||
|
||||
interface TimelineScheduleItem {
|
||||
key: HomeTimelineKey
|
||||
title: string
|
||||
startMs: number
|
||||
endMs: number
|
||||
}
|
||||
|
||||
interface UseHomeLandingViewEffectsParams {
|
||||
router: Router
|
||||
store: AwardsStore
|
||||
authStore: AuthStore
|
||||
modalOpen: Readonly<Ref<boolean>>
|
||||
accountModalOpen: Readonly<Ref<boolean>>
|
||||
privacyModalOpen: Readonly<Ref<boolean>>
|
||||
archiveModalOpen: Readonly<Ref<boolean>>
|
||||
submitted: Readonly<Ref<boolean>>
|
||||
streamLive: Readonly<Ref<boolean>>
|
||||
archiveYear: Readonly<Ref<number>>
|
||||
nominationPhase: Readonly<Ref<boolean>>
|
||||
votingPhase: Readonly<Ref<boolean>>
|
||||
reviewPhase: Readonly<Ref<boolean>>
|
||||
completedPhase: Readonly<Ref<boolean>>
|
||||
initializeHomeInteractions: () => Promise<void>
|
||||
submitNomination: (nominationContext: HomeNominationSubmitContext) => Promise<void>
|
||||
submitClip: (clipContext: { clipUrl: string; selectedNomineeIndex: number; description: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
|
||||
const {
|
||||
router,
|
||||
store,
|
||||
authStore,
|
||||
modalOpen,
|
||||
accountModalOpen,
|
||||
privacyModalOpen,
|
||||
archiveModalOpen,
|
||||
submitted,
|
||||
streamLive,
|
||||
archiveYear,
|
||||
nominationPhase,
|
||||
votingPhase,
|
||||
reviewPhase,
|
||||
completedPhase,
|
||||
initializeHomeInteractions,
|
||||
submitNomination,
|
||||
submitClip,
|
||||
} = params
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const landingLoaderVisible = ref(true)
|
||||
const nominationCatEl = ref<HTMLSelectElement | null>(null)
|
||||
const nominationNameEl = ref<HTMLInputElement | null>(null)
|
||||
const nominationStreamUrlEl = ref<HTMLInputElement | null>(null)
|
||||
const clipUrlEl = ref<HTMLInputElement | null>(null)
|
||||
const clipNomEl = ref<HTMLSelectElement | null>(null)
|
||||
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
|
||||
const countdownRefs = {
|
||||
labelEl: ref<HTMLElement | null>(null),
|
||||
streamLabelEl: ref<HTMLElement | null>(null),
|
||||
dEl: ref<HTMLElement | null>(null),
|
||||
hEl: ref<HTMLElement | null>(null),
|
||||
mEl: ref<HTMLElement | null>(null),
|
||||
sEl: ref<HTMLElement | null>(null),
|
||||
bdEl: ref<HTMLElement | null>(null),
|
||||
bhEl: ref<HTMLElement | null>(null),
|
||||
bmEl: ref<HTMLElement | null>(null),
|
||||
bsEl: ref<HTMLElement | null>(null),
|
||||
}
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let landingLoaderTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setRootEl(element: Element | ComponentPublicInstance | null) {
|
||||
rootEl.value = element instanceof HTMLElement ? element : null
|
||||
}
|
||||
|
||||
function handleClipSubmit() {
|
||||
return submitClip({
|
||||
clipUrl: clipUrlEl.value?.value.trim() ?? '',
|
||||
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
|
||||
description: clipDescEl.value?.value.trim() ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
function handleNominationSubmit() {
|
||||
return submitNomination({
|
||||
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
|
||||
name: nominationNameEl.value?.value.trim() ?? '',
|
||||
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
function assignDomRefs() {
|
||||
const root = rootEl.value
|
||||
if (!root) return
|
||||
countdownRefs.labelEl.value = root.querySelector('[data-dc-ref="phaseCountdownLabelRef"]')
|
||||
countdownRefs.streamLabelEl.value = root.querySelector('[data-dc-ref="streamCountdownLabelRef"]')
|
||||
countdownRefs.dEl.value = root.querySelector('[data-dc-ref="dRef"]')
|
||||
countdownRefs.hEl.value = root.querySelector('[data-dc-ref="hRef"]')
|
||||
countdownRefs.mEl.value = root.querySelector('[data-dc-ref="mRef"]')
|
||||
countdownRefs.sEl.value = root.querySelector('[data-dc-ref="sRef"]')
|
||||
countdownRefs.bdEl.value = root.querySelector('[data-dc-ref="bdRef"]')
|
||||
countdownRefs.bhEl.value = root.querySelector('[data-dc-ref="bhRef"]')
|
||||
countdownRefs.bmEl.value = root.querySelector('[data-dc-ref="bmRef"]')
|
||||
countdownRefs.bsEl.value = root.querySelector('[data-dc-ref="bsRef"]')
|
||||
nominationCatEl.value = root.querySelector('[data-dc-ref="nominationCatRef"]')
|
||||
nominationNameEl.value = root.querySelector('[data-dc-ref="nominationNameRef"]')
|
||||
nominationStreamUrlEl.value = root.querySelector('[data-dc-ref="nominationStreamUrlRef"]')
|
||||
clipUrlEl.value = root.querySelector('[data-dc-ref="clipUrlRef"]')
|
||||
clipNomEl.value = root.querySelector('[data-dc-ref="clipNomRef"]')
|
||||
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
|
||||
}
|
||||
|
||||
function wireInteractiveStyles() {
|
||||
const root = rootEl.value
|
||||
if (!root) return
|
||||
root.querySelectorAll<HTMLElement>('[style-hover]').forEach((element) => {
|
||||
if (element.dataset.hoverBound === '1') return
|
||||
element.dataset.hoverBound = '1'
|
||||
const base = element.getAttribute('style') ?? ''
|
||||
const hover = element.getAttribute('style-hover') ?? ''
|
||||
element.addEventListener('mouseenter', () => { element.setAttribute('style', `${base}${hover}`) })
|
||||
element.addEventListener('mouseleave', () => { element.setAttribute('style', base) })
|
||||
})
|
||||
root.querySelectorAll<HTMLElement>('[style-focus]').forEach((element) => {
|
||||
if (element.dataset.focusBound === '1') return
|
||||
element.dataset.focusBound = '1'
|
||||
const base = element.getAttribute('style') ?? ''
|
||||
const focus = element.getAttribute('style-focus') ?? ''
|
||||
element.addEventListener('focus', () => { element.setAttribute('style', `${base}${focus}`) })
|
||||
element.addEventListener('blur', () => { element.setAttribute('style', base) })
|
||||
})
|
||||
}
|
||||
|
||||
function setupDom() {
|
||||
assignDomRefs()
|
||||
wireInteractiveStyles()
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const now = Date.now()
|
||||
const activeTarget = resolvePhaseCountdownTarget(store, resolveSelectedPhaseKey(), now)
|
||||
const showTarget = parseShowStartMs(store)
|
||||
const activeTargetMs = Number.isNaN(activeTarget.target) ? showTarget : activeTarget.target
|
||||
const activeCountdown = split(activeTarget.direction === 'since' ? now - activeTargetMs : activeTargetMs - now)
|
||||
const showCompleted = resolveIsCompletedPhase(store.overview.currentPhase)
|
||||
const showStarted = !Number.isNaN(showTarget) && now >= showTarget
|
||||
const showCountdown = split(showCompleted ? 0 : showStarted ? now - showTarget : showTarget - now)
|
||||
|
||||
setText(countdownRefs.labelEl.value, activeTarget.label)
|
||||
setText(countdownRefs.streamLabelEl.value, showCompleted ? 'Award abgeschlossen' : showStarted ? 'Stream läuft seit' : 'Finale startet in')
|
||||
setText(countdownRefs.dEl.value, pad(activeCountdown.d))
|
||||
setText(countdownRefs.hEl.value, pad(activeCountdown.h))
|
||||
setText(countdownRefs.mEl.value, pad(activeCountdown.m))
|
||||
setText(countdownRefs.sEl.value, pad(activeCountdown.s))
|
||||
setText(countdownRefs.bdEl.value, pad(showCountdown.d))
|
||||
setText(countdownRefs.bhEl.value, pad(showCountdown.h))
|
||||
setText(countdownRefs.bmEl.value, pad(showCountdown.m))
|
||||
setText(countdownRefs.bsEl.value, pad(showCountdown.s))
|
||||
}
|
||||
|
||||
function resolveSelectedPhaseKey(): HomeTimelineKey {
|
||||
if (nominationPhase.value) return 'nomination'
|
||||
if (votingPhase.value) return 'voting'
|
||||
if (reviewPhase.value) return 'review'
|
||||
if (completedPhase.value) return 'completed'
|
||||
return 'show'
|
||||
}
|
||||
|
||||
watch([modalOpen, accountModalOpen, privacyModalOpen, archiveModalOpen], () => {
|
||||
document.body.style.overflow = modalOpen.value || accountModalOpen.value || privacyModalOpen.value || archiveModalOpen.value ? 'hidden' : ''
|
||||
nextTick(setupDom)
|
||||
})
|
||||
|
||||
watch([submitted, streamLive, archiveYear], () => {
|
||||
nextTick(setupDom)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!authStore.hydrated) {
|
||||
await authStore.hydrate()
|
||||
}
|
||||
await store.loadHomeData()
|
||||
if (store.lastPublicErrorKind) {
|
||||
await router.replace({
|
||||
name: 'network-error',
|
||||
query: { from: '/' },
|
||||
})
|
||||
return
|
||||
}
|
||||
await initializeHomeInteractions()
|
||||
setupDom()
|
||||
tick()
|
||||
timer = setInterval(tick, 1000)
|
||||
landingLoaderTimer = setTimeout(() => {
|
||||
landingLoaderVisible.value = false
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
if (timer) clearInterval(timer)
|
||||
if (landingLoaderTimer) clearTimeout(landingLoaderTimer)
|
||||
})
|
||||
|
||||
return {
|
||||
setRootEl,
|
||||
landingLoaderVisible,
|
||||
handleNominationSubmit,
|
||||
handleClipSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function parseBackendDateMs(value: string, endOfDay = false) {
|
||||
if (!value) return Number.NaN
|
||||
const date = new Date(`${value}T${endOfDay ? '23:59:59' : '00:00:00'}`)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
function parseShowStartMs(store: AwardsStore) {
|
||||
const date = store.overview.showDate
|
||||
if (!date) return Number.NaN
|
||||
const time = normalizeBackendTime(store.overview.showStartsAt)
|
||||
return new Date(`${date}T${time}`).getTime()
|
||||
}
|
||||
|
||||
function normalizeBackendTime(value: string) {
|
||||
if (!value) return '20:00:00'
|
||||
return value.length === 5 ? `${value}:00` : value
|
||||
}
|
||||
|
||||
function resolvePhaseCountdownTarget(
|
||||
store: AwardsStore,
|
||||
selectedPhaseKey: HomeTimelineKey,
|
||||
now: number,
|
||||
): PhaseCountdownTarget {
|
||||
if (selectedPhaseKey === 'completed' || resolveIsCompletedPhase(store.overview.currentPhase)) {
|
||||
return {
|
||||
label: 'Award-Jahr abgeschlossen',
|
||||
target: now,
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = buildTimelineSchedule(store)
|
||||
const selectedIndex = schedule.findIndex((item) => item.key === selectedPhaseKey)
|
||||
const selectedItem = selectedIndex >= 0 ? schedule[selectedIndex] : null
|
||||
|
||||
if (selectedItem) {
|
||||
const selectedTarget = resolveItemCountdownTarget(selectedItem, now)
|
||||
if (selectedTarget) {
|
||||
return selectedTarget
|
||||
}
|
||||
|
||||
const nextTarget = schedule
|
||||
.slice(selectedIndex + 1)
|
||||
.map((item) => resolveItemCountdownTarget(item, now))
|
||||
.find((target): target is PhaseCountdownTarget => Boolean(target))
|
||||
|
||||
if (nextTarget) {
|
||||
return nextTarget
|
||||
}
|
||||
}
|
||||
|
||||
const globalTarget = schedule
|
||||
.map((item) => resolveItemCountdownTarget(item, now))
|
||||
.find((target): target is PhaseCountdownTarget => Boolean(target))
|
||||
|
||||
if (globalTarget) {
|
||||
return globalTarget
|
||||
}
|
||||
|
||||
const showTarget = parseBackendDateMs(store.overview.showDate, true)
|
||||
return {
|
||||
label: 'Phase abgeschlossen',
|
||||
target: Number.isNaN(showTarget) ? now : showTarget,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
|
||||
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'review', 'show']
|
||||
|
||||
return phaseOrder
|
||||
.map((key): TimelineScheduleItem | null => {
|
||||
const entry = store.overview.timeline.find((item) => item.key === key)
|
||||
const fallbackDate = key === 'show' ? store.overview.showDate : ''
|
||||
const startsAt = entry?.startsAt || fallbackDate
|
||||
const endsAt = entry?.endsAt || fallbackDate
|
||||
const startMs = key === 'show' ? parseShowStartMs(store) : parseBackendDateMs(startsAt)
|
||||
const endMs = parseBackendDateMs(endsAt, true)
|
||||
|
||||
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
title: entry?.title || phaseTitle(key),
|
||||
startMs,
|
||||
endMs,
|
||||
}
|
||||
})
|
||||
.filter((item): item is TimelineScheduleItem => item !== null)
|
||||
}
|
||||
|
||||
function resolveItemCountdownTarget(item: TimelineScheduleItem, now: number): PhaseCountdownTarget | null {
|
||||
if (now < item.startMs) {
|
||||
return {
|
||||
label: `${item.title} startet in`,
|
||||
target: item.startMs,
|
||||
}
|
||||
}
|
||||
|
||||
if (now <= item.endMs) {
|
||||
if (item.key === 'show') {
|
||||
return {
|
||||
label: 'Award-Show läuft seit',
|
||||
target: item.startMs,
|
||||
direction: 'since',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${item.title} noch offen`,
|
||||
target: item.endMs,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveIsCompletedPhase(currentPhase: string) {
|
||||
const value = currentPhase.trim().toLowerCase()
|
||||
return value.includes('abgeschlossen') || value.includes('archiv') || value.includes('complete') || value.includes('ended')
|
||||
}
|
||||
|
||||
function phaseTitle(key: HomeTimelineKey) {
|
||||
return key === 'nomination'
|
||||
? 'Nominierung'
|
||||
: key === 'voting'
|
||||
? 'Voting'
|
||||
: key === 'review'
|
||||
? 'Review & Auswertung'
|
||||
: 'Award Show'
|
||||
}
|
||||
|
||||
function split(milliseconds: number) {
|
||||
const seconds = Math.floor(Math.max(0, milliseconds) / 1000)
|
||||
return {
|
||||
d: Math.floor(seconds / 86400),
|
||||
h: Math.floor((seconds % 86400) / 3600),
|
||||
m: Math.floor((seconds % 3600) / 60),
|
||||
s: seconds % 60,
|
||||
}
|
||||
}
|
||||
|
||||
function setText(element: HTMLElement | null, value: string) {
|
||||
if (element) {
|
||||
element.textContent = value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { watch, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import type { AuthStore } from '../../stores/auth'
|
||||
import type { AwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
|
||||
import { useHomeParticipationSessionActions } from './useHomeParticipationSessionActions'
|
||||
import { useHomeParticipationSubmitActions } from './useHomeParticipationSubmitActions'
|
||||
|
||||
export function useHomeParticipationActions(params: {
|
||||
store: AwardsStore
|
||||
authStore: AuthStore
|
||||
routerPush: (path: string) => Promise<unknown>
|
||||
twitchUser: ComputedRef<string>
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||
archiveYears: ComputedRef<Array<{ year: number }>>
|
||||
archiveYear: Ref<number>
|
||||
previewPhase: Ref<HomePreviewPhase>
|
||||
activeCat: Ref<number>
|
||||
deleteConfirm: Ref<boolean>
|
||||
accountActionError: Ref<string>
|
||||
openModal: (kind: HomeInteractionModalKind) => void
|
||||
closeAccountModal: () => void
|
||||
votes: Ref<Record<string, number>>
|
||||
submitted: Ref<boolean>
|
||||
submitting: Ref<boolean>
|
||||
formError: Ref<string>
|
||||
successKind: Ref<null | HomeSuccessKind>
|
||||
clipCatIdx: Ref<number>
|
||||
clipDsgvo: Ref<boolean>
|
||||
}) {
|
||||
const {
|
||||
store,
|
||||
authStore,
|
||||
routerPush,
|
||||
twitchUser,
|
||||
displayCategories,
|
||||
archiveYears,
|
||||
archiveYear,
|
||||
previewPhase,
|
||||
activeCat,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
openModal,
|
||||
closeAccountModal,
|
||||
votes,
|
||||
submitted,
|
||||
submitting,
|
||||
formError,
|
||||
successKind,
|
||||
clipCatIdx,
|
||||
clipDsgvo,
|
||||
} = params
|
||||
|
||||
const {
|
||||
ensureViewerSession,
|
||||
loadMyParticipation,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onConfirmDelete,
|
||||
openAdminPanel,
|
||||
} = useHomeParticipationSessionActions({
|
||||
store,
|
||||
authStore,
|
||||
routerPush,
|
||||
displayCategories,
|
||||
votes,
|
||||
formError,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
clipDsgvo,
|
||||
closeAccountModal,
|
||||
})
|
||||
const {
|
||||
setCat,
|
||||
pickNominee,
|
||||
submitVote,
|
||||
submitReminder,
|
||||
submitNomination,
|
||||
clipDsgvoChange,
|
||||
onClipCatChange,
|
||||
submitClip,
|
||||
} = useHomeParticipationSubmitActions({
|
||||
store,
|
||||
displayCategories,
|
||||
activeCat,
|
||||
previewPhase,
|
||||
votes,
|
||||
submitted,
|
||||
submitting,
|
||||
formError,
|
||||
successKind,
|
||||
clipCatIdx,
|
||||
clipDsgvo,
|
||||
ensureViewerSession,
|
||||
loadMyParticipation,
|
||||
fallbackCreatorName: twitchUser,
|
||||
})
|
||||
|
||||
async function initializeHomeInteractions() {
|
||||
syncPreviewPhaseFromOverview()
|
||||
await loadMyParticipation()
|
||||
archiveYear.value = archiveYears.value[0]?.year ?? store.overview.year - 1
|
||||
}
|
||||
|
||||
function onPrimaryPhaseAction(event?: Event) {
|
||||
if (previewPhase.value === 'nomination') {
|
||||
event?.preventDefault()
|
||||
openModal('nominate')
|
||||
return
|
||||
}
|
||||
if (previewPhase.value === 'voting') {
|
||||
event?.preventDefault()
|
||||
openModal('vote')
|
||||
return
|
||||
}
|
||||
if (previewPhase.value === 'review') {
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionAction(event?: Event) {
|
||||
onPrimaryPhaseAction(event)
|
||||
}
|
||||
|
||||
function onTimelineFinalAction(event?: Event) {
|
||||
if (previewPhase.value === 'show') return
|
||||
event?.preventDefault()
|
||||
openModal('show')
|
||||
}
|
||||
|
||||
function syncPreviewPhaseFromOverview() {
|
||||
const activeTimelineItem = store.overview.timeline.find((entry) => entry.state === 'active')
|
||||
if (isHomePreviewPhaseKey(activeTimelineItem?.key)) {
|
||||
previewPhase.value = activeTimelineItem.key
|
||||
return
|
||||
}
|
||||
|
||||
if (isCompletedPhase(store.overview.currentPhase)) {
|
||||
previewPhase.value = 'completed'
|
||||
}
|
||||
}
|
||||
|
||||
watch(displayCategories, (categories) => {
|
||||
if (activeCat.value >= categories.length) {
|
||||
activeCat.value = 0
|
||||
}
|
||||
if (clipCatIdx.value >= categories.length) {
|
||||
clipCatIdx.value = 0
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => store.overview.currentPhase, () => {
|
||||
syncPreviewPhaseFromOverview()
|
||||
})
|
||||
|
||||
watch(() => authStore.session?.twitchUserId, () => {
|
||||
void loadMyParticipation()
|
||||
})
|
||||
|
||||
return {
|
||||
initializeHomeInteractions,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onConfirmDelete,
|
||||
openAdminPanel,
|
||||
setCat,
|
||||
pickNominee,
|
||||
submitVote,
|
||||
submitReminder,
|
||||
submitNomination,
|
||||
clipDsgvoChange,
|
||||
onClipCatChange,
|
||||
submitClip,
|
||||
onPrimaryPhaseAction,
|
||||
onSectionAction,
|
||||
onTimelineFinalAction,
|
||||
syncPreviewPhaseFromOverview,
|
||||
}
|
||||
}
|
||||
|
||||
function isHomePreviewPhaseKey(value: string | undefined): value is HomePreviewPhase {
|
||||
return value === 'nomination' || value === 'voting' || value === 'review' || value === 'show'
|
||||
}
|
||||
|
||||
function isCompletedPhase(value: string) {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized.includes('abgeschlossen') || normalized.includes('archiv') || normalized.includes('complete') || normalized.includes('ended')
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||
|
||||
import type { AwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory, HomeInteractionModalKind, HomePreviewPhase, HomeSuccessKind } from './homeLandingTypes'
|
||||
|
||||
export function useHomeParticipationPresentation(params: {
|
||||
store: AwardsStore
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||
activeCat: Ref<number>
|
||||
modal: Ref<null | HomeInteractionModalKind>
|
||||
previewPhase: Ref<HomePreviewPhase>
|
||||
votes: Ref<Record<string, number>>
|
||||
submitted: Ref<boolean>
|
||||
successKind: Ref<null | HomeSuccessKind>
|
||||
clipCatIdx: Ref<number>
|
||||
clipDsgvo: Ref<boolean>
|
||||
}) {
|
||||
const {
|
||||
store,
|
||||
displayCategories,
|
||||
activeCat,
|
||||
modal,
|
||||
previewPhase,
|
||||
votes,
|
||||
submitted,
|
||||
successKind,
|
||||
clipCatIdx,
|
||||
clipDsgvo,
|
||||
} = params
|
||||
|
||||
const notSubmitted = computed(() => !submitted.value)
|
||||
const voteCount = computed(() => Object.keys(votes.value).length)
|
||||
const totalCats = computed(() => displayCategories.value.length)
|
||||
const canSubmitVote = computed(() => previewPhase.value === 'voting' && voteCount.value > 0)
|
||||
const activeCategory = computed(() => displayCategories.value[activeCat.value] ?? displayCategories.value[0] ?? null)
|
||||
const activeCatName = computed(() => activeCategory.value?.name ?? '')
|
||||
const pickerTitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Streamer nominieren' : modal.value === 'nominate' ? 'Eingegangene Nominierungen' : 'Deine Stimme zählt'))
|
||||
const pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche Name und Stream-Link ein. Optional kannst du direkt einen Clip mitschicken.' : modal.value === 'nominate' ? 'Die Nominierungsphase ist abgeschlossen — hier sind alle eingereichten Kandidat:innen.' : 'Wähle pro Kategorie deine:n Favorit:in. Eine Stimme pro Kategorie.'))
|
||||
const successTitle = computed(() => successKind.value === 'nomination' ? 'Nominierung eingereicht ✦' : successKind.value === 'clip' ? 'Clip eingereicht ✦' : successKind.value === 'show' ? 'Erinnerung aktiviert ✦' : 'Stimme gespeichert ✩')
|
||||
const successText = computed(() => successKind.value === 'nomination' ? 'Danke! Name und Stream-Link wurden gespeichert und landen im Admin-Review.' : successKind.value === 'clip' ? 'Danke! Dein Clip wurde im Backend gespeichert und wird vom Team geprüft.' : successKind.value === 'show' ? `Wir erinnern dich rechtzeitig vor der Award-Show am ${formatShowDate(store)}.` : 'Danke fürs Abstimmen! Deine Auswahl wurde im Backend gespeichert.')
|
||||
const clipNomOptions = computed(() => (displayCategories.value[clipCatIdx.value]?.candidates ?? []).map((candidate, index) => ({ id: index, label: `${candidate.displayName}` })))
|
||||
const catOptions = computed(() => displayCategories.value.map((category, index) => ({ id: index, label: `${category.icon} ${category.name}` })))
|
||||
const canSubmitClip = computed(() => previewPhase.value === 'nomination' && clipDsgvo.value)
|
||||
const clipSubmitStyle = computed(() => canSubmitClip.value ? "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:pointer;box-shadow:0 10px 24px rgba(124,86,196,.3);" : "width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:16px;border-radius:13px;border:none;background:#d1c4e9;color:#9e8cc5;font-family:'Outfit',sans-serif;font-weight:600;font-size:15px;cursor:not-allowed;")
|
||||
|
||||
return {
|
||||
notSubmitted,
|
||||
voteCount,
|
||||
totalCats,
|
||||
canSubmitVote,
|
||||
activeCategory,
|
||||
activeCatName,
|
||||
pickerTitle,
|
||||
pickerSubtitle,
|
||||
successTitle,
|
||||
successText,
|
||||
clipNomOptions,
|
||||
catOptions,
|
||||
canSubmitClip,
|
||||
clipSubmitStyle,
|
||||
}
|
||||
}
|
||||
|
||||
function formatShowDate(store: AwardsStore) {
|
||||
const value = store.overview.showDate
|
||||
if (!value) return 'Noch nicht terminiert'
|
||||
const date = new Date(`${value}T00:00:00`)
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { api } from '../../lib/api'
|
||||
import type { AuthSession } from '../../types/awards'
|
||||
import type { AuthStore } from '../../stores/auth'
|
||||
import type { AwardsStore } from '../../stores/awards'
|
||||
import type { HomeDisplayCategory } from './homeLandingTypes'
|
||||
|
||||
export function useHomeParticipationSessionActions(params: {
|
||||
store: AwardsStore
|
||||
authStore: AuthStore
|
||||
routerPush: (path: string) => Promise<unknown>
|
||||
displayCategories: ComputedRef<HomeDisplayCategory[]>
|
||||
votes: Ref<Record<string, number>>
|
||||
formError: Ref<string>
|
||||
deleteConfirm: Ref<boolean>
|
||||
accountActionError: Ref<string>
|
||||
clipDsgvo: Ref<boolean>
|
||||
closeAccountModal: () => void
|
||||
}) {
|
||||
const {
|
||||
store,
|
||||
authStore,
|
||||
routerPush,
|
||||
displayCategories,
|
||||
votes,
|
||||
formError,
|
||||
deleteConfirm,
|
||||
accountActionError,
|
||||
clipDsgvo,
|
||||
closeAccountModal,
|
||||
} = params
|
||||
|
||||
async function ensureViewerSession(): Promise<AuthSession> {
|
||||
if (authStore.session) return authStore.session
|
||||
|
||||
await authStore.login({
|
||||
twitchUserId: 'local_user',
|
||||
displayName: 'Local User',
|
||||
role: 'viewer',
|
||||
})
|
||||
|
||||
if (!authStore.session) {
|
||||
throw new Error('Eine aktive Session konnte nicht erstellt werden.')
|
||||
}
|
||||
|
||||
return authStore.session
|
||||
}
|
||||
|
||||
async function loadMyParticipation() {
|
||||
if (!authStore.session || !store.overview.year) return
|
||||
try {
|
||||
const participation = await api.getMyParticipation(store.overview.year)
|
||||
const nextVotes: Record<string, number> = {}
|
||||
for (const vote of participation.votes) {
|
||||
const category = displayCategories.value.find((item) => Number(item.id) === vote.categoryId)
|
||||
const candidateIndex = category?.candidates.findIndex((candidate) => candidate.id === vote.candidateId) ?? -1
|
||||
if (category && candidateIndex >= 0) {
|
||||
nextVotes[category.id] = candidateIndex
|
||||
}
|
||||
}
|
||||
votes.value = nextVotes
|
||||
} catch {
|
||||
votes.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
formError.value = ''
|
||||
await ensureViewerSession()
|
||||
await loadMyParticipation()
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
await authStore.logout()
|
||||
closeAccountModal()
|
||||
deleteConfirm.value = false
|
||||
accountActionError.value = ''
|
||||
clipDsgvo.value = false
|
||||
votes.value = {}
|
||||
await routerPush('/login')
|
||||
}
|
||||
|
||||
async function onConfirmDelete() {
|
||||
accountActionError.value = ''
|
||||
try {
|
||||
await authStore.deleteMyData()
|
||||
closeAccountModal()
|
||||
deleteConfirm.value = false
|
||||
clipDsgvo.value = false
|
||||
votes.value = {}
|
||||
await store.loadHomeData()
|
||||
await routerPush('/login')
|
||||
} catch (error) {
|
||||
accountActionError.value = error instanceof Error ? error.message : 'Deine Daten konnten gerade nicht gelöscht werden.'
|
||||
}
|
||||
}
|
||||
|
||||
async function openAdminPanel(event?: Event) {
|
||||
event?.preventDefault()
|
||||
await routerPush(authStore.isAdmin ? '/admin' : '/login?redirect=/admin')
|
||||
}
|
||||
|
||||
return {
|
||||
ensureViewerSession,
|
||||
loadMyParticipation,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onConfirmDelete,
|
||||
openAdminPanel,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user