Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
313d0d418c
|
||
|
|
8587b9577a
|
||
|
|
9911eaf812
|
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Outfit-Voting Abi26</title>
|
||||
</head>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(true);
|
||||
moveKnob(event.clientX);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
moveKnob(event.clientX);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging) return;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
setDragging(false);
|
||||
completeIfNeeded();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={`slide-control${dragging ? ' is-dragging' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={label}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onComplete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="slide-label">{label}</span>
|
||||
<span className="slide-hint">Nach rechts wischen</span>
|
||||
<span className="slide-knob" style={{ transform: `translateX(${position}px)` }}>
|
||||
<ArrowRight size={28} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ a {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 120px 1rem 1rem 1rem;
|
||||
padding: 140px 1rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,36 @@ export default function AdminPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (cat: string) => {
|
||||
const catIds = options.filter(o => o.Category === cat).map(o => o.ID);
|
||||
if (catIds.length === 0) return;
|
||||
openDialog(
|
||||
"Kategorie leeren?",
|
||||
`Möchtest du wirklich alle ${catIds.length} Einträge in der Kategorie "${cat}" unwiderruflich löschen?`,
|
||||
async () => {
|
||||
await api.bulkDeleteVoteEntries(catIds);
|
||||
setSelectedIds(new Set([...selectedIds].filter(id => !catIds.includes(id))));
|
||||
loadData();
|
||||
closeDialog();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
if (options.length === 0) return;
|
||||
openDialog(
|
||||
"Alle Einträge löschen?",
|
||||
`ACHTUNG: Möchtest du wirklich ALLE ${options.length} Einträge unwiderruflich löschen?`,
|
||||
async () => {
|
||||
const allIds = options.map(o => o.ID);
|
||||
await api.bulkDeleteVoteEntries(allIds);
|
||||
setSelectedIds(new Set());
|
||||
loadData();
|
||||
closeDialog();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
await api.setVoteCount(id, editVotes);
|
||||
setEditId(null);
|
||||
@@ -205,13 +235,19 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
{selectedIds.size > 0 ? (
|
||||
<div className="bulk-actions panel">
|
||||
<span>{selectedIds.size} Einträge ausgewählt</span>
|
||||
<button className="btn-danger" onClick={handleBulkDelete}>
|
||||
<Trash2 size={18} /> Ausgewählte Löschen
|
||||
</button>
|
||||
</div>
|
||||
) : options.length > 0 && (
|
||||
<div className="bulk-actions panel" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn-danger" onClick={handleDeleteAll}>
|
||||
<Trash2 size={18} /> Alle Kategorien & Einträge löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-tables">
|
||||
@@ -245,13 +281,18 @@ export default function AdminPage() {
|
||||
style={{ padding: '0.4rem 0.8rem', borderRadius: 'var(--radius-m)', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--panel-border)', fontSize: '0.9rem', width: '160px' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn-icon add-to-cat" onClick={() => {
|
||||
setBulkCategory(cat);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setTimeout(() => document.getElementById('bulkNames')?.focus(), 300);
|
||||
}}>
|
||||
<Plus size={16} /> Hier hinzufügen
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn-icon add-to-cat" onClick={() => {
|
||||
setBulkCategory(cat);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setTimeout(() => document.getElementById('bulkNames')?.focus(), 300);
|
||||
}}>
|
||||
<Plus size={16} /> Hier hinzufügen
|
||||
</button>
|
||||
<button className="btn-icon delete" onClick={() => handleDeleteCategory(cat)}>
|
||||
<Trash2 size={16} /> Alle löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
|
||||
@@ -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<VoteOption[]>([]);
|
||||
const [selections, setSelections] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState<Record<string, boolean>>({});
|
||||
const [introUnlocked, setIntroUnlocked] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Abstimmen - Outfit-Voting Abi26';
|
||||
@@ -94,12 +96,31 @@ export default function VotingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleUnlockIntro = () => {
|
||||
setIntroUnlocked(true);
|
||||
};
|
||||
|
||||
if (!introUnlocked) {
|
||||
return (
|
||||
<>
|
||||
<Header showResultsLink={true} />
|
||||
<div className="voting-page" style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center', minHeight: '60vh', padding: '2rem' }}>
|
||||
<h1 className="page-title" style={{ fontSize: '2.4rem', marginBottom: '1.5rem', fontFamily: "'Sora', sans-serif" }}>Outfit des Abends Voting</h1>
|
||||
<p className="page-subtitle" style={{ maxWidth: '600px', margin: '2rem auto 4rem auto', lineHeight: '1.7', fontSize: '1.15rem' }}>
|
||||
Auf dieser Seite kannst du für das beste Outfit des Abends des Abi-Jahrgangs 2026 der Goetheschule Wetzlar abstimmen.
|
||||
</p>
|
||||
<SlideToContinue label="Weiter zum Voting" onComplete={handleUnlockIntro} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header showResultsLink={true} />
|
||||
<div className="voting-page">
|
||||
<h1 className="page-title">Outfit Voting</h1>
|
||||
<p className="page-subtitle">Wähle deine Favoriten in jeder Kategorie.</p>
|
||||
<p className="page-subtitle">Wähle deinen Favoriten in jeder Kategorie.</p>
|
||||
|
||||
<div className="categories-grid">
|
||||
{categories.map(cat => {
|
||||
|
||||
Reference in new issue
Block a user