Add team roles and content management updates

This commit is contained in:
AzuTear
2026-06-25 19:52:46 +02:00
parent 54293f4c45
commit 693769e5a8
126 changed files with 6966 additions and 738 deletions
-131
View File
@@ -1,131 +0,0 @@
<svg viewBox="0 0 720 880" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Jayuhime Keyvisual (stilisierter Platzhalter)">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#efe6ff"/>
<stop offset="55%" stop-color="#f6ecff"/>
<stop offset="100%" stop-color="#fff1da"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="34%" r="55%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffe39b"/>
<stop offset="55%" stop-color="#f6b938"/>
<stop offset="100%" stop-color="#d98e1d"/>
</linearGradient>
<linearGradient id="robe" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b6bff"/>
<stop offset="100%" stop-color="#5b34c9"/>
</linearGradient>
<linearGradient id="robeDark" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#6f4fe0"/>
<stop offset="100%" stop-color="#4a279f"/>
</linearGradient>
<linearGradient id="hair" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#ece4fb"/>
</linearGradient>
<linearGradient id="rainbow" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c4b5fd"/>
<stop offset="28%" stop-color="#f5a9d6"/>
<stop offset="52%" stop-color="#fcd34d"/>
<stop offset="76%" stop-color="#86efac"/>
<stop offset="100%" stop-color="#7dd3fc"/>
</linearGradient>
</defs>
<rect width="720" height="880" fill="url(#bg)"/>
<rect width="720" height="880" fill="url(#glow)"/>
<!-- sparkles -->
<g fill="#f6b938" opacity="0.85">
<circle cx="120" cy="130" r="4"/><circle cx="610" cy="100" r="5"/><circle cx="665" cy="250" r="3"/>
<circle cx="70" cy="350" r="3"/><circle cx="650" cy="470" r="4"/><circle cx="150" cy="540" r="3"/>
</g>
<g fill="#a78bff" opacity="0.65">
<circle cx="205" cy="90" r="3"/><circle cx="545" cy="170" r="3"/><circle cx="80" cy="230" r="4"/><circle cx="605" cy="370" r="3"/>
</g>
<g fill="#ffffff">
<path d="M150 200 l6 14 14 6 -14 6 -6 14 -6 -14 -14 -6 14 -6z" opacity="0.9"/>
<path d="M585 300 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.85"/>
<path d="M120 640 l5 11 11 5 -11 5 -5 11 -5 -11 -11 -5 11 -5z" opacity="0.8"/>
</g>
<!-- soft halo behind host -->
<circle cx="360" cy="300" r="210" fill="#ffffff" opacity="0.45"/>
<!-- ===== back hair (long locks framing the body) ===== -->
<path d="M286 250 C232 360 226 520 252 690 C272 650 300 648 318 596 C300 470 300 350 322 286 Z" fill="url(#hair)"/>
<path d="M434 250 C488 360 494 520 468 690 C448 650 420 648 402 596 C420 470 420 350 398 286 Z" fill="url(#hair)"/>
<!-- rainbow streaks inside the locks -->
<path d="M296 320 C268 420 266 540 286 660 C300 624 308 600 308 560 C298 470 298 384 308 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<path d="M424 320 C452 420 454 540 434 660 C420 624 412 600 412 560 C422 470 422 384 412 330 Z" fill="url(#rainbow)" opacity="0.9"/>
<!-- ===== dress (bell skirt) ===== -->
<path d="M322 452 C300 600 250 760 214 868 C300 884 420 884 506 868 C470 760 420 600 398 452 Z" fill="url(#robe)"/>
<!-- skirt shading -->
<path d="M360 452 C352 600 340 760 332 868 L388 868 C380 760 368 600 360 452 Z" fill="#ffffff" opacity="0.12"/>
<!-- gold star accents on skirt -->
<g fill="url(#gold)" opacity="0.92">
<path d="M300 640 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
<path d="M420 600 l4 10 11 4 -11 4 -4 10 -4 -10 -11 -4 11 -4z"/>
<path d="M360 730 l5 12 13 5 -13 5 -5 12 -5 -12 -13 -5 13 -5z"/>
</g>
<!-- white ruffle hem -->
<path d="M214 862 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 48 0 q24 -22 48 0 q24 22 52 0 l0 22 -340 0 z" fill="#ffffff" opacity="0.95"/>
<!-- ===== bodice ===== -->
<path d="M316 352 C322 336 398 336 404 352 L400 458 L320 458 Z" fill="url(#robeDark)"/>
<!-- star cutout on chest -->
<path d="M360 392 l8 18 19 7 -19 8 -8 18 -8 -18 -19 -8 19 -7z" fill="#f6ecff" opacity="0.85"/>
<!-- waist sash -->
<rect x="318" y="446" width="84" height="16" rx="8" fill="url(#gold)"/>
<!-- ===== lower (left) arm + puff sleeve ===== -->
<path d="M322 372 C300 420 292 470 300 512" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="318" cy="372" r="26" fill="#ffffff"/>
<!-- ===== raised (right) arm holding trophy ===== -->
<path d="M402 372 C448 350 486 300 500 250" fill="none" stroke="#fff4ec" stroke-width="22" stroke-linecap="round"/>
<circle cx="402" cy="372" r="26" fill="#ffffff"/>
<circle cx="500" cy="246" r="15" fill="#fff4ec"/>
<!-- ===== neck + collar ruffle ===== -->
<rect x="350" y="300" width="20" height="40" rx="9" fill="#fff4ec"/>
<path d="M324 344 q18 -16 36 0 q18 16 36 0 l0 14 -72 0 z" fill="#ffffff"/>
<!-- ===== face ===== -->
<circle cx="360" cy="252" r="60" fill="#fff4ec"/>
<!-- cheeks -->
<circle cx="326" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<circle cx="394" cy="268" r="9" fill="#ffc6cf" opacity="0.7"/>
<!-- eyes -->
<ellipse cx="341" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<ellipse cx="379" cy="254" rx="6" ry="8" fill="#6b4bd6"/>
<circle cx="343" cy="251" r="2" fill="#ffffff"/>
<circle cx="381" cy="251" r="2" fill="#ffffff"/>
<!-- smile -->
<path d="M349 276 q11 9 22 0" fill="none" stroke="#caa6a0" stroke-width="3" stroke-linecap="round"/>
<!-- monocle on right eye -->
<circle cx="379" cy="254" r="17" fill="none" stroke="url(#gold)" stroke-width="3.5"/>
<path d="M379 271 q5 24 22 32" fill="none" stroke="url(#gold)" stroke-width="2" stroke-linecap="round"/>
<!-- ===== bangs over forehead ===== -->
<path d="M300 250 C300 168 330 138 360 138 C390 138 420 168 420 250 C402 222 384 214 360 214 C336 214 318 222 300 250 Z" fill="url(#hair)"/>
<path d="M360 214 C346 214 334 220 326 234 L334 250 C342 230 378 230 386 250 L394 234 C386 220 374 214 360 214 Z" fill="#ece4fb" opacity="0.6"/>
<!-- ===== star hairbuns ===== -->
<path d="M296 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<path d="M424 168 l10 22 23 9 -23 10 -10 22 -10 -22 -23 -10 23 -9z" fill="url(#gold)"/>
<!-- ===== trophy in raised hand ===== -->
<g transform="translate(458 110)">
<path d="M22 0 h84 v28 q0 50 -42 64 q-42 -14 -42 -64 z" fill="url(#gold)"/>
<path d="M22 7 h-22 q0 36 30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<path d="M106 7 h22 q0 36 -30 40" fill="none" stroke="url(#gold)" stroke-width="10" stroke-linecap="round"/>
<rect x="58" y="90" width="12" height="30" fill="url(#gold)"/>
<rect x="40" y="118" width="48" height="14" rx="5" fill="url(#gold)"/>
<path d="M64 16 l8 19 20 1 -15 13 5 20 -18 -11 -18 11 5 -20 -15 -13 20 -1z" fill="#ffffff" opacity="0.95"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

@@ -1,100 +0,0 @@
<svg width="1536" height="1000" viewBox="0 0 1536 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="backCard" x1="783" y1="193" x2="1072" y2="633" gradientUnits="userSpaceOnUse">
<stop stop-color="#C599FF"/>
<stop offset=".46" stop-color="#8E65EE"/>
<stop offset="1" stop-color="#5D40BB"/>
</linearGradient>
<linearGradient id="backCardShade" x1="909" y1="236" x2="1049" y2="680" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF7FF" stop-opacity=".34"/>
<stop offset="1" stop-color="#2F166F" stop-opacity=".24"/>
</linearGradient>
<linearGradient id="paperFill" x1="442" y1="244" x2="820" y2="713" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset=".58" stop-color="#FFF8FC"/>
<stop offset="1" stop-color="#F7EDFF"/>
</linearGradient>
<linearGradient id="paperStroke" x1="381" y1="253" x2="672" y2="768" gradientUnits="userSpaceOnUse">
<stop stop-color="#F08CAD"/>
<stop offset="1" stop-color="#F5C58D"/>
</linearGradient>
<linearGradient id="feather" x1="869" y1="224" x2="1102" y2="660" gradientUnits="userSpaceOnUse">
<stop stop-color="#E18CF9"/>
<stop offset=".45" stop-color="#8B5AEA"/>
<stop offset="1" stop-color="#4D2BAE"/>
</linearGradient>
<linearGradient id="featherLight" x1="982" y1="219" x2="903" y2="577" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFD3ED" stop-opacity=".9"/>
<stop offset="1" stop-color="#C8A8FF" stop-opacity=".2"/>
</linearGradient>
<linearGradient id="starGold" x1="568" y1="360" x2="679" y2="495" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEAB0"/>
<stop offset=".55" stop-color="#FFC86C"/>
<stop offset="1" stop-color="#F29C44"/>
</linearGradient>
<linearGradient id="shadowFade" x1="365" y1="768" x2="1084" y2="829" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB36B" stop-opacity=".18"/>
<stop offset=".6" stop-color="#9B6DFF" stop-opacity=".14"/>
<stop offset="1" stop-color="#FFB36B" stop-opacity="0"/>
</linearGradient>
<filter id="cardShadow" x="0" y="0" width="1536" height="1000" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="34" stdDeviation="36" flood-color="#7551D6" flood-opacity=".18"/>
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#F3A75C" flood-opacity=".1"/>
</filter>
<filter id="sparkGlow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="2.5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<clipPath id="paperClip">
<path d="M378 246C390 220 420 208 449 214L720 267C757 274 779 309 770 346L686 718C679 749 651 770 619 768L329 749C293 747 269 711 283 678L337 549C344 532 344 512 337 495L301 406C291 381 299 353 320 337L352 313C360 307 366 299 370 290L378 246Z"/>
</clipPath>
</defs>
<g filter="url(#sparkGlow)" opacity=".9">
<path d="M180 236l17 41 41 17-41 17-17 41-17-41-41-17 41-17 17-41z" fill="#FFC96B"/>
<path d="M235 647l14 34 34 14-34 14-14 34-14-34-34-14 34-14 14-34z" fill="#FFD98E"/>
<path d="M1247 292l14 34 34 14-34 14-14 34-14-34-34-14 34-14 14-34z" fill="#FFC96B"/>
<path d="M1329 593l18 44 44 18-44 18-18 44-18-44-44-18 44-18 18-44z" fill="#FFD98E"/>
<path d="M108 565l7 17 17 7-17 7-7 17-7-17-17-7 17-7 7-17z" fill="#F8B861"/>
<path d="M352 249l8 20 20 8-20 8-8 20-8-20-20-8 20-8 8-20z" fill="#F6C978"/>
<path d="M1362 399l8 20 20 8-20 8-8 20-8-20-20-8 20-8 8-20z" fill="#F8B861"/>
<circle cx="322" cy="575" r="9" fill="#FFC46A"/>
<circle cx="1165" cy="623" r="8" fill="#FFD98E"/>
<circle cx="129" cy="409" r="6" fill="#FFD98E"/>
</g>
<ellipse cx="748" cy="789" rx="443" ry="44" fill="url(#shadowFade)"/>
<g filter="url(#cardShadow)">
<path d="M739 206C739 186 756 170 776 172L1014 191C1034 193 1048 210 1047 230L1025 690C1024 713 1004 729 982 725L744 685C726 682 713 666 714 648L739 206Z" fill="url(#backCard)"/>
<path d="M775 216L1007 235L988 669L759 632L775 216Z" fill="url(#backCardShade)"/>
<path d="M784 211L1012 231" stroke="#D2B5FF" stroke-width="18" stroke-linecap="round" opacity=".45"/>
<path d="M1005 265L985 664" stroke="#3F268E" stroke-width="13" stroke-linecap="round" opacity=".24"/>
<path d="M378 246C390 220 420 208 449 214L720 267C757 274 779 309 770 346L686 718C679 749 651 770 619 768L329 749C293 747 269 711 283 678L337 549C344 532 344 512 337 495L301 406C291 381 299 353 320 337L352 313C360 307 366 299 370 290L378 246Z" fill="url(#paperFill)" stroke="url(#paperStroke)" stroke-width="16" stroke-linejoin="round"/>
<g clip-path="url(#paperClip)">
<path d="M284 678C347 651 422 650 506 675C569 693 630 696 688 681L674 759L310 744L284 678Z" fill="#FFEFE3"/>
<path d="M343 324C441 340 575 368 743 409" stroke="#F8C4D0" stroke-width="7" opacity=".45"/>
</g>
<path d="M590 352l34 75 82 9-61 55 17 81-72-41-72 41 17-81-61-55 82-9 34-75z" fill="url(#starGold)" stroke="#F7B354" stroke-width="17" stroke-linejoin="round"/>
<path d="M591 397l18 39 43 5-32 29 9 42-38-21-38 21 9-42-32-29 43-5 18-39z" fill="#FFF7F0" opacity=".45"/>
<path d="M420 514h190" stroke="#CFB9EE" stroke-width="21" stroke-linecap="round"/>
<path d="M418 595h213" stroke="#C4AFE8" stroke-width="21" stroke-linecap="round"/>
<path d="M414 675h139" stroke="#BBA5E5" stroke-width="21" stroke-linecap="round"/>
<circle cx="405" cy="427" r="10" fill="#F4C4C5"/>
<path d="M827 602C926 424 1067 286 1267 195C1177 355 1057 521 868 657L765 698L827 602Z" fill="url(#feather)" stroke="#6E45C9" stroke-width="16" stroke-linejoin="round"/>
<path d="M867 582C966 451 1084 338 1232 223C1154 338 1044 470 868 657" fill="url(#featherLight)"/>
<path d="M819 638C923 525 1049 403 1207 244" stroke="#4C2FA7" stroke-width="16" stroke-linecap="round"/>
<path d="M938 443C1002 411 1075 365 1158 305" stroke="#A783F5" stroke-width="11" stroke-linecap="round" opacity=".78"/>
<path d="M887 527C938 498 992 459 1049 410" stroke="#C7ABFF" stroke-width="9" stroke-linecap="round" opacity=".75"/>
<path d="M809 616L747 724" stroke="#F4A344" stroke-width="18" stroke-linecap="round"/>
<path d="M747 724L706 750" stroke="#BF6E37" stroke-width="16" stroke-linecap="round"/>
<path d="M724 741C752 761 779 766 805 755C799 785 774 811 735 816C708 812 687 798 672 774L724 741Z" fill="#FFF6FA" stroke="#C6A2DB" stroke-width="13" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

-34
View File
@@ -1,34 +0,0 @@
<svg width="420" height="260" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="paper" x1="126" y1="47" x2="252" y2="183" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8FF"/>
<stop offset="1" stop-color="#F4ECFF"/>
</linearGradient>
<linearGradient id="violet" x1="230" y1="38" x2="318" y2="160" gradientUnits="userSpaceOnUse">
<stop stop-color="#B98CFF"/>
<stop offset="1" stop-color="#7353D9"/>
</linearGradient>
<linearGradient id="gold" x1="229" y1="57" x2="262" y2="93" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE6A8"/>
<stop offset="1" stop-color="#F2AF36"/>
</linearGradient>
<filter id="softShadow" x="75" y="26" width="286" height="190" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="16" stdDeviation="16" flood-color="#7C5CFF" flood-opacity="0.16"/>
</filter>
</defs>
<g opacity=".85">
<path d="M67 66l5 12 12 5-12 5-5 12-5-12-12-5 12-5 5-12zM350 54l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9zM332 146l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z" fill="#F8C263"/>
<path d="M98 151l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#B78CFF"/>
</g>
<g filter="url(#softShadow)">
<rect x="182" y="43" width="118" height="135" rx="12" fill="url(#violet)" stroke="#9A7BEE" stroke-width="5"/>
<rect x="199" y="32" width="108" height="135" rx="12" fill="#A77BFA" stroke="#7C5CFF" stroke-width="5"/>
<rect x="111" y="64" width="124" height="139" rx="14" fill="url(#paper)" stroke="#F0A3B9" stroke-width="5"/>
<path d="M138 104h61M138 124h58M138 144h42" stroke="#B8A6D9" stroke-width="7" stroke-linecap="round"/>
<path d="M175 77l10 23 25 3-18 16 5 25-22-13-22 13 5-25-18-16 25-3 10-23z" fill="url(#gold)" stroke="#F0B851" stroke-width="5" stroke-linejoin="round"/>
<path d="M240 158c28-43 60-72 105-83-27 33-54 71-101 100l-18 5 14-22z" fill="#815BE7" stroke="#6B49CB" stroke-width="5" stroke-linejoin="round"/>
<path d="M264 144c22-23 45-42 70-58" stroke="#D8C5FF" stroke-width="5" stroke-linecap="round"/>
<path d="M222 186c19-11 28-18 37-35" stroke="#F2B85F" stroke-width="7" stroke-linecap="round"/>
</g>
<ellipse cx="211" cy="218" rx="119" ry="12" fill="#C9B5F8" opacity=".18"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

-35
View File
@@ -1,35 +0,0 @@
<svg width="420" height="260" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="box" x1="111" y1="91" x2="291" y2="214" gradientUnits="userSpaceOnUse">
<stop stop-color="#A67BFF"/>
<stop offset=".55" stop-color="#7653D9"/>
<stop offset="1" stop-color="#5C3AB9"/>
</linearGradient>
<linearGradient id="lid" x1="127" y1="56" x2="294" y2="128" gradientUnits="userSpaceOnUse">
<stop stop-color="#DDBBFF"/>
<stop offset="1" stop-color="#8B63ED"/>
</linearGradient>
<filter id="softShadow" x="79" y="38" width="268" height="205" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#6E4DD4" flood-opacity=".18"/>
</filter>
</defs>
<g opacity=".85">
<path d="M70 57l5 12 12 5-12 5-5 12-5-12-12-5 12-5 5-12zM341 71l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9zM332 168l3 7 7 3-7 3-3 7-3-7-7-3 7-3 3-7z" fill="#F8C263"/>
<path d="M101 159l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#B78CFF"/>
</g>
<path d="M89 147c-18-25 5-48 35-38M321 138c22-12 18-42-12-52" stroke="#F3D4FF" stroke-width="8" stroke-linecap="round" opacity=".65"/>
<g filter="url(#softShadow)">
<path d="M178 42l86 20 24 78-118-24-26-57c-3-7 4-15 12-13l22-4z" fill="#FFF8FF" stroke="#D7A7E8" stroke-width="5"/>
<path d="M218 80l8 18 20 2-15 13 5 20-18-10-18 10 5-20-15-13 20-2 8-18z" fill="#F8C263"/>
<rect x="105" y="99" width="210" height="40" rx="10" fill="url(#lid)" stroke="#8C67E8" stroke-width="5"/>
<path d="M124 128h172l-17 84H142l-18-84z" fill="url(#box)" stroke="#6D4EC8" stroke-width="5" stroke-linejoin="round"/>
<path d="M153 140l13 56M267 140l-13 56" stroke="#A78BFA" stroke-width="4" opacity=".7"/>
<path d="M210 158c-9-17-40-9-40 14 0 24 40 42 40 42s40-18 40-42c0-23-31-31-40-14z" fill="#FFF8FF"/>
<path d="M140 103h138" stroke="#E6D8FF" stroke-width="5" stroke-linecap="round"/>
</g>
<path d="M68 122c35-34 68-34 96-8" stroke="#C6A7FF" stroke-width="8" stroke-linecap="round" opacity=".55"/>
<path d="M161 113l-16-4 8 16 8-12z" fill="#C6A7FF" opacity=".65"/>
<path d="M253 113c37-33 71-31 98 1" stroke="#C6A7FF" stroke-width="8" stroke-linecap="round" opacity=".55"/>
<path d="M348 114l-16-4 8 16 8-12z" fill="#C6A7FF" opacity=".65"/>
<ellipse cx="210" cy="226" rx="119" ry="12" fill="#C9B5F8" opacity=".18"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg width="420" height="260" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="cup" x1="163" y1="44" x2="257" y2="169" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFE7A8"/>
<stop offset=".52" stop-color="#F6B94B"/>
<stop offset="1" stop-color="#C87A2D"/>
</linearGradient>
<linearGradient id="base" x1="134" y1="150" x2="285" y2="224" gradientUnits="userSpaceOnUse">
<stop stop-color="#D8BFFF"/>
<stop offset="1" stop-color="#7956DB"/>
</linearGradient>
<filter id="softShadow" x="86" y="31" width="263" height="207" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#A77821" flood-opacity=".17"/>
</filter>
</defs>
<g opacity=".9">
<path d="M72 55l5 12 12 5-12 5-5 12-5-12-12-5 12-5 5-12zM334 49l6 14 14 6-14 6-6 14-6-14-14-6 14-6 6-14zM333 161l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#F8C263"/>
<path d="M98 153l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9zM366 128l4 9 9 4-9 4-4 9-4-9-9-4 9-4 4-9z" fill="#A77BFA"/>
</g>
<g filter="url(#softShadow)">
<path d="M141 77h-35c0 44 25 70 62 72" stroke="#F6B94B" stroke-width="15" stroke-linecap="round"/>
<path d="M279 77h35c0 44-25 70-62 72" stroke="#F6B94B" stroke-width="15" stroke-linecap="round"/>
<path d="M148 47h124l-13 80c-4 26-24 45-49 45s-45-19-49-45L148 47z" fill="url(#cup)" stroke="#C87A2D" stroke-width="6" stroke-linejoin="round"/>
<path d="M210 82l9 20 22 3-16 15 4 22-19-11-19 11 4-22-16-15 22-3 9-20z" fill="#FFF8FF" stroke="#FAD481" stroke-width="5" stroke-linejoin="round"/>
<path d="M196 168h28l8 32h-44l8-32z" fill="#C87A2D" stroke="#9E6427" stroke-width="5"/>
<rect x="145" y="190" width="130" height="37" rx="8" fill="url(#base)" stroke="#6D4EC8" stroke-width="5"/>
<rect x="171" y="181" width="78" height="19" rx="7" fill="#F8C263" stroke="#C87A2D" stroke-width="5"/>
<path d="M178 209h64" stroke="#F6D18C" stroke-width="6" stroke-linecap="round"/>
</g>
<path d="M96 207h228" stroke="#D7C4FF" stroke-width="12" stroke-linecap="round" opacity=".55"/>
<ellipse cx="210" cy="231" rx="121" ry="12" fill="#C9B5F8" opacity=".18"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

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

Before

Width:  |  Height:  |  Size: 496 B

+24 -7
View File
@@ -3,6 +3,7 @@ import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import AppShellAccountModals from './AppShellAccountModals.vue'
import { privacyContentToHtml } from '../lib/privacyContent'
import { useAuthStore } from '../stores/auth'
import { useAwardsStore } from '../stores/awards'
import type { AuthRole } from '../types/awards'
@@ -22,6 +23,7 @@ const accountOpen = ref(false)
const deleteConfirm = ref(false)
const privacyOpen = ref(false)
const accountActionError = ref('')
const accountActionSuccess = ref('')
defineExpose({ privacyOpen })
@@ -36,12 +38,7 @@ const privacyContent = computed(
const privacyEmail = computed(
() => awardsStore.overview.siteContent.privacyEmail || awardsStore.adminSiteSettings.privacyEmail,
)
const privacyContentBlocks = computed(() =>
privacyContent.value
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyContentHtml = computed(() => privacyContentToHtml(privacyContent.value))
async function doLogin() {
try {
@@ -56,6 +53,21 @@ async function doLogout() {
await router.replace({ name: 'login' })
}
async function bindTeamTwitch() {
accountActionError.value = ''
accountActionSuccess.value = ''
try {
await authStore.startTwitchAuthorization({
purpose: 'team-binding',
returnUrl: route.fullPath,
})
} catch (error) {
accountActionError.value = error instanceof Error
? error.message
: 'Twitch-Login konnte nicht gestartet werden.'
}
}
async function deleteMyData() {
accountActionError.value = ''
try {
@@ -74,6 +86,8 @@ async function deleteMyData() {
function closeAccountModal() {
accountOpen.value = false
deleteConfirm.value = false
accountActionError.value = ''
accountActionSuccess.value = ''
}
function openPrivacyModal() {
@@ -101,6 +115,7 @@ function isActive(to: string) {
const linkBase = 'padding:9px 14px;border-radius:9px;font-family:\'Outfit\',sans-serif;font-size:14px;text-decoration:none;display:inline-block;transition:all .15s;'
const linkActive = linkBase + 'background:rgba(139,108,219,.1);color:#5f44ad;font-weight:600;'
const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weight:500;'
</script>
<template>
@@ -175,9 +190,10 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
:privacy-open="privacyOpen"
:delete-confirm="deleteConfirm"
:session="authStore.session"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:privacy-email="privacyEmail"
:account-action-error="accountActionError"
:account-action-success="accountActionSuccess"
:auth-loading="authStore.loading"
@close-account="closeAccountModal"
@close-privacy="closePrivacyModal"
@@ -185,6 +201,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh
@request-delete="requestAccountDeletion"
@cancel-delete="cancelAccountDeletion"
@logout="doLogout"
@bind-team-twitch="bindTeamTwitch"
@confirm-delete="deleteMyData"
/>
</template>
@@ -8,9 +8,10 @@ const props = defineProps<{
privacyOpen: boolean
deleteConfirm: boolean
session: AuthSession | null
privacyContentBlocks: string[]
privacyContentHtml: string
privacyEmail: string
accountActionError: string
accountActionSuccess: string
authLoading: boolean
}>()
@@ -21,11 +22,14 @@ defineEmits<{
'request-delete': []
'cancel-delete': []
logout: []
'bind-team-twitch': []
'confirm-delete': []
}>()
const twitchUserId = computed(() => props.session?.twitchUserId ?? '')
const role = computed(() => props.session?.role ?? 'viewer')
const isTeamSession = computed(() => Boolean(props.session?.teamLogin))
const canBindTwitch = computed(() => isTeamSession.value && !props.session?.mustChangePassword)
</script>
<template>
@@ -67,6 +71,14 @@ const role = computed(() => props.session?.role ?? 'viewer')
<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 v-if="session?.teamLogin" style="display:flex;justify-content:space-between;font-size:13.5px;">
<span style="color:#6f6685;">Team-Login</span>
<span style="font-weight:600;color:#3f3556;">@{{ session.teamLogin }}</span>
</div>
<div v-if="session?.boundTwitchUserId" style="display:flex;justify-content:space-between;font-size:13.5px;">
<span style="color:#6f6685;">Gebundenes Twitch</span>
<span style="font-weight:600;color:#3f3556;">@{{ session.boundTwitchUserId }}</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>
@@ -77,6 +89,34 @@ const role = computed(() => props.session?.role ?? 'viewer')
</div>
</div>
</div>
<form
v-if="isTeamSession"
style="padding:16px;border-radius:14px;background:#f8f5ff;border:1px solid #ede4fb;display:grid;gap:12px;"
@submit.prevent="$emit('bind-team-twitch')"
>
<div>
<p style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px;color:#8b6cdb;margin:0 0 4px;">Twitch verbinden</p>
<p style="font-size:12.5px;color:#6f6685;margin:0;line-height:1.45;">Verbinde deinen privaten Twitch-Account über den offiziellen Twitch Login. Danach kannst du dich im Admin-Panel per Twitch anmelden.</p>
</div>
<div v-if="session?.boundTwitchUserId" style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border-radius:12px;background:white;border:1px solid #ede4fb;font-size:13px;">
<span style="color:#6f6685;">Aktuell verbunden</span>
<span style="font-weight:800;color:#3f3556;">@{{ session.boundTwitchUserId }}</span>
</div>
<p v-if="session?.mustChangePassword" style="font-size:12.5px;color:#b45309;margin:0;font-weight:700;">Bitte ändere zuerst dein temporäres Passwort.</p>
<p v-if="accountActionError" style="font-size:12.5px;color:#be123c;margin:0;font-weight:700;">{{ accountActionError }}</p>
<p v-if="accountActionSuccess" style="font-size:12.5px;color:#047857;margin:0;font-weight:700;">{{ accountActionSuccess }}</p>
<button
:disabled="authLoading || !canBindTwitch"
type="submit"
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 14px;border-radius:12px;border:none;background:#6f4fd1;color:white;font-family:'Outfit',sans-serif;font-size:13.5px;font-weight:800;cursor:pointer;disabled:opacity:.6;"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<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>
{{ authLoading ? 'Twitch wird geöffnet...' : session?.boundTwitchUserId ? 'Twitch neu verbinden' : 'Mit Twitch verbinden' }}
</button>
</form>
<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')"
@@ -142,15 +182,9 @@ const role = computed(() => props.session?.role ?? 'viewer')
</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>
<div style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.65;color:#6f6685;">
<template v-if="privacyContentHtml">
<div class="app-shell-privacy-content" v-html="privacyContentHtml" />
</template>
<template v-else>
<div style="background:#f9f6ff;border:1px solid #ede4fb;border-radius:14px;padding:16px;">
@@ -158,9 +192,31 @@ const role = computed(() => props.session?.role ?? 'viewer')
<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>
<p v-if="privacyEmail" style="font-size:12px;color:#a99fc0;margin:18px 0 0;">Kontakt: {{ privacyEmail }}</p>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.app-shell-privacy-content :deep(p) {
margin: 0 0 14px;
}
.app-shell-privacy-content :deep(p:last-child),
.app-shell-privacy-content :deep(ul:last-child),
.app-shell-privacy-content :deep(ol:last-child) {
margin-bottom: 0;
}
.app-shell-privacy-content :deep(ul),
.app-shell-privacy-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.app-shell-privacy-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -0,0 +1,74 @@
<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">Footer Preview</p>
<h3 class="mt-2 text-xl font-bold text-slate-900">{{ title }}</h3>
<p v-if="url" class="mt-2 break-all text-sm text-slate-500">{{ url }}</p>
</div>
<button
type="button"
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-violet-100 text-violet-600 transition hover:bg-violet-200"
aria-label="Preview schließen"
@click="$emit('close')"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[calc(88vh-140px)] overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<div
v-if="contentHtml"
class="footer-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
v-html="contentHtml"
/>
<p v-else class="rounded-[18px] border border-amber-100 bg-amber-50 px-4 py-3 font-semibold text-amber-800">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { X } from '@lucide/vue'
defineProps<{
open: boolean
title: string
url: string
contentHtml: string
}>()
defineEmits<{
close: []
}>()
</script>
<style scoped>
.footer-preview-content :deep(p) {
margin: 0 0 0.85rem;
}
.footer-preview-content :deep(p:last-child),
.footer-preview-content :deep(ul:last-child),
.footer-preview-content :deep(ol:last-child) {
margin-bottom: 0;
}
.footer-preview-content :deep(ul),
.footer-preview-content :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.footer-preview-content :deep(li) {
margin: 0.2rem 0;
}
</style>
@@ -4,13 +4,13 @@
<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>
<p class="mt-2 text-sm leading-6 text-slate-500">Diese URLs und Inhalte 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' }}
{{ saving ? 'Speichert ...' : 'Footer speichern' }}
</Button>
</div>
</div>
@@ -32,22 +32,73 @@
<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>
<div class="mt-7 space-y-5">
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'imprint')">
<Eye class="h-4 w-4" />
Impressum Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.imprintContent"
label="Impressum Inhalt"
placeholder="Impressumstext..."
min-height-class="min-h-[300px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'contact')">
<Eye class="h-4 w-4" />
Kontakt Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.contactContent"
label="Kontakt Inhalt"
placeholder="Kontakttext..."
min-height-class="min-h-[260px]"
/>
</div>
<div class="space-y-3">
<div class="flex justify-end">
<Button variant="ghost" size="sm" class="gap-2 rounded-2xl border border-violet-200 bg-white px-4 text-violet-700 shadow-none hover:bg-violet-50" @click="$emit('open-preview', 'sponsors')">
<Eye class="h-4 w-4" />
Sponsoren Preview
</Button>
</div>
<AdminRichTextEditor
v-model="form.sponsorsContent"
label="Sponsoren & Partner Inhalt"
placeholder="Sponsor:innen, Partner und Hinweise..."
min-height-class="min-h-[260px]"
/>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Link2, Save } from '@lucide/vue'
import { Eye, Link2, Save } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
export type FooterPreviewKey = 'imprint' | 'contact' | 'sponsors'
const props = defineProps<{
form: AdminContentForm
saving: boolean
saveSiteSettings: (sectionLabel?: string) => Promise<void>
}>()
defineEmits<{
'open-preview': [key: FooterPreviewKey]
}>()
function onSave() {
return props.saveSiteSettings('Footer & Kontakt')
}
@@ -21,15 +21,13 @@
<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">
<div class="max-h-[calc(88vh-140px)] overflow-y-auto bg-[radial-gradient(circle_at_top,#f7ecff_0%,#ffffff_48%,#f7f1ff_100%)] px-7 py-6 text-sm leading-7 text-slate-600">
<div
v-if="contentHtml"
class="privacy-preview-content rounded-[22px] border border-violet-50 bg-white/90 px-5 py-4 shadow-sm"
v-html="contentHtml"
/>
<p v-else 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>
@@ -43,7 +41,7 @@ import { X } from '@lucide/vue'
defineProps<{
open: boolean
blocks: string[]
contentHtml: string
updatedLabel: string
}>()
@@ -51,3 +49,25 @@ defineEmits<{
close: []
}>()
</script>
<style scoped>
.privacy-preview-content :deep(p) {
margin: 0 0 0.85rem;
}
.privacy-preview-content :deep(p:last-child),
.privacy-preview-content :deep(ul:last-child),
.privacy-preview-content :deep(ol:last-child) {
margin-bottom: 0;
}
.privacy-preview-content :deep(ul),
.privacy-preview-content :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.privacy-preview-content :deep(li) {
margin: 0.2rem 0;
}
</style>
@@ -31,15 +31,13 @@
<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>
<AdminRichTextEditor
v-model="form.privacyPolicyContent"
class="mt-6"
label="Datenschutz Inhalt"
placeholder="Datenschutztext..."
min-height-class="min-h-[560px]"
/>
</Card>
</section>
</template>
@@ -49,6 +47,7 @@ import { Eye, Save, ShieldCheck } from '@lucide/vue'
import Button from '../ui/Button.vue'
import Card from '../ui/Card.vue'
import AdminRichTextEditor from './AdminRichTextEditor.vue'
import type { AdminContentForm } from './adminContentTypes'
const props = defineProps<{
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { KeyRound, Loader2, LockKeyhole, Save, ShieldCheck, Wrench } from '@lucide/vue'
import { ref } from 'vue'
import { KeyRound, Loader2, LockKeyhole, Save, Settings, 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'
import Modal from '../ui/Modal.vue'
import PasswordField from '../ui/PasswordField.vue'
defineProps<{
form: AdminOperationalSettingsForm
@@ -14,10 +17,16 @@ defineProps<{
error: string
success: string
demoPassword: string
twitchClientSecret: string
demoPasswordHint: string
demoPasswordSet: boolean
demoManagedByDatabase: boolean
demoCredentialsComplete: boolean
twitchSecretHint: string
twitchClientSecretSet: boolean
twitchAuthConfigured: boolean
twitchAuthManagedByDatabase: boolean
twitchAuthComplete: boolean
summary: AdminSettingsStatusSummary[]
dirty: boolean
canManage: boolean
@@ -26,10 +35,17 @@ defineProps<{
const emit = defineEmits<{
save: []
'update:demoPassword': [value: string]
'update:twitchClientSecret': [value: string]
}>()
function readInput(event: Event) {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
const twitchModalOpen = ref(false)
function defaultRedirectUri() {
if (typeof window === 'undefined') {
return 'https://deine-domain.de/api/auth/twitch/callback'
}
return `${window.location.origin.replace(/:\d+$/, ':5084')}/api/auth/twitch/callback`
}
function toneClasses(tone: AdminSettingsTone) {
@@ -89,7 +105,7 @@ function toneClasses(tone: AdminSettingsTone) {
Ungespeicherte Änderungen vorhanden. Beim Verlassen der Seite fragt das Panel nach.
</p>
<div class="grid gap-3 md:grid-cols-3">
<div class="grid gap-3 md:grid-cols-4">
<div
v-for="item in summary"
:key="item.label"
@@ -102,6 +118,46 @@ function toneClasses(tone: AdminSettingsTone) {
</div>
</div>
<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-center 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">
<Settings class="h-5 w-5" />
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-violet-500">Twitch OAuth</p>
<h3 class="mt-1 text-xl font-bold text-slate-900">Offiziellen Twitch Login konfigurieren</h3>
<p class="mt-1 text-sm leading-6 text-slate-500">
Einmal mit Client-ID, Client Secret und Redirect URI verbinden. Danach verwenden Login und Account-Verknüpfung den offiziellen Twitch-Flow.
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold">
<span class="rounded-full px-3 py-1" :class="twitchAuthConfigured ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ twitchAuthConfigured ? 'OAuth bereit' : 'OAuth fehlt' }}
</span>
<span class="rounded-full bg-violet-50 px-3 py-1 text-violet-700">
{{ twitchAuthManagedByDatabase ? 'Quelle: Datenbank' : 'Quelle: Config' }}
</span>
<span class="rounded-full px-3 py-1" :class="twitchClientSecretSet ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'">
{{ twitchClientSecretSet ? 'Secret vorhanden' : 'Secret fehlt' }}
</span>
</div>
</div>
</div>
<Button
type="button"
variant="secondary"
class="gap-2 rounded-2xl px-5"
:disabled="loading || saving || !canManage"
@click="twitchModalOpen = true"
>
<LockKeyhole v-if="!canManage" class="h-4 w-4" />
<Settings v-else class="h-4 w-4" />
{{ !canManage ? 'Nur Owner' : 'Twitch konfigurieren' }}
</Button>
</div>
</section>
<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">
@@ -140,7 +196,15 @@ function toneClasses(tone: AdminSettingsTone) {
</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))" />
<PasswordField
:model-value="demoPassword"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="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"
@update:model-value="emit('update:demoPassword', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ demoPasswordHint }}</span>
</label>
<label class="block">
@@ -207,4 +271,90 @@ function toneClasses(tone: AdminSettingsTone) {
</div>
</div>
</Card>
<Modal
:open="twitchModalOpen"
title="Twitch OAuth"
subtitle="Client-Daten aus der Twitch Developer Console. Das Secret wird gespeichert, aber nie wieder angezeigt."
@close="twitchModalOpen = false"
>
<div class="space-y-5">
<div class="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3 text-sm leading-6 text-violet-900">
In Twitch muss dieselbe Redirect URI eingetragen sein, die hier gespeichert ist. Ohne eigene Redirect URI nutzt das Backend automatisch den Callback deiner API.
</div>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Client-ID</span>
<input
v-model="form.twitchClientId"
:disabled="saving || !canManage"
type="text"
autocomplete="off"
placeholder="Twitch Client-ID"
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">Client Secret</span>
<PasswordField
:model-value="twitchClientSecret"
:disabled="saving || !canManage"
autocomplete="new-password"
placeholder="Leer lassen = behalten"
root-class="mt-2"
input-class="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"
@update:model-value="emit('update:twitchClientSecret', $event)"
/>
<span class="mt-2 block text-xs leading-5 text-slate-500">{{ twitchSecretHint }}</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Redirect URI</span>
<input
v-model="form.twitchRedirectUri"
:disabled="saving || !canManage"
type="url"
autocomplete="off"
:placeholder="defaultRedirectUri()"
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">Muss exakt in deiner Twitch-App unter OAuth Redirect URLs eingetragen sein.</span>
</label>
<label class="block">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Scopes</span>
<input
v-model="form.twitchScope"
:disabled="saving || !canManage"
type="text"
autocomplete="off"
placeholder="Optional, z.B. user:read:email"
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">Für reines Login/Profil bleibt das Feld normalerweise leer.</span>
</label>
<p
v-if="form.twitchClientId && !twitchAuthComplete"
class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"
>
Zum Aktivieren fehlt noch ein Client Secret.
</p>
<div v-if="error || success" class="space-y-3">
<p v-if="error" class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{{ error }}</p>
<p v-else class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{{ success }}</p>
</div>
</div>
<template #footer>
<Button type="button" variant="ghost" :disabled="saving" @click="twitchModalOpen = false">Schließen</Button>
<Button type="button" class="gap-2" :disabled="saving || !canManage" @click="emit('save')">
<Loader2 v-if="saving" class="h-4 w-4 animate-spin" />
<Save v-else class="h-4 w-4" />
{{ saving ? 'Speichert ...' : 'Twitch speichern' }}
</Button>
</template>
</Modal>
</template>
@@ -38,7 +38,7 @@ const emit = defineEmits<{
<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>
<h3 class="mt-1 text-xl font-bold text-slate-900">{{ nomination.candidateText || 'Name im Review festlegen' }}</h3>
<p class="mt-2 text-sm text-slate-500">
Eingereicht von {{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -126,7 +126,7 @@ const emit = defineEmits<{
<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' }}
ID {{ related.id }} · {{ related.submittedByTwitchId }} · {{ related.candidateText || related.streamUrl || related.reviewNote || 'Name offen' }}
</li>
</ul>
</div>
@@ -22,7 +22,7 @@ defineProps<{
<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="font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festgelegt' }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ nomination.categoryName }} · {{ nomination.submittedByTwitchId }}
<span v-if="nomination.candidateDisplayName"> · Kandidat: {{ nomination.candidateDisplayName }}</span>
@@ -32,7 +32,7 @@ const emit = defineEmits<{
ID {{ nomination.id }}
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<h3 class="mt-2 truncate text-base font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
<p class="mt-1 truncate text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -0,0 +1,254 @@
<template>
<label class="block space-y-2">
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{{ label }}</span>
<div class="overflow-hidden rounded-[28px] border border-violet-200 bg-[#fcfbff] shadow-inner shadow-violet-100/50 transition focus-within:border-violet-400 focus-within:ring-4 focus-within:ring-violet-100">
<div class="flex flex-wrap items-center gap-2 border-b border-violet-100 bg-white/80 px-3 py-3">
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<button type="button" class="rich-editor-tool" title="Fett" aria-label="Fett" @mousedown.prevent @click="runCommand('bold')">
<Bold class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Kursiv" aria-label="Kursiv" @mousedown.prevent @click="runCommand('italic')">
<Italic class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Unterstreichen" aria-label="Unterstreichen" @mousedown.prevent @click="runCommand('underline')">
<Underline class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<button type="button" class="rich-editor-tool" title="Linksbündig" aria-label="Linksbündig" @mousedown.prevent @click="runCommand('justifyLeft')">
<AlignLeft class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Zentrieren" aria-label="Zentrieren" @mousedown.prevent @click="runCommand('justifyCenter')">
<AlignCenter class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Rechtsbündig" aria-label="Rechtsbündig" @mousedown.prevent @click="runCommand('justifyRight')">
<AlignRight class="h-4 w-4" />
</button>
</div>
<div class="flex items-center gap-1 rounded-2xl border border-violet-100 bg-white p-1">
<button type="button" class="rich-editor-tool" title="Aufzählung" aria-label="Aufzählung" @mousedown.prevent @click="runCommand('insertUnorderedList')">
<List class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Nummerierte Liste" aria-label="Nummerierte Liste" @mousedown.prevent @click="runCommand('insertOrderedList')">
<ListOrdered class="h-4 w-4" />
</button>
<button type="button" class="rich-editor-tool" title="Formatierung entfernen" aria-label="Formatierung entfernen" @mousedown.prevent @click="runCommand('removeFormat')">
<Eraser class="h-4 w-4" />
</button>
</div>
<label class="flex items-center gap-2 rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
<Type class="h-4 w-4 text-violet-500" />
<select v-model="selectedFont" class="bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applyFont">
<option v-for="font in fontOptions" :key="font" :value="font">{{ font }}</option>
</select>
</label>
<label class="rounded-2xl border border-violet-100 bg-white px-3 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
Größe
<select v-model="selectedSize" class="ml-2 bg-transparent text-sm font-semibold normal-case tracking-normal text-slate-700 outline-none" @change="applySize">
<option v-for="size in sizeOptions" :key="size.value" :value="size.value">{{ size.label }}</option>
</select>
</label>
</div>
<div
ref="editorRef"
class="rich-editor w-full px-5 py-4 text-sm leading-7 text-slate-700 outline-none"
:class="[minHeightClass, { 'rich-editor--empty': editorEmpty }]"
contenteditable="true"
role="textbox"
:aria-label="label"
:data-placeholder="placeholder"
@blur="handleEditorBlur"
@focus="isFocused = true"
@input="updateModelFromEditor"
@keyup="saveSelection"
@mouseup="saveSelection"
@paste="handlePaste"
/>
</div>
</label>
</template>
<script setup lang="ts">
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Eraser,
Italic,
List,
ListOrdered,
Type,
Underline,
} from '@lucide/vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { privacyContentToHtml, sanitizePrivacyHtml, stripPrivacyHtml } from '../../lib/privacyContent'
const props = withDefaults(defineProps<{
modelValue: string
label: string
placeholder: string
minHeightClass?: string
}>(), {
minHeightClass: 'min-h-[320px]',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const editorRef = ref<HTMLElement | null>(null)
const isFocused = ref(false)
const selectedFont = ref('Outfit')
const selectedSize = ref('3')
let savedSelection: Range | null = null
const fontOptions = ['Outfit', 'Inter', 'Arial', 'Georgia', 'Times New Roman', 'Verdana']
const sizeOptions = [
{ value: '2', label: '13 px' },
{ value: '3', label: '15 px' },
{ value: '4', label: '18 px' },
{ value: '5', label: '22 px' },
]
const editorEmpty = computed(() => !stripPrivacyHtml(props.modelValue))
watch(
() => props.modelValue,
(content) => {
if (!isFocused.value) {
syncEditorContent(content)
}
},
)
onMounted(() => syncEditorContent(props.modelValue))
function syncEditorContent(content: string) {
const editor = editorRef.value
if (!editor) {
return
}
const html = privacyContentToHtml(content)
if (editor.innerHTML !== html) {
editor.innerHTML = html
}
}
function updateModelFromEditor() {
const editor = editorRef.value
if (!editor) {
return
}
saveSelection()
emit('update:modelValue', sanitizePrivacyHtml(editor.innerHTML))
}
function saveSelection() {
const editor = editorRef.value
const selection = window.getSelection()
if (!editor || !selection?.rangeCount) {
return
}
const range = selection.getRangeAt(0)
const selectionInsideEditor = editor.contains(range.commonAncestorContainer)
|| editor === range.commonAncestorContainer
if (selectionInsideEditor) {
savedSelection = range.cloneRange()
}
}
function restoreSelection() {
const editor = editorRef.value
const selection = window.getSelection()
if (!editor || !selection || !savedSelection) {
return
}
const selectionInsideEditor = editor.contains(savedSelection.commonAncestorContainer)
|| editor === savedSelection.commonAncestorContainer
if (!selectionInsideEditor) {
return
}
selection.removeAllRanges()
selection.addRange(savedSelection)
}
function handleEditorBlur() {
saveSelection()
isFocused.value = false
}
async function focusEditor() {
await nextTick()
editorRef.value?.focus()
restoreSelection()
}
async function runCommand(command: string, value?: string) {
await focusEditor()
document.execCommand(command, false, value)
updateModelFromEditor()
}
function applyFont() {
return runCommand('fontName', selectedFont.value)
}
function applySize() {
return runCommand('fontSize', selectedSize.value)
}
function handlePaste(event: ClipboardEvent) {
event.preventDefault()
const html = event.clipboardData?.getData('text/html')
const text = event.clipboardData?.getData('text/plain') ?? ''
const safeHtml = html ? sanitizePrivacyHtml(html) : privacyContentToHtml(text)
document.execCommand('insertHTML', false, safeHtml)
updateModelFromEditor()
}
</script>
<style scoped>
.rich-editor-tool {
display: grid;
height: 2rem;
width: 2rem;
place-items: center;
border-radius: 999px;
color: #6d5a86;
transition:
background-color 160ms ease,
color 160ms ease;
}
.rich-editor-tool:hover {
background: #f3e8ff;
color: #7c3aed;
}
.rich-editor :deep(p) {
margin: 0 0 0.85rem;
}
.rich-editor :deep(ul),
.rich-editor :deep(ol) {
margin: 0 0 0.85rem 1.25rem;
padding-left: 1rem;
}
.rich-editor :deep(li) {
margin: 0.2rem 0;
}
.rich-editor--empty::before {
content: attr(data-placeholder);
color: #a9a1b8;
pointer-events: none;
}
</style>
@@ -69,11 +69,11 @@ const props = defineProps<{
<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>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aufbereitung 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>
<span class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Aufbereitung 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">
@@ -118,7 +118,7 @@ const phaseCards = computed(() =>
...phase,
ordinal: String(index + 1).padStart(2, '0'),
finalLocked,
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Aktivieren',
actionLabel: phase.active ? 'Aktiv' : isCompletedPhase ? 'Beenden nutzen' : 'Phase aktivieren',
actionTitle: isCompletedPhase
? showHasPassed
? 'Abschluss erfolgt über den Beenden-Flow in den Grunddaten.'
@@ -46,7 +46,7 @@
<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.' }}
{{ form.isCurrent ? 'Dieses Jahr ist auf der Landingpage sichtbar.' : canActivatePublic ? 'Mit Speichern oder Button als Landingpage-Jahr aktivieren.' : 'Erst die Public-Readiness-Blocker unten loesen.' }}
</span>
</span>
</label>
@@ -55,11 +55,15 @@
<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">
<div class="grid gap-3 border-t border-violet-100 pt-4 md:grid-cols-4">
<Button variant="ghost" class="w-full gap-2 border border-rose-100 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="!selectedSeasonId || !canDeleteSelectedSeason" @click="openDeleteSeasonModal">
<Trash2 class="h-4 w-4" />
Löschen
</Button>
<Button variant="ghost" class="w-full gap-2 border border-emerald-100 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-50" :disabled="saving || !selectedSeasonId || form.isCurrent || !canActivatePublic" @click="activatePublicSeason">
<Globe2 class="h-4 w-4" />
{{ saving ? 'Aktiviert ...' : 'Public aktivieren' }}
</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' }}
@@ -130,7 +134,7 @@
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle2, History, Trash2 } from '@lucide/vue'
import { AlertTriangle, CheckCircle2, Globe2, History, Trash2 } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { AdminSeasonForm, AdminSeasonReadinessItem } from './adminSeasonTypes'
@@ -155,6 +159,7 @@ defineProps<{
canCompleteSelectedSeason: boolean
selectedSeasonIsCurrent: boolean
openDeleteSeasonModal: () => void
activatePublicSeason: () => Promise<boolean | void> | boolean | void
saveSeason: () => Promise<boolean | void> | boolean | void
completeSeason: () => Promise<void>
}>()
@@ -4,9 +4,9 @@
<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>
<h2 class="mt-1 text-xl font-bold leading-tight text-slate-900">Phasen und Pausen 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.
Pflege die echten Zeitfenster für Landingpage, Public-API und Teilnahme-Gates. Die Aufbereitung ist die planbare Pause zwischen Voting und Show.
</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">
@@ -89,7 +89,7 @@
<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.
Hinweis: Nominierung, Voting, Aufbereitung und Show sind als Systemfenster verdrahtet, damit Public-API, Vorschau und Teilnahme-Gates sauber zusammenlaufen.
</p>
</div>
</Card>
@@ -19,8 +19,11 @@ export type AdminContentForm = {
privacyEmail: string
privacyPolicyContent: string
imprintUrl: string
imprintContent: string
contactUrl: string
contactContent: string
sponsorsUrl: string
sponsorsContent: string
socialLinks: SocialLinkForm[]
faq: FaqFormItem[]
}
@@ -1,4 +1,4 @@
export type PhaseKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type PhaseKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export type SeasonDateField =
| 'nominationStartsAt'
@@ -58,9 +58,9 @@ export const SEASON_PHASES: PhaseRowConfig[] = [
editable: true,
},
{
key: 'review',
title: 'Review & Auswertung',
description: 'Team prüft Votes, Clips und Ergebnisse.',
key: 'preparation',
title: 'Aufbereitung',
description: 'Pause vor der Show: Ergebnisse, Clips und Ablauf vorbereiten.',
start: 'reviewStartsAt',
end: 'reviewEndsAt',
editable: true,
@@ -116,7 +116,7 @@ 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('aufbereit') || phase.includes('vorbereit') || phase.includes('pause') || phase.includes('review') || phase.includes('auswert')) return 'preparation'
if (phase.includes('vot')) return 'voting'
if (phase.includes('nomin')) return 'nomination'
return phase
@@ -7,6 +7,9 @@ export interface AdminOperationalSettingsForm {
demoLoginIdentifier: string
demoLoginTwitchUserId: string
demoLoginDisplayName: string
twitchClientId: string
twitchRedirectUri: string
twitchScope: string
maintenanceModeEnabled: boolean
maintenanceTitle: string
maintenanceMessage: string
@@ -5,6 +5,7 @@ import {
simpleIconForKey,
socialIconOptionForKey,
} from '../../lib/socialIcons'
import { privacyContentForStorage, privacyContentToHtml } from '../../lib/privacyContent'
import { useAwardsStore } from '../../stores/awards'
import type { AdminContentForm, SocialLinkForm } from './adminContentTypes'
@@ -16,8 +17,11 @@ function createEmptyForm(): AdminContentForm {
privacyEmail: '',
privacyPolicyContent: '',
imprintUrl: '',
imprintContent: '',
contactUrl: '',
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
socialLinks: [],
faq: [],
}
@@ -74,8 +78,11 @@ export function useAdminContentManager() {
form.privacyEmail = settings.privacyEmail
form.privacyPolicyContent = settings.privacyPolicyContent
form.imprintUrl = settings.imprintUrl
form.imprintContent = settings.imprintContent
form.contactUrl = settings.contactUrl
form.contactContent = settings.contactContent
form.sponsorsUrl = settings.sponsorsUrl
form.sponsorsContent = settings.sponsorsContent
form.socialLinks = settings.socialLinks.map((item) => ({
label: item.label ?? '',
platform: item.platform ?? '',
@@ -92,12 +99,7 @@ export function useAdminContentManager() {
{ immediate: true, deep: true },
)
const privacyPreviewBlocks = computed(() =>
form.privacyPolicyContent
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyPreviewHtml = computed(() => privacyContentToHtml(form.privacyPolicyContent))
const privacyUpdatedLabel = computed(() => {
const updatedAt = store.adminSiteSettings.privacyPolicyUpdatedAt
@@ -241,10 +243,13 @@ export function useAdminContentManager() {
hostTagline: form.hostTagline,
newsletterUrl: form.newsletterUrl,
privacyEmail: form.privacyEmail,
privacyPolicyContent: form.privacyPolicyContent,
privacyPolicyContent: privacyContentForStorage(form.privacyPolicyContent),
imprintUrl: form.imprintUrl,
imprintContent: privacyContentForStorage(form.imprintContent),
contactUrl: form.contactUrl,
contactContent: privacyContentForStorage(form.contactContent),
sponsorsUrl: form.sponsorsUrl,
sponsorsContent: privacyContentForStorage(form.sponsorsContent),
socialLinks: form.socialLinks
.map((item) => ({
label: item.label.trim(),
@@ -277,7 +282,7 @@ export function useAdminContentManager() {
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
@@ -3,6 +3,7 @@ import { BarChart3, Clock3, ShieldAlert, Sparkles, Tags, Users } from '@lucide/v
import { getRiskMetricValue, getVoteMetricValue } from '../../lib/adminMetrics'
import { useAwardsStore } from '../../stores/awards'
import { useAuthStore } from '../../stores/auth'
const metricToneMap = {
Nominierungen: {
@@ -29,6 +30,7 @@ const metricToneMap = {
export function useAdminDashboardOverview() {
const store = useAwardsStore()
const authStore = useAuthStore()
const metrics = computed(() => store.admin.metrics)
const activities = computed(() => store.admin.activities)
@@ -108,6 +110,17 @@ export function useAdminDashboardOverview() {
icon: ShieldAlert,
},
])
function canOpenAdminPath(path: string) {
if (path.startsWith('/admin/nominations')) return authStore.hasPermission('nominations')
if (path.startsWith('/admin/risk')) return authStore.hasPermission('risk')
if (path.startsWith('/admin/categories')) return authStore.hasPermission('categories')
if (path.startsWith('/admin/candidates')) return authStore.hasPermission('candidates')
if (path.startsWith('/admin/clips')) return authStore.hasPermission('clips')
if (path.startsWith('/admin/winners')) return authStore.hasPermission('winners')
if (path.startsWith('/admin/analytics')) return authStore.hasPermission('analytics')
return true
}
const priorityActions = computed(() => [
{
label: 'Reviews bearbeiten',
@@ -141,7 +154,7 @@ export function useAdminDashboardOverview() {
icon: Users,
tone: 'emerald',
},
])
].filter((item) => canOpenAdminPath(item.to)))
const operationChecks = computed(() => {
const categoriesWithoutCandidates = store.adminSeasonDetail.categories.filter((category) =>
!store.adminSeasonDetail.candidates.some((candidate) => candidate.categoryId === category.id),
@@ -172,7 +185,7 @@ export function useAdminDashboardOverview() {
state: openRiskCount.value === 0 ? 'ok' : 'danger',
note: openRiskCount.value === 0 ? 'Keine offenen Hinweise.' : 'Missbrauchsschutz zuerst prüfen.',
},
]
].filter((item) => canOpenAdminPath(item.to))
})
return {
@@ -16,6 +16,10 @@ export function useAdminOperationalSettings() {
const demoPasswordSet = ref(false)
const demoManagedByDatabase = ref(false)
const demoPasswordInput = ref('')
const twitchClientSecretSet = ref(false)
const twitchAuthConfigured = ref(false)
const twitchAuthManagedByDatabase = ref(false)
const twitchClientSecretInput = ref('')
const savedOperationalSnapshot = ref('')
const operationalForm = reactive<AdminOperationalSettingsForm>({
@@ -23,6 +27,9 @@ export function useAdminOperationalSettings() {
demoLoginIdentifier: '',
demoLoginTwitchUserId: '',
demoLoginDisplayName: '',
twitchClientId: '',
twitchRedirectUri: '',
twitchScope: '',
maintenanceModeEnabled: false,
maintenanceTitle: fallbackMaintenanceTitle,
maintenanceMessage: fallbackMaintenanceMessage,
@@ -52,6 +59,23 @@ export function useAdminOperationalSettings() {
),
)
const twitchAuthComplete = computed(() =>
Boolean(operationalForm.twitchClientId.trim())
&& (twitchClientSecretSet.value || Boolean(twitchClientSecretInput.value.trim()))
)
const twitchSecretHint = computed(() => {
if (twitchClientSecretInput.value.trim()) {
return 'Dieses neue Secret wird beim Speichern gesetzt.'
}
if (twitchClientSecretSet.value) {
return 'Client Secret ist gesetzt. Leer lassen, wenn es bleiben soll.'
}
return 'Noch kein Client Secret gesetzt. Beim ersten Speichern erforderlich.'
})
const operationalSummary = computed<AdminSettingsStatusSummary[]>(() => [
{
label: 'Demo Login',
@@ -73,6 +97,14 @@ export function useAdminOperationalSettings() {
: 'Öffentliche Seiten werden normal ausgeliefert.',
tone: operationalForm.maintenanceModeEnabled ? 'warning' : 'good',
},
{
label: 'Twitch OAuth',
value: twitchAuthConfigured.value ? 'Konfiguriert' : 'Fehlt',
note: twitchAuthConfigured.value
? twitchAuthManagedByDatabase.value ? 'Quelle: Datenbank' : 'Quelle: App-Konfiguration'
: 'Offizieller Twitch Login ist noch nicht aktiv.',
tone: twitchAuthConfigured.value ? 'good' : 'warning',
},
{
label: 'Passwort',
value: demoPasswordSet.value ? 'Gesetzt' : 'Fehlt',
@@ -91,12 +123,19 @@ export function useAdminOperationalSettings() {
operationalForm.demoLoginIdentifier = response.demoLoginEmail
operationalForm.demoLoginTwitchUserId = response.demoLoginTwitchUserId
operationalForm.demoLoginDisplayName = response.demoLoginDisplayName
operationalForm.twitchClientId = response.twitchClientId
operationalForm.twitchRedirectUri = response.twitchRedirectUri
operationalForm.twitchScope = response.twitchScope
operationalForm.maintenanceModeEnabled = response.maintenanceModeEnabled
operationalForm.maintenanceTitle = response.maintenanceTitle
operationalForm.maintenanceMessage = response.maintenanceMessage
demoPasswordSet.value = response.demoLoginPasswordSet
demoManagedByDatabase.value = response.demoLoginManagedByDatabase
twitchClientSecretSet.value = response.twitchClientSecretSet
twitchAuthConfigured.value = response.twitchAuthConfigured
twitchAuthManagedByDatabase.value = response.twitchAuthManagedByDatabase
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
rememberSavedOperationalSettings()
}
@@ -106,6 +145,10 @@ export function useAdminOperationalSettings() {
demoLoginIdentifier: operationalForm.demoLoginIdentifier,
demoLoginTwitchUserId: operationalForm.demoLoginTwitchUserId,
demoLoginDisplayName: operationalForm.demoLoginDisplayName,
twitchClientId: operationalForm.twitchClientId,
twitchRedirectUri: operationalForm.twitchRedirectUri,
twitchScope: operationalForm.twitchScope,
twitchClientSecretInput: twitchClientSecretInput.value,
maintenanceModeEnabled: operationalForm.maintenanceModeEnabled,
maintenanceTitle: operationalForm.maintenanceTitle,
maintenanceMessage: operationalForm.maintenanceMessage,
@@ -137,8 +180,8 @@ export function useAdminOperationalSettings() {
}
function validateOperationalSettings() {
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 12) {
operationalError.value = 'Das Demo-Passwort muss mindestens 12 Zeichen lang sein.'
if (demoPasswordInput.value.trim() && demoPasswordInput.value.trim().length < 10) {
operationalError.value = 'Das Demo-Passwort muss mindestens 10 Zeichen lang sein.'
return false
}
@@ -147,6 +190,24 @@ export function useAdminOperationalSettings() {
return false
}
const hasAnyTwitchAuthInput = Boolean(
operationalForm.twitchClientId.trim()
|| operationalForm.twitchRedirectUri.trim()
|| operationalForm.twitchScope.trim()
|| twitchClientSecretInput.value.trim()
|| twitchClientSecretSet.value,
)
if (hasAnyTwitchAuthInput && !operationalForm.twitchClientId.trim()) {
operationalError.value = 'Twitch OAuth braucht eine Client-ID.'
return false
}
if (operationalForm.twitchClientId.trim() && !twitchAuthComplete.value) {
operationalError.value = 'Twitch OAuth braucht beim ersten Speichern ein Client Secret.'
return false
}
return true
}
@@ -164,11 +225,14 @@ export function useAdminOperationalSettings() {
...operationalForm,
demoLoginEmail: operationalForm.demoLoginIdentifier,
demoLoginPassword: demoPasswordInput.value.trim() || undefined,
twitchClientSecret: twitchClientSecretInput.value.trim() || undefined,
})
demoPasswordSet.value = result.demoLoginPasswordSet
demoManagedByDatabase.value = true
twitchClientSecretSet.value = result.twitchClientSecretSet
demoPasswordInput.value = ''
twitchClientSecretInput.value = ''
await loadOperationalSettings({ silent: true })
clearSiteStatusCache()
operationalSuccess.value = 'Demo-Zugang und Wartungsmodus wurden gespeichert.'
@@ -187,6 +251,10 @@ export function useAdminOperationalSettings() {
operationalForm.demoLoginIdentifier,
operationalForm.demoLoginTwitchUserId,
operationalForm.demoLoginDisplayName,
operationalForm.twitchClientId,
operationalForm.twitchRedirectUri,
operationalForm.twitchScope,
twitchClientSecretInput.value,
operationalForm.maintenanceModeEnabled,
operationalForm.maintenanceTitle,
operationalForm.maintenanceMessage,
@@ -210,8 +278,14 @@ export function useAdminOperationalSettings() {
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
twitchClientSecretSet,
twitchAuthConfigured,
twitchAuthManagedByDatabase,
twitchClientSecretInput,
demoPasswordHint,
twitchSecretHint,
demoCredentialsComplete,
twitchAuthComplete,
operationalSummary,
hasUnsavedOperationalChanges,
loadOperationalSettings,
@@ -27,7 +27,7 @@ export function useAdminReviewsManager() {
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]
(!query || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, extractNominationStreamUrl(nomination)]
.join(' ')
.toLowerCase()
.includes(query)),
@@ -74,7 +74,7 @@ export function useAdminReviewsManager() {
return false
}
const sameName = nomination.candidateText.trim().toLowerCase() === selectedName
const sameName = Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName
const sameStreamUrl = selectedStreamUrl && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl
return sameName || sameStreamUrl
})
@@ -84,9 +84,13 @@ export function useAdminReviewsManager() {
if (!selectedNomination.value) return null
const selectedName = selectedNomination.value.candidateText.trim().toLowerCase()
const selectedStreamUrl = extractNominationStreamUrl(selectedNomination.value).toLowerCase()
const relatedNominations = seasonDetail.value.pendingNominations.filter((nomination) =>
nomination.categoryId === selectedNomination.value?.categoryId &&
nomination.candidateText.trim().toLowerCase() === selectedName,
(
(Boolean(selectedName) && nomination.candidateText.trim().toLowerCase() === selectedName) ||
(Boolean(selectedStreamUrl) && extractNominationStreamUrl(nomination).toLowerCase() === selectedStreamUrl)
),
)
const submitters = new Set(relatedNominations.map((nomination) => nomination.submittedByTwitchId.trim().toLowerCase()).filter(Boolean))
const platforms = new Set(
@@ -72,7 +72,7 @@ export function useAdminSeasonManager() {
const selectedSeason = computed(() =>
store.adminSeasons.find((season) => season.id === selectedSeasonId.value) ?? null,
)
const phasePresets = ['Nominierung', 'Community Voting', 'Review & Auswertung', 'Award Show', 'Abgeschlossen']
const phasePresets = ['Nominierung', 'Community Voting', 'Aufbereitung', 'Award Show', 'Abgeschlossen']
const readinessItems = computed(() => buildReadinessItems(seasonDetail.value, form.currentPhase))
const publicReadinessIssues = computed(() =>
readinessItems.value
@@ -80,6 +80,9 @@ export function useAdminSeasonManager() {
.map((item) => item.note),
)
const archiveReadinessIssues = computed(() => buildArchiveReadinessIssues(seasonDetail.value))
const visibleArchiveReadinessIssues = computed(() =>
normalizePhaseKey(form.currentPhase) === 'completed' ? archiveReadinessIssues.value : [],
)
const createPublicReadinessIssues = computed(() => buildCreatePublicReadinessIssues(createForm))
const canActivatePublic = computed(() => publicReadinessIssues.value.length === 0)
const canCompleteSelectedSeason = computed(() =>
@@ -173,7 +176,7 @@ export function useAdminSeasonManager() {
createForm.votingStartsAt = `${year}-08-25`
createForm.votingEndsAt = `${year}-09-11`
createForm.reviewStartsAt = `${year}-09-12`
createForm.reviewEndsAt = `${year}-09-15`
createForm.reviewEndsAt = `${year}-09-19`
createForm.showDate = `${year}-09-20`
createForm.showStartsAt = '20:00'
createForm.copyStructureFromSeasonId = findCopySourceForYear(year)
@@ -229,6 +232,19 @@ export function useAdminSeasonManager() {
return persistSeason('Jahresstatus gespeichert.')
}
async function activatePublicSeason() {
if (!selectedSeasonId.value || saving.value || form.isCurrent) {
return false
}
form.isCurrent = true
const saved = await persistSeason(`Award-Jahr ${form.year} ist jetzt auf der Landingpage aktiv.`)
if (!saved) {
form.isCurrent = false
}
return saved
}
async function activatePhase(phase: string) {
if (!selectedSeasonId.value || saving.value || form.currentPhase === phase) {
return
@@ -392,7 +408,7 @@ export function useAdminSeasonManager() {
selectedSeason,
readinessItems,
publicReadinessIssues,
archiveReadinessIssues,
archiveReadinessIssues: visibleArchiveReadinessIssues,
createPublicReadinessIssues,
canActivatePublic,
phasePresets,
@@ -404,6 +420,7 @@ export function useAdminSeasonManager() {
latestSeasonAuditMeta,
canCreate,
activatePhase,
activatePublicSeason,
openCreateModal,
saveSeason,
completeSeason,
@@ -477,7 +494,7 @@ function buildReadinessItems(
label: 'Reviews',
note: detail.pendingNominations.length === 0
? 'Keine offenen Nominierungsreviews.'
: `${detail.pendingNominations.length} Reviews sollten vor Voting-Freeze entschieden werden.`,
: `${detail.pendingNominations.length} Reviews sollten vor dem Voting-Freeze entschieden werden.`,
complete: detail.pendingNominations.length === 0,
blocking: false,
to: '/admin/nominations?review=1',
@@ -31,6 +31,11 @@ export function useAdminSettingsOverview() {
siteSettings.value.sponsorsUrl,
siteSettings.value.newsletterUrl,
].filter((url) => url.trim()).length)
const configuredFooterPages = computed(() => [
siteSettings.value.imprintContent,
siteSettings.value.contactContent,
siteSettings.value.sponsorsContent,
].filter((content) => content.trim()).length)
const contentChecks = computed<AdminSettingsCheckItem[]>(() => [
{
label: 'Host',
@@ -57,8 +62,8 @@ export function useAdminSettingsOverview() {
},
{
label: 'Footer Links',
value: configuredFooterLinks.value === 4,
note: `${configuredFooterLinks.value} von 4 Link-Zielen gepflegt`,
value: configuredFooterLinks.value === 4 && configuredFooterPages.value === 3,
note: `${configuredFooterLinks.value} von 4 Link-Zielen, ${configuredFooterPages.value} von 3 Footer-Seiten gepflegt`,
icon: Link2,
to: '/admin/content',
},
@@ -0,0 +1,276 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ApiRequestError, api } from '../../lib/api'
import type { AdminTeamMember, AdminTeamPermission, AdminTeamRole } from '../../types/awards'
export interface TeamMemberForm {
login: string
displayName: string
role: string
isActive: boolean
}
const emptyMemberForm = (): TeamMemberForm => ({
login: '',
displayName: '',
role: 'member',
isActive: true,
})
export function useAdminTeamManager() {
const loading = ref(true)
const saving = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const generatedPassword = ref('')
const generatedPasswordLogin = ref('')
const confirmDeleteMemberId = ref<number | null>(null)
const members = ref<AdminTeamMember[]>([])
const roles = ref<AdminTeamRole[]>([])
const permissions = ref<AdminTeamPermission[]>([])
const editingMemberId = ref<number | null>(null)
const rolePermissionDrafts = ref<Record<string, string[]>>({})
const savedRoleSnapshot = ref('')
const memberForm = reactive<TeamMemberForm>(emptyMemberForm())
const activeMembers = computed(() => members.value.filter((member) => member.isActive).length)
const pendingPasswordChanges = computed(() => members.value.filter((member) => member.mustChangePassword).length)
const roleOptions = computed(() => roles.value.map((role) => ({ value: role.key, label: role.label })))
const selectedMember = computed(() => members.value.find((member) => member.id === editingMemberId.value) ?? null)
const hasRoleChanges = computed(() =>
Boolean(savedRoleSnapshot.value) && JSON.stringify(rolePermissionDrafts.value) !== savedRoleSnapshot.value,
)
function resetMessages() {
errorMessage.value = ''
successMessage.value = ''
}
function applyTeamResponse(response: { members: AdminTeamMember[]; roles: AdminTeamRole[]; permissions: AdminTeamPermission[] }) {
members.value = response.members
roles.value = response.roles
permissions.value = response.permissions
rolePermissionDrafts.value = Object.fromEntries(
response.roles.map((role) => [role.key, [...role.permissionKeys]]),
)
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
}
async function loadTeam() {
loading.value = true
resetMessages()
try {
applyTeamResponse(await api.getAdminTeam())
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Daten konnten nicht geladen werden.'
} finally {
loading.value = false
}
}
function startCreateMember() {
editingMemberId.value = null
Object.assign(memberForm, emptyMemberForm())
generatedPassword.value = ''
generatedPasswordLogin.value = ''
resetMessages()
}
function startEditMember(member: AdminTeamMember) {
editingMemberId.value = member.id
memberForm.login = member.login
memberForm.displayName = member.displayName
memberForm.role = member.role
memberForm.isActive = member.isActive
generatedPassword.value = ''
generatedPasswordLogin.value = ''
resetMessages()
}
function validateMemberForm() {
if (!memberForm.login.trim() && editingMemberId.value === null) {
errorMessage.value = 'Bitte gib einen Login an.'
return false
}
if (!memberForm.displayName.trim()) {
errorMessage.value = 'Bitte gib einen Anzeigenamen an.'
return false
}
return true
}
async function saveMember() {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
if (!validateMemberForm()) return false
saving.value = true
try {
if (editingMemberId.value === null) {
const result = await api.createAdminTeamMember({
login: memberForm.login,
displayName: memberForm.displayName,
role: memberForm.role,
})
await loadTeam()
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = memberForm.login
successMessage.value = 'Team-Login wurde erstellt. Das temporäre Passwort ist nur jetzt sichtbar.'
} else {
await api.updateAdminTeamMember(editingMemberId.value, {
login: memberForm.login,
displayName: memberForm.displayName,
role: memberForm.role,
isActive: memberForm.isActive,
})
await loadTeam()
successMessage.value = 'Team-Mitglied wurde gespeichert.'
}
return true
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Mitglied konnte nicht gespeichert werden.'
return false
} finally {
saving.value = false
}
}
async function resetMemberPassword(member: AdminTeamMember) {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
saving.value = true
try {
const result = await api.resetAdminTeamMemberPassword(member.id)
await loadTeam()
generatedPassword.value = result.generatedPassword
generatedPasswordLogin.value = member.login
successMessage.value = 'Passwort wurde zurückgesetzt. Das Mitglied muss es beim nächsten Login ändern.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Passwort konnte nicht zurückgesetzt werden.'
} finally {
saving.value = false
}
}
function requestDeleteMember(member: AdminTeamMember) {
confirmDeleteMemberId.value = member.id
resetMessages()
}
function cancelDeleteMember() {
confirmDeleteMemberId.value = null
}
async function deleteMember(member: AdminTeamMember) {
resetMessages()
generatedPassword.value = ''
generatedPasswordLogin.value = ''
saving.value = true
try {
await api.deleteAdminTeamMember(member.id)
confirmDeleteMemberId.value = null
if (editingMemberId.value === member.id) {
startCreateMember()
}
await loadTeam()
successMessage.value = 'Team-Mitglied wurde gelöscht.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Team-Mitglied konnte nicht gelöscht werden.'
} finally {
saving.value = false
}
}
function roleHasPermission(roleKey: string, permissionKey: string) {
return rolePermissionDrafts.value[roleKey]?.includes(permissionKey) ?? false
}
function setRolePermission(roleKey: string, permissionKey: string, checked: boolean) {
if (roleKey === 'owner') return
const current = new Set(rolePermissionDrafts.value[roleKey] ?? [])
if (checked) {
current.add(permissionKey)
} else {
current.delete(permissionKey)
}
rolePermissionDrafts.value = {
...rolePermissionDrafts.value,
[roleKey]: [...current].sort(),
}
}
async function saveRolePermissions() {
resetMessages()
saving.value = true
try {
const result = await api.updateAdminTeamRoles({
roles: roles.value.map((role) => ({
key: role.key,
permissionKeys: rolePermissionDrafts.value[role.key] ?? [],
})),
})
roles.value = result.roles
savedRoleSnapshot.value = JSON.stringify(rolePermissionDrafts.value)
successMessage.value = 'Rollen und Berechtigungen wurden gespeichert.'
} catch (error) {
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Berechtigungen konnten nicht gespeichert werden.'
} finally {
saving.value = false
}
}
onMounted(loadTeam)
return {
loading,
saving,
errorMessage,
successMessage,
generatedPassword,
generatedPasswordLogin,
confirmDeleteMemberId,
members,
roles,
permissions,
memberForm,
editingMemberId,
selectedMember,
activeMembers,
pendingPasswordChanges,
roleOptions,
hasRoleChanges,
loadTeam,
startCreateMember,
startEditMember,
saveMember,
resetMemberPassword,
requestDeleteMember,
cancelDeleteMember,
deleteMember,
roleHasPermission,
setRolePermission,
saveRolePermissions,
}
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
const props = defineProps<{
privacyModalOpen: boolean
privacyContentBlocks: string[]
privacyContentHtml: string
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
@@ -32,14 +32,11 @@ const props = defineProps<{
</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 style="overflow-y:auto;padding:28px 32px;flex:1;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="props.privacyContentHtml" class="home-privacy-content" v-html="props.privacyContentHtml" />
<div v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #f1ecfb;background:#fcfbff;">
Datenschutzerklärung wird geladen.
</div>
</div>
</div>
</div>
@@ -99,3 +96,25 @@ const props = defineProps<{
</div>
</template>
</template>
<style scoped>
.home-privacy-content :deep(p) {
margin: 0 0 14px;
}
.home-privacy-content :deep(p:last-child),
.home-privacy-content :deep(ul:last-child),
.home-privacy-content :deep(ol:last-child) {
margin-bottom: 0;
}
.home-privacy-content :deep(ul),
.home-privacy-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.home-privacy-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -37,7 +37,7 @@ const props = defineProps<{
: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>
<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.winnerCount }}</span>
</button>
</div>
</aside>
@@ -46,7 +46,7 @@ const props = defineProps<{
const nominationCatValue = ref(0)
const clipCatValue = ref(0)
const clipNomValue = ref(0)
const clipNomQuery = ref('')
watch(
() => props.catOptions,
@@ -61,21 +61,13 @@ watch(
{ 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
clipNomQuery.value = ''
notifyClipCategoryChange(value)
}
@@ -134,7 +126,7 @@ function notifyClipCategoryChange(value: number) {
<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>
<p style="font-size:13px;line-height:1.45;color:#8a8398;margin:0;">Der Stream-Link geht direkt in den Admin-Review; den Anzeigenamen vergibt das Team.</p>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:700;color:#5f44ad;margin-bottom:6px;">Kategorie</label>
@@ -145,14 +137,10 @@ function notifyClipCategoryChange(value: number) {
: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>
<input data-dc-ref="nominationStreamUrlRef" type="url" placeholder="https://plattform.de/dein-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;">Offizieller Kanal- oder Stream-Link der Person.</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' }}
@@ -166,7 +154,7 @@ function notifyClipCategoryChange(value: number) {
</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;" />
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" 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>
@@ -180,13 +168,19 @@ function notifyClipCategoryChange(value: number) {
</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"
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
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;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
@@ -235,7 +229,7 @@ function notifyClipCategoryChange(value: number) {
<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;" />
<input data-dc-ref="clipUrlRef" type="url" placeholder="https://plattform.de/dein-clip" 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>
@@ -249,13 +243,19 @@ function notifyClipCategoryChange(value: number) {
</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"
<input
v-model="clipNomQuery"
data-dc-ref="clipNomSearchRef"
type="text"
list="clip-nominee-options"
placeholder="Name suchen"
autocomplete="off"
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;"
/>
<datalist id="clip-nominee-options">
<option v-for="option in props.clipNomOptions" :key="option.id" :value="option.label" />
</datalist>
</div>
</div>
<div>
@@ -26,12 +26,12 @@ const {
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
privacyContentHtml,
publicStreamUrl,
displayCategories,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -145,7 +145,7 @@ const {
const previewPhaseButtons = [
{ key: 'nomination', label: 'Nominierung', hint: 'Einreichen & Clips' },
{ key: 'voting', label: 'Voting', hint: 'Community stimmt ab' },
{ key: 'review', label: 'Review', hint: 'Auswertung' },
{ key: 'preparation', label: 'Aufbereitung', hint: 'Pause vor Show' },
{ key: 'show', label: 'Show', hint: 'Live-Finale' },
] as const
@@ -162,7 +162,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
@@ -250,7 +250,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
<HomeTimelineSection
:nomination-phase="nominationPhase"
:voting-phase="votingPhase"
:review-phase="reviewPhase"
:preparation-phase="preparationPhase"
:show-phase="showPhase"
:completed-phase="completedPhase"
:timeline-line-style="timelineLineStyle"
@@ -278,7 +278,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:section-action-disabled="sectionActionDisabled"
:section-action-style="sectionActionStyle"
:nomination-phase="nominationPhase"
:review-phase="reviewPhase"
:preparation-phase="preparationPhase"
:on-section-action="onSectionAction"
/>
@@ -342,7 +342,7 @@ const { setRootEl, landingLoaderVisible, handleNominationSubmit, handleClipSubmi
:winner-platform-key="winnerPlatformKey"
:winner-platform-label="winnerPlatformLabel"
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
@@ -58,7 +58,7 @@ defineProps<{
winnerPlatformKey: (url: string) => string
winnerPlatformLabel: (url: string) => string
privacyModalOpen: boolean
privacyContentBlocks: string[]
privacyContentHtml: string
onClosePrivacy: () => void
privacyModalStop: (event: Event) => void
accountModalOpen: boolean
@@ -132,7 +132,7 @@ defineProps<{
<HomeAccountAndPrivacyModals
:privacy-modal-open="privacyModalOpen"
:privacy-content-blocks="privacyContentBlocks"
:privacy-content-html="privacyContentHtml"
:on-close-privacy="onClosePrivacy"
:privacy-modal-stop="privacyModalStop"
:account-modal-open="accountModalOpen"
@@ -7,7 +7,7 @@ const props = defineProps<{
sectionActionDisabled: boolean
sectionActionStyle: string
nominationPhase: boolean
reviewPhase: boolean
preparationPhase: boolean
onSectionAction: (event?: Event) => void
}>()
</script>
@@ -72,7 +72,7 @@ const props = defineProps<{
: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 }}
<svg width="22" height="22" viewBox="0 0 24 24" :fill="props.nominationPhase ? '#E855A5' : props.preparationPhase ? '#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>
@@ -1,4 +1,8 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
interface HomeSocialLink {
label: string
platform: string
@@ -7,8 +11,10 @@ interface HomeSocialLink {
}
interface FooterLink {
key: string
label: string
url: string
content: string
}
interface SiteContent {
@@ -31,6 +37,25 @@ const props = defineProps<{
socialSimpleIconColor: (platform: string | null | undefined) => string
platformKey: (platform: string | null | undefined) => string
}>()
const activeFooterLink = ref<FooterLink | null>(null)
const activeFooterHtml = computed(() => privacyContentToHtml(activeFooterLink.value?.content || ''))
function openFooterLink(event: Event, link: FooterLink) {
if (!link.content.trim()) {
if (!link.url.trim()) {
event.preventDefault()
}
return
}
event.preventDefault()
activeFooterLink.value = link
}
function closeFooterLink() {
activeFooterLink.value = null
}
</script>
<template>
@@ -111,10 +136,83 @@ const props = defineProps<{
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>
<template v-for="link in props.footerLinks" :key="link.key || link.label">
<button
v-if="link.content"
type="button"
style="background:none;border:none;padding:0;color:var(--muted,#8a8398);text-decoration:none;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:inherit;"
style-hover="color:var(--accent,#8b6cdb);"
@click="openFooterLink($event, link)"
>
{{ link.label }}
</button>
<a
v-else
:href="link.url || '#'"
target="_blank"
rel="noopener"
style="color:var(--muted,#8a8398);text-decoration:none;"
style-hover="color:var(--accent,#8b6cdb);"
@click="openFooterLink($event, link)"
>
{{ link.label }}
</a>
</template>
<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 &amp; Chaos</div>
</div>
</footer>
<template v-if="activeFooterLink">
<div class="home-modal-overlay" @click="closeFooterLink" style="position:fixed;inset:0;z-index:420;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.stop style="position:relative;width:100%;max-width:720px;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;gap:18px;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;">Footer Seite</div>
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:700;font-size:26px;margin:0;color:#3f3556;">{{ activeFooterLink.label }}</h2>
</div>
<button @click="closeFooterLink" 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;font-family:'Outfit',sans-serif;font-size:14px;line-height:1.7;color:#6f6685;">
<div v-if="activeFooterHtml" class="home-footer-page-content" v-html="activeFooterHtml" />
<p v-else style="margin:0;padding:14px 16px;border-radius:16px;border:1px solid #fde68a;background:#fffbeb;color:#92400e;font-weight:600;">
Für diese Footer-Seite ist noch kein Inhalt hinterlegt.
</p>
<a
v-if="activeFooterLink.url"
:href="activeFooterLink.url"
target="_blank"
rel="noopener"
style="display:inline-flex;align-items:center;gap:9px;margin-top:18px;padding:12px 16px;border-radius:14px;background:#f6f1fd;border:1px solid #e6dcf6;color:#6a4fb8;text-decoration:none;font-weight:700;"
style-hover="background:#f1ecfb;"
>
Externe Seite öffnen
</a>
</div>
</div>
</div>
</template>
</template>
<style scoped>
.home-footer-page-content :deep(p) {
margin: 0 0 14px;
}
.home-footer-page-content :deep(p:last-child),
.home-footer-page-content :deep(ul:last-child),
.home-footer-page-content :deep(ol:last-child) {
margin-bottom: 0;
}
.home-footer-page-content :deep(ul),
.home-footer-page-content :deep(ol) {
margin: 0 0 14px 20px;
padding-left: 16px;
}
.home-footer-page-content :deep(li) {
margin: 3px 0;
}
</style>
@@ -2,8 +2,8 @@
<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 &amp; Auswertung bis ganz zum Schluss zur grossen Show.</p>
<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 Schritten 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 die Aufbereitung 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>
@@ -58,29 +58,29 @@
</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;'">
<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);' : preparationPhase ? '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">
<template v-else-if="preparationPhase">
<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 &amp; 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>
<div :style="preparationPhase ? '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="preparationPhase ? '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;'">{{ preparationPhase ? 'AUFBEREITUNG' : showPhase || completedPhase ? 'ABGESCHLOSSEN' : 'BEVORSTEHEND' }}</div>
<h3 style="font-family:'Outfit',sans-serif;font-weight:700;font-size:21px;margin:0 0 6px;color:#3f3556;">Aufbereitung</h3>
<div :style="preparationPhase ? '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('preparation') }}</div>
<p style="font-size:14.5px;line-height:1.6;color:#7d7491;margin:0 0 16px;">Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.</p>
<button
type="button"
disabled
aria-disabled="true"
:style="reviewButtonStyle(reviewPhase, showPhase, completedPhase)"
:style="preparationButtonStyle(preparationPhase, showPhase, completedPhase)"
>
{{ reviewPhase ? 'Auswertung läuft' : showPhase || completedPhase ? 'Auswertung abgeschlossen' : 'Review folgt' }}
{{ preparationPhase ? 'Aufbereitung läuft' : showPhase || completedPhase ? 'Aufbereitung abgeschlossen' : 'Aufbereitung folgt' }}
</button>
</div>
</div>
@@ -131,12 +131,12 @@
defineProps<{
nominationPhase: boolean
votingPhase: boolean
reviewPhase: boolean
preparationPhase: boolean
showPhase: boolean
completedPhase: boolean
timelineLineStyle: string
publicStreamUrl: string
formatTimelineRange: (key: 'nomination' | 'voting' | 'review' | 'show') => string
formatTimelineRange: (key: 'nomination' | 'voting' | 'preparation' | 'show') => string
openNominate: (event?: Event) => void
openVote: (event?: Event) => void
onTimelineFinalAction: (event?: Event) => void
@@ -158,8 +158,8 @@ function voteButtonStyle(votingPhase: boolean) {
: "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) {
function preparationButtonStyle(preparationPhase: boolean, showPhase: boolean, completedPhase: boolean) {
if (preparationPhase) {
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;"
}
@@ -2,19 +2,18 @@ import type { CandidateSummary } from '../../types/awards'
export type HomeInteractionModalKind = 'show' | 'vote' | 'nominate' | 'clip'
export type HomePreviewPhase = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
export type HomePreviewPhase = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
export type HomeSuccessKind = 'vote' | 'show' | 'clip' | 'nomination'
export interface HomeClipSubmitContext {
clipUrl: string
selectedNomineeIndex: number
selectedNomineeQuery: string
description: string
}
export interface HomeNominationSubmitContext {
categoryIndex: number
name: string
streamUrl: string
}
@@ -37,6 +37,7 @@ export interface HomeSelectionOption {
export interface HomeArchiveYearItem {
year: number
label: string
winnerCount: number
winners: Array<unknown>
active: boolean
}
@@ -6,16 +6,30 @@ 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)
const knownYears = new Map<number, number>()
for (const entry of store.overview.archiveYears ?? []) {
knownYears.set(entry.year, entry.winnerCount)
}
return [...knownYears]
.sort((left, right) => right - left)
.map((year) => ({
for (const entry of store.overview.winnersPreview) {
if (!knownYears.has(entry.year)) {
knownYears.set(
entry.year,
store.overview.winnersPreview.filter((winner) => winner.year === entry.year).length,
)
}
}
if (store.archive.items.length > 0) {
knownYears.set(store.archive.year, store.archive.items.length)
}
return [...knownYears.entries()]
.sort(([left], [right]) => right - left)
.map(([year, winnerCount]) => ({
year,
label: String(year),
winnerCount: year === store.archive.year ? store.archive.items.length : winnerCount,
winners: year === store.archive.year ? store.archive.items : store.overview.winnersPreview.filter((entry) => entry.year === year),
active: archiveYear.value === year,
}))
@@ -7,7 +7,7 @@ import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandin
type AwardsStore = ReturnType<typeof useAwardsStore>
type AuthStore = ReturnType<typeof useAuthStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show'
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show'
const CATEGORY_ICONS = ['✦', '★', '✧', '♬', '⚔', '☻', '♡', '✶'] as const
@@ -40,8 +40,10 @@ export function useHomeLandingOverviewPresentation(store: AwardsStore, authStore
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 }))
(store.overview.archiveYears ?? []).length > 0
? (store.overview.archiveYears ?? []).map((entry) => ({ year: entry.year }))
: store.overview.winnersPreview.length > 0
? [...new Set(store.overview.winnersPreview.map((winner) => winner.year))].map((year) => ({ year }))
: [{ year: store.overview.year - 1 }],
)
@@ -46,7 +46,7 @@ export function useHomeLandingState() {
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
privacyContentHtml,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,
@@ -101,7 +101,7 @@ export function useHomeLandingState() {
const {
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -257,13 +257,13 @@ export function useHomeLandingState() {
communitySocialLinks,
footerLinks,
faqItems,
privacyContentBlocks,
privacyContentHtml,
publicStreamUrl,
displayCategories,
candidateCount,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -7,7 +7,7 @@ import type { HomeNominationSubmitContext } from './homeLandingTypes'
type AuthStore = ReturnType<typeof useAuthStore>
type AwardsStore = ReturnType<typeof useAwardsStore>
type HomeTimelineKey = 'nomination' | 'voting' | 'review' | 'show' | 'completed'
type HomeTimelineKey = 'nomination' | 'voting' | 'preparation' | 'show' | 'completed'
interface PhaseCountdownTarget {
label: string
@@ -35,11 +35,11 @@ interface UseHomeLandingViewEffectsParams {
archiveYear: Readonly<Ref<number>>
nominationPhase: Readonly<Ref<boolean>>
votingPhase: Readonly<Ref<boolean>>
reviewPhase: Readonly<Ref<boolean>>
preparationPhase: 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>
submitClip: (clipContext: { clipUrl: string; selectedNomineeQuery: string; description: string }) => Promise<void>
}
export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParams) {
@@ -56,7 +56,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
archiveYear,
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
completedPhase,
initializeHomeInteractions,
submitNomination,
@@ -66,10 +66,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
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 clipNomSearchEl = ref<HTMLInputElement | null>(null)
const clipDescEl = ref<HTMLTextAreaElement | null>(null)
const countdownRefs = {
labelEl: ref<HTMLElement | null>(null),
@@ -93,7 +92,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function handleClipSubmit() {
return submitClip({
clipUrl: clipUrlEl.value?.value.trim() ?? '',
selectedNomineeIndex: Number.parseInt(clipNomEl.value?.value || '0', 10) || 0,
selectedNomineeQuery: clipNomSearchEl.value?.value.trim() ?? '',
description: clipDescEl.value?.value.trim() ?? '',
})
}
@@ -101,7 +100,6 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function handleNominationSubmit() {
return submitNomination({
categoryIndex: Number.parseInt(nominationCatEl.value?.value || '0', 10) || 0,
name: nominationNameEl.value?.value.trim() ?? '',
streamUrl: nominationStreamUrlEl.value?.value.trim() ?? '',
})
}
@@ -120,10 +118,9 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
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"]')
clipNomSearchEl.value = root.querySelector('[data-dc-ref="clipNomSearchRef"]')
clipDescEl.value = root.querySelector('[data-dc-ref="clipDescRef"]')
}
@@ -178,7 +175,7 @@ export function useHomeLandingViewEffects(params: UseHomeLandingViewEffectsParam
function resolveSelectedPhaseKey(): HomeTimelineKey {
if (nominationPhase.value) return 'nomination'
if (votingPhase.value) return 'voting'
if (reviewPhase.value) return 'review'
if (preparationPhase.value) return 'preparation'
if (completedPhase.value) return 'completed'
return 'show'
}
@@ -297,7 +294,7 @@ function resolvePhaseCountdownTarget(
}
function buildTimelineSchedule(store: AwardsStore): TimelineScheduleItem[] {
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'review', 'show']
const phaseOrder: Exclude<HomeTimelineKey, 'completed'>[] = ['nomination', 'voting', 'preparation', 'show']
return phaseOrder
.map((key): TimelineScheduleItem | null => {
@@ -358,8 +355,8 @@ function phaseTitle(key: HomeTimelineKey) {
? 'Nominierung'
: key === 'voting'
? 'Voting'
: key === 'review'
? 'Review & Auswertung'
: key === 'preparation'
? 'Aufbereitung'
: 'Award Show'
}
@@ -113,7 +113,7 @@ export function useHomeParticipationActions(params: {
openModal('vote')
return
}
if (previewPhase.value === 'review') {
if (previewPhase.value === 'preparation') {
event?.preventDefault()
return
}
@@ -180,7 +180,7 @@ export function useHomeParticipationActions(params: {
}
function isHomePreviewPhaseKey(value: string | undefined): value is HomePreviewPhase {
return value === 'nomination' || value === 'voting' || value === 'review' || value === 'show'
return value === 'nomination' || value === 'voting' || value === 'preparation' || value === 'show'
}
function isCompletedPhase(value: string) {
@@ -35,9 +35,9 @@ export function useHomeParticipationPresentation(params: {
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 pickerSubtitle = computed(() => (modal.value === 'nominate' && previewPhase.value === 'nomination' ? 'Reiche den offiziellen Stream- oder Kanal-Link ein. Den Anzeigenamen vergibt das Team im Review.' : 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 successText = computed(() => successKind.value === 'nomination' ? 'Danke! Der Stream-Link wurde gespeichert und landet 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)
@@ -113,13 +113,7 @@ export function useHomeParticipationSubmitActions(params: {
return
}
const name = nominationContext.name.trim()
const streamUrl = nominationContext.streamUrl.trim()
if (!name) {
formError.value = 'Bitte gib den Namen des VTubers oder Streamers ein.'
return
}
if (!streamUrl) {
formError.value = 'Bitte füge einen Stream- oder Kanal-Link hinzu.'
return
@@ -143,7 +137,7 @@ export function useHomeParticipationSubmitActions(params: {
year: store.overview.year,
categoryId: Number(category.id),
twitchUserId: session.twitchUserId,
nominations: [{ name, streamUrl }],
nominations: [{ streamUrl }],
})
await loadMyParticipation()
submitted.value = true
@@ -165,7 +159,12 @@ export function useHomeParticipationSubmitActions(params: {
const url = clipContext.clipUrl.trim()
if (!url) {
formError.value = 'Bitte gib einen Twitch- oder YouTube-Clip-Link ein.'
formError.value = 'Bitte gib einen Clip-Link ein.'
return
}
if (!isHttpUrl(url)) {
formError.value = 'Bitte gib einen gültigen http(s)-Link ein.'
return
}
@@ -173,7 +172,12 @@ export function useHomeParticipationSubmitActions(params: {
try {
const session = await ensureViewerSession()
const category = displayCategories.value[clipCatIdx.value]
const selectedCreator = category?.candidates[clipContext.selectedNomineeIndex]
const selectedCreator = resolveSelectedCreator(category, clipContext.selectedNomineeQuery)
if (category?.candidates.length && !selectedCreator) {
formError.value = 'Bitte wähle einen VTuber aus den Vorschlägen aus.'
return
}
await store.submitClip({
year: store.overview.year,
categoryId: category ? Number(category.id) : null,
@@ -204,6 +208,21 @@ export function useHomeParticipationSubmitActions(params: {
}
}
function resolveSelectedCreator(category: HomeDisplayCategory | undefined, query: string) {
const candidates = category?.candidates ?? []
const normalizedQuery = normalizeSearchValue(query)
if (!normalizedQuery) return null
return candidates.find((candidate) => normalizeSearchValue(candidate.displayName) === normalizedQuery)
?? candidates.find((candidate) => normalizeSearchValue(candidate.channelSlug) === normalizedQuery)
?? candidates.find((candidate) => normalizeSearchValue(candidate.displayName).includes(normalizedQuery))
?? null
}
function normalizeSearchValue(value: string) {
return value.trim().toLowerCase()
}
function isHttpUrl(value: string) {
try {
const url = new URL(value)
@@ -10,7 +10,7 @@ export function useHomePhasePresentation(params: {
showDate: ComputedRef<string>
showStartsAt: ComputedRef<string>
currentYear: ComputedRef<string>
formatRange: (key: 'nomination' | 'voting' | 'review') => string
formatRange: (key: 'nomination' | 'voting' | 'preparation') => string
formatShowDate: () => string
}) {
const {
@@ -27,7 +27,7 @@ export function useHomePhasePresentation(params: {
const nominationPhase = computed(() => previewPhase.value === 'nomination')
const votingPhase = computed(() => previewPhase.value === 'voting')
const reviewPhase = computed(() => previewPhase.value === 'review')
const preparationPhase = computed(() => previewPhase.value === 'preparation')
const showPhase = computed(() => previewPhase.value === 'show')
const completedPhase = computed(() => previewPhase.value === 'completed')
const currentTimestamp = ref(Date.now())
@@ -39,13 +39,13 @@ export function useHomePhasePresentation(params: {
const showStartMs = computed(() => parseShowStartMs(showDate.value, showStartsAt.value))
const streamLive = computed(() => showPhase.value && !Number.isNaN(showStartMs.value) && currentTimestamp.value >= showStartMs.value)
const streamLocked = computed(() => !streamLive.value)
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : reviewPhase.value ? 'Review & Auswertung' : 'Award Show Live')
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche deine Favoriten und Highlight-Clips ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : reviewPhase.value ? 'Das Voting ist abgeschlossen. Das Team prüft Ergebnisse, Clips und finale Show-Momente.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : reviewPhase.value ? `Review-Zeitraum · ${formatRange('review')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : reviewPhase.value ? 'IN PRÜFUNG' : 'AKTIV')
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : reviewPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : reviewPhase.value ? '✦ Auswertung läuft' : '● Zum Live-Stream')
const phasePrimaryDisabled = computed(() => reviewPhase.value || completedPhase.value)
const phaseCardTitle = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Community Nominierung' : votingPhase.value ? 'Community Voting' : preparationPhase.value ? 'Aufbereitung bis zur Show' : 'Award Show Live')
const phaseCardDescription = computed(() => completedPhase.value ? 'Die grosse Award-Show ist beendet. Das Jahr ist abgeschlossen und alle Teilnahme-Aktionen sind gesperrt.' : nominationPhase.value ? 'Die Nominierungsphase läuft gerade. Reiche deine Favoriten und Highlight-Clips ein.' : votingPhase.value ? 'Die Nominierungsphase ist abgeschlossen.\nJetzt liegt es an dir: Stimme für deine Favoriten!' : preparationPhase.value ? 'Das Voting ist abgeschlossen. Das Team bereitet Clips, Ablauf und Gewinner-Momente für die Show vor.' : 'Die Award-Show läuft jetzt live. Zeit für Bühne, Gewinner:innen und ganz viel Glitzer.')
const phaseCardRange = computed(() => completedPhase.value ? `Finale abgeschlossen · ${formatShowDate()}` : nominationPhase.value ? `Nominierungszeitraum · ${formatRange('nomination')}` : votingPhase.value ? `Voting-Zeitraum · ${formatRange('voting')}` : preparationPhase.value ? `Aufbereitungszeit · ${formatRange('preparation')}` : `Live · ${formatShowDate()} · ${formatTimeLabel(showStartsAt.value)} Uhr`)
const phaseStatusLabel = computed(() => completedPhase.value ? 'ABGESCHLOSSEN' : streamLive.value ? 'LIVE' : showPhase.value ? 'STARTET BALD' : preparationPhase.value ? 'IN AUFBEREITUNG' : 'AKTIV')
const phaseStatusStyle = computed(() => completedPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#f1ecfb;color:#8b6cdb;font-size:11px;font-weight:700;letter-spacing:.5px;' : showPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#ffe5ec;color:#ec3b5a;font-size:11px;font-weight:700;letter-spacing:.5px;' : preparationPhase.value ? 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#fff1d6;color:#b7791f;font-size:11px;font-weight:700;letter-spacing:.5px;' : 'display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:#e3f7ec;color:#1f9d5a;font-size:11px;font-weight:700;letter-spacing:.5px;')
const phasePrimaryLabel = computed(() => completedPhase.value ? '✦ Award beendet' : nominationPhase.value ? '✦ Nominieren & Clip' : votingPhase.value ? '★ Jetzt voten' : preparationPhase.value ? '✦ Show wird vorbereitet' : '● Zum Live-Stream')
const phasePrimaryDisabled = computed(() => preparationPhase.value || completedPhase.value)
const phasePrimaryActionStyle = computed(() => phasePrimaryDisabled.value
? 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:#eee7f8;color:#9b8abf;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:none;cursor:not-allowed;'
: 'display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:15px 18px;border-radius:13px;background:linear-gradient(135deg,#8b6cdb,#7355c8);color:#fff;border:none;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:600;font-size:15px;box-shadow:0 10px 22px rgba(124,86,196,.32);cursor:pointer;')
@@ -68,14 +68,14 @@ export function useHomePhasePresentation(params: {
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#e2d6f4 0%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: votingPhase.value
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 33.33%,#e2d6f4 33.33%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: reviewPhase.value
: preparationPhase.value
? 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 66.66%,#e2d6f4 66.66%,#e2d6f4 100%);border-radius:3px;z-index:0;'
: 'position:absolute;top:27px;left:10%;right:10%;height:3px;background:linear-gradient(90deg,#8b6cdb 0%,#8b6cdb 100%,#e2d6f4 100%,#e2d6f4 100%);border-radius:3px;z-index:0;')
const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : reviewPhase.value ? 'Das Voting wird gerade ausgewertet ✦' : 'Die Gewinner werden jetzt live gekürt ✦')
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : reviewPhase.value ? 'Die Community hat abgestimmt. Jetzt prüft das Team Ergebnisse, Clips und finale Showeinspieler für die Award-Nacht.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : reviewPhase.value ? 'Auswertung läuft' : 'Zum Live-Stream')
const sectionTitle = computed(() => completedPhase.value ? 'Das Award-Jahr ist abgeschlossen ✦' : nominationPhase.value ? 'Jetzt Highlights und Favoriten einreichen ✦' : votingPhase.value ? 'Meine Favoriten unterstützen ⭐' : preparationPhase.value ? 'Die Show wird vorbereitet ✦' : 'Die Gewinner werden jetzt live gekürt ✦')
const sectionText = computed(() => completedPhase.value ? 'Danke an alle, die nominiert, abgestimmt und live mitgefiebert haben. Die nächsten Aktionen sind gesperrt, bis ein neues Award-Jahr startet.' : nominationPhase.value ? 'Reiche deine Lieblingsmomente ein und hilf mit, die stärksten Clips und spannendsten Namen in die Show zu bringen.' : votingPhase.value ? 'Jede Stimme erzählt eine Geschichte. Unterstütze die Creator, die dich zum Lachen, Staunen und Mitfiebern bringen.' : preparationPhase.value ? 'Die Community hat abgestimmt. Jetzt bereitet das Team Clips, Ablauf und Show-Momente für die Award-Nacht vor.' : 'Die Bühne ist offen. Schau live zu, wie die Stars der Szene ausgezeichnet werden und die besten Momente gezeigt werden.')
const sectionActionLabel = computed(() => completedPhase.value ? 'Award-Jahr abgeschlossen' : nominationPhase.value ? 'Nominieren & Clip einreichen' : votingPhase.value ? 'Mit Twitch anmelden & voten' : preparationPhase.value ? 'Aufbereitung läuft' : 'Zum Live-Stream')
const sectionActionHref = computed(() => showPhase.value ? publicStreamUrl.value : '#')
const sectionActionDisabled = computed(() => reviewPhase.value || completedPhase.value)
const sectionActionDisabled = computed(() => preparationPhase.value || completedPhase.value)
const sectionActionStyle = computed(() => sectionActionDisabled.value
? 'display:inline-flex;align-items:center;gap:11px;padding:17px 38px;border-radius:14px;background:rgba(255,255,255,.72);color:#9b8abf;text-decoration:none;font-family:\'Outfit\',sans-serif;font-weight:700;font-size:18px;box-shadow:none;cursor:not-allowed;border:none;'
: nominationPhase.value
@@ -85,7 +85,7 @@ export function useHomePhasePresentation(params: {
return {
nominationPhase,
votingPhase,
reviewPhase,
preparationPhase,
showPhase,
completedPhase,
showCountdown,
@@ -1,5 +1,6 @@
import { computed, type ComputedRef } from 'vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { simpleIconForKey } from '../../lib/socialIcons'
import type { OverviewResponse } from '../../types/awards'
@@ -26,15 +27,10 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
)
const footerLinks = computed(() =>
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && link.url),
(siteContent.value.footerLinks ?? []).filter((link) => link?.label && (link.url || link.content)),
)
const privacyContentBlocks = computed(() =>
(siteContent.value.privacyPolicyContent || '')
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean),
)
const privacyContentHtml = computed(() => privacyContentToHtml(siteContent.value.privacyPolicyContent || ''))
function platformKey(value: string | null | undefined) {
return (value ?? 'link').trim().toLowerCase()
@@ -56,7 +52,7 @@ export function useHomeSocialPresentation(siteContent: ComputedRef<OverviewRespo
hostSocialLinks,
communitySocialLinks,
footerLinks,
privacyContentBlocks,
privacyContentHtml,
platformKey,
isUploadedSocialIcon,
socialSimpleIconPath,
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { Eye, EyeOff } from '@lucide/vue'
import { computed, ref, useAttrs } from 'vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<{
modelValue: string
inputClass?: string
rootClass?: string
variant?: 'default' | 'login'
}>(), {
inputClass: '',
rootClass: '',
variant: 'default',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const attrs = useAttrs()
const visible = ref(false)
const inputType = computed(() => visible.value ? 'text' : 'password')
const toggleLabel = computed(() => visible.value ? 'Passwort ausblenden' : 'Passwort anzeigen')
const ToggleIcon = computed(() => visible.value ? EyeOff : Eye)
const disabled = computed(() =>
attrs.disabled === true || attrs.disabled === '' || attrs.disabled === 'true',
)
function updateValue(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
function toggleVisibility() {
visible.value = !visible.value
}
</script>
<template>
<div class="password-field" :class="[`password-field--${variant}`, rootClass]">
<input
v-bind="attrs"
:value="modelValue"
:type="inputType"
:class="['password-field__input', inputClass]"
@input="updateValue"
>
<button
type="button"
class="password-field__toggle"
:aria-label="toggleLabel"
:aria-pressed="visible"
:disabled="disabled"
@click="toggleVisibility"
@mousedown.prevent
>
<component :is="ToggleIcon" class="password-field__icon" />
</button>
</div>
</template>
<style scoped>
.password-field {
position: relative;
width: 100%;
}
.password-field__input {
width: 100%;
padding-right: 3rem !important;
}
.password-field__toggle {
position: absolute;
top: 50%;
right: 0.62rem;
display: inline-grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: #7c3aed;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
transform: translateY(-50%);
}
.password-field__toggle:hover:not(:disabled),
.password-field__toggle:focus-visible {
background: rgba(124, 58, 237, 0.1);
color: #5b21b6;
outline: none;
}
.password-field__toggle:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.password-field__icon {
width: 1rem;
height: 1rem;
}
.password-field--login .password-field__input {
box-sizing: border-box;
border: 1.5px solid #ded4ff;
border-radius: 22px;
padding: 17px 54px 17px 18px !important;
background: rgba(255, 255, 255, 0.82);
color: #3f3556;
font: 700 17px 'Outfit', sans-serif;
outline: none;
box-shadow: 0 12px 32px rgba(139, 108, 219, 0.06);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.password-field--login .password-field__input:focus {
border-color: #8b6cdb;
box-shadow: 0 0 0 5px rgba(139, 108, 219, 0.14);
transform: translateY(-1px);
}
.password-field--login .password-field__toggle {
right: 0.8rem;
color: #7355c8;
}
</style>
+20 -1
View File
@@ -9,17 +9,22 @@ import type {
AdminSeasonDetailResponse,
AdminSeasonListItem,
AdminSiteSettingsResponse,
AdminTeamResponse,
ApproveNominationPayload,
BulkResolveRiskFlagsPayload,
CreateTeamMemberPayload,
CreateSeasonPayload,
RejectNominationPayload,
ResolveRiskFlagPayload,
SetAwardResultPayload,
TeamMemberPasswordResponse,
UpdateClipStatusPayload,
UpdateOperationalSettingsPayload,
UpdateRiskRulesPayload,
UpdateSeasonPayload,
UpdateSiteSettingsPayload,
UpdateTeamMemberPayload,
UpdateTeamRolesPayload,
UpsertCandidatePayload,
UpsertCategoryPayload,
} from '../../types/awards'
@@ -83,6 +88,20 @@ export const adminApi = {
getAdminSiteSettings: () => requestJson<AdminSiteSettingsResponse>('/api/admin/site-settings'),
getAdminOperationalSettings: () =>
requestJson<AdminOperationalSettingsResponse>('/api/admin/operational-settings'),
getAdminTeam: () => requestJson<AdminTeamResponse>('/api/admin/team'),
createAdminTeamMember: (payload: CreateTeamMemberPayload) =>
requestJson<TeamMemberPasswordResponse>('/api/admin/team/members', jsonRequest('POST', payload)),
updateAdminTeamMember: (memberId: number, payload: UpdateTeamMemberPayload) =>
requestJson<{ saved: boolean; member: AdminTeamResponse['members'][number] }>(
`/api/admin/team/members/${memberId}`,
jsonRequest('PUT', payload),
),
resetAdminTeamMemberPassword: (memberId: number) =>
requestJson<TeamMemberPasswordResponse>(`/api/admin/team/members/${memberId}/reset-password`, jsonRequest('POST', {})),
deleteAdminTeamMember: (memberId: number) =>
requestJson<{ deleted: boolean; memberId: number }>(`/api/admin/team/members/${memberId}`, { method: 'DELETE' }),
updateAdminTeamRoles: (payload: UpdateTeamRolesPayload) =>
requestJson<{ saved: boolean; roles: AdminTeamResponse['roles'] }>('/api/admin/team/roles', jsonRequest('PUT', payload)),
createAdminSeason: (payload: CreateSeasonPayload) =>
requestJson<{ saved: boolean; seasonId: number }>('/api/admin/seasons', jsonRequest('POST', payload)),
updateAdminSeason: (seasonId: number, payload: UpdateSeasonPayload) =>
@@ -94,7 +113,7 @@ export const adminApi = {
updateAdminSiteSettings: (payload: UpdateSiteSettingsPayload) =>
requestJson<{ saved: boolean }>('/api/admin/site-settings', jsonRequest('PUT', payload)),
updateAdminOperationalSettings: (payload: UpdateOperationalSettingsPayload) =>
requestJson<{ saved: boolean; demoLoginPasswordSet: boolean }>('/api/admin/operational-settings', jsonRequest('PUT', payload)),
requestJson<{ saved: boolean; demoLoginPasswordSet: boolean; twitchClientSecretSet: boolean }>('/api/admin/operational-settings', jsonRequest('PUT', payload)),
createAdminCategory: (seasonId: number, payload: UpsertCategoryPayload) =>
requestJson<{ saved: boolean; categoryId: number }>(`/api/admin/seasons/${seasonId}/categories`, jsonRequest('POST', payload)),
updateAdminCategory: (categoryId: number, payload: UpsertCategoryPayload) =>
+21 -1
View File
@@ -1,4 +1,14 @@
import type { AuthSession, DemoLoginPayload, LoginPayload } from '../../types/awards'
import type {
AuthSession,
BindTeamTwitchPayload,
ChangePasswordPayload,
DemoLoginPayload,
LoginPayload,
TeamLoginPayload,
TeamTwitchLoginPayload,
TwitchAuthorizePayload,
TwitchAuthorizeResponse,
} from '../../types/awards'
import { requestJson } from '../http'
import { jsonRequest } from './requestOptions'
@@ -8,6 +18,16 @@ export const authApi = {
requestJson<AuthSession>('/api/auth/dev-login', jsonRequest('POST', payload)),
demoLogin: (payload: DemoLoginPayload) =>
requestJson<AuthSession>('/api/auth/demo-login', jsonRequest('POST', payload)),
teamLogin: (payload: TeamLoginPayload) =>
requestJson<AuthSession>('/api/auth/team-login', jsonRequest('POST', payload)),
teamTwitchLogin: (payload: TeamTwitchLoginPayload) =>
requestJson<AuthSession>('/api/auth/team-twitch-login', jsonRequest('POST', payload)),
changePassword: (payload: ChangePasswordPayload) =>
requestJson<AuthSession>('/api/auth/password/change', jsonRequest('POST', payload)),
bindTeamTwitch: (payload: BindTeamTwitchPayload) =>
requestJson<AuthSession>('/api/auth/team/twitch-binding', jsonRequest('POST', payload)),
startTwitchAuthorization: (payload: TwitchAuthorizePayload) =>
requestJson<TwitchAuthorizeResponse>('/api/auth/twitch/authorize', jsonRequest('POST', payload)),
logout: () =>
requestJson<{ loggedOut: boolean }>('/api/auth/logout', jsonRequest('POST', {})),
deleteMyData: () =>
+201
View File
@@ -0,0 +1,201 @@
const allowedTags = new Set([
'A',
'B',
'BR',
'DIV',
'EM',
'FONT',
'H2',
'H3',
'H4',
'I',
'LI',
'OL',
'P',
'SPAN',
'STRONG',
'U',
'UL',
])
const allowedStyleProperties = new Set([
'font-family',
'font-size',
'font-style',
'font-weight',
'text-align',
'text-decoration',
])
const dangerousTags = new Set(['IFRAME', 'OBJECT', 'SCRIPT', 'STYLE', 'TEMPLATE'])
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function hasHtmlMarkup(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}
function normalizePlainText(value: string) {
return value
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean)
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
.join('')
}
function isSafeStyleValue(property: string, value: string) {
const normalizedValue = value.trim()
if (!normalizedValue || /url\s*\(|expression\s*\(|javascript:/i.test(normalizedValue)) {
return false
}
if (property === 'text-align') {
return /^(left|center|right|justify)$/i.test(normalizedValue)
}
if (property === 'font-size') {
return /^([1-4](\.\d)?rem|[1-3]?\dpx|[1-2]?\dpt|small|medium|large|x-large)$/i.test(normalizedValue)
}
if (property === 'font-family') {
return /^[a-z0-9\s"',.-]+$/i.test(normalizedValue)
}
if (property === 'font-weight') {
return /^(normal|bold|[1-9]00)$/i.test(normalizedValue)
}
if (property === 'font-style') {
return /^(normal|italic)$/i.test(normalizedValue)
}
return /^(none|underline|line-through)$/i.test(normalizedValue)
}
function sanitizeStyle(style: string) {
return style
.split(';')
.map((declaration) => declaration.trim())
.filter(Boolean)
.map((declaration) => {
const [property, ...valueParts] = declaration.split(':')
const normalizedProperty = property?.trim().toLowerCase()
const value = valueParts.join(':').trim()
if (!allowedStyleProperties.has(normalizedProperty) || !isSafeStyleValue(normalizedProperty, value)) {
return ''
}
return `${normalizedProperty}: ${value}`
})
.filter(Boolean)
.join('; ')
}
function unwrapElement(element: Element) {
const parent = element.parentNode
if (!parent) {
return
}
while (element.firstChild) {
parent.insertBefore(element.firstChild, element)
}
parent.removeChild(element)
}
function sanitizeElement(element: Element) {
if (dangerousTags.has(element.tagName)) {
element.remove()
return
}
if (!allowedTags.has(element.tagName)) {
unwrapElement(element)
return
}
for (const attribute of Array.from(element.attributes)) {
const name = attribute.name.toLowerCase()
const value = attribute.value
if (name === 'style') {
const safeStyle = sanitizeStyle(value)
if (safeStyle) {
element.setAttribute('style', safeStyle)
} else {
element.removeAttribute(attribute.name)
}
continue
}
if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:)/i.test(value)) {
element.setAttribute('href', value)
element.setAttribute('rel', 'noopener noreferrer')
continue
}
if (element.tagName === 'FONT' && ['face', 'size'].includes(name) && /^[a-z0-9\s"',.-]+$/i.test(value)) {
continue
}
element.removeAttribute(attribute.name)
}
}
export function sanitizePrivacyHtml(html: string) {
if (typeof document === 'undefined') {
return hasHtmlMarkup(html) ? escapeHtml(stripPrivacyHtml(html)) : normalizePlainText(html)
}
const template = document.createElement('template')
template.innerHTML = html
const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT)
const nodes: Node[] = []
while (walker.nextNode()) {
nodes.push(walker.currentNode)
}
for (const node of nodes) {
if (node.nodeType === Node.COMMENT_NODE) {
node.parentNode?.removeChild(node)
continue
}
sanitizeElement(node as Element)
}
return template.innerHTML.trim()
}
export function stripPrivacyHtml(value: string) {
if (typeof document === 'undefined') {
return value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
const template = document.createElement('template')
template.innerHTML = value
return (template.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function privacyContentToHtml(value: string) {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
return hasHtmlMarkup(trimmed) ? sanitizePrivacyHtml(trimmed) : normalizePlainText(trimmed)
}
export function privacyContentForStorage(value: string) {
return privacyContentToHtml(value)
}
+46 -10
View File
@@ -5,7 +5,37 @@ import { useAwardsStore } from './stores/awards'
import { routes } from './router/routes'
const DEMO_GATE_FALLBACK_ENABLED = import.meta.env.VITE_DEMO_GATE_ENABLED !== 'false'
const contentAdminRoutes = new Set(['admin-content', 'admin-settings'])
const adminRoutePermissions: Record<string, string> = {
'admin-dashboard': 'dashboard',
'admin-years': 'years',
'admin-nominations': 'nominations',
'admin-categories': 'categories',
'admin-candidates': 'candidates',
'admin-clips': 'clips',
'admin-risk': 'risk',
'admin-users-logs': 'audit',
'admin-team': 'team',
'admin-analytics': 'analytics',
'admin-winners': 'winners',
'admin-content': 'content',
'admin-settings': 'settings',
}
function firstAllowedAdminRoute(permissionKeys: string[] = []) {
const preferredRoutes = [
'admin-dashboard',
'admin-nominations',
'admin-clips',
'admin-content',
'admin-analytics',
'admin-settings',
'admin-team',
]
return preferredRoutes.find((routeName) => permissionKeys.includes(adminRoutePermissions[routeName]))
?? Object.entries(adminRoutePermissions).find(([, permission]) => permissionKeys.includes(permission))?.[0]
?? 'home'
}
const router = createRouter({
history: createWebHistory(),
@@ -31,6 +61,7 @@ router.beforeEach(async (to) => {
}
const isLoginRoute = to.name === 'login'
const isTwitchCallbackRoute = to.name === 'twitch-auth-callback'
const isMaintenanceRoute = to.name === 'maintenance'
const isAdminRoute = to.path.startsWith('/admin')
const isMaintenancePreview = isMaintenanceRoute && to.query.preview === 'true'
@@ -43,7 +74,7 @@ router.beforeEach(async (to) => {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (siteStatus?.maintenanceModeEnabled && !isMaintenanceRoute && !isLoginRoute && !isAdminRoute) {
if (siteStatus?.maintenanceModeEnabled && !isMaintenanceRoute && !isLoginRoute && !isTwitchCallbackRoute && !isAdminRoute) {
return { name: 'maintenance', query: { from: to.fullPath } }
}
@@ -51,11 +82,15 @@ router.beforeEach(async (to) => {
return { name: 'home' }
}
if (demoGateEnabled && !isLoginRoute && !isMaintenanceRoute && !authStore.isLoggedIn) {
if (demoGateEnabled && !isLoginRoute && !isTwitchCallbackRoute && !isMaintenanceRoute && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (demoGateEnabled && isLoginRoute && authStore.isLoggedIn) {
if (authStore.session?.mustChangePassword && !isLoginRoute && !isTwitchCallbackRoute) {
return { name: 'login', query: { redirect: to.fullPath, change: 'password' } }
}
if (demoGateEnabled && isLoginRoute && authStore.isLoggedIn && !authStore.session?.mustChangePassword) {
const redirect = Array.isArray(to.query.redirect)
? to.query.redirect[0]
: to.query.redirect
@@ -73,12 +108,13 @@ router.beforeEach(async (to) => {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (
to.path.startsWith('/admin') &&
authStore.session?.role === 'content_admin' &&
!contentAdminRoutes.has(String(to.name ?? ''))
) {
return { name: 'admin-content' }
if (to.path.startsWith('/admin') && authStore.session) {
const routeName = String(to.name ?? '')
const neededPermission = adminRoutePermissions[routeName]
if (neededPermission && !authStore.session.permissionKeys.includes(neededPermission)) {
const fallback = firstAllowedAdminRoute(authStore.session.permissionKeys)
return fallback === 'home' ? { name: 'home' } : { name: fallback }
}
}
// Phasen-Gate: eine Aktion ist nur in ihrem aktiven Zeitfenster erreichbar.
+14
View File
@@ -65,6 +65,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminUsersLogsView.vue'),
meta: { keepAlive: true },
},
{
path: 'team',
name: 'admin-team',
component: () => import('../views/admin/AdminTeamView.vue'),
meta: { keepAlive: true },
},
{
path: 'analytics',
name: 'admin-analytics',
@@ -105,6 +111,14 @@ export const routes: RouteRecordRaw[] = [
bareShell: true,
},
},
{
path: '/auth/twitch/callback',
name: 'twitch-auth-callback',
component: () => import('../views/TwitchAuthCallbackView.vue'),
meta: {
bareShell: true,
},
},
{
path: '/status/network-error',
name: 'network-error',
+99 -6
View File
@@ -2,10 +2,32 @@ import { defineStore } from 'pinia'
import { AUTH_TOKEN_KEY, api } from '../lib/api'
import { ApiRequestError } from '../lib/http'
import type { AuthSession, DemoLoginPayload, LoginPayload } from '../types/awards'
import type {
AuthSession,
BindTeamTwitchPayload,
ChangePasswordPayload,
DemoLoginPayload,
LoginPayload,
TeamLoginPayload,
TeamTwitchLoginPayload,
TwitchAuthorizePayload,
} from '../types/awards'
const adminAccessRoles = new Set(['content_admin', 'admin', 'owner'])
const adminWorkspaceRoles = new Set(['admin', 'owner'])
const adminAccessRoles = new Set(['member', 'reviewer', 'organization_team', 'content_admin', 'admin', 'owner', 'creator'])
const contentManageRoles = new Set(['content_admin', 'admin', 'owner', 'creator'])
const adminWorkspaceRoles = new Set(['admin', 'owner', 'creator'])
const adminWorkspacePermissions = new Set([
'dashboard',
'years',
'nominations',
'categories',
'candidates',
'clips',
'risk',
'audit',
'analytics',
'winners',
])
function readStoredToken() {
if (typeof window === 'undefined') return null
@@ -32,10 +54,15 @@ export const useAuthStore = defineStore('auth', {
isLoggedIn: (state) => Boolean(state.session),
isAdmin: (state) => adminAccessRoles.has(state.session?.role ?? ''),
canAccessAdmin: (state) => adminAccessRoles.has(state.session?.role ?? ''),
canManageContent: (state) => adminAccessRoles.has(state.session?.role ?? ''),
canManageAdminWorkspace: (state) => adminWorkspaceRoles.has(state.session?.role ?? ''),
canManageOperationalSettings: (state) => state.session?.role === 'owner',
canManageContent: (state) => contentManageRoles.has(state.session?.role ?? '') || (state.session?.permissionKeys ?? []).includes('content'),
canManageAdminWorkspace: (state) => adminWorkspaceRoles.has(state.session?.role ?? '') || (state.session?.permissionKeys ?? []).some((key) => adminWorkspacePermissions.has(key)),
canManageOperationalSettings: (state) => state.session?.role === 'owner' || state.session?.role === 'creator',
canManageTeam: (state) => (state.session?.permissionKeys ?? []).includes('team'),
hasPermission: (state) => (permissionKey: string) => (state.session?.permissionKeys ?? []).includes(permissionKey),
isOwner: (state) => state.session?.role === 'owner',
isCreator: (state) => state.session?.role === 'creator',
isOwnerOrCreator: (state) => state.session?.role === 'owner' || state.session?.role === 'creator',
isTeamSession: (state) => Boolean(state.session?.teamLogin),
},
actions: {
async hydrate() {
@@ -75,6 +102,72 @@ export const useAuthStore = defineStore('auth', {
this.loading = false
}
},
async teamLogin(payload: TeamLoginPayload) {
this.loading = true
try {
const session = await api.teamLogin(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async teamTwitchLogin(payload: TeamTwitchLoginPayload) {
this.loading = true
try {
const session = await api.teamTwitchLogin(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async changePassword(payload: ChangePasswordPayload) {
this.loading = true
try {
const session = await api.changePassword(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async bindTeamTwitch(payload: BindTeamTwitchPayload) {
this.loading = true
try {
const session = await api.bindTeamTwitch(payload)
this.session = session
writeStoredToken(session.sessionToken)
} finally {
this.loading = false
}
},
async startTwitchAuthorization(payload: Omit<TwitchAuthorizePayload, 'frontendOrigin'>) {
this.loading = true
try {
const response = await api.startTwitchAuthorization({
...payload,
frontendOrigin: window.location.origin,
})
window.location.assign(response.authorizationUrl)
} finally {
this.loading = false
}
},
async completeOAuthSession(sessionToken: string) {
this.loading = true
try {
writeStoredToken(sessionToken)
this.session = await api.getSession()
this.hydrated = true
} catch (error) {
writeStoredToken(null)
this.session = null
throw error
} finally {
this.loading = false
}
},
async logout() {
this.loading = true
try {
+5 -1
View File
@@ -45,7 +45,11 @@ export const useAwardsStore = defineStore('awards', {
try {
this.overview = await api.getOverview()
this.categories = await api.getSeasonCategories(this.overview.year)
this.archive = await api.getWinnerArchive(this.overview.winnersPreview[0]?.year ?? this.overview.year - 1)
const overviewArchiveYears = this.overview.archiveYears ?? []
const initialArchiveYear = overviewArchiveYears[0]?.year
?? this.overview.winnersPreview[0]?.year
?? this.overview.year - 1
this.archive = await api.getWinnerArchive(initialArchiveYear)
this.apiMode = 'api'
} catch (error) {
this.apiMode = 'fallback'
+4
View File
@@ -29,6 +29,7 @@ export function createEmptyOverview(): OverviewResponse {
timeline: [],
featuredCategories: [],
winnersPreview: [],
archiveYears: [],
siteContent: {
hostDisplayName: '',
hostTagline: '',
@@ -116,8 +117,11 @@ export function createEmptyAdminSiteSettings(): AdminSiteSettingsResponse {
privacyPolicyUpdatedBy: null,
privacyPolicyUpdatedAt: null,
imprintUrl: '',
imprintContent: '',
contactUrl: '',
contactContent: '',
sponsorsUrl: '',
sponsorsContent: '',
socialLinks: [],
faq: [],
}
+74
View File
@@ -232,8 +232,11 @@ export interface AdminSiteSettingsResponse {
privacyPolicyUpdatedBy: string | null
privacyPolicyUpdatedAt: string | null
imprintUrl: string
imprintContent: string
contactUrl: string
contactContent: string
sponsorsUrl: string
sponsorsContent: string
socialLinks: PublicSocialLink[]
faq: FaqItem[]
}
@@ -245,7 +248,78 @@ export interface AdminOperationalSettingsResponse {
demoLoginPasswordSet: boolean
demoLoginTwitchUserId: string
demoLoginDisplayName: string
twitchAuthManagedByDatabase: boolean
twitchAuthConfigured: boolean
twitchClientId: string
twitchClientSecretSet: boolean
twitchRedirectUri: string
twitchScope: string
maintenanceModeEnabled: boolean
maintenanceTitle: string
maintenanceMessage: string
}
export interface AdminTeamPermission {
key: string
label: string
description: string
menuPath: string
readOnlySupported: boolean
}
export interface AdminTeamRole {
key: string
label: string
description: string
isSystemRole: boolean
permissionKeys: string[]
}
export interface AdminTeamMember {
id: number
login: string
displayName: string
role: string
boundTwitchUserId: string | null
boundTwitchDisplayName: string | null
isActive: boolean
mustChangePassword: boolean
createdAt: string
updatedAt: string | null
lastLoginAt: string | null
twitchBoundAt: string | null
passwordResetAt: string | null
}
export interface AdminTeamResponse {
members: AdminTeamMember[]
roles: AdminTeamRole[]
permissions: AdminTeamPermission[]
}
export interface CreateTeamMemberPayload {
login: string
displayName: string
role: string
}
export interface UpdateTeamMemberPayload {
login: string
displayName: string
role: string
isActive: boolean
}
export interface UpdateTeamRolesPayload {
roles: Array<{
key: string
permissionKeys: string[]
}>
}
export interface TeamMemberPasswordResponse {
saved: boolean
memberId: number
generatedPassword: string
mustChangePassword: boolean
}
+38 -1
View File
@@ -1,10 +1,15 @@
export type AuthRole = 'viewer' | 'content_admin' | 'admin' | 'owner'
export type AuthRole = 'viewer' | 'member' | 'reviewer' | 'organization_team' | 'content_admin' | 'admin' | 'owner' | 'creator'
export interface AuthSession {
sessionToken: string
twitchUserId: string
displayName: string
role: AuthRole
permissionKeys: string[]
mustChangePassword: boolean
teamLogin: string | null
boundTwitchUserId: string | null
boundTwitchDisplayName: string | null
}
export interface LoginPayload {
@@ -17,3 +22,35 @@ export interface DemoLoginPayload {
login: string
password: string
}
export interface TeamLoginPayload {
login: string
password: string
}
export interface TeamTwitchLoginPayload {
twitchUserId: string
displayName?: string
}
export interface ChangePasswordPayload {
currentPassword: string
newPassword: string
}
export interface BindTeamTwitchPayload {
twitchUserId: string
displayName?: string
}
export type TwitchAuthorizePurpose = 'team-login' | 'team-binding'
export interface TwitchAuthorizePayload {
purpose: TwitchAuthorizePurpose
returnUrl?: string
frontendOrigin: string
}
export interface TwitchAuthorizeResponse {
authorizationUrl: string
}
+8 -1
View File
@@ -6,7 +6,7 @@ export interface CreateNominationPayload {
twitchUserId: string
nominees?: string[]
nominations?: Array<{
name: string
name?: string
streamUrl: string
}>
}
@@ -136,8 +136,11 @@ export interface UpdateSiteSettingsPayload {
privacyEmail: string
privacyPolicyContent: string
imprintUrl: string
imprintContent: string
contactUrl: string
contactContent: string
sponsorsUrl: string
sponsorsContent: string
socialLinks: PublicSocialLink[]
faq: FaqItem[]
}
@@ -148,6 +151,10 @@ export interface UpdateOperationalSettingsPayload {
demoLoginPassword?: string
demoLoginTwitchUserId: string
demoLoginDisplayName: string
twitchClientId: string
twitchClientSecret?: string
twitchRedirectUri: string
twitchScope: string
maintenanceModeEnabled: boolean
maintenanceTitle: string
maintenanceMessage: string
+8
View File
@@ -23,6 +23,11 @@ export interface WinnerPreview {
winnerUrl: string
}
export interface ArchiveYearSummary {
year: number
winnerCount: number
}
export interface FaqItem {
question: string
answer: string
@@ -38,8 +43,10 @@ export interface PublicSocialLink {
}
export interface FooterLink {
key: string
label: string
url: string
content: string
}
export interface PublicSiteContent {
@@ -72,6 +79,7 @@ export interface OverviewResponse {
timeline: TimelineItem[]
featuredCategories: FeaturedCategory[]
winnersPreview: WinnerPreview[]
archiveYears: ArchiveYearSummary[]
siteContent: PublicSiteContent
faq: FaqItem[]
}
+241 -18
View File
@@ -2,6 +2,7 @@
import { computed, reactive, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import PasswordField from '../components/ui/PasswordField.vue'
import { useAuthStore } from '../stores/auth'
import { ApiRequestError } from '../lib/http'
@@ -14,7 +15,17 @@ const form = reactive({
password: '',
})
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const loginMode = ref<'password' | 'twitch'>('password')
const errorMessage = ref('')
const successMessage = ref('')
const passwordChangeRequired = computed(() => Boolean(authStore.session?.mustChangePassword))
const redirectTarget = computed(() => {
const redirect = Array.isArray(route.query.redirect)
@@ -30,26 +41,86 @@ const redirectTarget = computed(() => {
async function submitLogin() {
errorMessage.value = ''
successMessage.value = ''
try {
await authStore.demoLogin({
login: form.login,
password: form.password,
})
} catch (error) {
try {
await authStore.teamLogin({
login: form.login,
password: form.password,
})
} catch (teamError) {
if (error instanceof ApiRequestError && error.status === 503) {
errorMessage.value = 'Der Demo-Login ist im Backend noch nicht vollständig konfiguriert.'
return
}
if (error instanceof ApiRequestError && error.status === 404 && teamError instanceof ApiRequestError && teamError.status === 404) {
errorMessage.value = 'Der Team-Login ist auf diesem Deployment nicht aktiviert.'
return
}
errorMessage.value = 'Login oder Passwort stimmt nicht.'
return
}
}
if (authStore.session?.mustChangePassword) {
passwordForm.currentPassword = form.password
form.password = ''
successMessage.value = 'Bitte setze jetzt ein eigenes Passwort für diesen Team-Login.'
return
}
await router.replace(redirectTarget.value)
}
async function startTwitchLogin() {
errorMessage.value = ''
successMessage.value = ''
try {
await authStore.startTwitchAuthorization({
purpose: 'team-login',
returnUrl: redirectTarget.value,
})
} catch (error) {
errorMessage.value = error instanceof Error
? error.message
: 'Twitch-Login konnte nicht gestartet werden.'
}
}
async function submitPasswordChange() {
errorMessage.value = ''
successMessage.value = ''
if (passwordForm.newPassword.length < 10) {
errorMessage.value = 'Das neue Passwort muss mindestens 10 Zeichen lang sein.'
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
errorMessage.value = 'Die neuen Passwörter stimmen nicht überein.'
return
}
try {
await authStore.changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
})
await router.replace(redirectTarget.value)
} catch (error) {
if (error instanceof ApiRequestError && error.status === 404) {
errorMessage.value = 'Der Demo-Login ist auf diesem Deployment nicht aktiviert.'
return
}
if (error instanceof ApiRequestError && error.status === 503) {
errorMessage.value = 'Der Demo-Login ist im Backend noch nicht vollständig konfiguriert.'
return
}
errorMessage.value = 'Login oder Passwort stimmt nicht.'
errorMessage.value = error instanceof ApiRequestError
? error.message
: 'Das Passwort konnte nicht geändert werden.'
}
}
</script>
@@ -69,13 +140,35 @@ async function submitLogin() {
</RouterLink>
<section class="login-card" aria-labelledby="login-title">
<div class="login-card__eyebrow">Demo Zugang</div>
<h1 id="login-title">Admin Login</h1>
<div class="login-card__eyebrow">{{ passwordChangeRequired ? 'Erster Login' : 'Team Zugang' }}</div>
<h1 id="login-title">{{ passwordChangeRequired ? 'Passwort ändern' : 'Admin Login' }}</h1>
<p>
Melde dich mit den Demo-Credentials an, um das Admin-Panel für die Präsentation zu öffnen.
<template v-if="passwordChangeRequired">
Dieser Account wurde mit einem temporären Passwort erstellt. Bitte vergib jetzt ein eigenes Passwort.
</template>
<template v-else>
Melde dich mit Team-Login an oder nutze deinen gebundenen Twitch-Account.
</template>
</p>
<form class="login-form" @submit.prevent="submitLogin">
<div v-if="!passwordChangeRequired" class="login-mode-toggle" role="tablist" aria-label="Login-Art">
<button
type="button"
:class="loginMode === 'password' ? 'login-mode-toggle__button login-mode-toggle__button--active' : 'login-mode-toggle__button'"
@click="loginMode = 'password'"
>
Login
</button>
<button
type="button"
:class="loginMode === 'twitch' ? 'login-mode-toggle__button login-mode-toggle__button--active' : 'login-mode-toggle__button'"
@click="loginMode = 'twitch'"
>
Twitch
</button>
</div>
<form v-if="!passwordChangeRequired && loginMode === 'password'" class="login-form" @submit.prevent="submitLogin">
<label>
<span>Login</span>
<input
@@ -89,25 +182,99 @@ async function submitLogin() {
<label>
<span>Passwort</span>
<input
<PasswordField
v-model="form.password"
type="password"
variant="login"
autocomplete="current-password"
required
placeholder="Demo-Passwort eingeben"
>
/>
</label>
<p v-if="errorMessage" class="login-form__error" role="alert">
{{ errorMessage }}
</p>
<p v-if="successMessage" class="login-form__success" role="status">
{{ successMessage }}
</p>
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
<span v-if="authStore.loading">Sternentor öffnet...</span>
<span v-else>Admin Panel öffnen</span>
</button>
</form>
<form v-else-if="!passwordChangeRequired" class="login-form" @submit.prevent="startTwitchLogin">
<div class="login-form__oauth-note">
<strong>Offizieller Twitch Login</strong>
<span>Du wirst zu Twitch weitergeleitet und danach automatisch zurück ins Admin Panel gebracht.</span>
</div>
<p v-if="errorMessage" class="login-form__error" role="alert">
{{ errorMessage }}
</p>
<p v-if="successMessage" class="login-form__success" role="status">
{{ successMessage }}
</p>
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
<span v-if="authStore.loading">Twitch wird geöffnet...</span>
<span v-else>Mit Twitch offiziell anmelden</span>
</button>
</form>
<form v-else class="login-form" @submit.prevent="submitPasswordChange">
<label>
<span>Aktuelles Passwort</span>
<PasswordField
v-model="passwordForm.currentPassword"
variant="login"
autocomplete="current-password"
required
placeholder="Temporäres Passwort"
/>
</label>
<label>
<span>Neues Passwort</span>
<PasswordField
v-model="passwordForm.newPassword"
variant="login"
autocomplete="new-password"
required
minlength="10"
placeholder="Mindestens 10 Zeichen"
/>
</label>
<label>
<span>Passwort wiederholen</span>
<PasswordField
v-model="passwordForm.confirmPassword"
variant="login"
autocomplete="new-password"
required
minlength="10"
placeholder="Neues Passwort bestätigen"
/>
</label>
<p v-if="errorMessage" class="login-form__error" role="alert">
{{ errorMessage }}
</p>
<p v-if="successMessage" class="login-form__success" role="status">
{{ successMessage }}
</p>
<button class="login-form__submit" type="submit" :disabled="authStore.loading">
<span v-if="authStore.loading">Passwort wird gespeichert...</span>
<span v-else>Passwort speichern</span>
</button>
</form>
<div class="login-card__footer">
<RouterLink to="/">Zurück zur Landingpage</RouterLink>
<span>Demo-Modus</span>
@@ -253,6 +420,35 @@ async function submitLogin() {
margin-top: 30px;
}
.login-mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin: 24px 0 18px;
padding: 6px;
border: 1px solid rgba(124, 86, 196, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.65);
}
.login-mode-toggle__button {
min-height: 42px;
border: 0;
border-radius: 11px;
background: transparent;
color: #7c7192;
font-family: 'Outfit', sans-serif;
font-size: 14px;
font-weight: 800;
cursor: pointer;
}
.login-mode-toggle__button--active {
background: #6f4fd1;
color: #fff;
box-shadow: 0 10px 22px rgba(111, 79, 209, 0.24);
}
.login-form label {
display: grid;
gap: 9px;
@@ -283,6 +479,23 @@ async function submitLogin() {
transform: translateY(-1px);
}
.login-form__oauth-note {
display: grid;
gap: 6px;
padding: 16px;
border: 1px solid rgba(145, 70, 255, 0.16);
border-radius: 18px;
background: rgba(248, 245, 255, 0.88);
color: #6f6685;
font-size: 14px;
line-height: 1.5;
}
.login-form__oauth-note strong {
color: #3f3556;
font-size: 15px;
}
.login-form__error {
padding: 13px 15px;
border: 1px solid rgba(225, 29, 72, 0.18);
@@ -293,6 +506,16 @@ async function submitLogin() {
font-weight: 800;
}
.login-form__success {
padding: 13px 15px;
border: 1px solid rgba(22, 163, 74, 0.18);
border-radius: 18px;
background: rgba(240, 253, 244, 0.9);
color: #15803d !important;
font-size: 14px !important;
font-weight: 800;
}
.login-form__submit {
min-height: 58px;
border: 0;
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const errorMessage = ref('')
const statusMessage = ref('Twitch Login wird abgeschlossen...')
const hasError = computed(() => Boolean(errorMessage.value))
onMounted(async () => {
const params = readCallbackParams()
const returnUrl = normalizeReturnUrl(params.get('returnUrl'))
const status = params.get('status')
const sessionToken = params.get('sessionToken')
const message = params.get('message')
window.history.replaceState(null, '', '/auth/twitch/callback')
if (status === 'error') {
errorMessage.value = message || 'Twitch Login konnte nicht abgeschlossen werden.'
return
}
try {
if (sessionToken) {
await authStore.completeOAuthSession(sessionToken)
} else if (!authStore.hydrated) {
await authStore.hydrate()
}
if (authStore.session?.mustChangePassword) {
await router.replace({ name: 'login', query: { redirect: returnUrl, change: 'password' } })
return
}
statusMessage.value = status === 'connected'
? 'Twitch Account wurde verbunden.'
: 'Twitch Login war erfolgreich.'
await router.replace(returnUrl)
} catch (error) {
errorMessage.value = error instanceof Error
? error.message
: 'Twitch Login konnte nicht abgeschlossen werden.'
}
})
function readCallbackParams() {
const hash = window.location.hash.startsWith('#')
? window.location.hash.slice(1)
: window.location.hash
return new URLSearchParams(hash || window.location.search.replace(/^\?/, ''))
}
function normalizeReturnUrl(value: string | null) {
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/auth/twitch/callback')) {
return '/admin'
}
return value
}
</script>
<template>
<main class="twitch-callback-page">
<section class="twitch-callback-card" aria-live="polite">
<div class="twitch-callback-card__icon" aria-hidden="true">
<svg width="28" height="28" 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>
</div>
<p class="twitch-callback-card__eyebrow">Twitch</p>
<h1>{{ hasError ? 'Login fehlgeschlagen' : 'Login wird bestätigt' }}</h1>
<p>{{ hasError ? errorMessage : statusMessage }}</p>
<RouterLink v-if="hasError" class="twitch-callback-card__link" to="/login">
Zur Login-Maske
</RouterLink>
</section>
</main>
</template>
<style scoped>
.twitch-callback-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(145, 70, 255, 0.16), transparent 30%),
radial-gradient(circle at 80% 20%, rgba(247, 108, 173, 0.14), transparent 32%),
linear-gradient(145deg, #fbf6ff 0%, #f4edff 58%, #fff8f1 100%);
color: #3f3556;
font-family: 'Outfit', sans-serif;
}
.twitch-callback-card {
width: min(100%, 430px);
display: grid;
gap: 12px;
justify-items: center;
padding: 34px 28px;
border: 1px solid #ede4fb;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 28px 70px rgba(80, 48, 132, 0.18);
text-align: center;
}
.twitch-callback-card__icon {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border-radius: 18px;
background: #9146ff;
color: #fff;
}
.twitch-callback-card__eyebrow {
margin: 8px 0 0;
color: #8b6cdb;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.twitch-callback-card h1 {
margin: 0;
font-family: 'Cormorant Garamond', serif;
font-size: 32px;
line-height: 1.05;
}
.twitch-callback-card p {
margin: 0;
color: #6f6685;
font-size: 15px;
line-height: 1.55;
}
.twitch-callback-card__link {
margin-top: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
background: #6f4fd1;
color: #fff;
font-weight: 800;
text-decoration: none;
}
</style>
+48 -3
View File
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { FileText } from '@lucide/vue'
import { computed, ref } from 'vue'
import AdminContentBasicsSection from '../../components/admin/AdminContentBasicsSection.vue'
import AdminContentFaqSection from '../../components/admin/AdminContentFaqSection.vue'
import AdminContentFooterPreviewModal from '../../components/admin/AdminContentFooterPreviewModal.vue'
import AdminContentLinksSection from '../../components/admin/AdminContentLinksSection.vue'
import type { FooterPreviewKey } from '../../components/admin/AdminContentLinksSection.vue'
import AdminContentPrivacyPreviewModal from '../../components/admin/AdminContentPrivacyPreviewModal.vue'
import AdminContentPrivacySection from '../../components/admin/AdminContentPrivacySection.vue'
import AdminContentSectionNav from '../../components/admin/AdminContentSectionNav.vue'
import AdminContentSocialLinksSection from '../../components/admin/AdminContentSocialLinksSection.vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { privacyContentToHtml } from '../../lib/privacyContent'
import { useAdminContentManager } from '../../components/admin/useAdminContentManager'
const sectionLinks = [
@@ -26,7 +30,7 @@ const {
saveError,
privacyPreviewOpen,
iconUploadError,
privacyPreviewBlocks,
privacyPreviewHtml,
privacyUpdatedLabel,
addSocialLink,
removeSocialLink,
@@ -44,6 +48,35 @@ const {
saveSiteSettings,
adminSiteSettings,
} = useAdminContentManager()
const footerPreviewOpen = ref(false)
const footerPreviewKey = ref<FooterPreviewKey>('imprint')
const footerPreviewPages = computed<Record<FooterPreviewKey, { title: string, url: string, content: string }>>(() => ({
imprint: {
title: 'Impressum',
url: form.imprintUrl,
content: form.imprintContent,
},
contact: {
title: 'Kontakt',
url: form.contactUrl,
content: form.contactContent,
},
sponsors: {
title: 'Sponsoren & Partner',
url: form.sponsorsUrl,
content: form.sponsorsContent,
},
}))
const activeFooterPreview = computed(() => footerPreviewPages.value[footerPreviewKey.value])
const activeFooterPreviewHtml = computed(() => privacyContentToHtml(activeFooterPreview.value.content))
function openFooterPreview(key: FooterPreviewKey) {
footerPreviewKey.value = key
footerPreviewOpen.value = true
}
</script>
<template>
@@ -66,7 +99,12 @@ const {
<div class="space-y-6">
<AdminContentBasicsSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentLinksSection :form="form" :saving="saving" :save-site-settings="saveSiteSettings" />
<AdminContentLinksSection
:form="form"
:saving="saving"
:save-site-settings="saveSiteSettings"
@open-preview="openFooterPreview"
/>
<AdminContentSocialLinksSection
:form="form"
:saving="saving"
@@ -104,9 +142,16 @@ const {
<AdminContentPrivacyPreviewModal
:open="privacyPreviewOpen"
:blocks="privacyPreviewBlocks"
:content-html="privacyPreviewHtml"
:updated-label="privacyUpdatedLabel"
@close="privacyPreviewOpen = false"
/>
<AdminContentFooterPreviewModal
:open="footerPreviewOpen"
:title="activeFooterPreview.title"
:url="activeFooterPreview.url"
:content-html="activeFooterPreviewHtml"
@close="footerPreviewOpen = false"
/>
</div>
</template>
+17 -20
View File
@@ -12,6 +12,7 @@ import {
Settings,
Tags,
Trophy,
UserCog,
Users,
FileText,
} from '@lucide/vue'
@@ -33,47 +34,43 @@ const fullNavGroups = computed(() => [
{
label: 'Betrieb',
items: [
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, badge: () => null },
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
{ label: 'Dashboard', to: '/admin/dashboard', description: 'Live-Lage und Aufgaben', icon: LayoutDashboard, permission: 'dashboard', badge: () => null },
{ label: 'Nominierungen', to: '/admin/nominations', description: 'Eingang und Backlog', icon: ClipboardList, permission: 'nominations', badge: () => `${store.adminSeasonDetail.pendingNominations.length}` },
],
},
{
label: 'Inhalte',
items: [
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, badge: () => null },
{ label: 'Jahre', to: '/admin/years', description: 'Jahresstatus und Setup', icon: CalendarCog, permission: 'years', badge: () => `${store.adminSeasons.length}` },
{ label: 'Kategorien', to: '/admin/categories', description: 'Struktur und Limits', icon: Tags, permission: 'categories', badge: () => `${store.adminSeasonDetail.categories.length}` },
{ label: 'Kandidaten', to: '/admin/candidates', description: 'Kandidatenbasis pflegen', icon: Users, permission: 'candidates', badge: () => `${store.adminSeasonDetail.candidates.length}` },
{ label: 'Clips', to: '/admin/clips', description: 'Clip-Einreichungen prüfen', icon: Film, permission: 'clips', badge: () => `${pendingClipCount.value}` },
{ label: 'Landingpage', to: '/admin/content', description: 'FAQ, Footer und Datenschutz', icon: FileText, permission: 'content', badge: () => null },
],
},
{
label: 'Kontrolle',
items: [
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, badge: () => `${openRiskCount.value}` },
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, badge: () => null },
{ label: 'Risiko', to: '/admin/risk', description: 'Flags entscheiden', icon: AlertTriangle, permission: 'risk', badge: () => `${openRiskCount.value}` },
{ label: 'Team', to: '/admin/team', description: 'Logins und Rollen', icon: UserCog, permission: 'team', badge: () => null },
{ label: 'Audit-Log', to: '/admin/users-logs', description: 'Admin-Aktionen', icon: FileClock, permission: 'audit', badge: () => null },
],
},
{
label: 'Auswertung',
items: [
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, badge: () => null },
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, badge: () => `${store.adminSeasonDetail.results.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, badge: () => null },
{ label: 'Analytics', to: '/admin/analytics', description: 'Metriken und Rankings', icon: BarChart3, permission: 'analytics', badge: () => null },
{ label: 'Gewinner', to: '/admin/winners', description: 'Finale Ergebnisse freigeben', icon: Trophy, permission: 'winners', badge: () => `${store.adminSeasonDetail.results.length}` },
{ label: 'Einstellungen', to: '/admin/settings', description: 'Systemchecks und Status', icon: Settings, permission: 'settings', badge: () => null },
],
},
])
const navGroups = computed(() => {
if (authStore.canManageAdminWorkspace) {
return fullNavGroups.value
}
const allowedRoutes = new Set(['/admin/content', '/admin/settings'])
return fullNavGroups.value
.map((group) => ({
...group,
items: group.items.filter((item) => allowedRoutes.has(item.to)),
items: group.items.filter((item) => !item.permission || authStore.hasPermission(item.permission)),
}))
.filter((group) => group.items.length > 0)
})
@@ -97,10 +94,10 @@ async function ensureAdminWorkspace() {
try {
if (authStore.canManageAdminWorkspace) {
await store.initializeAdminWorkspace()
} else {
} else if (authStore.hasPermission('content') || authStore.hasPermission('settings')) {
await store.loadAdminContentWorkspace()
}
adminWorkspaceLoaded.value = store.apiMode === 'api'
adminWorkspaceLoaded.value = true
} finally {
adminWorkspaceLoading.value = false
}
@@ -31,7 +31,7 @@ const filteredNominations = computed(() => {
(statusFilter.value === 'selected' && !!categoryFilter.value) ||
(statusFilter.value === 'empty-category' && candidateCount === 0) ||
(statusFilter.value === 'heavy' && heavyCategoryIds.has(nomination.categoryId))
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId]
const matchesSearch = !search || [nomination.categoryName, nomination.candidateText, nomination.submittedByTwitchId, nomination.streamUrl]
.join(' ')
.toLowerCase()
.includes(search)
@@ -176,7 +176,7 @@ watch(
<input
v-model="query"
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="Nach Kandidat, User oder Kategorie suchen"
placeholder="Nach Link, User oder Kategorie suchen"
/>
</label>
<button
@@ -206,7 +206,7 @@ watch(
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-violet-500">{{ nomination.categoryName }}</p>
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText }}</h3>
<h3 class="mt-1 truncate text-lg font-semibold text-slate-900">{{ nomination.candidateText || nomination.streamUrl || 'Name im Review festlegen' }}</h3>
<p class="mt-1 text-sm text-slate-500">
{{ nomination.submittedByTwitchId }} · {{ new Date(nomination.createdAt).toLocaleString('de-DE') }}
</p>
@@ -38,6 +38,7 @@ const {
latestSeasonAuditMeta,
canCreate,
activatePhase,
activatePublicSeason,
openCreateModal,
saveSeason,
completeSeason,
@@ -83,6 +84,7 @@ const {
:can-complete-selected-season="canCompleteSelectedSeason"
:selected-season-is-current="selectedSeason?.isCurrent ?? false"
:open-delete-season-modal="openDeleteSeasonModal"
:activate-public-season="activatePublicSeason"
:save-season="saveSeason"
:complete-season="completeSeason"
/>
@@ -37,8 +37,14 @@ const {
demoPasswordSet,
demoManagedByDatabase,
demoPasswordInput,
twitchClientSecretSet,
twitchAuthConfigured,
twitchAuthManagedByDatabase,
twitchClientSecretInput,
demoPasswordHint,
twitchSecretHint,
demoCredentialsComplete,
twitchAuthComplete,
operationalSummary,
hasUnsavedOperationalChanges,
saveOperationalSettings,
@@ -82,6 +88,7 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
<AdminOperationalSettingsCard
v-model:demo-password="demoPasswordInput"
v-model:twitch-client-secret="twitchClientSecretInput"
:form="operationalForm"
:loading="operationalLoading"
:saving="operationalSaving"
@@ -91,6 +98,11 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
:demo-password-set="demoPasswordSet"
:demo-managed-by-database="demoManagedByDatabase"
:demo-credentials-complete="demoCredentialsComplete"
:twitch-secret-hint="twitchSecretHint"
:twitch-client-secret-set="twitchClientSecretSet"
:twitch-auth-configured="twitchAuthConfigured"
:twitch-auth-managed-by-database="twitchAuthManagedByDatabase"
:twitch-auth-complete="twitchAuthComplete"
:summary="operationalSummary"
:dirty="hasUnsavedOperationalChanges"
:can-manage="canManageOperationalSettings"
+535
View File
@@ -0,0 +1,535 @@
<script setup lang="ts">
import { KeyRound, LockKeyhole, RotateCcw, Save, ShieldCheck, Trash2, UserPlus, Users } from '@lucide/vue'
import { computed, reactive, ref } from 'vue'
import AdminPageHeader from '../../components/admin/AdminPageHeader.vue'
import { useAdminTeamManager } from '../../components/admin/useAdminTeamManager'
import Button from '../../components/ui/Button.vue'
import Card from '../../components/ui/Card.vue'
import PasswordField from '../../components/ui/PasswordField.vue'
import { useAuthStore } from '../../stores/auth'
import type { AdminTeamMember } from '../../types/awards'
const authStore = useAuthStore()
const canResetPasswords = computed(() => authStore.isOwnerOrCreator)
const canDeleteMembers = computed(() => authStore.isOwnerOrCreator)
const canEditMemberLogin = computed(() => authStore.isOwnerOrCreator)
const canChangeOwnPassword = computed(() => authStore.isOwnerOrCreator && authStore.isTeamSession)
const passwordModalOpen = ref(false)
const createMemberModalOpen = ref(false)
const editMemberModalOpen = ref(false)
const profileError = ref('')
const profileSuccess = ref('')
const ownPasswordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const {
loading,
saving,
errorMessage,
successMessage,
generatedPassword,
generatedPasswordLogin,
confirmDeleteMemberId,
members,
roles,
permissions,
memberForm,
selectedMember,
activeMembers,
pendingPasswordChanges,
roleOptions,
hasRoleChanges,
startCreateMember,
startEditMember,
saveMember,
resetMemberPassword,
requestDeleteMember,
cancelDeleteMember,
deleteMember,
roleHasPermission,
setRolePermission,
saveRolePermissions,
} = useAdminTeamManager()
const memberPendingDeletion = computed(() =>
members.value.find((member) => member.id === confirmDeleteMemberId.value) ?? null,
)
const summaryItems = computed(() => [
{ label: 'Mitglieder', value: members.value.length, note: `${activeMembers.value} aktiv` },
{ label: 'Rollen', value: roles.value.length, note: 'Owner, Creator und Team' },
{ label: 'PW-Wechsel', value: pendingPasswordChanges.value, note: 'temporäre Passwörter offen' },
])
function roleLabel(roleKey: string) {
return roles.value.find((role) => role.key === roleKey)?.label ?? roleKey
}
function formatDate(value: string | null) {
if (!value) return 'Nie'
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value))
}
function memberStatus(member: AdminTeamMember) {
if (!member.isActive) return 'Inaktiv'
return member.mustChangePassword ? 'PW-Wechsel offen' : 'Aktiv'
}
function isOwnTeamAccount(member: AdminTeamMember) {
const teamLogin = authStore.session?.teamLogin?.toLowerCase()
const boundTwitchUserId = authStore.session?.boundTwitchUserId?.toLowerCase()
return Boolean(teamLogin && member.login.toLowerCase() === teamLogin)
|| Boolean(boundTwitchUserId && member.boundTwitchUserId?.toLowerCase() === boundTwitchUserId)
}
function canDeleteMember(member: AdminTeamMember) {
if (!canDeleteMembers.value) return false
if (member.role === 'creator') {
return isOwnTeamAccount(member)
}
if (member.role === 'owner') {
return authStore.isCreator || isOwnTeamAccount(member)
}
return true
}
function handlePermissionChange(roleKey: string, permissionKey: string, event: Event) {
setRolePermission(roleKey, permissionKey, event.target instanceof HTMLInputElement && event.target.checked)
}
function openCreateMemberModal() {
startCreateMember()
createMemberModalOpen.value = true
editMemberModalOpen.value = false
}
function openEditMemberModal(member: AdminTeamMember) {
startEditMember(member)
editMemberModalOpen.value = true
createMemberModalOpen.value = false
}
function closeMemberModals() {
createMemberModalOpen.value = false
editMemberModalOpen.value = false
startCreateMember()
}
async function submitMemberForm() {
const saved = await saveMember()
if (saved) {
createMemberModalOpen.value = false
editMemberModalOpen.value = false
}
}
function openDeleteMemberModal(member: AdminTeamMember) {
requestDeleteMember(member)
}
async function confirmDeleteMember() {
if (!memberPendingDeletion.value) return
await deleteMember(memberPendingDeletion.value)
}
function openPasswordModal() {
profileError.value = ''
profileSuccess.value = ''
ownPasswordForm.currentPassword = ''
ownPasswordForm.newPassword = ''
ownPasswordForm.confirmPassword = ''
passwordModalOpen.value = true
}
async function changeOwnPassword() {
profileError.value = ''
profileSuccess.value = ''
if (ownPasswordForm.newPassword.length < 10) {
profileError.value = 'Das neue Passwort muss mindestens 10 Zeichen lang sein.'
return
}
if (ownPasswordForm.newPassword !== ownPasswordForm.confirmPassword) {
profileError.value = 'Die neuen Passwörter stimmen nicht überein.'
return
}
try {
await authStore.changePassword({
currentPassword: ownPasswordForm.currentPassword,
newPassword: ownPasswordForm.newPassword,
})
profileSuccess.value = 'Dein Passwort wurde geändert.'
passwordModalOpen.value = false
} catch (error) {
profileError.value = error instanceof Error
? error.message
: 'Passwort konnte nicht geändert werden.'
}
}
</script>
<template>
<div class="space-y-6">
<AdminPageHeader
eyebrow="Team"
description="Mitglieder-Logins, Rollen und Berechtigungen lokal verwalten."
:icon="Users"
/>
<div class="grid gap-3 md:grid-cols-3">
<Card
v-for="item in summaryItems"
:key="item.label"
class="p-4"
>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-slate-400">{{ item.label }}</p>
<div class="mt-2 flex items-end justify-between gap-3">
<strong class="text-3xl leading-none text-violet-800">{{ item.value }}</strong>
<span class="text-xs font-semibold text-slate-500">{{ item.note }}</span>
</div>
</Card>
</div>
<Card v-if="authStore.isOwnerOrCreator" class="p-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<LockKeyhole class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Eigener Zugang</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Owner/Creator Passwort</h2>
<p class="mt-1 text-sm text-slate-500">
{{ canChangeOwnPassword ? 'Ändere dein eigenes Team-Passwort jederzeit selbst.' : 'Demo-Owner-Passwörter werden weiter in den Einstellungen gepflegt.' }}
</p>
</div>
</div>
<Button type="button" :disabled="!canChangeOwnPassword || authStore.loading" @click="openPasswordModal">
<KeyRound class="mr-2 h-4 w-4" />
Passwort ändern
</Button>
</div>
<div v-if="profileError" class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ profileError }}
</div>
<div v-if="profileSuccess" class="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{{ profileSuccess }}
</div>
</Card>
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{{ successMessage }}
</div>
<Card v-if="generatedPassword" class="border-amber-200 bg-amber-50/80 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-amber-600">Temporäres Passwort</p>
<p class="mt-1 text-sm text-amber-900">
Für <strong>{{ generatedPasswordLogin }}</strong>. Beim ersten Login wird ein neues Passwort verlangt.
</p>
</div>
<code class="rounded-lg border border-amber-200 bg-white px-3 py-2 text-sm font-bold text-amber-900">{{ generatedPassword }}</code>
</div>
</Card>
<Card class="p-4 md:p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Mitglieder</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">Logins und Status</h2>
</div>
<Button size="sm" type="button" @click="openCreateMemberModal">
<UserPlus class="mr-2 h-4 w-4" />
Neuer Login
</Button>
</div>
<div v-if="loading" class="mt-5 rounded-xl border border-dashed border-violet-200 p-4 text-sm text-slate-500">
Team wird geladen...
</div>
<div v-else class="mt-5 overflow-x-auto rounded-xl border border-violet-100">
<table class="min-w-[1040px] divide-y divide-violet-100 text-sm">
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
<tr>
<th class="px-5 py-3">Login</th>
<th class="px-5 py-3">Rolle</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3">Letzter Login</th>
<th class="px-5 py-3 text-right">Aktion</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white/70">
<tr
v-for="member in members"
:key="member.id"
class="align-middle"
:class="selectedMember?.id === member.id ? 'bg-violet-50/70' : ''"
>
<td class="px-5 py-4">
<button class="text-left transition hover:text-violet-700" type="button" @click="openEditMemberModal(member)">
<span class="block font-bold text-slate-900">{{ member.displayName }}</span>
<span class="text-xs text-slate-500">@{{ member.login }}</span>
</button>
</td>
<td class="px-5 py-4 font-semibold text-slate-600">{{ roleLabel(member.role) }}</td>
<td class="px-5 py-4">
<span class="rounded-full border px-3 py-1 text-xs font-bold" :class="member.isActive ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-slate-50 text-slate-500'">
{{ memberStatus(member) }}
</span>
</td>
<td class="px-5 py-4 text-sm font-semibold text-slate-500">{{ formatDate(member.lastLoginAt) }}</td>
<td class="px-5 py-4 text-right">
<div class="flex flex-wrap items-center justify-end gap-5">
<button
type="button"
class="text-sm font-bold text-slate-700 transition hover:text-violet-700 disabled:cursor-not-allowed disabled:text-slate-300"
:disabled="saving"
@click="openEditMemberModal(member)"
>
Bearbeiten
</button>
<Button
type="button"
variant="secondary"
size="sm"
:disabled="!canResetPasswords || saving"
@click="resetMemberPassword(member)"
>
<RotateCcw class="mr-2 h-4 w-4" />
Reset
</Button>
<button
type="button"
class="inline-flex items-center gap-2 text-sm font-bold text-slate-700 transition hover:text-rose-600 disabled:cursor-not-allowed disabled:text-slate-300"
:disabled="saving || !canDeleteMember(member)"
@click="openDeleteMemberModal(member)"
>
<Trash2 class="h-4 w-4" />
Löschen
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
<Card class="p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<ShieldCheck class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Rollenrechte</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Berechtigungen einstellen</h2>
</div>
</div>
<Button type="button" :disabled="saving || !hasRoleChanges" @click="saveRolePermissions">
<Save class="mr-2 h-4 w-4" />
Speicherbutton
</Button>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-violet-100">
<table class="min-w-[920px] divide-y divide-violet-100 text-sm">
<thead class="bg-violet-50/70 text-left text-[10px] font-bold uppercase tracking-[0.16em] text-violet-500">
<tr>
<th class="sticky left-0 z-10 bg-violet-50/95 px-3 py-2">Menüpunkt</th>
<th v-for="role in roles" :key="role.key" class="px-3 py-2 text-center">
{{ role.label }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-violet-100 bg-white/70">
<tr v-for="permission in permissions" :key="permission.key">
<td class="sticky left-0 z-10 max-w-[260px] bg-white px-3 py-3">
<span class="block font-bold text-slate-900">{{ permission.label }}</span>
<span class="block text-xs leading-5 text-slate-500">{{ permission.description }}</span>
</td>
<td v-for="role in roles" :key="`${role.key}-${permission.key}`" class="px-3 py-3 text-center">
<input
class="h-4 w-4 accent-violet-600 disabled:opacity-60"
type="checkbox"
:checked="roleHasPermission(role.key, permission.key)"
:disabled="role.key === 'owner' || role.key === 'creator'"
:aria-label="`${role.label}: ${permission.label}`"
@change="handlePermissionChange(role.key, permission.key, $event)"
>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
<Teleport to="body">
<div
v-if="createMemberModalOpen || editMemberModalOpen"
class="fixed inset-0 z-[310] flex items-center justify-center bg-slate-950/45 p-5 backdrop-blur-sm"
@click.self="closeMemberModals"
>
<Card class="w-full max-w-xl p-5 md:p-6">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<span class="grid h-12 w-12 place-items-center rounded-2xl bg-violet-50 text-violet-600">
<KeyRound class="h-5 w-5" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">
{{ createMemberModalOpen ? 'Login erstellen' : 'Profil bearbeiten' }}
</p>
<h2 class="mt-1 text-2xl font-bold text-slate-900">
{{ createMemberModalOpen ? 'Neues Mitglied' : memberForm.displayName }}
</h2>
</div>
</div>
<Button type="button" variant="ghost" size="sm" @click="closeMemberModals">Schließen</Button>
</div>
<form class="mt-6 grid gap-5" @submit.prevent="submitMemberForm">
<label class="grid gap-2 text-base font-bold text-slate-600">
Login
<input
v-model.trim="memberForm.login"
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100 disabled:bg-slate-50 disabled:text-slate-400"
type="text"
autocomplete="username"
:disabled="editMemberModalOpen && !canEditMemberLogin"
placeholder="z.B. reviewer_mina"
>
</label>
<label class="grid gap-2 text-base font-bold text-slate-600">
Anzeigename
<input
v-model.trim="memberForm.displayName"
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
type="text"
autocomplete="name"
placeholder="Name im Admin-Panel"
>
</label>
<label class="grid gap-2 text-base font-bold text-slate-600">
Rolle
<select
v-model="memberForm.role"
class="h-12 rounded-2xl border border-violet-100 bg-white px-4 text-base font-bold text-slate-900 outline-none transition focus:border-violet-300 focus:ring-4 focus:ring-violet-100"
>
<option v-for="role in roleOptions" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
</label>
<label v-if="editMemberModalOpen" class="flex h-14 items-center justify-between gap-3 rounded-2xl border border-violet-100 bg-violet-50/60 px-4 text-base font-bold text-slate-700">
Account aktiv
<input v-model="memberForm.isActive" type="checkbox" class="h-5 w-5 accent-violet-600">
</label>
<div v-if="errorMessage" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ errorMessage }}
</div>
<Button type="submit" :disabled="saving" class="h-14 text-base">
<Save class="mr-2 h-5 w-5" />
{{ saving ? 'Speichert...' : createMemberModalOpen ? 'Login erstellen' : 'Speichern' }}
</Button>
</form>
</Card>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="memberPendingDeletion"
class="fixed inset-0 z-[315] flex items-center justify-center bg-slate-950/45 p-5 backdrop-blur-sm"
@click.self="cancelDeleteMember"
>
<Card class="w-full max-w-md p-5">
<div class="flex items-start gap-3">
<span class="grid h-10 w-10 place-items-center rounded-2xl bg-rose-50 text-rose-600">
<Trash2 class="h-5 w-5" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-rose-500">Account löschen</p>
<h2 class="mt-1 text-xl font-bold text-slate-900">{{ memberPendingDeletion.displayName }}</h2>
<p class="mt-2 text-sm font-semibold leading-6 text-slate-500">
Dieser Team-Login wird entfernt. Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap justify-end gap-3">
<Button type="button" variant="ghost" :disabled="saving" @click="cancelDeleteMember">Abbrechen</Button>
<Button type="button" variant="secondary" :disabled="saving" @click="confirmDeleteMember">
<Trash2 class="mr-2 h-4 w-4" />
{{ saving ? 'Löscht...' : 'Löschen' }}
</Button>
</div>
</Card>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="passwordModalOpen"
class="fixed inset-0 z-[320] flex items-center justify-center bg-slate-950/45 p-5 backdrop-blur-sm"
@click.self="passwordModalOpen = false"
>
<Card class="w-full max-w-md p-5">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-violet-50 text-violet-600">
<KeyRound class="h-4 w-4" />
</span>
<div>
<p class="text-[10px] font-bold uppercase tracking-[0.18em] text-violet-500">Passwort</p>
<h2 class="mt-1 text-lg font-bold text-slate-900">Eigenes Passwort ändern</h2>
</div>
</div>
<Button type="button" variant="ghost" size="sm" @click="passwordModalOpen = false">Schließen</Button>
</div>
<form class="mt-5 grid gap-4" @submit.prevent="changeOwnPassword">
<label class="grid gap-1.5 text-sm font-semibold text-slate-600">
Aktuelles Passwort
<PasswordField v-model="ownPasswordForm.currentPassword" autocomplete="current-password" required input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
</label>
<label class="grid gap-1.5 text-sm font-semibold text-slate-600">
Neues Passwort
<PasswordField v-model="ownPasswordForm.newPassword" autocomplete="new-password" required minlength="10" input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
</label>
<label class="grid gap-1.5 text-sm font-semibold text-slate-600">
Passwort wiederholen
<PasswordField v-model="ownPasswordForm.confirmPassword" autocomplete="new-password" required minlength="10" input-class="rounded-xl border border-violet-100 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-violet-300 focus:ring-2 focus:ring-violet-100" />
</label>
<div v-if="profileError" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
{{ profileError }}
</div>
<Button type="submit" :disabled="authStore.loading">
<Save class="mr-2 h-4 w-4" />
{{ authStore.loading ? 'Speichert...' : 'Passwort speichern' }}
</Button>
</form>
</Card>
</div>
</Teleport>
</div>
</template>