Compare commits
3 Commits
8587b9577a
...
v0.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
5695d90049
|
|||
|
6a363a7f1a
|
|||
|
313d0d418c
|
@@ -57,8 +57,12 @@
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
.header-brand {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-dropdown {
|
.search-select .select-dropdown.panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.5rem);
|
top: calc(100% + 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -44,6 +44,10 @@
|
|||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background: rgba(8, 14, 20, 0.88) !important;
|
||||||
|
backdrop-filter: blur(24px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(24px) !important;
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-box {
|
.search-box {
|
||||||
|
|||||||
@@ -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) {
|
@media (max-width: 768px) {
|
||||||
.app-container {
|
.app-container {
|
||||||
padding: 120px 1rem 1rem 1rem;
|
padding: 140px 1rem 1rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +100,6 @@ a {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-title {
|
.category-title {
|
||||||
|
|||||||
@@ -183,6 +183,26 @@
|
|||||||
color: var(--teal);
|
color: var(--teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-table-container {
|
||||||
|
height: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for the table */
|
||||||
|
.admin-table-container::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.admin-table-container::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.admin-table-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.admin-table-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table {
|
.admin-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -196,9 +216,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-table th {
|
.admin-table th {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(20, 26, 33, 0.95);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-table tr:hover td {
|
.admin-table tr:hover td {
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ export default function AdminPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table className="admin-table">
|
<div className="admin-table-container">
|
||||||
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '40px' }}></th>
|
<th style={{ width: '40px' }}></th>
|
||||||
@@ -362,6 +363,7 @@ export default function AdminPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '../../api';
|
|||||||
import type { VoteOption } from '../../types';
|
import type { VoteOption } from '../../types';
|
||||||
import { LockScreen } from '../../components/LockScreen';
|
import { LockScreen } from '../../components/LockScreen';
|
||||||
import { SearchableSelect } from '../../components/SearchableSelect';
|
import { SearchableSelect } from '../../components/SearchableSelect';
|
||||||
|
import { SlideToContinue } from '../../components/SlideToContinue';
|
||||||
import { Header } from '../../components/Header';
|
import { Header } from '../../components/Header';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import './Voting.css';
|
import './Voting.css';
|
||||||
@@ -13,6 +14,7 @@ export default function VotingPage() {
|
|||||||
const [options, setOptions] = useState<VoteOption[]>([]);
|
const [options, setOptions] = useState<VoteOption[]>([]);
|
||||||
const [selections, setSelections] = useState<Record<string, string>>({});
|
const [selections, setSelections] = useState<Record<string, string>>({});
|
||||||
const [submitted, setSubmitted] = useState<Record<string, boolean>>({});
|
const [submitted, setSubmitted] = useState<Record<string, boolean>>({});
|
||||||
|
const [introUnlocked, setIntroUnlocked] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Abstimmen - Outfit-Voting Abi26';
|
document.title = 'Abstimmen - Outfit-Voting Abi26';
|
||||||
@@ -94,15 +96,34 @@ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header showResultsLink={true} />
|
<Header showResultsLink={true} />
|
||||||
<div className="voting-page">
|
<div className="voting-page">
|
||||||
<h1 className="page-title">Outfit Voting</h1>
|
<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">
|
<div className="categories-grid">
|
||||||
{categories.map(cat => {
|
{categories.map((cat, index) => {
|
||||||
const catOptions = options
|
const catOptions = options
|
||||||
.filter(o => o.Category === cat)
|
.filter(o => o.Category === cat)
|
||||||
.map(o => ({ value: o.ID, label: o.Name }));
|
.map(o => ({ value: o.ID, label: o.Name }));
|
||||||
@@ -110,7 +131,7 @@ export default function VotingPage() {
|
|||||||
const isSubmitted = submitted[cat];
|
const isSubmitted = submitted[cat];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cat} className="category-card panel">
|
<div key={cat} className="category-card panel" style={{ position: 'relative', zIndex: categories.length - index }}>
|
||||||
<h3 className="category-title">{cat}</h3>
|
<h3 className="category-title">{cat}</h3>
|
||||||
|
|
||||||
<div className="category-content">
|
<div className="category-content">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package database
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"tomatentum.net/outfit-voting-abi26/internal/util"
|
"tomatentum.net/outfit-voting-abi26/internal/util"
|
||||||
@@ -16,7 +17,7 @@ const BULKINSERTVOTEENTRY string = "INSERT INTO vote(id, category, name, votes)
|
|||||||
const SETVOTE string = "UPDATE vote SET votes = $1 WHERE id = $2"
|
const SETVOTE string = "UPDATE vote SET votes = $1 WHERE id = $2"
|
||||||
const INCVOTE string = "UPDATE vote SET votes = votes + 1 WHERE id = $1"
|
const INCVOTE string = "UPDATE vote SET votes = votes + 1 WHERE id = $1"
|
||||||
const DELVOTEENTRY string = "DELETE FROM vote WHERE id = $1"
|
const DELVOTEENTRY string = "DELETE FROM vote WHERE id = $1"
|
||||||
const BULKDELVOTEENTRY string = "DELETE FROM vote WHERE key IN (%s)"
|
const BULKDELVOTEENTRY string = "DELETE FROM vote WHERE id IN (%s)"
|
||||||
const GETVOTES string = "SELECT * FROM vote ORDER BY votes DESC"
|
const GETVOTES string = "SELECT * FROM vote ORDER BY votes DESC"
|
||||||
const GETVOTEOPTIONS string = "SELECT id, category, name FROM vote"
|
const GETVOTEOPTIONS string = "SELECT id, category, name FROM vote"
|
||||||
const GETVOTE string = "SELECT * FROM vote WHERE id = $1"
|
const GETVOTE string = "SELECT * FROM vote WHERE id = $1"
|
||||||
@@ -120,12 +121,16 @@ func DelVoteEntry(id string) error {
|
|||||||
func BulkDelVoteEntry(ids []string) error {
|
func BulkDelVoteEntry(ids []string) error {
|
||||||
|
|
||||||
placeholders := make([]string, len(ids))
|
placeholders := make([]string, len(ids))
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
|
||||||
for i := 0; i < len(ids); i++ {
|
for i := 0; i < len(ids); i++ {
|
||||||
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||||
|
args[i] = ids[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := db.Exec(fmt.Sprintf(BULKDELVOTEENTRY, strings.Join(placeholders, ", ")), ids)
|
stmnt := fmt.Sprintf(BULKDELVOTEENTRY, strings.Join(placeholders, ", "))
|
||||||
|
log.Println(stmnt)
|
||||||
|
res, err := db.Exec(stmnt, args...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user