This is the first episode of "Apps For Your Portfolio" series. In this post, we are going to build a Meditation App with my favorite front-end framework Vue.js.
1.0 / Setup
2.0 / Assets
3.0 / Components
- 3.1 | ContainerBackground.vue
- 3.2 | ContainerPopUp.vue
- 3.3 | Sound.vue
- 3.4 | ContainerTimer.vue
- 3.5 | ContainerLinks.vue
[ 1.1 ] Vue 3 Config
# Install latest stable of Vue
yarn global add @vue/cli
[ 1.2 ] Creating a new project
# Create a New Vue Application name 'meditation-app'
vue create meditation-app
cd meditation-app
yarn
[ 1.3 ] Install Vuex & Sass Loader
We also need to install sass-loader pre-processors to animate easily our future timer. If you don't know Sass is an extension of CSS that enables you to use things like variables, nested rules, inline imports and more.
You can find more information on the official documentation :
https://sass-lang.com/documentation
yarn add -D sass-loader sass
To help my code organized in this application. I decide to use the state management pattern Vuex.
yarn add vuex@next --save
To use Vuex, we need also to change main.js and create a new folder name "store" with four files :
- index.js
- actions.js
- mutations.js
- getters.js
# ../src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import store from './store/index.js'
const app = createApp(App);
app.use(store);
app.mount('#app');
src
|
|-- store |-- actions.js
|-- getters.js
|-- index.js
|-- mutations.js
To build our meditation application, let's initialize all states, actions, mutations and getters as following :
'isPlaying'
'timeSelected'
'vibeSelected'
'step'
'choices'
# ../store/index.js
import { createStore } from 'vuex'
import rootMutations from './mutations.js'
import rootActions from './actions.js'
import rootGetters from './getters.js'
const store = createStore({
state() {
return {
isPlaying: false,
timeSelected: 0,
vibeSelected: { value: 'bird'},
step: 0,
choices: [
{
id: 1,
name: '10 minutes',
imgSrc: require("@/assets/images/Bouton_10_minutes.png"),
category: 'timer',
value: 600,
},
{
id: 2,
name: '20 minutes',
imgSrc: require("@/assets/images/Bouton_20_minutes.png"),
category: 'timer',
value: 1200,
},
{
id: 3,
name: '30 minutes',
imgSrc: require("@/assets/images/Bouton_30_minutes.png"),
category: 'timer',
value: 1800,
},
{
id: 4,
name: 'In The Space',
imgSrc: require("@/assets/images/Bouton_Space.png"),
category: 'vibe',
value: 'space',
},
{
id: 5,
name: 'On The Beach',
imgSrc: require("@/assets/images/Bouton_Beach.png"),
category: 'vibe',
value: 'beach',
},
{
id: 6,
name: 'Under The Rain',
imgSrc: require("@/assets/images/Bouton_Rain.png"),
category: 'vibe',
value: 'rain',
},
]
}
},
mutations: rootMutations,
actions: rootActions,
getters: rootGetters,
});
export default store;
# ../src/store/actions.js
export default {
changeTimer(context, time) {
context.commit('changeTimer', time)
},
changeVibe(context, vibe) {
context.commit('changeVibe', vibe)
},
changeStep(context) {
context.commit('changeStep')
},
activeIsPlaying(context) {
context.commit('activeIsPlaying')
},
togglePlayPause(context) {
context.commit('togglePlayPause')
},
}
# ../src/store/mutations.js
export default {
changeTimer(state, time) {
state.timeSelected = time
},
changeVibe(state, vibe) {
state.vibeSelected = vibe
},
changeStep(state) {
state.step++
},
activeIsPlaying(state) {
state.isPlaying = true
},
togglePlayPause(state) {
state.isPlaying = !state.isPlaying
},
}
# ../src/store/getters.js
export default {
timeSelected(state) {
return state.timeSelected
},
vibeSelected(state) {
return state.vibeSelected
},
step(state) {
return state.step
},
choices(state) {
return state.choices
},
isPlaying(state) {
return state.isPlaying
}
}
Great ! The next step is importing all assets.
Let's create four new folders in assets :
- images,
- sounds,
- svg,
- videos,
src
|
|-- assets --|-- images
|-- sounds
|-- svg
|-- videos
[ 2.1 ] Assets Images
Maybe you didn't notice but the state 'choices' is an array of objects. Each object has an attribute 'imgSrc' as this example.
{
id: 6,
name: 'Under The Rain',
imgSrc: require("@/assets/images/Bouton_Rain.png"),
category: 'vibe',
value: 'rain',
},
As you can see it 'require("@/assets/images/Bouton_Rain.png")' is the path to import the file 'Bouton_Rain.png'
You need to create six illustrations relative to timer and to background video as following :
I took some image from https://unsplash.com/ and resizing it to 375px width | 250px height.
src
|
|-- assets --|-- images -- Bouton_10_minutes.png
|-- Bouton_20_minutes.png
|-- Bouton_30_minutes.png
|-- Bouton_Beach.png
|-- Bouton_Rain.png
|-- Bouton_Space.png
[ 2.2 ] Assets Sounds
Download four sound effects from https://www.videvo.net/ and import them in sounds folder.
src
|
|-- assets -- sounds -- beach.mp3
|-- calmSpace.mp3
|-- rain.mp3
|-- songOfBirds.mp3
[ 2.3 ] Assets Svg
Create eight components in svg folder :
src
|
|-- assets -- svg -- BeachSvg.vue
|-- GithubSvg.vue
|-- LinkedInSvg.vue
|-- PauseSvg.vue
|-- PlaySvg.vue
|-- PraySvg.vue
|-- RainSvg.vue
|-- SpaceSvg.vue
# ../src/assets/svg/BeachSvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
<path d="M115.4 200.8L217.5 238.15C252.63 156.53 303.75 93.75 356.5 64.45C260.62 59.575 167.7 101.41 108 176.15C101.2 184.6 105.2 197.2 115.4 200.8ZM247.6 249L486.1 335.87C521.85 214.47 504.72 104.27 443.47 81.97C436.095 79.345 428.35 77.908 420.35 77.908C362.4 77.88 292.1 147.13 247.6 249ZM521.5 124.51C527.75 140.76 532.25 159.13 534.63 179.76C540.38 229.63 533.254 287.86 515.75 346.66L618.35 384.03C628.48 387.78 639.6 380.655 639.85 369.91C642.3 274.1 598 182.4 521.5 124.51ZM528 512H321L386 333.5L325.87 311.63L252.99 512.03H48C21.49 512 0 533.5 0 560C0 568.8 7.163 576 16 576H560C568.837 576 576 568.837 576 560.9C576 533.5 554.5 512 528 512Z" :fill="defaultColor"/>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/GithubSvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M320 140C220.4 140 140 220.4 140 320C140 399.2 191.6 466.4 263.6 490.4C273.2 491.6 275.6 486.8 275.6 482V450.8C225.2 461.6 214.4 426.8 214.4 426.8C206 406.4 194 400.4 194 400.4C177.2 389.6 195.2 389.6 195.2 389.6C213.2 390.8 222.8 407.6 222.8 407.6C238.4 435.2 264.8 426.8 275.6 422C276.8 410 281.6 402.8 287.6 398C248 393.2 206 377.6 206 309.2C206 290 213.2 273.2 224 261.2C221.6 256.4 215.6 238.4 225.2 213.2C225.2 213.2 240.8 208.4 274.4 231.2C288.8 227.6 304.4 225.2 320 225.2C335.6 225.2 351.2 227.6 365.6 231.2C400.4 208.4 414.8 213.2 414.8 213.2C424.4 238.4 418.4 256.4 416 261.2C428 273.2 434 290 434 309.2C434 378.8 392 393.2 352.4 398C358.4 404 364.4 414.8 364.4 431.6V480.8C364.4 485.6 368 491.6 376.4 489.2C448.4 465.2 498.8 398 498.8 318.8C500 220.4 419.6 140 320 140Z" :fill="defaultColor"/>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/LinkedInSvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 96H127.9C110.3 96 96 110.5 96 128.3V511.7C96 529.5 110.3 544 127.9 544H512C529.6 544 544 529.5 544 511.7V128.3C544 110.5 529.6 96 512 96ZM231.4 480H165V266.2H231.5V480H231.4ZM198.2 237C176.9 237 159.7 219.7 159.7 198.5C159.7 177.3 176.9 160 198.2 160C219.4 160 236.7 177.3 236.7 198.5C236.7 219.8 219.5 237 198.2 237V237ZM480.3 480H413.9V376C413.9 351.2 413.4 319.3 379.4 319.3C344.8 319.3 339.5 346.3 339.5 374.2V480H273.1V266.2H336.8V295.4H337.7C346.6 278.6 368.3 260.9 400.6 260.9C467.8 260.9 480.3 305.2 480.3 362.8V480V480Z" :fill="defaultColor"/>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/PauseSvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
<path d="M432 127.1H400C373.49 127.1 352 148.59 352 174.2V462.2C352 488.71 373.49 510.2 400 510.2L432 512C458.51 512 480 490.51 480 464V176C480 149.49 458.5 127.1 432 127.1ZM240 127.1H208C181.49 127.1 160 148.59 160 175.1V463.1C160 490.5 181.49 512 208 512H240C266.51 512 288 490.51 288 464V176C288 149.49 266.5 127.1 240 127.1Z" :fill="defaultColor"/>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/PlaySvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
<path d="M176 543.98C148.6 543.98 128 521.58 128 495.98V143.98C128 118.6 148.4 96 176.01 96C184.696 96 193.36 98.352 201.03 103.031L489.03 279.031C503.3 287.78 512 303.28 512 319.98C512 336.68 503.297 352.21 489.03 360.93L201.03 536.93C193.4 541.58 184.7 543.98 176 543.98Z" :fill="defaultColor"/>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/PraySvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M272 255.9C254.38 255.9 240 270.25 240 287.87V367.9C240 376.775 232.875 383.9 224 383.9C215.125 383.9 208 376.775 208 367.9V291.4C208 274.03 212.75 256.9 221.75 242.03L299.5 112.41C308.5 97.29 303.625 77.65 288.5 68.53C273.1 59.775 255.8 64.1289 246.1 77.63C245.1 77.88 245.5 77.88 245.4 78.13L128.1 254C117.5 269.9 112 288.3 112 307.3V387.54L21.87 416.64C8.75 421.9 0 434.1 0 447.9V543.89C0 554.77 8.5 574.99 32 574.99C34.75 574.99 37.375 574.74 40 573.99L219.3 527.37C269.1 514 304 467.8 304 415.9V287.9C304 270.3 289.6 255.9 272 255.9ZM618.1 417.6L528 387.6V307.4C528 288.4 522.5 270.03 511.88 254.15L394.58 78.25C394.455 78 393.955 78.0013 393.83 77.7513C384.205 64.2513 365.95 59.9013 351.45 68.5223C336.33 77.6473 331.45 97.2823 340.45 112.532L418.2 242.032C427.3 257 432 274 432 291.5V367.99C432 376.865 424.875 383.99 416 383.99C407.125 383.99 400 376.865 400 367.99V287.1C400 269.48 385.62 255.13 368 255.13C350.38 255.13 336 269.51 336 286.23V413.33C336 465.2 370.88 511.45 420.75 525.73L600 575C602.6 575.6 605.4 576 608 576C631.5 576 640 554.75 640 544.9V448.91C640 434.3 631.3 422 618.1 417.6Z" :fill="defaultColor"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="640" height="512" fill="white" transform="translate(0 64)"/>
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/RainSvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
<path d="M146.56 295.9C127.79 327.4 96 384.6 96 411.4C96 449.3 124.65 480 160 480C195.35 480 224 449.27 224 411.36C224 384.64 192.21 327.4 173.44 295.96C167.14 285.4 152.86 285.4 146.56 295.9ZM320 219.4C320 192.68 288.21 135.44 269.44 104C263.133 93.43 248.86 93.43 242.55 104C223.8 135.4 192 192.6 192 219.4C192 257.3 220.7 288 256 288C291.3 288 320 257.3 320 219.4ZM430.6 199.5C423.684 189.49 408.33 189.49 401.41 199.5C367.8 248.1 288 369.2 288 422.3C288 489.5 345.3 544 416 544C486.7 544 544 489.5 544 422.3C544 369.2 464.2 248.1 430.6 199.5Z" :fill="defaultColor"/>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
# ../src/assets/svg/SpaceSvg.vue
<template>
<svg :width="defaultWidth" :height="defaultHeight" viewBox="0 0 640 640" :fill="defaultColor" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path d="M323.7 150L373.36 170.63L393.98 220.3C395.105 222.675 397.478 224.007 399.978 224.007C402.478 224.007 404.854 222.675 405.979 220.3L426.599 170.63L476.219 150C478.594 148.875 479.967 146.499 479.967 143.998C479.967 141.497 478.594 139.124 476.219 137.999L426.6 117.38L405.98 67.75C404.9 65.375 402.5 64 400 64C397.5 64 395.128 65.375 394.003 67.75L373.383 117.38L323.723 138.01C321.348 139.135 320.011 141.509 320.011 144.009C320.011 146.509 321.3 148.88 323.7 150ZM428.2 331.2L323.4 315.92L276.6 220.7C268.225 203.7 243.88 203.57 235.38 220.7L188.5 315.1L83.71 331.2C64.8322 333.1 57.258 357.2 71.007 370.5L146.877 444.5L129.777 549.1C126.652 567.98 146.477 582.15 163.097 573.28L256.887 523.9L350.627 573.28C358.377 577.405 367.757 576.666 374.877 571.541C380.977 566.416 385.497 557.631 383.996 549.131L365.2 444.5L441.12 370.5C454.7 357.3 447.1 333.1 428.2 331.2ZM573 283.3L533.38 266.67L516.76 227.04C515.885 225.165 514.009 224.037 512.009 224.037C510.009 224.037 508.135 225.165 507.26 227.04L490.64 266.67L451 283.3C449.125 284.175 447.998 286.046 447.998 288.046C447.998 290.046 449.125 291.925 451 292.8L490.62 309.43L507.24 349.06C508.115 350.935 509.989 352.055 511.989 352.055C513.989 352.055 515.865 350.935 516.74 349.06L533.36 309.43L572.98 292.8C574.9 291.9 576 290 576 288C576 286 574.9 284.1 573 283.3Z" :fill="defaultColor"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="512" height="512" fill="white" transform="translate(64 64)"/>
</clipPath>
</defs>
</svg>
</template>
<script>
export default {
props: {
size: {
type: String,
required: false,
},
color: {
type: String,
required: false,
}
},
computed: {
defaultWidth() {
return this.size
},
defaultHeight() {
return this.size
},
defaultColor() {
return this.color
}
}
}
</script>
[ 2.4 ] Assets Videos
From https://www.videvo.net/ download and import 4 background videos in assets.
src
|
|-- assets -- videos -- bird.mp4
|-- rain.mp4
|-- space-galaxy.mp4
|-- sunny-beach.mp4
All assets are imported now ! Let's create all components 🙂
[ 3.1 ] ContainerBackground.vue
Add a new folder "container" in "../src/components" and create a new component "ContainerBackground.vue"
# ../src/components/container/ContainerBackground.vue
<template>
<div class="video-container">
<video
v-if="vibeSelected === 'space'"
class="video-wrapper"
autoplay
muted
loop
>
<source
:src="updateBackground"
type="video/mp4"
rel='preload'
/>
</video>
<video
v-if="vibeSelected === 'beach'"
class="video-wrapper"
autoplay
muted
loop
>
<source
:src="updateBackground"
type="video/mp4"
rel='preload'
/>
</video>
<video
v-if="vibeSelected === 'rain'"
class="video-wrapper"
autoplay
muted
loop
>
<source
:src="updateBackground"
type="video/mp4"
rel='preload'
/>
</video>
<video
v-if="vibeSelected === 'bird'"
class="video-wrapper"
autoplay
muted
loop
>
<source
:src="updateBackground"
type="video/mp4"
rel='preload'
/>
</video>
</div>
</template>
<script>
export default {
computed: {
updateBackground() {
if (
this.vibeSelected === 'space' ||
this.vibeSelected === null
)
{
return require("@/assets/videos/space-galaxy.mp4")
} else if (this.vibeSelected === 'beach') {
return require("@/assets/videos/sunny-beach.mp4")
} else if (this.vibeSelected === 'rain') {
return require("@/assets/videos/rain.mp4")
} else {
return require("@/assets/videos/bird.mp4")
}
},
vibeSelected() {
return this.$store.getters['vibeSelected'].value
}
},
}
</script>
<style>
.video-container {
position: absolute;
z-index: -5;
display: flex;
width: 100vw;
height: 100vh;
}
.video-wrapper {
position: absolute;
width: 100%;
height: auto;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: -10;
overflow: none;
}
@media (min-aspect-ratio: 16/9) {
.video-wrapper {
width:100%;
height: auto;
}
}
@media (max-aspect-ratio: 16/9) {
.video-wrapper {
width:auto;
height: 100%;
}
}
</style>
This component change background video depending of user choice.
[ 3.2 ] ContainerPopUp.vue
Unlike the 'ContainerBackground.vue' 'ContainerPopUp.vue' is a nested component. It allow us to encapsulate functionality and easily reuse them in multiple places in this application.
ContainerPopUp.vue
|-- ContainerSelector.vue
|-- Selector.vue
Firstly, Let's create 'ContainerPopUp.vue'
# ../src/components/container/ContainerPopUp.vue
<template>
<div v-if="step < 2" class="overlay">
<div class="popup">
<PraySvg
size="80"
color="white"
/>
<h1>Welcome to<br> Vibe Meditation App</h1>
<ContainerSelector
:question="questionDisplaying"
>
<Selector
v-if="step === 0"
:size="'normal'"
:mode="'timer'"
/>
<Selector
v-if="step > 0"
:size="'normal'"
:mode="'vibe'"
/>
</ContainerSelector>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import PraySvg from '@/assets/svg/PraySvg'
import ContainerSelector from '@/components/container/ContainerSelector'
import Selector from '@/components/Selector'
export default {
components: {
PraySvg,
ContainerSelector,
Selector
},
computed: {
questionDisplaying() {
if (this.step === 0) {
return 'How long do you want to meditate ?'
} else {
return 'Where do you want to meditate ?'
}
},
...mapGetters([
'timeSelected',
'vibeSelected',
'step'
])
}
}
</script>
<style scoped>
.overlay {
position: absolute;
width: 100vw;
height: 100vh;
}
.popup {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
width: 600px;
height: 600px;
top: 50%;
right: 50%;
transform: translate(50%,-50%);
background-color: rgba(5, 5, 5, 0.900);
border-radius: 15px;
box-shadow: 1px 1px rgba(255, 255, 255, 0.200);
color: white;
}
.bouton-primary {
width: 200px;
line-height: 30px;
border-radius: 5px;
margin: 30px;
background-color: rgba(193, 99, 89, 0.800);
border: rgba(193, 99, 89, 0.800);
color: aliceblue;
}
.bouton-off {
width: 200px;
line-height: 30px;
border-radius: 5px;
margin: 30px;
background-color: rgba(35, 35, 35, 0.500);
border: white;
color: aliceblue;
}
</style>
Second step is creating 'ContainerSelector.vue'
# ../src/components/container/ContainerSelector.vue
<template>
<section class="selector-container">
<h3>{{ question }}</h3>
<div class="selector-wrapper">
<slot></slot>
</div>
</section>
</template>
<script>
export default {
props: ['question']
}
</script>
<style>
.selector-container {
position: relative;
display: flex;
flex-direction: column;
width: 500px;
justify-content: center;
}
h3, .selector-container {
margin-bottom: 50px;
}
.selector-wrapper {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-around;
width: 100%;
}
</style>
Third step is creating 'Selector.vue'
# ../src/components/Selector.vue
<template>
<div
v-for="choice in filteredChoices"
:key="choice.id"
@click="selectChoice(choice.id)"
class="choice-card"
:class="hideUnselectedImg(choice.id)"
>
<img
:src="choice.imgSrc"
:alt="choice.name"
>
<label
:for="choice.name"
:style="fontSizeDynamic(choice.id)"
>
{{ choice.name }}
</label>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
props: ['size', 'mode'],
methods: {
hideUnselectedImg(id) {
if (this.vibeSelected.id === id) {
return 'img-selected'
}
else {
return 'img-unselected'
}
},
selectChoice(id) {
let index = this.choices.findIndex(x => x.id === id);
if (this.step === 0) {
let timeSelecting = {
id: this.choices[index].id,
value: this.choices[index].value
}
this.$store.dispatch('changeTimer', timeSelecting)
this.$store.dispatch('changeStep')
} else {
let vibeSelecting = {
id: this.choices[index].id,
value: this.choices[index].value
}
this.$store.dispatch('changeVibe', vibeSelecting)
this.$store.dispatch('changeStep')
this.$store.dispatch('activeIsPlaying')
}
},
fontSizeDynamic(id) {
if (this.timeSelected.id === id || this.vibeSelected.id === id) {
return 'font-weight: bold;'
}
else {
return 'font-weight: normal;'
}
},
},
computed: {
filteredChoices() {
return this.choices.filter(choice =>
{
if
(
this.mode === 'timer' &&
choice.category.includes('timer')
)
{
return true
}
if
(
this.mode === 'vibe' &&
choice.category.includes('vibe')
)
{
return true;
}
}
)
},
...mapGetters([
'timeSelected',
'vibeSelected',
'step',
'choices'
])
}
}
</script>
<style scoped>
.choice-card {
cursor: pointer;
display: flex;
flex-direction: column;
}
.img-selected {
display: none;
}
img:hover {
opacity: 1;
}
img {
opacity: 0.5;
height: 100px;
width: 150;
}
.img-unselected {
border-style: none;
}
label {
margin-top: 10px;
}
</style>
Perfect ! With this first nested component, the user can select time of meditation and the virtual area where he wants to meditate.
[ 3.3 ] Sound.vue
Previously, we created files which allow us to change background video. For a better User Experience, we need to implement sound effects !
Let's create a new simple component 'Sound.vue'
# ../src/components/Sound.vue
<template>
<audio :src="updateSound" preload="auto" autoplay loop ref="audioPlayer" />
</template>
<script>
export default {
computed: {
updateSound() {
if (this.vibeSelected === 'space') {
return require("@/assets/sounds/calmSpace.mp3")
} else if (this.vibeSelected === 'beach') {
return require("@/assets/sounds/beach.mp3")
} else if (this.vibeSelected === 'rain') {
return require("@/assets/sounds/rain.mp3")
} else {
return require("@/assets/sounds/songOfBirds.mp3")
}
},
vibeSelected() {
return this.$store.getters['vibeSelected'].value
}
},
}
</script>
[ 3.4 ] ContainerTimer.vue
Background Video, sound effects so what's next ? Timer Animation of course.
Let's create a new nested component 'ContainerTimer.vue'
ContainerTimer.vue
|-- TimerHeader.vue
|-- Timer.vue
|-- TimerRemaining.vue
|-- VibeSwitcher.vue
# ../src/components/container/ContainerTimer.vue
<template>
<section
v-if="step === 2"
class="container"
>
<TimerHeader />
<Timer
:timeLeft="timeLeft"
:timeSelected="timeSelected.value"
/>
<TimerRemaining
:timeLeft="timeLeft"
/>
<VibeSwitcher />
</section>
</template>
<script>
import { mapGetters } from 'vuex'
import Timer from '@/components/Timer.vue'
import TimerRemaining from '@/components/TimerRemaining'
import TimerHeader from '@/components/TimerHeader'
import VibeSwitcher from '@/components/VibeSwitcher'
export default {
components: {
Timer,
TimerRemaining,
TimerHeader,
VibeSwitcher
},
data() {
return {
timeLimit: 0,
timePassed: 0,
timerInterval : 0,
};
},
computed: {
...mapGetters([
'timeSelected',
'vibeSelected',
'step',
'isPlaying'
]),
timeLeft() {
if (this.timerInterval === null) {
return 0
} else if (this.timePassed === this.timeSelected.value) {
clearInterval(this.timerInterval)
this.$store.dispatch('changeVibe', { value: 'bird' })
this.$store.dispatch('changeStep')
console.log('step : ', this.step)
return 0
} else {
return this.timeSelected.value - this.timePassed
}
},
},
watch: {
isPlaying(isInProgress) {
if (isInProgress) {
this.timerInterval = setInterval(() => (this.timePassed += 1), 1000);
} else {
clearInterval(this.timerInterval)
}
}
},
}
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 80%;
width: 400px;
background-color: rgba(5, 5, 5, 0.700);
border-radius: 15px;
box-shadow: 1px 1px rgba(255, 255, 255, 0.500);
}
h1, .container {
color: white;
font-size: 25px;
}
</style>
# ../src/components/TimerHeader.vue
<template>
<div
v-show="vibeSelected === 'space'"
class="icon-header"
>
<SpaceSvg
size="40"
color="white"
/>
<h1>Meditation <br> In The Space</h1>
</div>
<div
v-show="vibeSelected === 'beach'"
class="icon-header"
>
<BeachSvg
size="40"
color="white"
/>
<h1>Meditation <br> In On Beach</h1>
</div>
<div
v-show="vibeSelected === 'rain'"
class="icon-header"
>
<RainSvg
size="40"
color="white"
/>
<h1>Meditation <br> Under The Rain</h1>
</div>
<div
v-show="vibeSelected === 'alarm'"
class="icon-header"
>
<RainSvg
size="40"
color="white"
/>
<h1>Meditation <br> Finished</h1>
</div>
</template>
<script>
import SpaceSvg from '@/assets/svg/SpaceSvg'
import BeachSvg from '@/assets/svg/BeachSvg'
import RainSvg from '@/assets/svg/RainSvg'
export default {
name: 'HeaderTimer',
components: {
SpaceSvg,
BeachSvg,
RainSvg,
},
computed: {
vibeSelected() {
return this.$store.getters['vibeSelected'].value
}
}
}
</script>
<style scoped>
.icon-header {
min-height: 40px;
}
h1 {
margin-bottom: 30px;
margin-top: 0px;
}
</style>
# ../src/components/Timer.vue
<template>
<div class="base-timer">
<svg
class="base-timer__svg"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<g class="base-timer__circle">
<circle
class="base-timer__path-elapsed"
cx="50"
cy="50"
r="45"
/>
<path
:stroke-dasharray="circleDasharray"
:class="remainingPathColor"
class="base-timer__path-remaining"
d="
M 50, 50
m -45, 0
a 45,45 0 1,0 90,0
a 45,45 0 1,0 -90,0
">
</path>
</g>
</svg>
<span class="base-timer__label">
<PlaySvg
@click="togglePlayPause"
v-show="!isPlaying"
size="80"
color="white"
/>
<PauseSvg
@click="togglePlayPause"
v-show="isPlaying"
size="80"
color="white"
/>
</span>
</div>
</template>
<script>
import PlaySvg from '@/assets/svg/PlaySvg'
import PauseSvg from '@/assets/svg/PauseSvg'
import { mapGetters } from 'vuex'
export default {
components: {
PlaySvg,
PauseSvg
},
props: {
timeLeft: {
type: Number,
required: true
},
timeSelected: {
type: Number,
required: true
},
alertThreshold: {
type: Number,
default: 5
},
warningThreshold: {
type: Number,
default: 10
},
},
methods: {
togglePlayPause() {
this.$store.dispatch('togglePlayPause')
}
},
computed: {
...mapGetters([
'isPlaying',
]),
circleDasharray() {
return `${(this.timeFraction * 283).toFixed(0)} 283`;
},
timeFraction() {
const rawTimeFraction = this.timeLeft / this.timeSelected
return rawTimeFraction -
(1 / this.timeSelected) * (1 - rawTimeFraction)
},
formattedTimeLeft() {
const timeLeft = this.timeLeft
let minutes = Math.floor(timeLeft / 60)
const hours = Math.floor(minutes / 60)
let seconds = timeLeft % 60
if (seconds < 10) {
seconds = `0${seconds}`
}
if (minutes === 60) {
minutes = `00`
}
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
},
colorCodes() {
return {
info: {
color: "green"
},
warning: {
color: "orange",
threshold: this.warningThreshold
},
alert: {
color: "red",
threshold: this.alertThreshold
}
}
},
remainingPathColor() {
const { alert, warning, info } = this.colorCodes;
if (this.timeLeft <= alert.threshold) {
return alert.color;
} else if (this.timeLeft <= warning.threshold) {
return warning.color;
} else {
return info.color;
}
}
}
}
</script>
<style scoped lang="scss">
.base-timer {
position: relative;
width: 250px;
height: 250px;
&__path-remaining {
stroke-width: 7px;
stroke-linecap: round;
transform: rotate(90deg);
transform-origin: center;
transition: 1s linear all;
fill-rule: nonzero;
stroke: currentColor;
&.green {
color: rgb(65, 184, 131);
}
&.orange {
color: orange;
}
&.red {
color: rgba(193, 99, 89, 1.000);
}
}
&__svg {
/* Flips the svg and makes the animation to move left-to-right
transform: scaleX(-1); */
}
&__circle {
fill: rgba(35, 35, 35, 0.500);
stroke: rgba(35, 35, 35, 0.500);
}
&__path-elapsed {
stroke-width: 7px;
stroke: none;
}
&__label {
position: absolute;
width: 250px;
height: 250px;
top: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: white;
cursor: pointer;
}
}
</style>
# ../src/componnents/TimerRemaining.vue
<template>
<span class="time-remaining">Remaining Time</span>
<span
class="timer-flow"
:style="isPlaying ? 'opacity: 1.0;' : 'opacity: 0.5'"
>
{{ formattedTimeLeft }}
</span>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
props: {
timeLeft: {
type: Number,
required: true
},
},
computed: {
formattedTimeLeft() {
const timeLeft = this.timeLeft
let minutes = Math.floor(timeLeft / 60)
const hours = Math.floor(minutes / 60)
let seconds = timeLeft % 60
if (seconds < 10) {
seconds = `0${seconds}`
}
if (minutes === 60) {
minutes = `00`
}
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
},
...mapGetters([
'timeSelected',
'vibeSelected',
'step',
'isPlaying'
]),
},
}
</script>
<style>
.time-remaining {
margin-top: 10px;
font-weight: bold;
}
.timer-flow {
font-size: 40px
}
</style>
# ../src/components/VibeSwitcher.vue
<template>
<p>Would You Like Mediate Elsewhere ?</p>
<div class="switcher">
<span @click="selectPreviousVibe">{{ previousVibe }}</span>
<span> | </span>
<span @click="selectNextVibe">{{ nextVibe }}</span>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
methods: {
selectPreviousVibe() {
if (this.vibeSelected.value === 'space') {
this.$store.dispatch('changeVibe', { value: 'rain'})
} else if (this.vibeSelected.value === 'beach') {
this.$store.dispatch('changeVibe', { value: 'space'})
} else {
this.$store.dispatch('changeVibe', { value: 'beach'})
}
},
selectNextVibe() {
if (this.vibeSelected.value === 'space') {
this.$store.dispatch('changeVibe', { value: 'beach'})
} else if (this.vibeSelected.value === 'beach') {
this.$store.dispatch('changeVibe', { value: 'rain'})
} else {
this.$store.dispatch('changeVibe', { value: 'space'})
}
}
},
computed: {
...mapGetters([
'vibeSelected'
]),
previousVibe() {
if (this.vibeSelected.value === 'space') {
return 'Under The Rain'
} else if (this.vibeSelected.value === 'beach') {
return 'In The Space'
} else {
return 'On The Beach'
}
},
nextVibe() {
if (this.vibeSelected.value === 'space') {
return 'On The Beach'
} else if (this.vibeSelected.value === 'beach') {
return 'Under The Rain'
} else {
return 'In The Space'
}
}
},
}
</script>
<style scoped>
.switcher {
display: flex;
flex-direction: row;
justify-content: space-between;
}
span {
display: flex;
cursor: pointer;
font-size: 18px;
margin: 10px;
}
p {
font-size: 18px;
}
</style>
What do you think ? Nice view right ?
[ 3.5 ] ContainerLinks.vue
When Timer is over, I prepared a last view with my linkedIn, Github and my Portfolio.
You just need to replace links and Svg in this last component.
# ../src/components/container/ContainerLinks.vue
<template>
<div
v-if="step === 3"
class="container-end"
>
<PraySvg :size="'60'" :color="'white'"/>
<h2>Your Meditation Is Over !</h2>
<span>Made With Love<br> ❤️ By Sith Norvang ❤️</span>
<div class="container-network">
<a
class="icon-network"
href="https://github.com/slaoprp"
target="_blank"
>
<GithubSvg :size="'80'" :color="'white'" />
<span>Github</span>
</a>
<a
class="icon-network"
href="https://www.linkedin.com/in/sith-norvang-3a72501a5"
target="_blank"
>
<LinkedInSvg :size="'80'" :color="'white'" />
<span>LinkedIn</span>
</a>
<a
class="icon-network"
href="https://www.eliyote.com"
target="_blank"
>
<span>Portfolio</span>
</a>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import PraySvg from '@/assets/svg/PraySvg'
import GithubSvg from '@/assets/svg/GithubSvg'
import LinkedInSvg from '@/assets/svg/LinkedInSvg'
export default {
components: {
PraySvg,
GithubSvg,
LinkedInSvg
},
methods: {
linkTo() {
this.$router.push('www.eliyote.com')
}
},
computed: {
...mapGetters([
'step',
]),
}
}
</script>
<style scoped>
.container-end {
color: white;
width: auto;
height: auto;
padding: 50px;
background-color: rgba(5, 5, 5, 0.900);
border-radius: 15px;
box-shadow: 1px 1px rgba(255, 255, 255, 0.200);
}
.container-network {
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 20px;
}
.icon-network {
display: flex;
flex-direction: column;
cursor: pointer;
}
a {
text-decoration: none;
color: white;
}
</style>
Everything is already now ! We need to import all components in App.vue
# ../src/App.vue
<template>
<main class="app">
<ContainerPopUp />
<ContainerBackground />
<Sound />
<ContainerTimer />
<ContainerLinks />
</main>
</template>
<script>
import ContainerPopUp from '@/components/container/ContainerPopUp'
import ContainerBackground from '@/components/container/ContainerBackground'
import ContainerTimer from '@/components/container/ContainerTimer'
import ContainerLinks from '@/components/container/ContainerLinks'
import Sound from '@/components/Sound'
export default {
name: "App",
components: {
ContainerPopUp,
ContainerBackground,
ContainerTimer,
ContainerLinks,
Sound
},
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
body {
margin: 0;
padding: 0;
box-sizing: border-box;
overflow: hidden;
}
.app {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>
There are so many other way to build this meditation application.
I hope this will help a beginner in Vue. See you in the next post of this series "Portfolio Apps".
You can try this meditation-app here :
Top comments (2)
I love it, and i will used maybe in future i wanna make some link exchange with me app.
Thanks, this might be useful to me. I'm just thinking about build a meditation app