diff --git a/frontend/src/components/Header.css b/frontend/src/components/Header.css index 4abc5a1..0de382b 100644 --- a/frontend/src/components/Header.css +++ b/frontend/src/components/Header.css @@ -57,8 +57,12 @@ @media (max-width: 600px) { .app-header { - flex-direction: column; - gap: 1rem; padding: 1rem; } + .header-brand { + gap: 0.5rem; + } + .header-title { + font-size: 1.1rem; + } } diff --git a/frontend/src/components/SlideToContinue.css b/frontend/src/components/SlideToContinue.css new file mode 100644 index 0000000..4d2e484 --- /dev/null +++ b/frontend/src/components/SlideToContinue.css @@ -0,0 +1,66 @@ +.slide-control { + position: relative; + height: 78px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.09); + background: rgba(8, 11, 16, 0.88); + padding: 4px; + user-select: none; + touch-action: none; + cursor: pointer; + overflow: hidden; + max-width: 400px; + width: 100%; + margin: 0 auto; +} + +.slide-control::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, rgba(22, 193, 231, 0.26), transparent 44%); + opacity: 0.6; + pointer-events: none; +} + +.slide-control.is-dragging { + border-color: rgba(255, 255, 255, 0.2); +} + +.slide-label, +.slide-hint { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; +} + +.slide-label { + font-size: 1.15rem; + letter-spacing: 0.015em; + color: rgba(248, 251, 255, 0.86); +} + +.slide-hint { + margin-top: 46px; + font-size: 0.77rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgba(190, 210, 225, 0.57); +} + +.slide-knob { + position: absolute; + top: 4px; + left: 4px; + width: 68px; + height: 68px; + border-radius: 50%; + display: grid; + place-items: center; + color: #04070b; + background: linear-gradient(180deg, #fff 0%, #e8ecf0 100%); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); + transition: transform 160ms ease; +} diff --git a/frontend/src/components/SlideToContinue.tsx b/frontend/src/components/SlideToContinue.tsx new file mode 100644 index 0000000..5bdab26 --- /dev/null +++ b/frontend/src/components/SlideToContinue.tsx @@ -0,0 +1,87 @@ +import { ArrowRight } from 'lucide-react'; +import { useRef, useState, type PointerEvent } from 'react'; +import './SlideToContinue.css'; + +interface SlideToContinueProps { + label: string; + onComplete: () => void; +} + +const KNOB_SIZE = 68; +const TRACK_PADDING = 4; +const COMPLETE_THRESHOLD = 0.84; + +export function SlideToContinue({ label, onComplete }: SlideToContinueProps) { + const trackRef = useRef(null); + const [position, setPosition] = useState(0); + const [dragging, setDragging] = useState(false); + + const moveKnob = (clientX: number) => { + const track = trackRef.current; + if (!track) return; + + const bounds = track.getBoundingClientRect(); + const max = Math.max(bounds.width - KNOB_SIZE - TRACK_PADDING * 2, 1); + const next = clientX - bounds.left - KNOB_SIZE / 2 - TRACK_PADDING; + const clamped = Math.min(Math.max(next, 0), max); + setPosition(clamped); + }; + + const completeIfNeeded = () => { + const track = trackRef.current; + if (!track) return; + + const max = Math.max(track.getBoundingClientRect().width - KNOB_SIZE - TRACK_PADDING * 2, 1); + if (position / max >= COMPLETE_THRESHOLD) { + setPosition(max); + onComplete(); + return; + } + + setPosition(0); + }; + + const handlePointerDown = (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(true); + moveKnob(event.clientX); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!dragging) return; + moveKnob(event.clientX); + }; + + const handlePointerUp = (event: PointerEvent) => { + if (!dragging) return; + event.currentTarget.releasePointerCapture(event.pointerId); + setDragging(false); + completeIfNeeded(); + }; + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onComplete(); + } + }} + > + {label} + Nach rechts wischen + + + +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f4b313d..77d8124 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -81,7 +81,7 @@ a { @media (max-width: 768px) { .app-container { - padding: 120px 1rem 1rem 1rem; + padding: 140px 1rem 1rem 1rem; } } diff --git a/frontend/src/pages/voting/index.tsx b/frontend/src/pages/voting/index.tsx index 5e44996..5212a93 100644 --- a/frontend/src/pages/voting/index.tsx +++ b/frontend/src/pages/voting/index.tsx @@ -3,6 +3,7 @@ import { api } from '../../api'; import type { VoteOption } from '../../types'; import { LockScreen } from '../../components/LockScreen'; import { SearchableSelect } from '../../components/SearchableSelect'; +import { SlideToContinue } from '../../components/SlideToContinue'; import { Header } from '../../components/Header'; import { Check } from 'lucide-react'; import './Voting.css'; @@ -13,6 +14,7 @@ export default function VotingPage() { const [options, setOptions] = useState([]); const [selections, setSelections] = useState>({}); const [submitted, setSubmitted] = useState>({}); + const [introUnlocked, setIntroUnlocked] = useState(false); useEffect(() => { document.title = 'Abstimmen - Outfit-Voting Abi26'; @@ -94,12 +96,31 @@ export default function VotingPage() { ); } + const handleUnlockIntro = () => { + setIntroUnlocked(true); + }; + + if (!introUnlocked) { + return ( + <> +
+
+

Outfit des Abends Voting

+

+ Auf dieser Seite kannst du für das beste Outfit des Abends des Abi-Jahrgangs 2026 der Goetheschule Wetzlar abstimmen. +

+ +
+ + ); + } + return ( <>

Outfit Voting

-

Wähle deine Favoriten in jeder Kategorie.

+

Wähle deinen Favoriten in jeder Kategorie.

{categories.map(cat => {