0
Fork 0
mirror of https://codeberg.org/SafeTwitch/safetwitch.git synced 2025-02-03 01:58:48 -05:00
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)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<template>
<div class="rounded-lg z-50 flex m-3 h-20 bg-amber-400 border-4 border-amber-600">
<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>
</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"
>
<div class="mb-6">
<h1 class="font-bold text-5xl">{{ $t("error.oops") }}</h1>
<p class="font-bold text-3xl">{{ $t("error.notsupposedtohappen") }}</p>
<h1 class="font-bold text-5xl">{{ $t('error.oops') }}</h1>
<p class="font-bold text-3xl">{{ $t('error.notsupposedtohappen') }}</p>
</div>
<p class="text-xl">
{{ $t("error.serverexplain") }}
{{ $t('error.serverexplain') }}
</p>
</div>
</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"
>
<v-icon name="bi-heart-fill" scale="0.85"></v-icon>
<span v-if="isFollowing"> {{ $t("streamer.unfollow") }} </span>
<span v-else> {{ $t("streamer.follow") }} </span>
<span v-if="isFollowing"> {{ $t('streamer.unfollow') }} </span>
<span v-else> {{ $t('streamer.follow') }} </span>
</button>
</template>

View file

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

View file

@ -1,32 +1,37 @@
<template>
<div class="flex">
<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">
{{ names[i] }}
</option>
</select>
</div>
<div class="flex">
<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">
{{ names[i] }}
</option>
</select>
</div>
</template>
<script lang="ts">
export default {
setup() {
return {
langs: ['en-US', 'es-ES', 'nl-NL', 'pt-PT', 'fa-IR', 'he-IL'],
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()
}
setup() {
return {
langs: ['en-US', 'es-ES', 'nl-NL', 'pt-PT', 'fa-IR', 'he-IL'],
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()
}
}
}
</script>
</script>

View file

@ -1,7 +1,7 @@
<template>
<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">
<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>
</div>
</div>

View file

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

View file

@ -2,30 +2,30 @@
export default {
setup() {
return {
searchInput: "",
searchInput: ''
}
},
methods: {
redirectToSearch() {
const query = this.searchInput
this.$router.push({ path: '/search/', query: { query }})
this.$router.push({ path: '/search/', query: { query } })
}
}
}
</script>
<template>
<div class="relative hidden md:block">
<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>
<input
type="text"
id="searchBar"
name="searchBar"
:placeholder="$t('main.search')"
v-model="searchInput"
@keyup.enter=redirectToSearch
class="rounded-md p-1 pl-8 text-black bg-white placeholder:text-black"
/>
</div>
</template>
<div class="relative hidden md:block">
<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>
<input
type="text"
id="searchBar"
name="searchBar"
:placeholder="$t('main.search')"
v-model="searchInput"
@keyup.enter="redirectToSearch"
class="rounded-md p-1 pl-8 text-black bg-white placeholder:text-black"
/>
</div>
</template>

View file

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

View file

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

View file

@ -1,49 +1,54 @@
<template>
<div class="min-w-[300px]">
<div class="relative">
<RouterLink :to="'/videos/' + data.id">
<img :src="data.preview" class="rounded-md" width="300">
</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 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>
</div>
<div class="pt-2 space-x-2">
<div class="space-x-2 inline-flex">
<RouterLink :to="'/game/' + data.game.name">
<img :src="data.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>{{ data.streamer.login }}</p>
<p>{{ data.game.displayName || data.game.name }}</p>
</div>
</div>
</div>
</div>
<div class="min-w-[300px]">
<div class="relative">
<RouterLink :to="'/videos/' + videoData.id">
<img :src="videoData.preview" class="rounded-md" width="300" />
</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(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 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>
<script lang="ts">
import type { Video } from '@/types/VOD';
import { abbreviate } from '@/mixins';
import type { Video } from '@/types/VOD'
import { abbreviate } from '@/mixins'
export default {
props: {
data: Object
},
setup(props) {
return {
data: props.data as Video
}
},
methods: {
abbreviate
props: {
data: Object
},
setup(props) {
return {
videoData: props.data as Video
}
},
methods: {
abbreviate
}
}
</script>
</script>

View file

@ -3,26 +3,32 @@
-->
<template>
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<div class="inline-flex w-full">
<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 class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<div class="inline-flex w-full">
<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" :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>
<script lang="ts">
@ -33,25 +39,25 @@ import type { Shelve } from '@/types/VOD'
import VideoPreview from '@/components/user/VideoPreview.vue'
export default {
setup() {
return {
shelves: ref<Shelve[]>([]),
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
setup() {
return {
shelves: ref<Shelve[]>([]),
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
}
}
</script>
</script>

View file

@ -17,6 +17,6 @@ export default createI18n({
'nl-NL': nl,
'pt-PT': pt,
'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 router from './router'
import './assets/index.css'
import i18n from "./i18n"
import { version } from "../package.json"
import i18n from './i18n'
import { version } from '../package.json'
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"
// returns false, even if it is true.
// 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 wsProtocol = https ? 'wss://' : 'ws://'
@ -51,4 +50,4 @@ addIcons(
app.component('v-icon', OhVueIcon)
app.use(router)
app.mount('#app')
app.mount('#app')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,4 +5,4 @@ export * from './Chat'
export * from './Category'
export * from './CategoryData'
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 { getEndpoint, abbreviate } from '@/mixins'
export default {
inject: ['protocol'],
async setup() {
let data = ref<CategoryData>()
let status = ref<"ok" | "error">()
let status = ref<'ok' | 'error'>()
return {
data,
@ -21,13 +20,13 @@ export default {
}
},
async mounted() {
await getEndpoint("api/discover/" + this.$route.params.game)
.catch(() => {
this.status = "error"
})
.then((data: CategoryData) => {
this.data = data
})
await getEndpoint('api/discover/' + this.$route.params.game)
.catch(() => {
this.status = 'error'
})
.then((data: CategoryData) => {
this.data = data
})
this.getMoreStreams()
},
@ -45,10 +44,11 @@ export default {
if (!cursor) return
// get rest of streams from api
const resData = await getEndpoint(`api/discover/${this.$route.params.game}/?cursor=${cursor}`)
.catch((err) => {
throw err
})
const resData = await getEndpoint(
`api/discover/${this.$route.params.game}/?cursor=${cursor}`
).catch((err) => {
throw err
})
for (let stream of resData.streams) {
this.data!.streams.push(stream)
@ -56,7 +56,7 @@ export default {
}
}
},
abbreviate,
abbreviate
},
components: {
StreamPreviewVue,
@ -80,35 +80,47 @@ export default {
<div class="hidden md:block">
<div>
<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">{{ $t("main.viewers") }}: {{ abbreviate(data.viewers) }}</p>
<p class="font-bold text-white text-lg">
{{ $t('main.followers') }}: {{ abbreviate(data.followers) }}
</p>
<p class="font-bold text-white text-lg">
{{ $t('main.viewers') }}: {{ abbreviate(data.viewers) }}
</p>
</div>
<ul class="mb-5">
<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">{{
tag
}}</span>
<span
class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm"
>{{ tag }}</span
>
</li>
</ul>
</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 class="md:hidden">
<div>
<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">{{ $t("main.viewers") }}: {{ abbreviate(data.viewers) }}</p>
<p class="font-bold text-white text-lg">
{{ $t('main.followers') }}: {{ abbreviate(data.followers) }}
</p>
<p class="font-bold text-white text-lg">
{{ $t('main.viewers') }}: {{ abbreviate(data.viewers) }}
</p>
</div>
<ul class="mb-5">
<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">{{
tag
}}</span>
<span
class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm"
>{{ tag }}</span
>
</li>
</ul>
</div>
@ -118,7 +130,11 @@ export default {
<div class="max-w-[58rem] mx-auto">
<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>
</li>
</ul>

View file

@ -13,7 +13,7 @@ export default {
inject: ['protocol'],
async setup() {
let data = ref<CategoryPreviewInterface[]>()
let status = ref<"ok" | "error">()
let status = ref<'ok' | 'error'>()
return {
data,
@ -57,7 +57,9 @@ export default {
const cursor = this.data[this.data.length - 1].cursor
if (!cursor) return
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) {
throw new Error('Failed to fetch data')
@ -81,14 +83,13 @@ export default {
this.following = []
}
await getEndpoint("api/discover")
.catch(() => {
this.status = "error"
})
.then((data: CategoryPreviewInterface[]) => {
this.data = data
})
await getEndpoint('api/discover')
.catch(() => {
this.status = 'error'
})
.then((data: CategoryPreviewInterface[]) => {
this.data = data
})
},
components: {
StreamPreviewVue,
@ -119,13 +120,13 @@ export default {
</div>
<div class="p-2">
<h1 class="font-bold text-5xl text-white">{{ $t("home.discover") }}</h1>
<p class="text-xl text-white">{{ $t("home.discoverDescription") }}</p>
<h1 class="font-bold text-5xl text-white">{{ $t('home.discover') }}</h1>
<p class="text-xl text-white">{{ $t('home.discoverDescription') }}</p>
<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">
<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>
<input
type="text"
@ -143,10 +144,11 @@ export default {
<ul ref="categoryList">
<li
v-for="category in data"
:key="category.name"
ref="categoryItem"
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>
</ul>
</div>

View file

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

View file

@ -3,22 +3,22 @@ export default {}
</script>
<template>
<article
class="prose prose-invert border-2 bg-ctp-crust rounded-lg mx-auto p-8 pt-10 text-white"
>
<article class="prose prose-invert border-2 bg-ctp-crust rounded-lg mx-auto p-8 pt-10 text-white">
<h1>Privacy Policy</h1>
<p>
It's.... kind of empty here.
<br><br>
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>
<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>
</article>
</template>

View file

@ -8,90 +8,102 @@ import StreamPreviewVue from '@/components/StreamPreview.vue'
import ChannelPreview from '@/components/ChannelPreview.vue'
import { getEndpoint } from '@/mixins'
import type { ApiResponse, SearchResult, StreamerData } from '@/types'
import type { SearchResult, StreamerData } from '@/types'
export default {
inject: ['protocol'],
setup() {
let data = ref<SearchResult>()
const status = ref<"ok" | "error">()
return {
data,
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
inject: ['protocol'],
setup() {
let data = ref<SearchResult>()
const status = ref<'ok' | 'error'>()
return {
data,
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
}
}
</script>
<template>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<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>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<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"
:key="channel.id"
class="m-2 hover:scale-105 transition-transform"
>
<channel-preview :channel="channel"></channel-preview>
</li>
</ul>
</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 VideoTab from '@/components/user/VideoTab.vue'
import type { StreamerData, ApiResponse } from '@/types'
import type { StreamerData } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins'
export default {
inject: ["rootBackendUrl"],
inject: ['rootBackendUrl'],
async setup() {
const route = useRoute()
const username = route.params.username
const data = ref<StreamerData>()
const status = ref<"ok" | "error">()
const status = ref<'ok' | 'error'>()
const rootBackendUrl = inject('rootBackendUrl')
const videoOptions = {
autoplay: true,
@ -35,19 +35,19 @@ export default {
return {
data,
status,
videoOptions,
videoOptions
}
},
async mounted() {
const username = this.$route.params.username
await getEndpoint("api/users/" + username)
.then((data) => {
this.data = data
})
.catch(() => {
this.status = "error"
})
await getEndpoint('api/users/' + username)
.then((data) => {
this.data = data
})
.catch(() => {
this.status = 'error'
})
},
components: {
VideoPlayer,
@ -58,28 +58,33 @@ export default {
VideoTab
},
methods: {
truncate, abbreviate
truncate,
abbreviate
}
}
</script>
<template>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message>
<div
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"
>
<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">
<video-player :options="videoOptions"> </video-player>
</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="inline-flex md:w-4/5">
@ -90,10 +95,10 @@ export default {
:style="`border-color: ${data.colorHex};`"
/>
<span
<span
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"
>{{ $t("main.live") }}</span
>{{ $t('main.live') }}</span
>
</div>
@ -116,7 +121,9 @@ export default {
</p>
</div>
<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
v-for="tag in data.stream!.tags"
:key="tag"
@ -130,17 +137,19 @@ export default {
<div class="pt-2 inline-flex">
<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>
<!-- VIDEOS TAB -->
<video-tab class="mb-4"></video-tab>
<video-tab class="mb-4"></video-tab>
<!-- ABOUT TAB -->
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<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>
<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" />
<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">
<v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span>

View file

@ -7,23 +7,21 @@ import TwitchChat from '@/components/TwitchChat.vue'
import ErrorMessage from '@/components/ErrorMessage.vue'
import FollowButton from '@/components/FollowButton.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'
interface ChatComponent {
updateVodComments: (time: number) => void
}
export default {
inject: ["rootBackendUrl"],
inject: ['rootBackendUrl'],
async setup() {
const route = useRoute()
const vodID = route.params.vodID
const data = ref<Video>()
const status = ref<"ok" | "error">()
const status = ref<'ok' | 'error'>()
const rootBackendUrl = inject('rootBackendUrl')
const videoOptions = {
autoplay: true,
@ -40,30 +38,30 @@ export default {
return {
data,
status,
videoOptions,
videoOptions
}
},
async mounted() {
const vodID = this.$route.params.vodID
await getEndpoint("api/vods/" + vodID)
.then((data) => {
this.data = data
})
.catch(() => {
this.status = "error"
})
await getEndpoint('api/vods/' + vodID)
.then((data) => {
this.data = data
})
.catch(() => {
this.status = 'error'
})
},
components: {
VideoPlayer,
TwitchChat,
ErrorMessage,
FollowButton,
LoadingScreen,
VideoTab
LoadingScreen
},
methods: {
truncate, abbreviate,
truncate,
abbreviate,
handlePlayerTimeUpdate(time: number) {
const chat = this.$refs.chat as ChatComponent
chat.updateVodComments(time)
@ -73,31 +71,35 @@ export default {
</script>
<template>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message>
<div
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"
>
<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">
<video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate"> </video-player>
<video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate">
</video-player>
</div>
<div class="w-full flex-wrap md:p-3">
<div class="inline-flex md:w-full">
<img
:src="data.streamer.pfp"
class="rounded-full border-4 p-0.5 w-auto h-20"
:style="`border-color: ${data.streamer.colorHex};`"
/>
<router-link :to="'/' + data.streamer.login">
<img
:src="data.streamer.pfp"
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">
<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">
{{ truncate(data.title, 130) }}
</p>
@ -106,17 +108,16 @@ export default {
<div class="pt-2 inline-flex">
<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>
<!-- VIDEOS TAB -->
<!-- <video-tab class="mb-4"></video-tab> -->
<!-- ABOUT TAB -->
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<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>
<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" />
<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">
<v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span>