Vue 3 (muso ninjas)
Table of Content
Set up
package.json
package.json
{
"name": "muso-ninjas",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"core-js": "^3.6.5",
"firebase": "^8.2.0",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0"
}
}
Main components
App.vue
<template>
<Navbar />
<div class="content">
<router-view/>
</div>
</template>
<style>
.content {
margin: 0 auto;
max-width: 1200px;
padding: 0 20px;
}
</style>
<script>
import Navbar from "./components/Navbar";
export default {
components: {Navbar}
}
</script>
main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import {projectAuth} from "./firebase/config";
// global styles
import './assets/main.css'
let app
projectAuth.onAuthStateChanged(() => {
if (!app) {
app = createApp(App).use(router).mount('#app')
}
})
Views
Home.vue
<template>
<div class="home">
<div v-if="error" class="error">
Coult not fetch the data
</div>
<div v-if="documents">
<ListView :playlists="documents" />
</div>
</div>
</template>
<script>
// @ is an alias to /src
import getCollection from "../composables/getCollection";
import ListView from "../components/ListView";
export default {
name: 'Home',
setup() {
const {error, documents} = getCollection('playlists')
console.log(documents)
return {error, documents}
},
components: {
ListView
}
}
</script>
./playlists/CreatePlaylist.vue
<template>
<form @submit.prevent="handleSubmit">
<h4>Create New Playlist</h4>
<input type="text" required placeholder="Playlist title" v-model="title" />
<textarea required placeholder="Playlist description..." v-model="description"></textarea>
<label>Upload playlist cover image</label>
<input type="file" @change="handleChange">
<div class="error">{{fileError}}</div>
<div class="error"></div>
<button v-if="!isPending">Create</button>
<button v-else disabled>Saving...</button>
</form>
</template>
<script>
import {ref} from "vue";
import useStorage from "../../composables/useStorage";
import useCollection from '../../composables/useCollection'
import getUser from "../../composables/getUser";
import {timestamp} from "../../firebase/config";
import {useRouter} from "vue-router";
export default {
setup() {
const title = ref('')
const description = ref('')
const file = ref(null)
const fileError = ref(null)
const isPending = ref(false)
const {url, filePath, uploadImage} = useStorage()
const {error, addDoc} = useCollection('playlists')
const {user} = getUser()
const router = useRouter()
const handleSubmit = async () => {
if (file.value) {
isPending.value = true
await uploadImage(file.value)
const res = await addDoc({
title: title.value,
description: description.value,
userId: user.value.uid,
userName: user.value.displayName,
coverUrl: url.value,
filePath: filePath.value,
songs: [],
createdAt: timestamp()
})
isPending.value = false
if (!error.value) {
console.log('playlist added')
await router.push({name: 'PlaylistDetails', params: {id: res.id}})
}
}
}
//allowed types
const types = ['image/png', 'image/jpeg']
const handleChange = (e) => {
const selected = e.target.files[0]
if (selected && types.includes(selected.type)) {
file.value = selected
fileError.value = null
} else {
file.value = null
fileError.value = 'Please select and image file (png or jpg)'
}
}
return {title, description, handleSubmit, handleChange, fileError, isPending}
}
}
</script>
./playlists/PlaylistDetails.vue
<template>
<h2>Playlist details {{id}}</h2>
<div v-if="playlist" class="playlist-details">
<div class="playlist-info">
<div class="cover">
<img :src="playlist.coverUrl" alt="no image"/>
</div>
<h2>
{{playlist.title}}
</h2>
<p class="username">
Created by {{playlist.userName}}
</p>
<p class="description">
{{playlist.description}}
</p>
<button v-if="ownership" @click="handleDelete">
Delete playlist
</button>
</div>
<div class="song-list">
<div v-if="!playlist.songs.length">
No songs have been added to this playlist yet
</div>
<div v-for="song in playlist.songs" class="single-song" :key="song.id">
<div class="details">
<h3>{{song.title}}</h3>
<p>{{song.artist}}</p>
</div>
<button v-if="ownership" @click="() => {handleDeleteSong(song.id)}">Delete</button>
</div>
<AddSong v-if="ownership" :playlist="playlist" />
</div>
</div>
<div v-if="error" class="error">
{{error}}
</div>
</template>
<script>
import useDocument from "../../composables/useDocument";
import getDocument from "../../composables/getDocument";
import getUser from "../../composables/getUser";
import {computed} from "vue";
import useStorage from "../../composables/useStorage";
import {useRouter} from "vue-router";
import AddSong from "../../components/AddSong";
export default {
components: {AddSong},
setup(props) {
const {document: playlist, error} = getDocument('playlists', props.id)
const {deleteImage} = useStorage()
const {user} = getUser()
const {deleteDoc, updateDoc} = useDocument('playlists', props.id)
const router = useRouter()
const ownership = computed(() => {
return playlist.value && user.value && user.value.uid === playlist.value.userId
})
const handleDelete = async () => {
await deleteImage(playlist.value.filePath)
const res = await deleteDoc('playlists', props.id)
await router.push({name: 'Home'})
}
const handleDeleteSong = async (songId) => {
const newSongs = playlist.value.songs.filter(song => {
return song.id !== songId
})
await updateDoc({songs: newSongs})
}
return {playlist, error, ownership, handleDelete, handleDeleteSong}
},
props: {
id: {
type: Number,
required: true
}
}
}
</script>
./playlists/UserPlaylists.vue
<template>
<div class="user-playlists">
<h2>My playlists</h2>
<div v-if="playlists">
<ListView :playlists="playlists"/>
</div>
<router-link :to="{ name: 'CreatePlaylist' }" class="btn"> Create playlist</router-link>
</div>
</template>
<script>
import getUser from "../../composables/getUser";
import getCollection from "../../composables/getCollection";
import ListView from "../../components/ListView";
export default {
setup() {
const {user} = getUser()
const {documents: playlists} = getCollection('playlists',
['userId', '==', user.value.uid])
console.log(playlists)
return {playlists}
},
components: {
ListView
}
}
</script>
./auth/Login.vue
<template>
<form @submit.prevent="handleSubmit">
<h3>Login form</h3>
<input type="email" placeholder="Email" v-model="email"/>
<input type="password" placeholder="password" v-model="password"/>
<div v-if="error" class="error"> {{error}}</div>
<button v-if="!isPending">Log in</button>
<button v-if="isPending" disabled>Loading</button>
</form>
</template>
<script>
import {ref} from 'vue'
import useLogin from '../../composables/useLogin'
import {useRouter} from "vue-router";
export default {
setup() {
const {error, login, isPending} = useLogin()
const email = ref('')
const password = ref('')
const router = useRouter()
const handleSubmit = async () => {
const res = await login(email.value, password.value)
if (!error.value) {
await router.push({name: 'UserPlaylist'})
}
}
return {email, password, handleSubmit, error, isPending}
}
}
</script>
./auth/Signup.vue
<template>
<form @submit.prevent="handleSubmit">
<h3>Login form</h3>
<input type="text" placeholder="display name" v-model="displayName"/>
<input type="email" placeholder="Email" v-model="email"/>
<input type="password" placeholder="password" v-model="password"/>
<div v-if="error" class="error"> {{error}}</div>
<button v-if="!isPending">Log in</button>
<button v-if="isPending" disabled>Loading</button>
</form>
</template>
<script>
import {ref} from 'vue'
import useSignup from "../../composables/useSignup";
import {useRouter} from "vue-router";
export default {
setup() {
const {error, signup, isPending} = useSignup()
const email = ref('')
const displayName = ref('')
const password = ref('')
const router = useRouter()
const handleSubmit = async () => {
const res = await signup(email.value, password.value, displayName.value)
if (!error.value) {
await router.push({name: 'UserPlaylist'})
}
}
return {email, password, displayName, handleSubmit, error, isPending}
}
}
</script>
Router
./index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Login from '../views/auth/Login'
import Signup from "../views/auth/Signup";
import CreatePlaylist from "../views/playlists/CreatePlaylist";
import {projectAuth} from "../firebase/config";
import PlaylistDetails from "../views/playlists/PlaylistDetails";
import UserPlaylists from "../views/playlists/UserPlaylists";
const requireAuth = (to, from, next) => {
let user = projectAuth.currentUser
if (!user) {
next({name: 'Login'})
} else {
next()
}
}
const routes = [
{
path: '/',
name: 'Home',
component: Home,
beforeEnter: requireAuth
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/signup',
name: 'Signup',
component: Signup
},
{
path: '/playlist/create',
name: 'CreatePlaylist',
component: CreatePlaylist,
beforeEnter: requireAuth
},
{
path: '/playlists/user',
name: 'UserPlaylist',
component: UserPlaylists,
beforeEnter: requireAuth
},
{
path: '/playlist/:id',
name: 'PlaylistDetails',
component: PlaylistDetails,
beforeEnter: requireAuth,
props: true
},
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
Composables
getCollection.js
import { ref, watchEffect } from 'vue'
import { projectFirestore } from '../firebase/config'
const getCollection = (collection, query) => {
const documents = ref(null)
const error = ref(null)
// register the firestore collection reference
let collectionRef = projectFirestore.collection(collection)
.orderBy('createdAt')
if (query) {
collectionRef = collectionRef.where(...query)
}
const unsub = collectionRef.onSnapshot(snap => {
let results = []
snap.docs.forEach(doc => {
// must wait for the server to create the timestamp & send it back
doc.data().createdAt && results.push({...doc.data(), id: doc.id})
});
// update values
documents.value = results
error.value = null
}, err => {
console.log(err.message)
documents.value = null
error.value = 'could not fetch the data'
})
watchEffect((onInvalidate) => {
onInvalidate(() => unsub());
});
return { error, documents }
}
export default getCollection
getDocument.js
import { ref, watchEffect } from 'vue'
import { projectFirestore } from '../firebase/config'
const getDocument = (collection, id) => {
const document = ref(null)
const error = ref(null)
// register the firestore collection reference
let documentRef = projectFirestore.collection(collection).doc(id)
const unsub = documentRef.onSnapshot(doc => {
if (doc.data()) {
document.value = {...doc.data(), id: doc.id}
error.value = null
} else {
error.value = 'that document does not exist'
}
// update values
}, err => {
console.log(err.message)
document.value = null
error.value = 'could not fetch the data'
})
watchEffect((onInvalidate) => {
onInvalidate(() => unsub());
});
return { error, document }
}
export default getDocument
getUser.js
import { ref } from 'vue'
import { projectAuth } from '../firebase/config'
// refs
const user = ref(projectAuth.currentUser)
// auth changes
projectAuth.onAuthStateChanged(_user => {
console.log('User state change. Current user is:', _user)
user.value = _user
});
const getUser = () => {
return { user }
}
export default getUser
useCollection.js
import { ref } from 'vue'
import { projectFirestore } from '../firebase/config'
const useCollection = (collection) => {
const error = ref(null)
const isPending = ref(false)
// add a new document
const addDoc = async (doc) => {
error.value = null
isPending.value = true
try {
const res = await projectFirestore.collection(collection).add(doc)
isPending.value = false
return res
}
catch(err) {
console.log(err.message)
isPending.value = false
error.value = 'could not send the message'
}
}
return { error, addDoc, isPending}
}
export default useCollection
useDocument.js
import {ref} from 'vue'
import {projectFirestore} from "../firebase/config";
const useDocument = (collection, id) => {
const error = ref(null)
const isPending = ref(false)
let docRef = projectFirestore.collection(collection).doc(id)
const deleteDoc = async () => {
isPending.value = true
error.value = null
try {
const res = await docRef.delete()
isPending.value = false
error.value = false
return res
} catch (err) {
console.log(err.message)
isPending.value = false
error.value = 'Could not delete the document'
}
}
const updateDoc = async (updates) => {
isPending.value = true
error.value = null
try {
const res = await docRef.update(updates)
isPending.value = false
error.value = false
return res
} catch (err) {
console.log(err.message)
isPending.value = false
error.value = 'Could not update the document'
}
}
return {error, isPending, deleteDoc, updateDoc}
}
export default useDocument
useLogin.js
import { ref } from 'vue'
import { projectAuth } from '../firebase/config'
const error = ref(null)
const isPending = ref(false)
const login = async (email, password) => {
error.value = null
isPending.value = true
try {
const res = await projectAuth.signInWithEmailAndPassword(email, password)
error.value = null
isPending.value = false
return res
}
catch(err) {
console.log(err.message)
error.value = 'Incorrect login credentials'
isPending.value = false
}
}
const useLogin = () => {
return { error, login, isPending }
}
export default useLogin
useLogout.js
import { ref } from 'vue'
import { projectAuth } from '../firebase/config'
// refs
const error = ref(null)
const isPending = ref(false)
// logout function
const logout = async () => {
error.value = null
isPending.value = true
try {
await projectAuth.signOut()
isPending.value = false
}
catch(err) {
console.log(err.message)
error.value = err.message
isPending.value = false
}
}
const useLogout = () => {
return { error, logout, isPending }
}
export default useLogout
useSignup.js
import { ref } from 'vue'
import { projectAuth } from '../firebase/config'
const error = ref(null)
const isPending = ref(false)
const signup = async (email, password, displayName) => {
error.value = null
isPending.value = true
try {
const res = await projectAuth.createUserWithEmailAndPassword(email, password)
if (!res) {
throw new Error('Could not complete signup')
}
await res.user.updateProfile({ displayName })
isPending.value = false
error.value = null
return res
}
catch(err) {
console.log(err.message)
error.value = err.message
isPending.value = false
}
}
const useSignup = () => {
return { error, signup, isPending }
}
export default useSignup
useStorage.js
import {projectStorage} from "../firebase/config";
import getUser from "./getUser";
import {ref} from 'vue'
const {user} = getUser()
const useStorage = () => {
const error = ref(null)
const url = ref(null)
const filePath = ref(null)
const uploadImage = async (file) => {
filePath.value = `covers/${user.value.uid}/${file.name}`
const storageRef = projectStorage.ref(filePath.value)
try {
const res = await storageRef.put(file)
url.value = await res.ref.getDownloadURL()
} catch (err) {
console.log(err.message)
error.value = err.message
}
}
const deleteImage = async (path) => {
const storageRef = projectStorage.ref(path)
try {
await storageRef.delete()
} catch(err) {
console.log(err.message)
error.value = err.message
}
}
return {url, filePath, error, uploadImage, deleteImage}
}
export default useStorage
Reusable components
AddSong.vue
<template>
<div class="add-song">
<button v-if="!showForm" @click="showForm = true">Add songs</button>
<form v-if="showForm" @submit.prevent="handleSubmit">
<h4>Add a new song</h4>
<input type="text" placeholder="Song title" required v-model="title"/>
<input type="text" placeholder="Artist" required v-model="artist"/>
<button>Add</button>
</form>
</div>
</template>
<script>
import {ref} from 'vue'
import useDocument from "../composables/useDocument";
export default {
props: {
playlist: {
type: Object,
required: true
}
},
setup(props) {
const title = ref('')
const artist = ref('')
const showForm = ref(false)
const {updateDoc} = useDocument('playlists', props.playlist.id)
const handleSubmit = async () => {
const newSong = {
title: title.value,
artist: artist.value,
id: Math.floor(Math.random() * 1000000)
}
await updateDoc({
songs: [...props.playlist.songs, newSong]
})
title.value = ''
artist.value = ''
}
return {title, artist, showForm, handleSubmit}
}
}
</script>
ListView.vue
<template>
<div v-for="playlist in playlists" :key="playlist.id">
<router-link :to="{ name: 'PlaylistDetails', params: {id: playlist.id}}">
<div class="single">
<div class="thumbnail">
<img :src="playlist.coverUrl"/>
</div>
<div class="info">
<h3>{{playlist.title}}</h3>
<p>Created by {{playlist.userName}}</p>
</div>
<div class="song-number">
<p>{{playlist.songs.length}}</p>
</div>
</div>
</router-link>
</div>
</template>
<script>
export default {
props: {
playlists: {
type: Array,
required: true
}
}
}
</script>
Navbar.vue
<template>
<div class="navbar">
<nav>
<img src="../assets/ninja.png" alt=""/>
<h1>
<router-link :to="{name: 'Home'}">Muso Ninjas</router-link>
</h1>
<div class="links">
<div v-if="user">
<router-link :to="{name: 'CreatePlaylist'}">Create playlist</router-link>
<router-link :to="{name: 'UserPlaylist'}">My playlist</router-link>
<span>Hi there, {{user.displayName}}</span>
<button @click="handleSubmit">Logout</button>
<button v-if="isPending" disabled>Waiting...</button>
</div>
<div v-if="!user">
<router-link class="btn" :to="{name: 'Signup'}">Signup</router-link>
<router-link class="btn" :to="{name: 'Login'}">Log in</router-link>
</div>
</div>
</nav>
</div>
</template>
<script>
import useLogout from "../composables/useLogout";
import {useRouter} from "vue-router";
import getUser from "../composables/getUser";
export default {
setup() {
const {logout, error, isPending} = useLogout()
const {user} = getUser()
const router = useRouter()
const handleSubmit = async () => {
const res = await logout()
if (!error.value) {
await router.push({name: 'Login'})
}
}
return {handleSubmit, isPending, user}
}
}
</script>
Extra
Firebase
storage.rules
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /covers/{userId}/{document=**} {
allow read, create: if request.auth != null;
allow delete: if request.auth.uid != userId;
}
}
}
firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /playlists/{docId} {
allow read, create: if request.auth != null;
allow delete, update: if request.auth.uid == resource.data.userId;
}
}
}
firestore.indexes.json
{
"indexes": [],
"fieldOverrides": []
}
firebase.json
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
},
"storage": {
"rules": "storage.rules"
}
}
.firebaserc
{
"projects": {
"default": "muso-ninjas-2f9f9"
}
}
./firebase/config.js
import firebase from 'firebase/app'
import 'firebase/firestore'
import 'firebase/auth'
import 'firebase/storage'
const firebaseConfig = {
apiKey: "AIzaSUEi2rLk__6dL0Gv9VC4FqFQ",
authDomain: "muso-ninjas-f9.firebaseapp.com",
projectId: "muso-ninjas-2f9f9",
storageBucket: "muso-ninjas-f9.appspot.com",
messagingSenderId: "9866264",
appId: "1:986626753ec91ecf6363"
};
// init firebase
firebase.initializeApp(firebaseConfig)
const projectFirestore = firebase.firestore()
const projectAuth = firebase.auth()
const projectStorage = firebase.storage()
//timestamp
const timestamp = firebase.firestore.FieldValue.serverTimestamp
export { projectFirestore, projectAuth, projectStorage, timestamp}