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

@ -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: {

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

@ -18,7 +18,7 @@ export default {
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1 maximumFractionDigits: 1
}).format(text) }).format(text)
}, }
} }
} }
</script> </script>
@ -39,8 +39,10 @@ export default {
<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"
>{{ tag }}</span
>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -17,13 +17,15 @@ export default {
<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">
{{ channelData.about }}
</p>
</div> </div>
</div> </div>
</div> </div>

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

@ -1,6 +1,11 @@
<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
v-model="$i18n.locale"
@change="onChange()"
class="my-auto p-0 pr-9 bg-transparent border-0"
:selected="$i18n.locale"
>
<option v-for="(lang, i) in langs" :key="`Lang${i}`" :value="lang"> <option v-for="(lang, i) in langs" :key="`Lang${i}`" :value="lang">
{{ names[i] }} {{ names[i] }}
</option> </option>
@ -17,14 +22,14 @@ export default {
} }
}, },
mounted() { mounted() {
const savedLocale = localStorage.getItem("language") const savedLocale = localStorage.getItem('language')
if (savedLocale != null && this.langs.includes(savedLocale)) { if (savedLocale != null && this.langs.includes(savedLocale)) {
this.$i18n.locale = savedLocale this.$i18n.locale = savedLocale
} }
}, },
methods: { methods: {
onChange() { onChange() {
localStorage.setItem("language", this.$i18n.locale) localStorage.setItem('language', this.$i18n.locale)
window.location.reload() window.location.reload()
} }
} }

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,7 +2,7 @@
export default { export default {
setup() { setup() {
return { return {
searchInput: "", searchInput: ''
} }
}, },
methods: { methods: {
@ -16,7 +16,7 @@ export default {
<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"
@ -24,7 +24,7 @@ export default {
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>

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,7 +35,6 @@ 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())

View File

@ -1,37 +1,42 @@
<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"
>
{{ new Date(videoData.duration * 1000).toISOString().slice(11, 19) }}
</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(videoData.views) }} {{ $t('main.views') }}
</p>
</div> </div>
<div class="pt-2 space-x-2"> <div class="pt-2 space-x-2">
<div class="space-x-2 inline-flex"> <div class="space-x-2 inline-flex">
<RouterLink :to="'/game/' + data.game.name"> <RouterLink :to="'/game/' + videoData.game.name">
<img :src="data.game.image"> <img :src="videoData.game.image" />
</RouterLink> </RouterLink>
<div class="w-full"> <div class="w-full">
<p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p> <p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p>
<div class="text-xs text-gray-400"> <div class="text-xs text-gray-400">
<p>{{ data.streamer.login }}</p> <p>{{ videoData.streamer.login }}</p>
<p>{{ data.game.displayName || data.game.name }}</p> <p>{{ videoData.game.displayName || videoData.game.name }}</p>
</div> </div>
</div> </div>
</div> </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: {
@ -39,7 +44,7 @@ export default {
}, },
setup(props) { setup(props) {
return { return {
data: props.data as Video videoData: props.data as Video
} }
}, },
methods: { methods: {

View File

@ -12,14 +12,20 @@
<div v-else-if="shelves" class="mb-5"> <div v-else-if="shelves" class="mb-5">
<div class="space-y-5"> <div class="space-y-5">
<div v-for="shelve of shelves"> <div v-for="shelve of shelves" :key="shelve.title">
<h1 class="font-bold text-lg">{{ shelve.title }}</h1> <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"> <div
<video-preview v-for="video of shelve.videos" :data="video"></video-preview> 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>
</div> </div>
<hr> <hr />
</div> </div>
</div> </div>
</div> </div>
@ -36,18 +42,18 @@ export default {
setup() { setup() {
return { return {
shelves: ref<Shelve[]>([]), shelves: ref<Shelve[]>([]),
status: "" status: ''
} }
}, },
async mounted() { async mounted() {
const username = this.$route.params.username const username = this.$route.params.username
await getEndpoint("api/vods/shelve/" + username) await getEndpoint('api/vods/shelve/' + username)
.then((data) => { .then((data) => {
this.shelves = data this.shelves = data
}) })
.catch(() => { .catch(() => {
this.status = "error" this.status = 'error'
}) })
}, },
components: { components: {

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://'

View File

@ -6,7 +6,7 @@ export function truncate(value: string, length: number) {
} }
} }
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, {
@ -16,18 +16,15 @@ export function abbreviate(text: number) {
}).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
try {
const res = await fetch(rootBackendUrl + endpoint, { const res = await fetch(rootBackendUrl + endpoint, {
method: 'GET', method: 'GET',
headers: { headers: {
"Accept-Language": language 'Accept-Language': language
} }
}) })
const rawData = await res.json() const rawData = await res.json()
@ -39,10 +36,7 @@ export async function getEndpoint(endpoint: string) {
throw rawData throw rawData
} }
data = rawData.data const data = rawData.data
} catch (error) {
throw error
}
return 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,5 +1,4 @@
import type { Tag } from "./" import type { Tag } from './'
import type { StreamData } from "./"
export interface CategoryMinifiedStream { export interface CategoryMinifiedStream {
title: string title: string

View File

@ -1,13 +1,13 @@
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

View File

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

View File

@ -1,5 +1,5 @@
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[]

View File

@ -1,6 +1,6 @@
export interface Social { export interface Social {
type: string | null type: string | null
name: string, name: string
link: string link: string
} }

View File

@ -1,4 +1,4 @@
import type { StreamerData } from "./Streamer" import type { StreamerData } from './Streamer'
export interface MinifiedCategory { export interface MinifiedCategory {
image: string image: string
@ -30,8 +30,6 @@ export interface Shelve {
videos: Video[] videos: Video[]
} }
export interface VodMessager { export interface VodMessager {
name: string name: string
login: string login: string

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,9 +20,9 @@ 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
@ -45,8 +44,9 @@ 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}`
).catch((err) => {
throw err throw err
}) })
@ -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,6 +144,7 @@ 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"
> >

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> <br /><br />
No logs are kept. That's it. Nothing is stored from you interacting with the site. No logs are kept. That's it. Nothing is stored from you interacting with the site. Streamers
Streamers you follow are stored in your browser's LocalStorage, but never reaches the server. you follow are stored in your browser's LocalStorage, but never reaches the server. Selected
Selected language when using SafeTwitch is sent to the server among every request in order to send the data back in the correct language 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> <br /><br />
Non-official instances are under their own privacy policy, as they may host SafeTwitch with different practices that may log requests 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,22 +8,22 @@ 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() { async mounted() {
await getEndpoint("api/search/?query=" + this.$route.query.query) await getEndpoint('api/search/?query=' + this.$route.query.query)
.catch(() => { .catch(() => {
this.status = "error" this.status = 'error'
}) })
.then((data) => { .then((data) => {
this.data = data as SearchResult this.data = data as SearchResult
@ -53,45 +53,57 @@ export default {
<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-else-if="data" class="p-3 space-y-5">
<div v-if="data.channels.length > 0"> <div v-if="data.channels.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">Channels related to "{{ $route.query.query }}"</h1> <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"> <ul class="flex overflow-x-scroll overflow-y-hidden">
<li v-for="channel in data.channels" class="m-2 hover:scale-105 transition-transform"> <li
v-for="channel in data.channels"
:key="channel.id"
class="m-2 hover:scale-105 transition-transform"
>
<channel-preview :channel="channel"></channel-preview> <channel-preview :channel="channel"></channel-preview>
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="data.categories.length > 0"> <div v-if="data.categories.length > 0">
<h1 class="text-white font-bold text-4xl mb-2">Categories related to "{{ $route.query.query }}"</h1> <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"> <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"> <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> <category-preview :category-data="category"></category-preview>
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="data.relatedChannels.length > 0"> <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> <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"> <ul class="flex overflow-x-scroll space-x-5">
<li v-for="channel in data.relatedChannels"> <li v-for="channel in data.relatedChannels" :key="channel.id">
<stream-preview-vue :stream="getStream(channel)"></stream-preview-vue> <stream-preview-vue :stream="getStream(channel)"></stream-preview-vue>
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="data.channelsWithTag.length > 0"> <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> <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"> <ul class="inline-flex overflow-y-hidden overflow-x-scroll max-w-[100vw] space-x-5">
<li v-for="channel in data.channelsWithTag"> <li v-for="channel in data.channelsWithTag" :key="channel.id">
<channel-preview :channel="channel"></channel-preview> <channel-preview :channel="channel"></channel-preview>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</template> </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,18 +35,18 @@ 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: {
@ -58,7 +58,8 @@ export default {
VideoTab VideoTab
}, },
methods: { methods: {
truncate, abbreviate truncate,
abbreviate
} }
} }
</script> </script>
@ -73,13 +74,17 @@ export default {
> >
<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">
@ -93,7 +98,7 @@ export default {
<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,7 +137,9 @@ 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>
@ -140,7 +149,7 @@ export default {
<!-- 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,18 +38,18 @@ 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: {
@ -59,11 +57,11 @@ export default {
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)
@ -82,22 +80,26 @@ export default {
> >
<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">
<router-link :to="'/' + data.streamer.login">
<img <img
:src="data.streamer.pfp" :src="data.streamer.pfp"
class="rounded-full border-4 p-0.5 w-auto h-20" class="rounded-full border-4 p-0.5 w-auto h-20"
:style="`border-color: ${data.streamer.colorHex};`" :style="`border-color: ${data.streamer.colorHex};`"
/> />
</router-link>
<div class="ml-3 content-between"> <div class="ml-3 content-between">
<router-link :to="'/' + data.streamer.login">
<h1 class="text-2xl md:text-4xl font-bold">{{ data.streamer.username }}</h1> <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>