Files
horror/frontend/src/components/Einladung.vue
2026-10-09 22:23:13 +02:00

542 lines
14 KiB
Vue

<script setup>
// Eine Einladungskarte zum Verschicken: Titel, Datum, Uhrzeit, Genre, Ort,
// Adresse der Seite — als Bild fürs Handy. Zwei Designs: Horror und kein
// Horror. Jede Zeile ist ein Textfeld. Das Bild entsteht hier im Browser
// auf einem Canvas, der Host lädt es herunter und schickt es selbst herum.
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
const emit = defineEmits(['zu'])
const GEMERKT = 'filmabend_karte'
const B = 1080
const H = 1350
function naechsterMittwoch() {
const d = new Date()
d.setDate(d.getDate() + ((3 - d.getDay() + 7) % 7 || 7))
return d.toISOString().slice(0, 10)
}
const form = reactive({
stil: 'horror',
vorzeile: 'Einladung zum',
titel: 'Filmabend',
datum: naechsterMittwoch(),
uhrzeit: '20:00',
genre: 'Horror',
ort: 'Bananarama - Wohnzimmer',
url: 'horror.marha.de',
})
try {
Object.assign(form, JSON.parse(localStorage.getItem(GEMERKT) || '{}'))
} catch {
// Kaputte Erinnerung — dann eben die Vorgaben
}
const leinwand = ref(null)
function stilWaehlen(stil) {
form.stil = stil
form.genre = stil === 'horror' ? 'Horror' : 'Kein Horror'
}
// ── Malen ───────────────────────────────────────────────────────────
function datumText() {
const d = new Date(form.datum + 'T12:00:00')
if (Number.isNaN(d.getTime())) return form.datum
return d.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
// Text umbrechen, damit lange Sätze nicht aus dem Bild laufen
function zeilen(ctx, text, maxBreite) {
const aus = []
let zeile = ''
for (const wort of text.split(/\s+/)) {
const probe = zeile ? `${zeile} ${wort}` : wort
if (ctx.measureText(probe).width > maxBreite && zeile) {
aus.push(zeile)
zeile = wort
} else {
zeile = probe
}
}
if (zeile) aus.push(zeile)
return aus
}
function rundeck(ctx, x, y, b, h, r) {
ctx.beginPath()
ctx.roundRect(x, y, b, h, r)
ctx.closePath()
}
// Feste Zufallsfolge: Körnung und Spritzer bleiben bei jedem Tastendruck gleich
function zufall(saat) {
let z = saat
return () => {
z = (z * 1664525 + 1013904223) % 4294967296
return z / 4294967296
}
}
const SCHRIFT = 'system-ui, -apple-system, "Segoe UI", sans-serif'
const FARBEN = {
horror: { text: '#f1ecec', leise: '#9a8f92', akzent: '#e50914' },
nett: { text: '#1b2a41', leise: '#7d6b50', akzent: '#e0532f' },
}
// ── Horror: Dunkelheit, Körnung, Spinnennetz, Blut ──────────────────
function spinnennetz(ctx, cx, cy, radius, von, bis) {
ctx.save()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)'
ctx.lineWidth = 2
const speichen = 7
const winkel = []
for (let i = 0; i <= speichen; i++) winkel.push(von + ((bis - von) * i) / speichen)
for (const w of winkel) {
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(w) * radius, cy + Math.sin(w) * radius)
ctx.stroke()
}
// Die Querfäden hängen leicht durch
for (let r = radius / 6; r <= radius; r += radius / 6) {
ctx.beginPath()
winkel.forEach((w, i) => {
const x = cx + Math.cos(w) * r
const y = cy + Math.sin(w) * r
if (i === 0) return ctx.moveTo(x, y)
const m = (winkel[i - 1] + w) / 2
ctx.quadraticCurveTo(cx + Math.cos(m) * r * 0.9, cy + Math.sin(m) * r * 0.9, x, y)
})
ctx.stroke()
}
ctx.restore()
}
function blut(ctx) {
ctx.fillStyle = '#b3060f'
ctx.fillRect(0, 0, B, 30)
const tropfen = [
[50, 70, 16],
[150, 140, 22],
[230, 50, 14],
[330, 180, 26],
[430, 90, 18],
[520, 220, 24],
[640, 60, 16],
[720, 150, 20],
[820, 110, 22],
[910, 200, 26],
[1000, 80, 18],
]
for (const [x, laenge, breite] of tropfen) {
rundeck(ctx, x - breite / 2, 10, breite, laenge, breite / 2)
ctx.fill()
ctx.beginPath()
ctx.arc(x, 10 + laenge, breite * 0.7, 0, Math.PI * 2)
ctx.fill()
}
const r = zufall(3)
for (let i = 0; i < 40; i++) {
ctx.beginPath()
ctx.arc(r() * B, 30 + r() * 260, 2 + r() * 5, 0, Math.PI * 2)
ctx.fill()
}
}
function horrorHintergrund(ctx) {
ctx.fillStyle = '#07060a'
ctx.fillRect(0, 0, B, H)
const glut = ctx.createRadialGradient(B / 2, H * 0.55, 60, B / 2, H * 0.55, H * 0.75)
glut.addColorStop(0, 'rgba(150, 10, 20, 0.35)')
glut.addColorStop(1, 'rgba(0, 0, 0, 0)')
ctx.fillStyle = glut
ctx.fillRect(0, 0, B, H)
const r = zufall(7)
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
for (let i = 0; i < 6000; i++) ctx.fillRect(r() * B, r() * H, 2, 2)
const rand = ctx.createRadialGradient(B / 2, H / 2, H * 0.35, B / 2, H / 2, H * 0.85)
rand.addColorStop(0, 'rgba(0, 0, 0, 0)')
rand.addColorStop(1, 'rgba(0, 0, 0, 0.85)')
ctx.fillStyle = rand
ctx.fillRect(0, 0, B, H)
spinnennetz(ctx, B, 0, 380, Math.PI / 2, Math.PI)
spinnennetz(ctx, 0, H, 300, -Math.PI / 2, 0)
blut(ctx)
// Ein dünner Strich zwischen Wann und Was
ctx.fillStyle = 'rgba(229, 9, 20, 0.5)'
ctx.fillRect(300, 670, B - 600, 2)
}
// ── Kein Horror: ein Kinoticket auf einer Markise ───────────────────
const TICKET = { x: 70, y: 90, b: B - 140, h: H - 180, r: 36 }
function filmstreifen(ctx, y) {
ctx.fillStyle = FARBEN.nett.text
ctx.fillRect(TICKET.x, y, TICKET.b, 70)
ctx.fillStyle = '#fff4dc'
for (let x = TICKET.x + 30; x < TICKET.x + TICKET.b - 30; x += 70) {
rundeck(ctx, x, y + 20, 36, 30, 6)
ctx.fill()
}
}
function nettHintergrund(ctx) {
// Markise: schräge Streifen
ctx.fillStyle = '#f2bd3a'
ctx.fillRect(0, 0, B, H)
ctx.save()
ctx.translate(B / 2, H / 2)
ctx.rotate(-Math.PI / 4)
ctx.fillStyle = '#e9b02c'
for (let x = -H; x < H; x += 80) ctx.fillRect(x, -H, 40, 2 * H)
ctx.restore()
// Das Ticket mit Schatten
ctx.save()
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'
ctx.shadowBlur = 40
ctx.shadowOffsetY = 16
ctx.fillStyle = '#fff4dc'
rundeck(ctx, TICKET.x, TICKET.y, TICKET.b, TICKET.h, TICKET.r)
ctx.fill()
ctx.restore()
ctx.save()
rundeck(ctx, TICKET.x, TICKET.y, TICKET.b, TICKET.h, TICKET.r)
ctx.clip()
filmstreifen(ctx, TICKET.y)
filmstreifen(ctx, TICKET.y + TICKET.h - 70)
ctx.restore()
// Abrisskante mit Kerben
const ky = 670
ctx.fillStyle = '#f2bd3a'
for (const x of [TICKET.x, TICKET.x + TICKET.b]) {
ctx.beginPath()
ctx.arc(x, ky, 36, 0, Math.PI * 2)
ctx.fill()
}
ctx.save()
ctx.strokeStyle = FARBEN.nett.text
ctx.globalAlpha = 0.35
ctx.lineWidth = 4
ctx.setLineDash([18, 14])
ctx.beginPath()
ctx.moveTo(TICKET.x + 60, ky)
ctx.lineTo(TICKET.x + TICKET.b - 60, ky)
ctx.stroke()
ctx.restore()
}
// ── Text ────────────────────────────────────────────────────────────
function stempel(ctx, text, y, farbe) {
ctx.save()
ctx.translate(B / 2, y)
ctx.rotate(-0.06)
ctx.font = `bold 58px ${SCHRIFT}`
ctx.letterSpacing = '6px'
ctx.textBaseline = 'middle'
const breite = Math.min(ctx.measureText(text).width + 70, B - 160)
ctx.strokeStyle = farbe
ctx.lineWidth = 6
rundeck(ctx, -breite / 2, -52, breite, 96, 12)
ctx.stroke()
ctx.fillStyle = farbe
ctx.fillText(text, 3, 2, breite - 40)
ctx.restore()
}
function malen() {
const ctx = leinwand.value?.getContext('2d')
if (!ctx) return
const horror = form.stil === 'horror'
const farbe = FARBEN[form.stil]
if (horror) horrorHintergrund(ctx)
else nettHintergrund(ctx)
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
// Vorzeile und Überschrift
ctx.fillStyle = farbe.leise
ctx.font = `600 36px ${SCHRIFT}`
ctx.letterSpacing = '8px'
ctx.fillText(form.vorzeile.toUpperCase(), B / 2, 345, B - 160)
ctx.letterSpacing = '0px'
ctx.save()
if (horror) {
ctx.shadowColor = 'rgba(229, 9, 20, 0.7)'
ctx.shadowBlur = 40
}
ctx.fillStyle = horror ? farbe.akzent : farbe.text
ctx.font = `bold 104px ${SCHRIFT}`
ctx.letterSpacing = horror ? '10px' : '2px'
const titel = horror ? form.titel.toUpperCase() : form.titel
ctx.fillText(titel, B / 2, 440, B - 160)
ctx.restore()
// Wann
ctx.fillStyle = farbe.text
ctx.font = `600 50px ${SCHRIFT}`
ctx.fillText(datumText(), B / 2, 540, B - 160)
ctx.fillStyle = farbe.leise
ctx.font = `42px ${SCHRIFT}`
if (form.uhrzeit) ctx.fillText(`${form.uhrzeit} Uhr`, B / 2, 600, B - 160)
// Genre als Stempel
stempel(ctx, form.genre.toUpperCase(), 770, farbe.akzent)
// Kleine Titel über Discord und Webseite
const titelZeile = (text, y) => {
ctx.fillStyle = farbe.leise
ctx.font = `600 30px ${SCHRIFT}`
ctx.letterSpacing = '4px'
ctx.fillText(text, B / 2, y)
ctx.letterSpacing = '0px'
}
titelZeile('DISCORD', 890)
ctx.fillStyle = farbe.text
ctx.font = `46px ${SCHRIFT}`
let y = 950
for (const z of zeilen(ctx, form.ort, B - 200)) {
ctx.fillText(z, B / 2, y)
y += 56
}
titelZeile('WEBSEITE', y + 50)
ctx.fillStyle = farbe.text
ctx.font = `48px ${SCHRIFT}`
ctx.fillText(form.url, B / 2, y + 110, B - 200)
}
function speichern() {
leinwand.value.toBlob((blob) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `einladung-${form.datum}.png`
a.click()
URL.revokeObjectURL(url)
}, 'image/png')
}
watch(form, () => {
localStorage.setItem(GEMERKT, JSON.stringify(form))
malen()
})
function escape(e) {
if (e.key === 'Escape') emit('zu')
}
onMounted(() => {
window.addEventListener('keydown', escape)
malen()
})
onUnmounted(() => window.removeEventListener('keydown', escape))
</script>
<template>
<Teleport to="body">
<div class="hintergrund" @click.self="emit('zu')">
<div class="fenster" role="dialog" aria-modal="true">
<div class="formular">
<p class="label">Einladung</p>
<div class="stil">
<button
type="button"
:class="{ an: form.stil === 'horror' }"
@click="stilWaehlen('horror')"
>
Horror
</button>
<button
type="button"
:class="{ an: form.stil === 'nett' }"
@click="stilWaehlen('nett')"
>
Kein Horror
</button>
</div>
<div class="zeile">
<label>
Vorzeile
<input v-model="form.vorzeile" type="text" maxlength="30" />
</label>
<label>
Überschrift
<input v-model="form.titel" type="text" maxlength="24" />
</label>
</div>
<div class="zeile">
<label>
Datum
<input v-model="form.datum" type="date" />
</label>
<label>
Uhrzeit
<input v-model="form.uhrzeit" type="time" />
</label>
</div>
<label>
Genre
<input v-model="form.genre" type="text" maxlength="24" />
</label>
<label>
Discord
<input v-model="form.ort" type="text" maxlength="60" />
</label>
<label>
Webseite
<input v-model="form.url" type="text" maxlength="60" />
</label>
<div class="fuss">
<button type="button" class="speichern" @click="speichern">Als Bild speichern</button>
<button type="button" class="abbrechen" @click="emit('zu')">Schließen</button>
</div>
</div>
<canvas ref="leinwand" class="vorschau" :width="B" :height="H"></canvas>
</div>
</div>
</Teleport>
</template>
<style scoped>
.hintergrund {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.8);
}
.fenster {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1.5rem;
width: min(52rem, 100%);
max-height: 92vh;
padding: 1.5rem 1.75rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 14px;
}
.formular {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.label {
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stil {
display: flex;
gap: 0.4rem;
}
.stil button {
flex: 1;
padding: 0.5rem;
color: var(--muted);
font-weight: 600;
border: 1px solid var(--line);
border-radius: 8px;
}
.stil button.an {
color: var(--text);
border-color: var(--accent);
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: var(--muted);
font-size: 0.8rem;
}
.zeile {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
input[type='text'],
input[type='date'],
input[type='time'] {
padding: 0.45rem 0.6rem;
color: var(--text);
font-size: 0.95rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
color-scheme: dark;
}
.fuss {
display: flex;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.6rem;
}
.speichern {
padding: 0.6rem 1.2rem;
color: #fff;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.abbrechen {
padding: 0.6rem 1rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 8px;
}
/* Die Vorschau: das volle Bild, nur kleiner gezeigt */
.vorschau {
width: 100%;
height: auto;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}
@media (max-width: 760px) {
.fenster {
grid-template-columns: 1fr;
}
.vorschau {
max-width: 320px;
margin: 0 auto;
}
}
</style>