This commit is contained in:
team3
2026-10-08 14:29:05 +02:00
parent 9fdfe27fb9
commit 3f12b0742d
2 changed files with 457 additions and 0 deletions

View File

@@ -0,0 +1,444 @@
<script setup>
// Eine Einladungskarte zum Verschicken: Datum, Uhrzeit, Ort, der gezogene
// Film — als Bild fürs Handy. Zwei Designs: Horror und kein Horror. 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'
import { posterUrl } from '../images'
const emit = defineEmits(['zu'])
const GEMERKT = 'filmabend_einladung'
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',
titel: 'Filmabend',
datum: naechsterMittwoch(),
uhrzeit: '20:00',
ort: '',
satz: 'Bring was zu knabbern mit.',
filmZeigen: true,
})
try {
Object.assign(form, JSON.parse(localStorage.getItem(GEMERKT) || '{}'))
} catch {
// Kaputte Erinnerung — dann eben die Vorgaben
}
// Der gezogene Film vom Rad, falls es einen gibt
const film = ref(null)
let poster = null
const leinwand = ref(null)
async function filmLaden() {
try {
const resp = await fetch('/api/spin')
const daten = resp.ok ? await resp.json() : null
film.value = daten?.result?.gewinner || null
} catch {
film.value = null
}
}
async function posterLaden() {
poster = null
const pfad = film.value?.poster_path
if (!pfad) return
const bild = new Image()
// Sonst darf das Canvas nicht als Bild gespeichert werden
bild.crossOrigin = 'anonymous'
bild.src = posterUrl(pfad, 'w500')
try {
await bild.decode()
poster = bild
} catch {
poster = null
}
}
// ── 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()
}
function malen() {
const ctx = leinwand.value?.getContext('2d')
if (!ctx) return
const horror = form.stil === 'horror'
const farbe = {
hintergrund: horror ? '#0b0b0d' : '#ffe9cf',
text: horror ? '#f1f1f3' : '#3b2a1a',
leise: horror ? '#9a9aa3' : '#8a6d52',
akzent: horror ? '#e50914' : '#e8891d',
}
const schrift = 'system-ui, -apple-system, "Segoe UI", sans-serif'
// Hintergrund mit sanftem Lichtfleck
ctx.fillStyle = farbe.hintergrund
ctx.fillRect(0, 0, B, H)
const licht = ctx.createRadialGradient(B / 2, H * 0.45, 80, B / 2, H * 0.45, H * 0.8)
licht.addColorStop(0, horror ? 'rgba(229, 9, 20, 0.14)' : 'rgba(255, 250, 240, 0.9)')
licht.addColorStop(1, 'rgba(0, 0, 0, 0)')
ctx.fillStyle = licht
ctx.fillRect(0, 0, B, H)
if (horror) {
// Blut läuft von oben herunter
ctx.fillStyle = farbe.akzent
ctx.fillRect(0, 0, B, 26)
const tropfen = [60, 180, 250, 390, 520, 610, 760, 840, 960, 1030]
tropfen.forEach((x, i) => {
const laenge = [60, 100, 40, 120, 70, 130, 50, 110, 90, 60][i]
rundeck(ctx, x - 9, 10, 18, laenge, 9)
ctx.fill()
})
} else {
// Ein Rahmen wie eine Kinokarte
ctx.strokeStyle = farbe.akzent
ctx.lineWidth = 6
ctx.setLineDash([22, 14])
rundeck(ctx, 40, 40, B - 80, H - 80, 28)
ctx.stroke()
ctx.setLineDash([])
}
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
// Überschrift
ctx.fillStyle = horror ? farbe.akzent : farbe.text
ctx.font = `bold 104px ${schrift}`
ctx.letterSpacing = horror ? '8px' : '2px'
const titel = horror ? form.titel.toUpperCase() : form.titel
ctx.fillText(titel, B / 2, 265, B - 120)
ctx.letterSpacing = '0px'
// Wann und wo
ctx.fillStyle = farbe.text
ctx.font = `600 46px ${schrift}`
ctx.fillText(datumText(), B / 2, 345, B - 120)
ctx.fillStyle = farbe.leise
ctx.font = `40px ${schrift}`
const wo = [form.uhrzeit && `${form.uhrzeit} Uhr`, form.ort].filter(Boolean).join(' · ')
ctx.fillText(wo, B / 2, 400, B - 120)
// Der Film: Poster oder Überraschung
const pb = 420
const ph = 630
const px = (B - pb) / 2
const py = 460
if (form.filmZeigen && film.value) {
if (poster) {
ctx.save()
rundeck(ctx, px, py, pb, ph, horror ? 6 : 24)
ctx.clip()
ctx.drawImage(poster, px, py, pb, ph)
if (horror) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'
ctx.fillRect(px, py, pb, ph)
}
ctx.restore()
ctx.strokeStyle = farbe.akzent
ctx.lineWidth = horror ? 4 : 8
rundeck(ctx, px, py, pb, ph, horror ? 6 : 24)
ctx.stroke()
}
ctx.fillStyle = farbe.text
ctx.font = `bold 54px ${schrift}`
const name = film.value.year ? `${film.value.title} (${film.value.year})` : film.value.title
let y = poster ? py + ph + 80 : 700
for (const z of zeilen(ctx, name, B - 160)) {
ctx.fillText(z, B / 2, y)
y += 64
}
} else {
ctx.fillStyle = farbe.leise
ctx.font = `600 54px ${schrift}`
ctx.fillText('Film wird ausgelost', B / 2, 760)
ctx.font = `44px ${schrift}`
ctx.fillText('Vorschläge aufs Rad!', B / 2, 830)
}
// Der freie Satz
ctx.fillStyle = farbe.text
ctx.font = `44px ${schrift}`
let y = 1215
for (const z of zeilen(ctx, form.satz, B - 160)) {
ctx.fillText(z, B / 2, y)
y += 54
}
// Wo man die Seite findet
ctx.fillStyle = farbe.leise
ctx.font = `34px ${schrift}`
ctx.fillText(location.host, B / 2, H - 50)
}
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(async () => {
window.addEventListener('keydown', escape)
malen()
await filmLaden()
await posterLaden()
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="form.stil = 'horror'"
>
Horror
</button>
<button type="button" :class="{ an: form.stil === 'nett' }" @click="form.stil = 'nett'">
Kein Horror
</button>
</div>
<label>
Überschrift
<input v-model="form.titel" type="text" maxlength="24" />
</label>
<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>
Ort
<input v-model="form.ort" type="text" maxlength="60" placeholder="Bei Marek" />
</label>
<label>
Ein Satz
<input v-model="form.satz" type="text" maxlength="90" />
</label>
<label v-if="film" class="haken">
<input v-model="form.filmZeigen" type="checkbox" />
Film zeigen: {{ film.title }}
</label>
<p v-else class="hinweis">
Noch kein Film gezogen — die Karte sagt „Film wird ausgelost".
</p>
<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;
}
.haken {
flex-direction: row;
gap: 0.5rem;
align-items: center;
color: var(--text);
font-size: 0.9rem;
}
.hinweis {
color: var(--muted);
font-size: 0.85rem;
}
.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>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import FilmSuche from './FilmSuche.vue' import FilmSuche from './FilmSuche.vue'
import Einladung from './Einladung.vue'
import { posterUrl } from '../images' import { posterUrl } from '../images'
import PersonName from './PersonName.vue' import PersonName from './PersonName.vue'
@@ -47,6 +48,9 @@ async function schutzZeigen(user) {
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true } gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
} }
// Die Einladungskarte — ein eigenes Fenster
const einladung = ref(false)
// Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host // Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host
const hostFilmDialog = ref(false) const hostFilmDialog = ref(false)
const hostFilm = ref(null) const hostFilm = ref(null)
@@ -232,6 +236,15 @@ async function entfernen(user) {
<p v-else class="leer">Noch niemand in der Runde.</p> <p v-else class="leer">Noch niemand in der Runde.</p>
</section> </section>
<section v-if="host" class="einladung">
<h3>Einladung</h3>
<p class="erklaerung">
Eine Karte zum Verschicken: Datum, Ort, der gezogene Film. Als Bild fürs Handy.
</p>
<button type="button" class="klein" @click="einladung = true">Einladung erstellen</button>
</section>
<Einladung v-if="einladung" @zu="einladung = false" />
<section v-if="host" class="hostfilm"> <section v-if="host" class="hostfilm">
<h3>Host-Film</h3> <h3>Host-Film</h3>
<p class="erklaerung"> <p class="erklaerung">