update
This commit is contained in:
444
frontend/src/components/Einladung.vue
Normal file
444
frontend/src/components/Einladung.vue
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user