This commit is contained in:
dragongoose 2023-07-20 13:57:01 -04:00
parent 820ceda499
commit adcbfcb1be
No known key found for this signature in database
GPG Key ID: 01397EEC371CDAA5
37 changed files with 601 additions and 554 deletions

View File

@ -27,4 +27,4 @@ export const getBadgesFromMessage = (tags: any, allBadges: Badge[]) => {
}) })
return getBadges(allBadges, formatedBadges) return getBadges(allBadges, formatedBadges)
} }

View File

@ -4,7 +4,7 @@ import { getBadgesFromMessage } from './badges'
export function parseMessage(messageData: any, allBadges: Badge[]): ParsedMessage { export function parseMessage(messageData: any, allBadges: Badge[]): ParsedMessage {
const message = JSON.parse(messageData) const message = JSON.parse(messageData)
if (message.type === undefined && message.cursor !== "") { if (message.type === undefined && message.cursor !== '') {
const data: ParsedMessage = { const data: ParsedMessage = {
type: 'PRIVMSG', type: 'PRIVMSG',
data: { data: {
@ -23,9 +23,9 @@ export function parseMessage(messageData: any, allBadges: Badge[]): ParsedMessag
case 'PRIVMSG': { case 'PRIVMSG': {
const data: ParsedMessage = { const data: ParsedMessage = {
type: 'PRIVMSG', type: 'PRIVMSG',
data: { data: {
message: message.message, message: message.message,
username: message.username, username: message.username,
color: message.tags.color, color: message.tags.color,
badges: getBadgesFromMessage(message.tags, allBadges) badges: getBadgesFromMessage(message.tags, allBadges)
} }

View File

@ -11,7 +11,7 @@ export const createQualitySelector = (player: any) => {
const MenuItem = videojs.getComponent('MenuItem') const MenuItem = videojs.getComponent('MenuItem')
let formatedQualities: { name: string; index: number; id: string }[] let formatedQualities: { name: string; index: number; id: string }[]
let t = i18n.global.t const t = i18n.global.t
const setQuality = (id: string) => { const setQuality = (id: string) => {
const found = formatedQualities.find((i) => i.id === id) const found = formatedQualities.find((i) => i.id === id)
@ -56,10 +56,10 @@ export const createQualitySelector = (player: any) => {
videojs.registerComponent('CustomMenuButton', CustomMenuButton) videojs.registerComponent('CustomMenuButton', CustomMenuButton)
const formattedLevels = [] const formattedLevels = []
const updateLevels = (items: { name: string; index: number; id: string; }[]) => { const updateLevels = () => {
player.controlBar.removeChild('CustomMenuButton') player.controlBar.removeChild('CustomMenuButton')
player.controlBar.addChild('CustomMenuButton', { player.controlBar.addChild('CustomMenuButton', {
title: t("player.quality"), title: t('player.quality'),
items: formatedQualities items: formatedQualities
}) })
} }

View File

@ -1,2 +1,2 @@
export * from './Badge' export * from './Badge'
export * from './ParsedMessage' export * from './ParsedMessage'

View File

@ -1,48 +1,50 @@
<script lang="ts"> <script lang="ts">
export default { export default {
props: { props: {
categoryData: { categoryData: {
type: Object, type: Object,
default: () => {} default: () => {}
}
},
setup(props) {
return {
category: props.categoryData
}
},
methods: {
abbreviate(text: number) {
return Intl.NumberFormat('en-US', {
//@ts-ignore
notation: 'compact',
maximumFractionDigits: 1
}).format(text)
},
} }
},
setup(props) {
return {
category: props.categoryData
}
},
methods: {
abbreviate(text: number) {
return Intl.NumberFormat('en-US', {
//@ts-ignore
notation: 'compact',
maximumFractionDigits: 1
}).format(text)
}
}
} }
</script> </script>
<template> <template>
<div class="bg-ctp-crust w-40 lg:w-[11rem] md:w-[13.5rem] rounded-lg"> <div class="bg-ctp-crust w-40 lg:w-[11rem] md:w-[13.5rem] rounded-lg">
<router-link :to="`/game/${category.name}`"> <router-link :to="`/game/${category.name}`">
<img :src="category.image" class="rounded-lg rounded-b-none w-full" /> <img :src="category.image" class="rounded-lg rounded-b-none w-full" />
</router-link> </router-link>
<div class="p-2"> <div class="p-2">
<div> <div>
<p class="font-bold text-white text-xl sm:text-base md:text-xl"> <p class="font-bold text-white text-xl sm:text-base md:text-xl">
{{ category.displayName }} {{ category.displayName }}
</p> </p>
<p class="text-sm text-white">{{ abbreviate(category.viewers) }} viewers</p> <p class="text-sm text-white">{{ abbreviate(category.viewers) }} viewers</p>
</div> </div>
<ul class="h-8 overflow-hidden"> <ul class="h-8 overflow-hidden">
<li v-for="tag in category.tags" :key="tag" class="inline-flex"> <li v-for="tag in category.tags" :key="tag" class="inline-flex">
<span class="p-2.5 py-1.5 bg-ctp-surface0 rounded-md m-0.5 text-xs font-bold text-white">{{ tag <span
}}</span> class="p-2.5 py-1.5 bg-ctp-surface0 rounded-md m-0.5 text-xs font-bold text-white"
</li> >{{ tag }}</span
</ul> >
</div> </li>
</ul>
</div> </div>
</template> </div>
</template>

View File

@ -1,31 +1,33 @@
<script lang="ts"> <script lang="ts">
export default { export default {
props: { props: {
channel: { channel: {
type: Object type: Object
}
},
setup(props) {
return {
channelData: props.channel
}
} }
},
setup(props) {
return {
channelData: props.channel
}
}
} }
</script> </script>
<template> <template>
<router-link v-if="channelData" :to="'/' + channelData.username"> <router-link v-if="channelData" :to="'/' + channelData.username">
<div class="p-3 rounded-lg bg-ctp-crust w-max max-w-lg max-h-28"> <div class="p-3 rounded-lg bg-ctp-crust w-max max-w-lg max-h-28">
<div class="inline-flex space-x-3"> <div class="inline-flex space-x-3">
<img :src="channelData.pfp" class="rounded-full w-20"> <img :src="channelData.pfp" class="rounded-full w-20" />
<div> <div>
<div class="inline-flex w-full justify-between"> <div class="inline-flex w-full justify-between">
<h1 class="text-white text-3xl font-bold">{{ channelData.username }}</h1> <h1 class="text-white text-3xl font-bold">{{ channelData.username }}</h1>
<p class="text-white float-right ml-5">{{ channelData.followers }} followers</p> <p class="text-white float-right ml-5">{{ channelData.followers }} followers</p>
</div> </div>
<p class="text-white overflow-y-hidden overflow-ellipsis max-h-12">{{ channelData.about }}</p> <p class="text-white overflow-y-hidden overflow-ellipsis max-h-12">
</div> {{ channelData.about }}
</div> </p>
</div> </div>
</router-link> </div>
</template> </div>
</router-link>
</template>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="rounded-lg z-50 flex m-3 h-20 bg-amber-400 border-4 border-amber-600"> <div class="rounded-lg z-50 flex m-3 h-20 bg-amber-400 border-4 border-amber-600">
<div class="m-auto"> <div class="m-auto">
<h1 class="font-bold text-2xl">SafeTwitch is currently in development mode.</h1> <h1 class="font-bold text-2xl">SafeTwitch is currently in development mode.</h1>
</div> </div>
</div> </div>
</template> </template>

View File

@ -7,12 +7,12 @@ export default {}
class="flex flex-col max-w-prose justify-center text-center mx-auto p-6 bg-ctp-crust rounded-lg text-white" class="flex flex-col max-w-prose justify-center text-center mx-auto p-6 bg-ctp-crust rounded-lg text-white"
> >
<div class="mb-6"> <div class="mb-6">
<h1 class="font-bold text-5xl">{{ $t("error.oops") }}</h1> <h1 class="font-bold text-5xl">{{ $t('error.oops') }}</h1>
<p class="font-bold text-3xl">{{ $t("error.notsupposedtohappen") }}</p> <p class="font-bold text-3xl">{{ $t('error.notsupposedtohappen') }}</p>
</div> </div>
<p class="text-xl"> <p class="text-xl">
{{ $t("error.serverexplain") }} {{ $t('error.serverexplain') }}
</p> </p>
</div> </div>
</template> </template>

View File

@ -56,7 +56,7 @@ export default {
class="text-white text-sm font-bold p-2 py-1 rounded-md bg-purple-600" class="text-white text-sm font-bold p-2 py-1 rounded-md bg-purple-600"
> >
<v-icon name="bi-heart-fill" scale="0.85"></v-icon> <v-icon name="bi-heart-fill" scale="0.85"></v-icon>
<span v-if="isFollowing"> {{ $t("streamer.unfollow") }} </span> <span v-if="isFollowing"> {{ $t('streamer.unfollow') }} </span>
<span v-else> {{ $t("streamer.follow") }} </span> <span v-else> {{ $t('streamer.follow') }} </span>
</button> </button>
</template> </template>

View File

@ -5,14 +5,14 @@ export default {
setup() { setup() {
const version = inject('version') const version = inject('version')
return { return {
version version
} }
} }
} }
</script> </script>
<template> <template>
<div class="m-2 mt-5 flex justify-center"> <div class="m-2 mt-5 flex justify-center">
<p class="text-white font-bold">SafeTwitch v{{ version }}</p> <p class="text-white font-bold">SafeTwitch v{{ version }}</p>
</div> </div>
</template> </template>

View File

@ -1,32 +1,37 @@
<template> <template>
<div class="flex"> <div class="flex">
<select v-model="$i18n.locale" @change="onChange()" class="my-auto p-0 pr-9 bg-transparent border-0" :selected="$i18n.locale"> <select
<option v-for="(lang, i) in langs" :key="`Lang${i}`" :value="lang"> v-model="$i18n.locale"
{{ names[i] }} @change="onChange()"
</option> class="my-auto p-0 pr-9 bg-transparent border-0"
</select> :selected="$i18n.locale"
</div> >
<option v-for="(lang, i) in langs" :key="`Lang${i}`" :value="lang">
{{ names[i] }}
</option>
</select>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
export default { export default {
setup() { setup() {
return { return {
langs: ['en-US', 'es-ES', 'nl-NL', 'pt-PT', 'fa-IR', 'he-IL'], langs: ['en-US', 'es-ES', 'nl-NL', 'pt-PT', 'fa-IR', 'he-IL'],
names: ['English', 'Español', 'Nederlands', 'Português', 'فارسی', 'עִבְרִית'] names: ['English', 'Español', 'Nederlands', 'Português', 'فارسی', 'עִבְרִית']
}
},
mounted() {
const savedLocale = localStorage.getItem("language")
if (savedLocale != null && this.langs.includes(savedLocale)) {
this.$i18n.locale = savedLocale
}
},
methods: {
onChange() {
localStorage.setItem("language", this.$i18n.locale)
window.location.reload()
}
} }
},
mounted() {
const savedLocale = localStorage.getItem('language')
if (savedLocale != null && this.langs.includes(savedLocale)) {
this.$i18n.locale = savedLocale
}
},
methods: {
onChange() {
localStorage.setItem('language', this.$i18n.locale)
window.location.reload()
}
}
} }
</script> </script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="flex mx-auto justify-center bg-ctp-crust rounded-lg w-2/3 p-2 text-white"> <div class="flex mx-auto justify-center bg-ctp-crust rounded-lg w-2/3 p-2 text-white">
<div class="flex space-x-3"> <div class="flex space-x-3">
<h1 class="text-4xl font-bold">{{ $t("main.searching") }}</h1> <h1 class="text-4xl font-bold">{{ $t('main.searching') }}</h1>
<v-icon name="fa-circle-notch" class="animate-spin w-10 h-10"></v-icon> <v-icon name="fa-circle-notch" class="animate-spin w-10 h-10"></v-icon>
</div> </div>
</div> </div>

View File

@ -20,8 +20,8 @@ export default {
</div> </div>
<ul class="inline-flex space-x-6 font-medium"> <ul class="inline-flex space-x-6 font-medium">
<a href="https://codeberg.org/dragongoose/safetwitch">{{ $t("nav.code") }}</a> <a href="https://codeberg.org/dragongoose/safetwitch">{{ $t('nav.code') }}</a>
<router-link to="/privacy">{{ $t("nav.privacy") }}</router-link> <router-link to="/privacy">{{ $t('nav.privacy') }}</router-link>
<language-switcher></language-switcher> <language-switcher></language-switcher>
</ul> </ul>
</div> </div>

View File

@ -2,30 +2,30 @@
export default { export default {
setup() { setup() {
return { return {
searchInput: "", searchInput: ''
} }
}, },
methods: { methods: {
redirectToSearch() { redirectToSearch() {
const query = this.searchInput const query = this.searchInput
this.$router.push({ path: '/search/', query: { query }}) this.$router.push({ path: '/search/', query: { query } })
} }
} }
} }
</script> </script>
<template> <template>
<div class="relative hidden md:block"> <div class="relative hidden md:block">
<label for="searchBar" class="hidden">{{ $t("main.search") }}</label> <label for="searchBar" class="hidden">{{ $t('main.search') }}</label>
<v-icon name="io-search-outline" class="absolute my-auto inset-y-0 left-2"></v-icon> <v-icon name="io-search-outline" class="absolute my-auto inset-y-0 left-2"></v-icon>
<input <input
type="text" type="text"
id="searchBar" id="searchBar"
name="searchBar" name="searchBar"
:placeholder="$t('main.search')" :placeholder="$t('main.search')"
v-model="searchInput" v-model="searchInput"
@keyup.enter=redirectToSearch @keyup.enter="redirectToSearch"
class="rounded-md p-1 pl-8 text-black bg-white placeholder:text-black" class="rounded-md p-1 pl-8 text-black bg-white placeholder:text-black"
/> />
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ref, inject, provide } from 'vue' import { ref, inject } from 'vue'
import BadgeVue from './ChatBadge.vue' import BadgeVue from './ChatBadge.vue'
import { getBadges } from '@/assets/badges' import { getBadges } from '@/assets/badges'

View File

@ -35,11 +35,10 @@ export default {
const emit = this.$emit const emit = this.$emit
this.player = videojs('video-player', this.options, () => { this.player = videojs('video-player', this.options, () => {
createQualitySelector(this.player) createQualitySelector(this.player)
let i = 0
this.player.on('timeupdate', () => { this.player.on('timeupdate', () => {
emit('PlayerTimeUpdate', this.player.currentTime()) emit('PlayerTimeUpdate', this.player.currentTime())
}) })
}) })
}, },
unmounted() { unmounted() {

View File

@ -1,49 +1,54 @@
<template> <template>
<div class="min-w-[300px]"> <div class="min-w-[300px]">
<div class="relative"> <div class="relative">
<RouterLink :to="'/videos/' + data.id"> <RouterLink :to="'/videos/' + videoData.id">
<img :src="data.preview" class="rounded-md" width="300"> <img :src="videoData.preview" class="rounded-md" width="300" />
</RouterLink> </RouterLink>
<p class="absolute bottom-2 right-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"> {{ new Date(data.duration * 1000).toISOString().slice(11, 19) }}</p> <p
<p class="absolute bottom-2 left-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"> {{ abbreviate(data.views) }} {{ $t("main.views") }}</p> class="absolute bottom-2 right-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"
</div> >
{{ new Date(videoData.duration * 1000).toISOString().slice(11, 19) }}
<div class="pt-2 space-x-2"> </p>
<div class="space-x-2 inline-flex"> <p
<RouterLink :to="'/game/' + data.game.name"> class="absolute bottom-2 left-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"
<img :src="data.game.image"> >
</RouterLink> {{ abbreviate(videoData.views) }} {{ $t('main.views') }}
</p>
<div class="w-full">
<p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p>
<div class="text-xs text-gray-400">
<p>{{ data.streamer.login }}</p>
<p>{{ data.game.displayName || data.game.name }}</p>
</div>
</div>
</div>
</div>
</div> </div>
<div class="pt-2 space-x-2">
<div class="space-x-2 inline-flex">
<RouterLink :to="'/game/' + videoData.game.name">
<img :src="videoData.game.image" />
</RouterLink>
<div class="w-full">
<p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p>
<div class="text-xs text-gray-400">
<p>{{ videoData.streamer.login }}</p>
<p>{{ videoData.game.displayName || videoData.game.name }}</p>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import type { Video } from '@/types/VOD'; import type { Video } from '@/types/VOD'
import { abbreviate } from '@/mixins'; import { abbreviate } from '@/mixins'
export default { export default {
props: { props: {
data: Object data: Object
}, },
setup(props) { setup(props) {
return { return {
data: props.data as Video videoData: props.data as Video
}
},
methods: {
abbreviate
} }
},
methods: {
abbreviate
}
} }
</script> </script>

View File

@ -3,26 +3,32 @@
--> -->
<template> <template>
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3"> <div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<div class="inline-flex w-full"> <div class="inline-flex w-full">
<span class="pr-3 font-bold text-3xl">Videos</span> <span class="pr-3 font-bold text-3xl">Videos</span>
</div>
<h1 v-if="!shelves && status === 'error'">Error getting videos</h1>
<div v-else-if="shelves" class="mb-5">
<div class="space-y-5">
<div v-for="shelve of shelves">
<h1 class="font-bold text-lg">{{ shelve.title }}</h1>
<div class="whitespace-nowrap overflow-x-auto overflow-y-hidden w-full inline-flex space-x-5">
<video-preview v-for="video of shelve.videos" :data="video"></video-preview>
</div>
</div>
<hr>
</div>
</div>
</div> </div>
<h1 v-if="!shelves && status === 'error'">Error getting videos</h1>
<div v-else-if="shelves" class="mb-5">
<div class="space-y-5">
<div v-for="shelve of shelves" :key="shelve.title">
<h1 class="font-bold text-lg">{{ shelve.title }}</h1>
<div
class="whitespace-nowrap overflow-x-auto overflow-y-hidden w-full inline-flex space-x-5"
>
<video-preview
v-for="video of shelve.videos"
:key="video.title"
:data="video"
></video-preview>
</div>
</div>
<hr />
</div>
</div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -33,25 +39,25 @@ import type { Shelve } from '@/types/VOD'
import VideoPreview from '@/components/user/VideoPreview.vue' import VideoPreview from '@/components/user/VideoPreview.vue'
export default { export default {
setup() { setup() {
return { return {
shelves: ref<Shelve[]>([]), shelves: ref<Shelve[]>([]),
status: "" status: ''
}
},
async mounted() {
const username = this.$route.params.username
await getEndpoint("api/vods/shelve/" + username)
.then((data) => {
this.shelves = data
})
.catch(() => {
this.status = "error"
})
},
components: {
VideoPreview
} }
},
async mounted() {
const username = this.$route.params.username
await getEndpoint('api/vods/shelve/' + username)
.then((data) => {
this.shelves = data
})
.catch(() => {
this.status = 'error'
})
},
components: {
VideoPreview
}
} }
</script> </script>

View File

@ -17,6 +17,6 @@ export default createI18n({
'nl-NL': nl, 'nl-NL': nl,
'pt-PT': pt, 'pt-PT': pt,
'fa-IR': fa, 'fa-IR': fa,
'he-IL': he, 'he-IL': he
} }
}) })

View File

@ -2,9 +2,8 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './assets/index.css' import './assets/index.css'
import i18n from "./i18n" import i18n from './i18n'
import { version } from "../package.json" import { version } from '../package.json'
const app = createApp(App).use(i18n) const app = createApp(App).use(i18n)
@ -12,7 +11,7 @@ const app = createApp(App).use(i18n)
// For some reason, import.meta.env.VITE_HTTPS === "true" // For some reason, import.meta.env.VITE_HTTPS === "true"
// returns false, even if it is true. // returns false, even if it is true.
// Making a copy of the variable seems to work // Making a copy of the variable seems to work
const https = (import.meta.env.SAFETWITCH_HTTPS.slice() === "true") const https = import.meta.env.SAFETWITCH_HTTPS.slice() === 'true'
const protocol = https ? 'https://' : 'http://' const protocol = https ? 'https://' : 'http://'
const wsProtocol = https ? 'wss://' : 'ws://' const wsProtocol = https ? 'wss://' : 'ws://'
@ -51,4 +50,4 @@ addIcons(
app.component('v-icon', OhVueIcon) app.component('v-icon', OhVueIcon)
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')

View File

@ -1,48 +1,42 @@
export function truncate(value: string, length: number) { export function truncate(value: string, length: number) {
if (value.length > length) { if (value.length > length) {
return value.substring(0, length) + '...' return value.substring(0, length) + '...'
} else { } else {
return value return value
} }
} }
let language = localStorage.getItem("language") || "en-us" const language = localStorage.getItem('language') || 'en-us'
export function abbreviate(text: number) { export function abbreviate(text: number) {
return Intl.NumberFormat(language, { return Intl.NumberFormat(language, {
//@ts-ignore //@ts-ignore
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1 maximumFractionDigits: 1
}).format(text) }).format(text)
} }
const https = (import.meta.env.SAFETWITCH_HTTPS.slice() === "true") const https = import.meta.env.SAFETWITCH_HTTPS.slice() === 'true'
const protocol = https ? 'https://' : 'http://' const protocol = https ? 'https://' : 'http://'
const rootBackendUrl = `${protocol}${import.meta.env.SAFETWITCH_BACKEND_DOMAIN}/` const rootBackendUrl = `${protocol}${import.meta.env.SAFETWITCH_BACKEND_DOMAIN}/`
export async function getEndpoint(endpoint: string) { export async function getEndpoint(endpoint: string) {
let data const res = await fetch(rootBackendUrl + endpoint, {
method: 'GET',
try { headers: {
const res = await fetch(rootBackendUrl + endpoint, { 'Accept-Language': language
method: 'GET',
headers: {
"Accept-Language": language
}
})
const rawData = await res.json()
if (!res.ok) {
throw res
}
if (rawData.status !== 'ok') {
throw rawData
}
data = rawData.data
} catch (error) {
throw error
} }
})
const rawData = await res.json()
return data if (!res.ok) {
} throw res
}
if (rawData.status !== 'ok') {
throw rawData
}
const data = rawData.data
return data
}

View File

@ -1,6 +1,4 @@
import type * as all from "./"
export interface ApiResponse { export interface ApiResponse {
status: "ok" | "error", status: 'ok' | 'error'
data: any data: any
} }

View File

@ -1,11 +1,11 @@
export type Tag = string export type Tag = string
export interface CategoryPreview { export interface CategoryPreview {
name: string name: string
displayName: string displayName: string
viewers: number viewers: number
tags: Tag[] tags: Tag[]
createdAt?: Date createdAt?: Date
cursor?: string cursor?: string
image: string image: string
} }

View File

@ -1,25 +1,24 @@
import type { Tag } from "./" import type { Tag } from './'
import type { StreamData } from "./"
export interface CategoryMinifiedStream { export interface CategoryMinifiedStream {
title: string title: string
viewers: number viewers: number
preview: string preview: string
tags: Tag[] tags: Tag[]
cursor: string cursor: string
streamer: { streamer: {
name: string name: string
pfp: string pfp: string
colorHex: string colorHex: string
}
} }
}
export interface CategoryData { export interface CategoryData {
name: string name: string
cover: string cover: string
description: string description: string
viewers: number viewers: number
followers: number followers: number
tags: Tag[] tags: Tag[]
streams: CategoryMinifiedStream[] streams: CategoryMinifiedStream[]
} }

View File

@ -1,18 +1,18 @@
export interface TwitchChatOptions { export interface TwitchChatOptions {
login: { login: {
username: string, username: string
password: string password: string
}, }
channels: string[] channels: string[]
} }
export const MessageTypes = ['PRIVMSG', 'WHISPER'] export const MessageTypes = ['PRIVMSG', 'WHISPER']
export type MessageType = typeof MessageTypes[number]; export type MessageType = (typeof MessageTypes)[number]
export interface Metadata { export interface Metadata {
username: string username: string
messageType: MessageType messageType: MessageType
channel: string channel: string
message: string message: string
tags: { [k:string]:any } tags: { [k: string]: any }
} }

View File

@ -1,6 +1,6 @@
export interface Emote { export interface Emote {
name: string, name: string
urls : { urls: {
[k: string]: string [k: string]: string
} }
} }

View File

@ -1,9 +1,9 @@
import type { StreamData, StreamerData } from "./" import type { StreamerData } from './'
import type { CategoryPreview } from "./" import type { CategoryPreview } from './'
export interface SearchResult { export interface SearchResult {
channels: StreamerData[] channels: StreamerData[]
categories: CategoryPreview[] categories: CategoryPreview[]
relatedChannels: StreamerData[] relatedChannels: StreamerData[]
channelsWithTag: StreamerData[] channelsWithTag: StreamerData[]
} }

View File

@ -1,30 +1,30 @@
export interface Social { export interface Social {
type: string | null type: string | null
name: string, name: string
link: string link: string
} }
export interface StreamData { export interface StreamData {
tags: string[] tags: string[]
title: string title: string
topic: string topic: string
startedAt: number startedAt: number
viewers: number viewers: number
preview: string preview: string
cursor?: string cursor?: string
} }
export interface StreamerData { export interface StreamerData {
username: string username: string
login: string login: string
followers: number followers: number
isLive: boolean isLive: boolean
about: string about: string
socials?: Social[] socials?: Social[]
pfp: string pfp: string
banner: string banner: string
stream?: StreamData | null stream?: StreamData | null
isPartner: boolean | null isPartner: boolean | null
colorHex: string colorHex: string
id: number id: number
} }

View File

@ -1,51 +1,49 @@
import type { StreamerData } from "./Streamer" import type { StreamerData } from './Streamer'
export interface MinifiedCategory { export interface MinifiedCategory {
image: string image: string
id: string id: string
name: string name: string
displayName: string displayName: string
} }
export interface MinifiedStreamer { export interface MinifiedStreamer {
name: string name: string
login: string login: string
pfp: string pfp: string
colorHex: string colorHex: string
} }
export interface Video { export interface Video {
preview: string preview: string
game: MinifiedCategory game: MinifiedCategory
duration: number duration: number
title: string title: string
publishedAt: string publishedAt: string
views: number views: number
tag: string[] tag: string[]
streamer: StreamerData streamer: StreamerData
} }
export interface Shelve { export interface Shelve {
title: string title: string
videos: Video[] videos: Video[]
} }
export interface VodMessager { export interface VodMessager {
name: string name: string
login: string login: string
} }
export interface VodCommentBadge { export interface VodCommentBadge {
version: number version: number
setId: string setId: string
} }
export interface VodComment { export interface VodComment {
message: string message: string
messager: MinifiedStreamer messager: MinifiedStreamer
offset: number offset: number
cursor: string cursor: string
badges: VodCommentBadge[] badges: VodCommentBadge[]
} }

View File

@ -5,4 +5,4 @@ export * from './Chat'
export * from './Category' export * from './Category'
export * from './CategoryData' export * from './CategoryData'
export * from './ApiResponse' export * from './ApiResponse'
export * from './VOD' export * from './VOD'

View File

@ -8,12 +8,11 @@ import LoadingScreen from '@/components/LoadingScreen.vue'
import type { CategoryData } from '@/types' import type { CategoryData } from '@/types'
import { getEndpoint, abbreviate } from '@/mixins' import { getEndpoint, abbreviate } from '@/mixins'
export default { export default {
inject: ['protocol'], inject: ['protocol'],
async setup() { async setup() {
let data = ref<CategoryData>() let data = ref<CategoryData>()
let status = ref<"ok" | "error">() let status = ref<'ok' | 'error'>()
return { return {
data, data,
@ -21,13 +20,13 @@ export default {
} }
}, },
async mounted() { async mounted() {
await getEndpoint("api/discover/" + this.$route.params.game) await getEndpoint('api/discover/' + this.$route.params.game)
.catch(() => { .catch(() => {
this.status = "error" this.status = 'error'
}) })
.then((data: CategoryData) => { .then((data: CategoryData) => {
this.data = data this.data = data
}) })
this.getMoreStreams() this.getMoreStreams()
}, },
@ -45,10 +44,11 @@ export default {
if (!cursor) return if (!cursor) return
// get rest of streams from api // get rest of streams from api
const resData = await getEndpoint(`api/discover/${this.$route.params.game}/?cursor=${cursor}`) const resData = await getEndpoint(
.catch((err) => { `api/discover/${this.$route.params.game}/?cursor=${cursor}`
throw err ).catch((err) => {
}) throw err
})
for (let stream of resData.streams) { for (let stream of resData.streams) {
this.data!.streams.push(stream) this.data!.streams.push(stream)
@ -56,7 +56,7 @@ export default {
} }
} }
}, },
abbreviate, abbreviate
}, },
components: { components: {
StreamPreviewVue, StreamPreviewVue,
@ -80,35 +80,47 @@ export default {
<div class="hidden md:block"> <div class="hidden md:block">
<div> <div>
<div class="inline-flex my-1 space-x-3"> <div class="inline-flex my-1 space-x-3">
<p class="font-bold text-white text-lg">{{ $t("main.followers") }}: {{ abbreviate(data.followers) }}</p> <p class="font-bold text-white text-lg">
<p class="font-bold text-white text-lg">{{ $t("main.viewers") }}: {{ abbreviate(data.viewers) }}</p> {{ $t('main.followers') }}: {{ abbreviate(data.followers) }}
</p>
<p class="font-bold text-white text-lg">
{{ $t('main.viewers') }}: {{ abbreviate(data.viewers) }}
</p>
</div> </div>
<ul class="mb-5"> <ul class="mb-5">
<li v-for="tag in data.tags" :key="tag" class="inline-flex"> <li v-for="tag in data.tags" :key="tag" class="inline-flex">
<span class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm">{{ <span
tag class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm"
}}</span> >{{ tag }}</span
>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<p class="text-md text-gray-400 overflow-y-auto hidden md:block">{{ data.description }}</p> <p class="text-md text-gray-400 overflow-y-auto hidden md:block">
{{ data.description }}
</p>
</div> </div>
</div> </div>
<div class="md:hidden"> <div class="md:hidden">
<div> <div>
<div class="inline-flex my-1 space-x-3"> <div class="inline-flex my-1 space-x-3">
<p class="font-bold text-white text-lg">{{ $t("main.followers") }}: {{ abbreviate(data.followers) }}</p> <p class="font-bold text-white text-lg">
<p class="font-bold text-white text-lg">{{ $t("main.viewers") }}: {{ abbreviate(data.viewers) }}</p> {{ $t('main.followers') }}: {{ abbreviate(data.followers) }}
</p>
<p class="font-bold text-white text-lg">
{{ $t('main.viewers') }}: {{ abbreviate(data.viewers) }}
</p>
</div> </div>
<ul class="mb-5"> <ul class="mb-5">
<li v-for="tag in data.tags" :key="tag" class="inline-flex"> <li v-for="tag in data.tags" :key="tag" class="inline-flex">
<span class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm">{{ <span
tag class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm"
}}</span> >{{ tag }}</span
>
</li> </li>
</ul> </ul>
</div> </div>
@ -118,7 +130,11 @@ export default {
<div class="max-w-[58rem] mx-auto"> <div class="max-w-[58rem] mx-auto">
<ul> <ul>
<li v-for="stream in data.streams" class="inline-flex m-2 hover:scale-105 transition-transform"> <li
v-for="stream in data.streams"
:key="stream.title"
class="inline-flex m-2 hover:scale-105 transition-transform"
>
<StreamPreviewVue :stream="stream"></StreamPreviewVue> <StreamPreviewVue :stream="stream"></StreamPreviewVue>
</li> </li>
</ul> </ul>

View File

@ -13,7 +13,7 @@ export default {
inject: ['protocol'], inject: ['protocol'],
async setup() { async setup() {
let data = ref<CategoryPreviewInterface[]>() let data = ref<CategoryPreviewInterface[]>()
let status = ref<"ok" | "error">() let status = ref<'ok' | 'error'>()
return { return {
data, data,
@ -57,7 +57,9 @@ export default {
const cursor = this.data[this.data.length - 1].cursor const cursor = this.data[this.data.length - 1].cursor
if (!cursor) return if (!cursor) return
const res = await fetch( const res = await fetch(
`${this.protocol}${import.meta.env.SAFETWITCH_BACKEND_DOMAIN}/api/discover/?cursor=${cursor}` `${this.protocol}${
import.meta.env.SAFETWITCH_BACKEND_DOMAIN
}/api/discover/?cursor=${cursor}`
) )
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to fetch data') throw new Error('Failed to fetch data')
@ -81,14 +83,13 @@ export default {
this.following = [] this.following = []
} }
await getEndpoint("api/discover") await getEndpoint('api/discover')
.catch(() => { .catch(() => {
this.status = "error" this.status = 'error'
}) })
.then((data: CategoryPreviewInterface[]) => { .then((data: CategoryPreviewInterface[]) => {
this.data = data this.data = data
}) })
}, },
components: { components: {
StreamPreviewVue, StreamPreviewVue,
@ -119,13 +120,13 @@ export default {
</div> </div>
<div class="p-2"> <div class="p-2">
<h1 class="font-bold text-5xl text-white">{{ $t("home.discover") }}</h1> <h1 class="font-bold text-5xl text-white">{{ $t('home.discover') }}</h1>
<p class="text-xl text-white">{{ $t("home.discoverDescription") }}</p> <p class="text-xl text-white">{{ $t('home.discoverDescription') }}</p>
<div class="pt-5 inline-flex text-white"> <div class="pt-5 inline-flex text-white">
<p class="mr-2 font-bold text-white">{{ $t("home.tagDescription") }}</p> <p class="mr-2 font-bold text-white">{{ $t('home.tagDescription') }}</p>
<form class="relative"> <form class="relative">
<label for="searchBar" class="hidden">{{ $t("main.search") }}</label> <label for="searchBar" class="hidden">{{ $t('main.search') }}</label>
<v-icon name="io-search-outline" class="absolute my-auto inset-y-0 left-2"></v-icon> <v-icon name="io-search-outline" class="absolute my-auto inset-y-0 left-2"></v-icon>
<input <input
type="text" type="text"
@ -143,10 +144,11 @@ export default {
<ul ref="categoryList"> <ul ref="categoryList">
<li <li
v-for="category in data" v-for="category in data"
:key="category.name"
ref="categoryItem" ref="categoryItem"
class="inline-flex m-2 hover:scale-105 transition-transform" class="inline-flex m-2 hover:scale-105 transition-transform"
> >
<category-preview :category-data="category"></category-preview> <category-preview :category-data="category"></category-preview>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -4,8 +4,8 @@ export default {}
<template> <template>
<div class="flex flex-col items-center pt-10 font-bold text-5xl text-white"> <div class="flex flex-col items-center pt-10 font-bold text-5xl text-white">
<h1>{{ $t("error.oops") }}</h1> <h1>{{ $t('error.oops') }}</h1>
<h1>{{ $t("error.notfound") }}</h1> <h1>{{ $t('error.notfound') }}</h1>
<h2 class="text-4xl">maybe go <RouterLink to="/" class="text-gray-500">home</RouterLink>?</h2> <h2 class="text-4xl">maybe go <RouterLink to="/" class="text-gray-500">home</RouterLink>?</h2>
</div> </div>
</template> </template>

View File

@ -3,22 +3,22 @@ export default {}
</script> </script>
<template> <template>
<article <article class="prose prose-invert border-2 bg-ctp-crust rounded-lg mx-auto p-8 pt-10 text-white">
class="prose prose-invert border-2 bg-ctp-crust rounded-lg mx-auto p-8 pt-10 text-white"
>
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
<p> <p>
It's.... kind of empty here. It's.... kind of empty here.
<br><br>
No logs are kept. That's it. Nothing is stored from you interacting with the site. <br /><br />
Streamers you follow are stored in your browser's LocalStorage, but never reaches the server.
Selected language when using SafeTwitch is sent to the server among every request in order to send the data back in the correct language
<br><br>
Non-official instances are under their own privacy policy, as they may host SafeTwitch with different practices that may log requests No logs are kept. That's it. Nothing is stored from you interacting with the site. Streamers
you follow are stored in your browser's LocalStorage, but never reaches the server. Selected
language when using SafeTwitch is sent to the server among every request in order to send the
data back in the correct language
<br /><br />
Non-official instances are under their own privacy policy, as they may host SafeTwitch with
different practices that may log requests
</p> </p>
</article> </article>
</template> </template>

View File

@ -8,90 +8,102 @@ import StreamPreviewVue from '@/components/StreamPreview.vue'
import ChannelPreview from '@/components/ChannelPreview.vue' import ChannelPreview from '@/components/ChannelPreview.vue'
import { getEndpoint } from '@/mixins' import { getEndpoint } from '@/mixins'
import type { ApiResponse, SearchResult, StreamerData } from '@/types' import type { SearchResult, StreamerData } from '@/types'
export default { export default {
inject: ['protocol'], inject: ['protocol'],
setup() { setup() {
let data = ref<SearchResult>() let data = ref<SearchResult>()
const status = ref<"ok" | "error">() const status = ref<'ok' | 'error'>()
return { return {
data, data,
status, status
}
},
async mounted() {
await getEndpoint("api/search/?query=" + this.$route.query.query)
.catch(() => {
this.status = "error"
})
.then((data) => {
this.data = data as SearchResult
})
},
methods: {
getStream(channel: StreamerData) {
return {
...channel.stream,
streamer: {
name: channel.username,
pfp: channel.pfp
}
}
}
},
components: {
CategoryPreview,
ErrorMessage,
LoadingScreen,
StreamPreviewVue,
ChannelPreview
} }
},
async mounted() {
await getEndpoint('api/search/?query=' + this.$route.query.query)
.catch(() => {
this.status = 'error'
})
.then((data) => {
this.data = data as SearchResult
})
},
methods: {
getStream(channel: StreamerData) {
return {
...channel.stream,
streamer: {
name: channel.username,
pfp: channel.pfp
}
}
}
},
components: {
CategoryPreview,
ErrorMessage,
LoadingScreen,
StreamPreviewVue,
ChannelPreview
}
} }
</script> </script>
<template> <template>
<loading-screen v-if="!data && status != 'error'"></loading-screen> <loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message> <error-message v-else-if="status == 'error'"></error-message>
<div v-else-if="data" class="p-3 space-y-5">
<div v-if="data.channels.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">Channels related to "{{ $route.query.query }}"</h1>
<ul class="flex overflow-x-scroll overflow-y-hidden">
<li v-for="channel in data.channels" class="m-2 hover:scale-105 transition-transform">
<channel-preview :channel="channel"></channel-preview>
</li>
</ul>
</div>
<div v-if="data.categories.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">Categories related to "{{ $route.query.query }}"</h1>
<ul class="flex max-w-[100vw] max-h-[27rem] overflow-x-scroll overflow-y-hidden">
<li v-for="category in data.categories" class="m-2 hover:scale-105 transition-transform">
<category-preview :category-data="category"></category-preview>
</li>
</ul>
</div>
<div v-if="data.relatedChannels.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">Live channels with the tag "{{ $route.query.query }}"</h1>
<ul class="flex overflow-x-scroll space-x-5 ">
<li v-for="channel in data.relatedChannels">
<stream-preview-vue :stream="getStream(channel)"></stream-preview-vue>
</li>
</ul>
</div>
<div v-if="data.channelsWithTag.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">Channels with the tag "{{ $route.query.query }}"</h1>
<ul class="inline-flex overflow-y-hidden overflow-x-scroll max-w-[100vw] space-x-5">
<li v-for="channel in data.channelsWithTag">
<channel-preview :channel="channel"></channel-preview>
</li>
</ul>
</div>
<div v-else-if="data" class="p-3 space-y-5">
<div v-if="data.channels.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">
Channels related to "{{ $route.query.query }}"
</h1>
<ul class="flex overflow-x-scroll overflow-y-hidden">
<li
v-for="channel in data.channels"
:key="channel.id"
class="m-2 hover:scale-105 transition-transform"
>
<channel-preview :channel="channel"></channel-preview>
</li>
</ul>
</div> </div>
</template>
<div v-if="data.categories.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">
Categories related to "{{ $route.query.query }}"
</h1>
<ul class="flex max-w-[100vw] max-h-[27rem] overflow-x-scroll overflow-y-hidden">
<li
v-for="category in data.categories"
:key="category.name"
class="m-2 hover:scale-105 transition-transform"
>
<category-preview :category-data="category"></category-preview>
</li>
</ul>
</div>
<div v-if="data.relatedChannels.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">
Live channels with the tag "{{ $route.query.query }}"
</h1>
<ul class="flex overflow-x-scroll space-x-5">
<li v-for="channel in data.relatedChannels" :key="channel.id">
<stream-preview-vue :stream="getStream(channel)"></stream-preview-vue>
</li>
</ul>
</div>
<div v-if="data.channelsWithTag.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">
Channels with the tag "{{ $route.query.query }}"
</h1>
<ul class="inline-flex overflow-y-hidden overflow-x-scroll max-w-[100vw] space-x-5">
<li v-for="channel in data.channelsWithTag" :key="channel.id">
<channel-preview :channel="channel"></channel-preview>
</li>
</ul>
</div>
</div>
</template>

View File

@ -9,16 +9,16 @@ import FollowButton from '@/components/FollowButton.vue'
import LoadingScreen from '@/components/LoadingScreen.vue' import LoadingScreen from '@/components/LoadingScreen.vue'
import VideoTab from '@/components/user/VideoTab.vue' import VideoTab from '@/components/user/VideoTab.vue'
import type { StreamerData, ApiResponse } from '@/types' import type { StreamerData } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint } from '@/mixins'
export default { export default {
inject: ["rootBackendUrl"], inject: ['rootBackendUrl'],
async setup() { async setup() {
const route = useRoute() const route = useRoute()
const username = route.params.username const username = route.params.username
const data = ref<StreamerData>() const data = ref<StreamerData>()
const status = ref<"ok" | "error">() const status = ref<'ok' | 'error'>()
const rootBackendUrl = inject('rootBackendUrl') const rootBackendUrl = inject('rootBackendUrl')
const videoOptions = { const videoOptions = {
autoplay: true, autoplay: true,
@ -35,19 +35,19 @@ export default {
return { return {
data, data,
status, status,
videoOptions, videoOptions
} }
}, },
async mounted() { async mounted() {
const username = this.$route.params.username const username = this.$route.params.username
await getEndpoint("api/users/" + username) await getEndpoint('api/users/' + username)
.then((data) => { .then((data) => {
this.data = data this.data = data
}) })
.catch(() => { .catch(() => {
this.status = "error" this.status = 'error'
}) })
}, },
components: { components: {
VideoPlayer, VideoPlayer,
@ -58,28 +58,33 @@ export default {
VideoTab VideoTab
}, },
methods: { methods: {
truncate, abbreviate truncate,
abbreviate
} }
} }
</script> </script>
<template> <template>
<loading-screen v-if="!data && status != 'error'"></loading-screen> <loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message> <error-message v-else-if="status == 'error'"></error-message>
<div <div
v-else-if="data" v-else-if="data"
class="w-full justify-center md:inline-flex space-y-4 md:space-y-0 md:space-x-4 md:p-4" class="w-full justify-center md:inline-flex space-y-4 md:space-y-0 md:space-x-4 md:p-4"
> >
<div <div
class="flex bg-ctp-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-white" class="flex bg-ctp-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-white"
> >
<div v-if="data.isLive" class="w-full mx-auto rounded-lg mb-5"> <div v-if="data.isLive" class="w-full mx-auto rounded-lg mb-5">
<video-player :options="videoOptions"> </video-player> <video-player :options="videoOptions"> </video-player>
</div> </div>
<img v-else :src="data.banner" alt="Streamer banner" class="rounded-md opacity-70 relative mb-2"> <img
v-else
:src="data.banner"
alt="Streamer banner"
class="rounded-md opacity-70 relative mb-2"
/>
<div class="w-full flex-wrap md:p-3"> <div class="w-full flex-wrap md:p-3">
<div class="inline-flex md:w-4/5"> <div class="inline-flex md:w-4/5">
@ -90,10 +95,10 @@ export default {
:style="`border-color: ${data.colorHex};`" :style="`border-color: ${data.colorHex};`"
/> />
<span <span
v-if="data.isLive" v-if="data.isLive"
class="absolute flex left-1/2 translate-x-[-50%] whitespace-nowrap uppercase top-16 bg-ctp-red font-bold text-sm p-1.5 py-0.5 rounded-md" class="absolute flex left-1/2 translate-x-[-50%] whitespace-nowrap uppercase top-16 bg-ctp-red font-bold text-sm p-1.5 py-0.5 rounded-md"
>{{ $t("main.live") }}</span >{{ $t('main.live') }}</span
> >
</div> </div>
@ -116,7 +121,9 @@ export default {
</p> </p>
</div> </div>
<div v-else class="w-full"> <div v-else class="w-full">
<ul class="text-xs font-bold text-left md:text-right space-x-1 space-y-1 overflow-y-auto"> <ul
class="text-xs font-bold text-left md:text-right space-x-1 space-y-1 overflow-y-auto"
>
<li <li
v-for="tag in data.stream!.tags" v-for="tag in data.stream!.tags"
:key="tag" :key="tag"
@ -130,17 +137,19 @@ export default {
<div class="pt-2 inline-flex"> <div class="pt-2 inline-flex">
<follow-button :username="data.username"></follow-button> <follow-button :username="data.username"></follow-button>
<p class="align-baseline font-bold ml-3">{{ abbreviate(data.followers) }} {{ $t("main.followers") }}</p> <p class="align-baseline font-bold ml-3">
{{ abbreviate(data.followers) }} {{ $t('main.followers') }}
</p>
</div> </div>
</div> </div>
<!-- VIDEOS TAB --> <!-- VIDEOS TAB -->
<video-tab class="mb-4"></video-tab> <video-tab class="mb-4"></video-tab>
<!-- ABOUT TAB --> <!-- ABOUT TAB -->
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3"> <div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<div class="inline-flex w-full"> <div class="inline-flex w-full">
<span class="pr-3 font-bold text-3xl">{{ $t("streamer.about") }}</span> <span class="pr-3 font-bold text-3xl">{{ $t('streamer.about') }}</span>
</div> </div>
<p class="mb-5">{{ data.about }}</p> <p class="mb-5">{{ data.about }}</p>
@ -148,7 +157,7 @@ export default {
<hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" /> <hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" />
<ul class="flex font-semibold text-md justify-start flex-wrap flex-row"> <ul class="flex font-semibold text-md justify-start flex-wrap flex-row">
<li v-for="link in data.socials"> <li v-for="link in data.socials" :key="link.link">
<a :href="link.link" class="text-white hover:text-gray-400 mr-4"> <a :href="link.link" class="text-white hover:text-gray-400 mr-4">
<v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon> <v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span> <span>{{ link.name }}</span>

View File

@ -7,23 +7,21 @@ import TwitchChat from '@/components/TwitchChat.vue'
import ErrorMessage from '@/components/ErrorMessage.vue' import ErrorMessage from '@/components/ErrorMessage.vue'
import FollowButton from '@/components/FollowButton.vue' import FollowButton from '@/components/FollowButton.vue'
import LoadingScreen from '@/components/LoadingScreen.vue' import LoadingScreen from '@/components/LoadingScreen.vue'
import VideoTab from '@/components/user/VideoTab.vue'
import type { Video, ApiResponse } from '@/types' import type { Video } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins' import { truncate, abbreviate, getEndpoint } from '@/mixins'
interface ChatComponent { interface ChatComponent {
updateVodComments: (time: number) => void updateVodComments: (time: number) => void
} }
export default { export default {
inject: ["rootBackendUrl"], inject: ['rootBackendUrl'],
async setup() { async setup() {
const route = useRoute() const route = useRoute()
const vodID = route.params.vodID const vodID = route.params.vodID
const data = ref<Video>() const data = ref<Video>()
const status = ref<"ok" | "error">() const status = ref<'ok' | 'error'>()
const rootBackendUrl = inject('rootBackendUrl') const rootBackendUrl = inject('rootBackendUrl')
const videoOptions = { const videoOptions = {
autoplay: true, autoplay: true,
@ -40,30 +38,30 @@ export default {
return { return {
data, data,
status, status,
videoOptions, videoOptions
} }
}, },
async mounted() { async mounted() {
const vodID = this.$route.params.vodID const vodID = this.$route.params.vodID
await getEndpoint("api/vods/" + vodID) await getEndpoint('api/vods/' + vodID)
.then((data) => { .then((data) => {
this.data = data this.data = data
}) })
.catch(() => { .catch(() => {
this.status = "error" this.status = 'error'
}) })
}, },
components: { components: {
VideoPlayer, VideoPlayer,
TwitchChat, TwitchChat,
ErrorMessage, ErrorMessage,
FollowButton, FollowButton,
LoadingScreen, LoadingScreen
VideoTab
}, },
methods: { methods: {
truncate, abbreviate, truncate,
abbreviate,
handlePlayerTimeUpdate(time: number) { handlePlayerTimeUpdate(time: number) {
const chat = this.$refs.chat as ChatComponent const chat = this.$refs.chat as ChatComponent
chat.updateVodComments(time) chat.updateVodComments(time)
@ -73,31 +71,35 @@ export default {
</script> </script>
<template> <template>
<loading-screen v-if="!data && status != 'error'"></loading-screen> <loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message> <error-message v-else-if="status == 'error'"></error-message>
<div <div
v-else-if="data" v-else-if="data"
class="w-full justify-center md:inline-flex space-y-4 md:space-y-0 md:space-x-4 md:p-4" class="w-full justify-center md:inline-flex space-y-4 md:space-y-0 md:space-x-4 md:p-4"
> >
<div <div
class="flex bg-ctp-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-white" class="flex bg-ctp-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-white"
> >
<div class="w-full mx-auto rounded-lg mb-5"> <div class="w-full mx-auto rounded-lg mb-5">
<video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate"> </video-player> <video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate">
</video-player>
</div> </div>
<div class="w-full flex-wrap md:p-3"> <div class="w-full flex-wrap md:p-3">
<div class="inline-flex md:w-full"> <div class="inline-flex md:w-full">
<img <router-link :to="'/' + data.streamer.login">
:src="data.streamer.pfp" <img
class="rounded-full border-4 p-0.5 w-auto h-20" :src="data.streamer.pfp"
:style="`border-color: ${data.streamer.colorHex};`" class="rounded-full border-4 p-0.5 w-auto h-20"
/> :style="`border-color: ${data.streamer.colorHex};`"
/>
</router-link>
<div class="ml-3 content-between"> <div class="ml-3 content-between">
<h1 class="text-2xl md:text-4xl font-bold">{{ data.streamer.username }}</h1> <router-link :to="'/' + data.streamer.login">
<h1 class="text-2xl md:text-4xl font-bold">{{ data.streamer.username }}</h1>
</router-link>
<p class="text-sm font-bold text-gray-200 self-end"> <p class="text-sm font-bold text-gray-200 self-end">
{{ truncate(data.title, 130) }} {{ truncate(data.title, 130) }}
</p> </p>
@ -106,17 +108,16 @@ export default {
<div class="pt-2 inline-flex"> <div class="pt-2 inline-flex">
<follow-button :username="data.streamer.username"></follow-button> <follow-button :username="data.streamer.username"></follow-button>
<p class="align-baseline font-bold ml-3">{{ abbreviate(data.streamer.followers) }} {{ $t("main.followers") }}</p> <p class="align-baseline font-bold ml-3">
{{ abbreviate(data.streamer.followers) }} {{ $t('main.followers') }}
</p>
</div> </div>
</div> </div>
<!-- VIDEOS TAB -->
<!-- <video-tab class="mb-4"></video-tab> -->
<!-- ABOUT TAB --> <!-- ABOUT TAB -->
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3"> <div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<div class="inline-flex w-full"> <div class="inline-flex w-full">
<span class="pr-3 font-bold text-3xl">{{ $t("streamer.about") }}</span> <span class="pr-3 font-bold text-3xl">{{ $t('streamer.about') }}</span>
</div> </div>
<p class="mb-5">{{ data.streamer.about }}</p> <p class="mb-5">{{ data.streamer.about }}</p>
@ -124,7 +125,7 @@ export default {
<hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" /> <hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" />
<ul class="flex font-semibold text-md justify-start flex-wrap flex-row"> <ul class="flex font-semibold text-md justify-start flex-wrap flex-row">
<li v-for="link in data.streamer.socials"> <li v-for="link in data.streamer.socials" :key="link.link">
<a :href="link.link" class="text-white hover:text-gray-400 mr-4"> <a :href="link.link" class="text-white hover:text-gray-400 mr-4">
<v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon> <v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span> <span>{{ link.name }}</span>