feat: Realtime Functionality
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2024-03-23 17:40:59 +01:00
parent d6ec298e56
commit 5f1e3ee176
20 changed files with 759 additions and 116 deletions

View File

@@ -6,6 +6,8 @@ import NewBookRecommendation from '~/components/modal/NewBookRecommendation.vue'
import CastVote from '~/components/modal/CastVote.vue'
const dayjs = useDayjs()
const { $echo } = useNuxtApp()
const authStore = useAuthStore()
const bookRecommendationStore = useBookRecommendationStore()
bookRecommendationStore.fetchRecommendations()
@@ -68,6 +70,20 @@ const sort = ref({
function resolveStatus(status: string) {
return bookRecommendationStore.statusOptions.find(option => option.value === status)
}
onMounted(() => {
$echo.private(`BookRecommendation`)
.listen('.BookRecommendationUpdated', (e) => {
bookRecommendationStore.updateRecommendationWS(e.bookRecommendation)
})
.listen('.BookRecommendationDeleted', (e) => {
bookRecommendationStore.deleteRecommendationWS(e.bookRecommendation)
})
.listen('.BookRecommendationCreated', (e) => {
bookRecommendationStore.createRecommendationWS(e.bookRecommendation)
})
authStore.socketId = $echo.socketId()
})
</script>
<template>

View File

@@ -4,8 +4,24 @@ import BookInfoCard from '~/components/dashboard/BookInfoCard.vue'
definePageMeta({ middleware: ['auth'] })
const bookRecommendationStore = useBookRecommendationStore()
const authStore = useAuthStore()
const { $echo } = useNuxtApp()
bookRecommendationStore.fetchActiveRecommendations()
onMounted(() => {
$echo.private(`BookRecommendation`)
.listen('.BookRecommendationUpdated', (e) => {
bookRecommendationStore.updateRecommendationWS(e.bookRecommendation)
})
.listen('.BookRecommendationDeleted', (e) => {
bookRecommendationStore.deleteRecommendationWS(e.bookRecommendation)
})
.listen('.BookRecommendationCreated', (e) => {
bookRecommendationStore.createRecommendationWS(e.bookRecommendation)
})
authStore.socketId = $echo.socketId()
})
</script>
<template>

View File

@@ -1,5 +1,5 @@
import { ofetch } from 'ofetch'
import type { FetchOptions } from 'ofetch';
import type { FetchOptions } from 'ofetch'
export default defineNuxtPlugin({
name: 'app',
@@ -10,10 +10,10 @@ export default defineNuxtPlugin({
const auth = useAuthStore()
nuxtApp.provide('storage', (path: string): string => {
if (!path) return ''
if (!path) { return '' }
return path.startsWith('http://') || path.startsWith('https://') ?
path
return path.startsWith('http://') || path.startsWith('https://')
? path
: config.public.storageBase + path
})
@@ -21,45 +21,54 @@ export default defineNuxtPlugin({
// Initial headers with Accept
const initialHeaders = {
...headers,
'Accept': 'application/json',
};
Accept: 'application/json',
}
// Conditionally add server-specific headers
if (process.server) {
const serverHeaders = {
'referer': useRequestURL().toString(),
referer: useRequestURL().toString(),
...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']),
};
Object.assign(initialHeaders, serverHeaders);
}
Object.assign(initialHeaders, serverHeaders)
}
// Conditionally add authorization header if logged in
if (auth.isLoggedIn) {
const authHeaders = {
'Authorization': `Bearer ${auth.token}`,
};
Object.assign(initialHeaders, authHeaders);
Authorization: `Bearer ${auth.token}`,
}
Object.assign(initialHeaders, authHeaders)
}
return initialHeaders;
// Conditionally add X-Socket-ID header if socket is connected
if (auth.isLoggedIn && auth.socketId) {
console.log('auth.socketId', auth.socketId)
const socketHeaders = {
'X-Socket-ID': auth.socketId,
}
Object.assign(initialHeaders, socketHeaders)
}
return initialHeaders
}
function buildBaseURL(baseURL: string): string {
if (baseURL) return baseURL;
if (baseURL) { return baseURL }
return process.server ?
config.apiLocal + config.public.apiPrefix
: config.public.apiBase + config.public.apiPrefix;
return process.server
? config.apiLocal + config.public.apiPrefix
: config.public.apiBase + config.public.apiPrefix
}
function buildSecureMethod(options: FetchOptions): void {
if (process.server) return;
if (process.server) { return }
const method = options.method?.toLowerCase() ?? 'get'
if (options.body instanceof FormData && method === 'put') {
options.method = 'POST';
options.body.append('_method', 'PUT');
options.method = 'POST'
options.body.append('_method', 'PUT')
}
}
@@ -67,27 +76,27 @@ export default defineNuxtPlugin({
return !baseURL
&& !path.startsWith('/_nuxt')
&& !path.startsWith('http://')
&& !path.startsWith('https://');
&& !path.startsWith('https://')
}
globalThis.$fetch = ofetch.create({
retry: false,
onRequest({ request, options }) {
if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) return
if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) { return }
options.credentials = 'include';
options.credentials = 'include'
options.baseURL = buildBaseURL(options.baseURL ?? '');
options.headers = buildHeaders(options.headers);
options.baseURL = buildBaseURL(options.baseURL ?? '')
options.headers = buildHeaders(options.headers)
buildSecureMethod(options);
buildSecureMethod(options)
},
onRequestError({ error }) {
if (process.server) return;
if (process.server) { return }
if (error.name === 'AbortError') return;
if (error.name === 'AbortError') { return }
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
@@ -110,7 +119,8 @@ export default defineNuxtPlugin({
color: 'primary',
})
}
} else if (response.status !== 422) {
}
else if (response.status !== 422) {
if (process.client) {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
@@ -119,11 +129,11 @@ export default defineNuxtPlugin({
})
}
}
}
},
} as FetchOptions)
if (auth.isLoggedIn) {
await auth.fetchUser();
await auth.fetchUser()
}
},
})

