7 Commits

Author SHA1 Message Date
tueem 313d0d418c feat(frontend): add swipe to unlock page and various text changes
build / Go-Build (push) Successful in 45s
docker / docker (push) Successful in 5m17s
2026-06-25 19:56:50 +02:00
tueem 8587b9577a feat(frontend): add delete all buttons to admin page
build / Go-Build (push) Successful in 1m31s
docker / docker (push) Successful in 4m17s
2026-06-24 15:56:53 +02:00
tueem 9911eaf812 feat(frontend): change favicon 2026-06-24 15:56:37 +02:00
tueem cc3996b384 fix(docker): add ca-certificates to docker
build / Go-Build (push) Successful in 1m25s
docker / docker (push) Successful in 4m9s
2026-06-24 14:48:11 +02:00
tueem 1b946789a4 feat(docker): add compose 2026-06-24 14:45:42 +02:00
tueem fbe10a0288 feat(docker): adjust entrypoint.sh for new imprinturl flag
docker / docker (push) Successful in 4m48s
build / Go-Build (push) Failing after 51s
2026-06-24 12:12:51 +02:00
tueem bfafd63b33 feat(ci): add frontend build stage 2026-06-24 12:12:38 +02:00
13 changed files with 338 additions and 14 deletions
+15
View File
@@ -28,6 +28,21 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 26
cache: 'npm'
cache-dependency-path: ./frontend/package-lock.json
- name: Install Dependencies
working-directory: ./frontend
run: npm ci
- name: Run Vite Build
working-directory: ./frontend
run: npm run build
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
+10
View File
@@ -1,15 +1,25 @@
# Frontend build stage
FROM node:22-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Build stage # Build stage
FROM golang:latest AS builder FROM golang:latest AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=frontend-builder /app/dist ./frontend/dist
RUN CGO_ENABLED=0 GOOS=linux go build -v -o outfit-voting-abi26 ./cmd/ RUN CGO_ENABLED=0 GOOS=linux go build -v -o outfit-voting-abi26 ./cmd/
# Final stage # Final stage
FROM debian:bookworm-slim FROM debian:bookworm-slim
WORKDIR /root/ WORKDIR /root/
EXPOSE 4000 EXPOSE 4000
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/outfit-voting-abi26 /usr/local/bin/outfit-voting-abi26 COPY --from=builder /app/outfit-voting-abi26 /usr/local/bin/outfit-voting-abi26
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
+77
View File
@@ -0,0 +1,77 @@
services:
outfit-voting-abi26:
image: tueem/outfit-voting-abi26:latest
restart: unless-stopped
environment:
DB_HOST: "HOST"
DB_PORT: "PORT"
DB_NAME: "NAME"
DB_USER: "USER"
DB_PASS: "PASS"
OIDC_ISSUER: "OIDC_ISSUER"
OIDC_CLIENTID: "OIDC_CLIENTID"
OIDC_SECRET: "OIDC_SECRET"
OIDC_REDIRECT: "OIDC_REDIRECT"
SIGNINGKEY: "SIGNINGKEY"
IMPRINTURL: "IMPRINTURL"
networks:
- web-network
- backend
depends_on:
postgres:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`example.com`)"
- "traefik.http.routers.myapp.entrypoints=websecure"
- "traefik.http.routers.myapp.tls=true"
- "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
- "traefik.http.services.myapp.loadbalancer.server.port=4000"
postgres:
image: postgres:latest
restart: unless-stopped
shm_size: 128mb
environment:
POSTGRES_PASSWORD: "PASS"
volumes:
- db:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser -d mydatabase"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- backend
traefik:
image: traefik:v3.1
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "80:80" # HTTP port
- "443:443" # HTTPS port
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=your-email@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/acme.json"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik-ssl:/etc/traefik/acme
networks:
- web-network
networks:
web-network:
backend:
volumes:
db:
+4
View File
@@ -42,4 +42,8 @@ if [ -n "$SIGNINGKEY" ]; then
CMD="$CMD --signingKey $SIGNINGKEY" CMD="$CMD --signingKey $SIGNINGKEY"
fi fi
if [ -n "$IMPRINTURL" ]; then
CMD="$CMD --imprinturl $IMPRINTURL"
fi
eval exec $CMD "$@" eval exec $CMD "$@"
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Outfit-Voting Abi26</title> <title>Outfit-Voting Abi26</title>
</head> </head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

+6 -2
View File
@@ -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;
}
} }
@@ -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>
);
}
+1 -1
View File
@@ -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;
} }
} }
+49 -8
View File
@@ -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) => { const saveEdit = async (id: string) => {
await api.setVoteCount(id, editVotes); await api.setVoteCount(id, editVotes);
setEditId(null); setEditId(null);
@@ -205,13 +235,19 @@ export default function AdminPage() {
</div> </div>
</div> </div>
{selectedIds.size > 0 && ( {selectedIds.size > 0 ? (
<div className="bulk-actions panel"> <div className="bulk-actions panel">
<span>{selectedIds.size} Einträge ausgewählt</span> <span>{selectedIds.size} Einträge ausgewählt</span>
<button className="btn-danger" onClick={handleBulkDelete}> <button className="btn-danger" onClick={handleBulkDelete}>
<Trash2 size={18} /> Ausgewählte Löschen <Trash2 size={18} /> Ausgewählte Löschen
</button> </button>
</div> </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"> <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' }} 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> </div>
<button className="btn-icon add-to-cat" onClick={() => { <div style={{ display: 'flex', gap: '0.5rem' }}>
setBulkCategory(cat); <button className="btn-icon add-to-cat" onClick={() => {
window.scrollTo({ top: 0, behavior: 'smooth' }); setBulkCategory(cat);
setTimeout(() => document.getElementById('bulkNames')?.focus(), 300); window.scrollTo({ top: 0, behavior: 'smooth' });
}}> setTimeout(() => document.getElementById('bulkNames')?.focus(), 300);
<Plus size={16} /> Hier hinzufügen }}>
</button> <Plus size={16} /> Hier hinzufügen
</button>
<button className="btn-icon delete" onClick={() => handleDeleteCategory(cat)}>
<Trash2 size={16} /> Alle löschen
</button>
</div>
</div> </div>
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
+22 -1
View File
@@ -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,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 ( 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 => {