initial
|
@ -12,7 +12,8 @@
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.3.4"
|
"vue": "^3.3.4",
|
||||||
|
"vue-confetti": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.2.0",
|
"@rushstack/eslint-patch": "^1.2.0",
|
||||||
|
|
7
pnpm-lock.yaml
generated
|
@ -8,6 +8,9 @@ dependencies:
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.3.4
|
specifier: ^3.3.4
|
||||||
version: 3.3.4
|
version: 3.3.4
|
||||||
|
vue-confetti:
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@rushstack/eslint-patch':
|
'@rushstack/eslint-patch':
|
||||||
|
@ -2201,6 +2204,10 @@ packages:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/vue-confetti@2.3.0:
|
||||||
|
resolution: {integrity: sha512-zmPniVzBKv0ie/BEXBR6Isi08hYSd6lS18b8VduG5BzZ2tv6bO/rlwISg+IpGY2XsqAFTXFdTC28YR+UPocnAw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/vue-eslint-parser@9.3.1(eslint@8.39.0):
|
/vue-eslint-parser@9.3.1(eslint@8.39.0):
|
||||||
resolution: {integrity: sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==}
|
resolution: {integrity: sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==}
|
||||||
engines: {node: ^14.17.0 || >=16.0.0}
|
engines: {node: ^14.17.0 || >=16.0.0}
|
||||||
|
|
53
src/App.vue
|
@ -1,12 +1,61 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { app } from './main'
|
||||||
|
import Wordle from './components/Wordle.vue'
|
||||||
|
import Solution from './components/Solution.vue'
|
||||||
|
import Present from './components/Present.vue'
|
||||||
|
|
||||||
|
const solution = 'gutschein'
|
||||||
|
const video = ref<null | HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const nextTimeout = (delta: number, video: HTMLVideoElement) => {
|
||||||
|
const nextDelta = Math.round(2000 + Math.random() * 10000)
|
||||||
|
setTimeout(() => {
|
||||||
|
video.play()
|
||||||
|
nextTimeout(nextDelta, video)
|
||||||
|
}, delta)
|
||||||
|
}
|
||||||
|
nextTimeout(20000, video.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const level = ref(0)
|
||||||
|
watch(level, newLevel => {
|
||||||
|
if (newLevel === 0) return
|
||||||
|
const confetti = app.config.globalProperties.$confetti
|
||||||
|
|
||||||
|
if (newLevel === 1) {
|
||||||
|
confetti.update({
|
||||||
|
particles: [{ type: 'heart', size: 15 }, { type: 'circle', size: 5 }],
|
||||||
|
particlesPerFrame: 1,
|
||||||
|
})
|
||||||
|
confetti.start()
|
||||||
|
} else if (newLevel === 2) {
|
||||||
|
confetti.update({
|
||||||
|
particles: [{ type: 'heart', size: 20 }],
|
||||||
|
particlesPerFrame: .1,
|
||||||
|
windSpeedMax: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<header>
|
||||||
<video autoplay playsinline src="./happybirthday.mp4" width="852" height="480" />
|
<transition>
|
||||||
|
<video v-if="level < 2"
|
||||||
|
ref="video" autoplay playsinline
|
||||||
|
src="./assets/happybirthday.mp4" width="852" height="480"
|
||||||
|
/>
|
||||||
|
<img v-else
|
||||||
|
src="./assets/present.png"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
Happy Birthday
|
<Wordle :solution="solution" @success="level++" v-if="level === 0" />
|
||||||
|
<Solution :solution="solution" @success="level++" v-else-if="level === 1" />
|
||||||
|
<Present v-else />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
BIN
src/assets/mn01.jpg
Normal file
After Width: | Height: | Size: 74 KiB |
BIN
src/assets/mn02.jpg
Normal file
After Width: | Height: | Size: 49 KiB |
BIN
src/assets/mn03.jpg
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
src/assets/mn04.jpg
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
src/assets/mn05.jpg
Normal file
After Width: | Height: | Size: 23 KiB |
BIN
src/assets/mn06.jpg
Normal file
After Width: | Height: | Size: 83 KiB |
BIN
src/assets/mn07.jpg
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
src/assets/mn08.jpg
Normal file
After Width: | Height: | Size: 50 KiB |
BIN
src/assets/present.png
Normal file
After Width: | Height: | Size: 30 KiB |
22
src/components/Present.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p class="center">
|
||||||
|
Ein Gutschein für ein Fotoshooting mit
|
||||||
|
<a href="https://martin-neuhof.com/" target="_blank">Martin Neuhof</a>
|
||||||
|
im Studio mit allem drum und dran!
|
||||||
|
</p>
|
||||||
|
<div class="image-box">
|
||||||
|
<img src="../assets/mn06.jpg" />
|
||||||
|
<img src="../assets/mn05.jpg" />
|
||||||
|
<img src="../assets/mn04.jpg" />
|
||||||
|
<img src="../assets/mn02.jpg" />
|
||||||
|
<img src="../assets/mn03.jpg" />
|
||||||
|
<img src="../assets/mn01.jpg" />
|
||||||
|
<img src="../assets/mn07.jpg" />
|
||||||
|
</div>
|
||||||
|
<p class="center footer">
|
||||||
|
Du kannst den Gutschein jederzeit einlösen, wenn wir mal in Leipzig sind.
|
||||||
|
</p>
|
||||||
|
</template>
|
49
src/components/Solution.vue
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
solution: string
|
||||||
|
}
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
'Yeah! Gratuladingsda!',
|
||||||
|
'Ja, jetzt genieß doch erstmal ein bisschen deinen Triumpf!',
|
||||||
|
'Ist dir denn das tolle Konfetti gar nichts wert?! Guck doch mal!',
|
||||||
|
'Das Konfetti zu basteln, war echt gar nicht mal so einfach, okay?',
|
||||||
|
'Na gut, na gut... hier bitte, dein Geschenk. Alles Gute zum Geburtstag!',
|
||||||
|
]
|
||||||
|
const buttons = [
|
||||||
|
'Wattn jetze nu mein Geschenk?',
|
||||||
|
'Okay, habe ich. Jetzt her mit dem Geschenk!',
|
||||||
|
'Doch doch, ist alles total toll. Wo ist nochmal das Geschenk?',
|
||||||
|
'Ja ja, du bist richtig toll, hui, Konfetti! Geschenk?!',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
|
||||||
|
const index = ref(1)
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'success'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const success = ref(false)
|
||||||
|
|
||||||
|
function raiseIndex() {
|
||||||
|
index.value++
|
||||||
|
if (index.value > 4) {
|
||||||
|
success.value = true
|
||||||
|
setTimeout(() => emit('success'), 3500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="solution" :class="{ 'solved': success }">
|
||||||
|
<div class="history">
|
||||||
|
<div v-for="letter in solution" class="letter green">{{ letter }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-for="i in index">{{ messages[i - 1] }}</p>
|
||||||
|
<button @click="raiseIndex">{{ buttons[index - 1] }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
80
src/components/Wordle.vue
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
solution: string
|
||||||
|
}
|
||||||
|
type Status = 'red' | 'yellow' | 'green'
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'success'): void
|
||||||
|
}>()
|
||||||
|
const scrollElement = ref<null | HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const history = ref<{ status: Status, letter: string }[][]>([])
|
||||||
|
const input = ref<string>('')
|
||||||
|
let success = false
|
||||||
|
|
||||||
|
function addInput(key: string) {
|
||||||
|
if (input.value.length < props.solution.length) {
|
||||||
|
input.value = `${input.value}${event.key}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInput() {
|
||||||
|
if (input.value.length === 0) return
|
||||||
|
input.value = input.value.slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (input.value.length < props.solution.length) return
|
||||||
|
|
||||||
|
if (input.value.toLowerCase() === props.solution) {
|
||||||
|
success = true
|
||||||
|
emit('success')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const letters = input.value.split('')
|
||||||
|
const newHistoryEntry = letters.map((letter, i) => {
|
||||||
|
letter = letter.toLowerCase()
|
||||||
|
const entry = { status: 'red', letter }
|
||||||
|
|
||||||
|
if (props.solution.indexOf(letter) === i) entry.status = 'green'
|
||||||
|
else if (props.solution.indexOf(letter) >= 0) entry.status = 'yellow'
|
||||||
|
|
||||||
|
return entry
|
||||||
|
})
|
||||||
|
|
||||||
|
history.value.push(newHistoryEntry)
|
||||||
|
input.value = ''
|
||||||
|
if (scrollElement.value) scrollElement.value.scrollIntoView()
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyHandler ({ key }) {
|
||||||
|
if (success) return
|
||||||
|
if (key === 'Enter') submit()
|
||||||
|
else if (key === 'Backspace') removeInput()
|
||||||
|
else if (key.toLowerCase().match(/^[a-z0-9]$/)) addInput(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('keydown', keyHandler)
|
||||||
|
document.addEventListener('keydown', keyHandler)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="history" v-for="word in history">
|
||||||
|
<div v-for="{ letter, status } in word" :class="['letter', status]">
|
||||||
|
{{ letter }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input">
|
||||||
|
<div v-for="(_, i) in solution.length" class="letter">
|
||||||
|
{{ input[i] ?? '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="scrollElement"> </div>
|
||||||
|
</template>
|
90
src/main.css
|
@ -1,9 +1,91 @@
|
||||||
body {
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
body, #app {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
background: #444;
|
background: #444;
|
||||||
color: #EEE;
|
color: #EEE;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column no-wrap;
|
flex-flow: column nowrap;
|
||||||
|
align-items: center;
|
||||||
|
font: 20px/1.35 monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app > header {
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
width: 2em;
|
||||||
|
height: 2em;
|
||||||
|
border: 2px solid #444;
|
||||||
|
background: #222;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 2;
|
||||||
|
color: #EEE;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter.red { background: #222; }
|
||||||
|
.letter.yellow { background: #EE23; }
|
||||||
|
.letter.green { background: #2E23; }
|
||||||
|
|
||||||
|
.history, .input { display: flex; }
|
||||||
|
.input { margin-bottom: 3em; }
|
||||||
|
|
||||||
|
.solution {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity 3s ease-in;
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
.solution.solved {
|
||||||
|
opacity: 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 2px solid black;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: .5em 2em;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { margin: .5em 0; }
|
||||||
|
p:first-of-type { margin-top: 1em; }
|
||||||
|
p:last-of-type { margin-bottom: 1em; }
|
||||||
|
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-from,
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-box {
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: block;
|
||||||
|
height: 5em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import './assets/main.css'
|
import './main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import Confetti from 'vue-confetti'
|
||||||
|
|
||||||
|
export const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(Confetti)
|
||||||
|
app.mount('#app')
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
|
||||||
|
|