View File

@@ -0,0 +1,55 @@
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
declare global {
interface Window {
Pusher: typeof Pusher
}
}
window.Pusher = Pusher
export default defineNuxtPlugin(() => {
const runtimeConfig = useRuntimeConfig()
const authStore = useAuthStore()
const echo = new Echo({
broadcaster: runtimeConfig.public.echo.broadcaster,
key: runtimeConfig.public.echo.key,
cluster: 'mt1',
wsHost: runtimeConfig.public.echo.wsHost,
wsPort: runtimeConfig.public.echo.wsPort,
wssPort: runtimeConfig.public.echo.wsPort,
forceTLS: false,
encrypted: runtimeConfig.public.echo.encrypted,
disableStats: runtimeConfig.public.echo.disableStats,
enabledTransports: ['ws', 'wss'],
authorizer: (channel: { name: any }) => {
return {
authorize: async (socketId: string, callback: (error: any, respnse?: any) => void) => {
try {
const response = await $fetch(`/broadcasting/auth`, {
method: 'POST',
body: {
socket_id: socketId,
channel_name: channel.name,
},
})
callback(null, response)
}
catch (error) {
callback(error)
}
},
}
},
})
authStore.socketId = echo.socketId()
// Make Echo instance available through the Nuxt app
return {
provide: {
echo,
},
}
})

View File

@@ -24,6 +24,7 @@ export const useAuthStore = defineStore('auth', () => {
maxAge: 60 * 60 * 24 * 365,
})
const isLoggedIn = computed(() => !!token.value)
const socketId = ref('')
const { refresh: logout } = useFetch<any>('logout', {
method: 'POST',
@@ -49,5 +50,5 @@ export const useAuthStore = defineStore('auth', () => {
},
})
return { user, isLoggedIn, logout, fetchUser, token }
return { user, isLoggedIn, socketId, logout, fetchUser, token }
})

View File

@@ -21,11 +21,20 @@ export interface BookRecommendation {
email: string
avatar: string
}
votes?: Vote[]
status: BookRecommendationStatusEnum
cover_image?: string
published_at?: string
}
export interface Vote {
book_recommendation_id: number
id: number
user_id: number
created_at: string
updated_at: string
}
export const useBookRecommendationStore = defineStore('bookRecommendations', () => {
const recommendations = ref<BookRecommendation[]>([])
@@ -72,6 +81,48 @@ export const useBookRecommendationStore = defineStore('bookRecommendations', ()
},
})
const updateRecommendationWS = async (data: Partial<BookRecommendation>) => {
// This data can be Partial, the id should always be present. We need to only update the properties that are present in the data object.
// We also have a special case for activeRecommendations, in this case we could have a new recommendation that needs to be added to the list. This should only happen if the status is ACTIVE.
// If the Status is not ACTIVE, we need to remove the recommendation from the list.
const index = recommendations.value.findIndex(r => r.id === data.id)
if (index !== -1) {
recommendations.value[index] = { ...recommendations.value[index], ...data }
}
switch (data.status) {
case BookRecommendationStatusEnum.ACTIVE:
const activeIndex = recommendations.value.findIndex(r => r.id === data.id)
if (activeIndex === -1) {
await createRecommendationWS(data)
}
break
default:
const inactiveIndex = recommendations.value.findIndex(r => r.id === data.id)
if (inactiveIndex !== -1) {
recommendations.value.splice(inactiveIndex, 1)
}
break
}
}
const deleteRecommendationWS = async (data: Partial<BookRecommendation>) => {
const index = recommendations.value.findIndex(r => r.id === data.id)
if (index !== -1) {
recommendations.value.splice(index, 1)
}
}
const createRecommendationWS = async (data: Partial<BookRecommendation>) => {
// Here we need to get the missing with data from the server
await useFetch<BookRecommendation>(`book-recommendations/${data.id}?with=recommender,votes`, {
onResponse({ response }) {
if (response.status === 200) {
recommendations.value.push(response._data)
}
},
})
}
function resetRecommendations() {
recommendations.value = []
}
@@ -79,6 +130,9 @@ export const useBookRecommendationStore = defineStore('bookRecommendations', ()
return {
recommendations,
resetRecommendations,
updateRecommendationWS,
deleteRecommendationWS,
createRecommendationWS,
statusOptions,
fetchRecommendations,
fetchRecommendationsStatus,