More components
This commit is contained in:
49
src/app.d.ts
vendored
49
src/app.d.ts
vendored
@@ -16,6 +16,55 @@ declare global {
|
||||
username: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
type ServiceType = 'jellyfin' | 'youtube-music'
|
||||
|
||||
interface MediaItem {
|
||||
connectionId: string
|
||||
serviceType: string
|
||||
id: string
|
||||
name: string
|
||||
duration: number
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
interface Song extends MediaItem {
|
||||
artists: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
album?: {
|
||||
id: string
|
||||
name: string
|
||||
artists: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
audio: string
|
||||
video?: string
|
||||
releaseDate: string
|
||||
}
|
||||
|
||||
interface Album extends MediaItem {
|
||||
artists: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
songs: Song[]
|
||||
releaseDate: string
|
||||
}
|
||||
|
||||
interface Playlist extends MediaItem {
|
||||
songs: Song[]
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string
|
||||
name: string
|
||||
// Add more here in the future
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
40
src/lib/components/navbar/navTab.svelte
Normal file
40
src/lib/components/navbar/navTab.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface NavTab {
|
||||
pathname: string
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let disabled = false
|
||||
export let nav: NavTab
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="grid aspect-square w-full place-items-center transition-colors"
|
||||
{disabled}
|
||||
on:click={() => {
|
||||
dispatch('click')
|
||||
goto(nav.pathname)
|
||||
}}
|
||||
>
|
||||
<span class="pointer-events-none flex flex-col gap-2 text-xs">
|
||||
<i class="{nav.icon} text-xl" />
|
||||
{nav.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button:not(:disabled) {
|
||||
color: rgb(163 163, 163);
|
||||
}
|
||||
button:not(:disabled):hover {
|
||||
color: var(--lazuli-primary);
|
||||
}
|
||||
</style>
|
||||
38
src/lib/components/navbar/playlistTab.svelte
Normal file
38
src/lib/components/navbar/playlistTab.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts" context="module">
|
||||
export interface PlaylistTab {
|
||||
id: string
|
||||
name: string
|
||||
thumbnail: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let disabled = false
|
||||
export let playlist: PlaylistTab
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
title={playlist.name}
|
||||
{disabled}
|
||||
class="relative aspect-square w-full rounded-lg bg-cover bg-center transition-all"
|
||||
style="background-image: url({playlist.thumbnail});"
|
||||
on:click={() => {
|
||||
dispatch('click')
|
||||
goto(`/library?playlist=${playlist.id}}`)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
<span class="translate-x-3 overflow-clip text-ellipsis whitespace-nowrap rounded-md bg-slate-600 px-2 py-1 text-sm">{playlist.name}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
button:not(:disabled):not(:hover) {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script context="module" lang="ts">
|
||||
export interface NavTab {
|
||||
pathname: string
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let navTabs: NavTab[]
|
||||
export let currentPathname: string
|
||||
export let transitionTime: number = 200
|
||||
|
||||
import { fade } from 'svelte/transition'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
type PageTransitionDirection = 'left' | 'right'
|
||||
let direction: PageTransitionDirection = 'right'
|
||||
|
||||
const calculateDirection = (newPage: string, currentPage: string): PageTransitionDirection => {
|
||||
const newPageIndex = navTabs.findIndex((tab) => tab.pathname === newPage)
|
||||
const currentPageIndex = navTabs.findIndex((tab) => tab.pathname === currentPage)
|
||||
return newPageIndex > currentPageIndex ? 'right' : 'left'
|
||||
}
|
||||
|
||||
let indicatorBar: HTMLDivElement, tabList: HTMLDivElement
|
||||
|
||||
const calculateBar = (newPathname: string) => {
|
||||
const newTab = document.querySelector(`button[data-tab="${newPathname}"]`)
|
||||
if (newTab && indicatorBar && tabList) {
|
||||
const listRect = tabList.getBoundingClientRect(),
|
||||
tabRec = newTab.getBoundingClientRect()
|
||||
const shiftRight = () => (indicatorBar.style.right = `${listRect.right - tabRec.right}px`),
|
||||
shiftLeft = () => (indicatorBar.style.left = `${tabRec.left - listRect.left}px`)
|
||||
if (direction === 'right') {
|
||||
shiftRight()
|
||||
setTimeout(shiftLeft, transitionTime)
|
||||
} else {
|
||||
shiftLeft()
|
||||
setTimeout(shiftRight, transitionTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={tabList} id="tab-list" class="relative flex w-full items-center justify-around bg-black">
|
||||
{#each navTabs as tabData}
|
||||
<button
|
||||
class="transition-colors"
|
||||
data-tab={tabData.pathname}
|
||||
disabled={currentPathname === tabData.pathname}
|
||||
on:click={() => {
|
||||
direction = calculateDirection(tabData.pathname, currentPathname)
|
||||
dispatch('navigate', { direction, pathname: tabData.pathname })
|
||||
}}
|
||||
>
|
||||
<i class="{tabData.icon} pointer-events-none" />
|
||||
</button>
|
||||
{/each}
|
||||
{#if navTabs.some((tab) => tab.pathname === currentPathname)}
|
||||
<div bind:this={indicatorBar} transition:fade class="absolute bottom-0 h-1 rounded-full bg-white transition-all duration-300 ease-in-out" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#tab-list {
|
||||
padding: 16px 0px;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
color: rgb(163 163, 163);
|
||||
}
|
||||
button:not(:disabled):hover {
|
||||
color: var(--lazuli-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -15,9 +15,7 @@ type UserQueryParams = {
|
||||
includePassword?: boolean
|
||||
}
|
||||
|
||||
type ServiceType = 'jellyfin' | 'youtube-music'
|
||||
|
||||
interface Service {
|
||||
interface DBServiceData {
|
||||
id: string
|
||||
type: ServiceType
|
||||
userId: string
|
||||
@@ -31,10 +29,10 @@ interface DBServiceRow {
|
||||
url: string
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
interface DBConnectionData {
|
||||
id: string
|
||||
user: User
|
||||
service: Service
|
||||
service: DBServiceData
|
||||
accessToken: string
|
||||
refreshToken: string | null
|
||||
expiry: number | null
|
||||
@@ -81,13 +79,13 @@ export class Users {
|
||||
}
|
||||
|
||||
export class Services {
|
||||
static getService = (id: string): Service => {
|
||||
static getService = (id: string): DBServiceData => {
|
||||
const { type, userId, url } = db.prepare('SELECT * FROM Users WHERE id = ?').get(id) as DBServiceRow
|
||||
const service: Service = { id, type: type as ServiceType, userId, url: new URL(url) }
|
||||
const service: DBServiceData = { id, type: type as ServiceType, userId, url: new URL(url) }
|
||||
return service
|
||||
}
|
||||
|
||||
static addService = (type: ServiceType, userId: string, url: URL): Service => {
|
||||
static addService = (type: ServiceType, userId: string, url: URL): DBServiceData => {
|
||||
const serviceId = generateUUID()
|
||||
db.prepare('INSERT INTO Services(id, type, userId, url) VALUES(?, ?, ?, ?)').run(serviceId, type, userId, url.origin)
|
||||
return this.getService(serviceId)
|
||||
@@ -100,15 +98,15 @@ export class Services {
|
||||
}
|
||||
|
||||
export class Connections {
|
||||
static getConnection = (id: string): Connection => {
|
||||
static getConnection = (id: string): DBConnectionData => {
|
||||
const { userId, serviceId, accessToken, refreshToken, expiry } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as DBConnectionRow
|
||||
const connection: Connection = { id, user: Users.getUser(userId)!, service: Services.getService(serviceId), accessToken, refreshToken, expiry }
|
||||
const connection: DBConnectionData = { id, user: Users.getUser(userId)!, service: Services.getService(serviceId), accessToken, refreshToken, expiry }
|
||||
return connection
|
||||
}
|
||||
|
||||
static getUserConnections = (userId: string): Connection[] => {
|
||||
static getUserConnections = (userId: string): DBConnectionData[] => {
|
||||
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as DBConnectionRow[]
|
||||
const connections: Connection[] = []
|
||||
const connections: DBConnectionData[] = []
|
||||
const user = Users.getUser(userId)!
|
||||
connectionRows.forEach((row) => {
|
||||
const { id, serviceId, accessToken, refreshToken, expiry } = row
|
||||
@@ -117,7 +115,7 @@ export class Connections {
|
||||
return connections
|
||||
}
|
||||
|
||||
static addConnection = (userId: string, serviceId: string, accessToken: string, refreshToken: string | null, expiry: number | null): Connection => {
|
||||
static addConnection = (userId: string, serviceId: string, accessToken: string, refreshToken: string | null, expiry: number | null): DBConnectionData => {
|
||||
const connectionId = generateUUID()
|
||||
db.prepare('INSERT INTO Connections(id, userId, serviceId, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?, ?)').run(connectionId, userId, serviceId, accessToken, refreshToken, expiry)
|
||||
return this.getConnection(connectionId)
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { goto, beforeNavigate } from '$app/navigation'
|
||||
import { pageWidth } from '$lib/stores'
|
||||
import type { LayoutData } from './$types'
|
||||
import type { Tab } from './+layout'
|
||||
import { onMount } from 'svelte'
|
||||
import NavTabComponent, { type NavTab } from '$lib/components/navbar/navTab.svelte'
|
||||
import PlaylistTabComponent, { type PlaylistTab } from '$lib/components/navbar/playlistTab.svelte'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
@@ -21,81 +22,55 @@
|
||||
return (pathname.startsWith(rootPathname) && rootPathname !== '/') || (pathname === '/' && rootPathname === '/')
|
||||
}
|
||||
|
||||
const calculateDirection = (newTab: Tab): void => {
|
||||
const newTabIndex = data.tabs.findIndex((tab) => tab === newTab)
|
||||
directionMultiplier = newTabIndex > currentTabIndex ? -1 : 1
|
||||
currentTabIndex = newTabIndex
|
||||
}
|
||||
// const calculateDirection = (newTab: Tab): void => {
|
||||
// const newTabIndex = data.tabs.findIndex((tab) => tab === newTab)
|
||||
// directionMultiplier = newTabIndex > currentTabIndex ? -1 : 1
|
||||
// currentTabIndex = newTabIndex
|
||||
// }
|
||||
|
||||
const navigate = (newPathname: string): void => {
|
||||
const newTabIndex = data.tabs.findIndex((tab) => inPathnameHeirarchy(newPathname, tab.pathname))
|
||||
// const navigate = (newPathname: string): void => {
|
||||
// const newTabIndex = data.tabs.findIndex((tab) => inPathnameHeirarchy(newPathname, tab.pathname))
|
||||
|
||||
if (newTabIndex < 0) indicatorBar.style.opacity = '0'
|
||||
// if (newTabIndex < 0) indicatorBar.style.opacity = '0'
|
||||
|
||||
const newTab = data.tabs[newTabIndex]
|
||||
if (!newTab?.button) return
|
||||
// const newTab = data.tabs[newTabIndex]
|
||||
// if (!newTab?.button) return
|
||||
|
||||
const tabRec = newTab.button.getBoundingClientRect(),
|
||||
listRect = tabList.getBoundingClientRect()
|
||||
// const tabRec = newTab.button.getBoundingClientRect(),
|
||||
// listRect = tabList.getBoundingClientRect()
|
||||
|
||||
const shiftTop = () => (indicatorBar.style.top = `${tabRec.top - listRect.top}px`),
|
||||
shiftBottom = () => (indicatorBar.style.bottom = `${listRect.bottom - tabRec.bottom}px`)
|
||||
// const shiftTop = () => (indicatorBar.style.top = `${tabRec.top - listRect.top}px`),
|
||||
// shiftBottom = () => (indicatorBar.style.bottom = `${listRect.bottom - tabRec.bottom}px`)
|
||||
|
||||
if (directionMultiplier > 0) {
|
||||
shiftTop()
|
||||
setTimeout(shiftBottom, pageTransitionTime)
|
||||
} else {
|
||||
shiftBottom()
|
||||
setTimeout(shiftTop, pageTransitionTime)
|
||||
}
|
||||
// if (directionMultiplier > 0) {
|
||||
// shiftTop()
|
||||
// setTimeout(shiftBottom, pageTransitionTime)
|
||||
// } else {
|
||||
// shiftBottom()
|
||||
// setTimeout(shiftTop, pageTransitionTime)
|
||||
// }
|
||||
|
||||
setTimeout(() => (indicatorBar.style.opacity = '100%'), pageTransitionTime + 300)
|
||||
}
|
||||
// setTimeout(() => (indicatorBar.style.opacity = '100%'), pageTransitionTime + 300)
|
||||
// }
|
||||
|
||||
onMount(() => setTimeout(() => navigate(data.url.pathname))) // More stupid fucking non-blocking event loop shit
|
||||
beforeNavigate(({ to }) => {
|
||||
if (to) navigate(to.url.pathname)
|
||||
})
|
||||
// onMount(() => setTimeout(() => navigate(data.url.pathname))) // More stupid fucking non-blocking event loop shit
|
||||
// beforeNavigate(({ to }) => {
|
||||
// if (to) navigate(to.url.pathname)
|
||||
// })
|
||||
</script>
|
||||
|
||||
{#if $pageWidth >= 768}
|
||||
<div class="grid h-full grid-rows-1 gap-8 overflow-hidden">
|
||||
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full grid-cols-1 grid-rows-[min-content_auto] gap-6 p-3 pt-12" bind:this={tabList}>
|
||||
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full grid-cols-1 grid-rows-[min-content_auto] gap-6 p-3 pt-12 w-20" bind:this={tabList}>
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each data.tabs.filter((tab) => tab.type === 'nav') as nav, index}
|
||||
<button
|
||||
class="navTab grid aspect-square w-14 place-items-center transition-colors"
|
||||
bind:this={data.tabs[index].button}
|
||||
disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)}
|
||||
on:click={() => {
|
||||
calculateDirection(nav)
|
||||
goto(nav.pathname)
|
||||
}}
|
||||
>
|
||||
<span class="pointer-events-none flex flex-col gap-2 text-xs">
|
||||
<i class="{nav.icon} text-xl" />
|
||||
{nav.name}
|
||||
</span>
|
||||
</button>
|
||||
{#each data.navTabs as nav}
|
||||
<NavTabComponent {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)}/>
|
||||
{/each}
|
||||
<div bind:this={indicatorBar} class="absolute left-0 w-[0.2rem] rounded-full bg-white transition-all duration-300 ease-in-out" />
|
||||
<!-- <div bind:this={indicatorBar} class="absolute left-0 w-[0.2rem] rounded-full bg-white transition-all duration-300 ease-in-out" /> -->
|
||||
</div>
|
||||
<div class="no-scrollbar flex flex-col gap-6 overflow-y-scroll">
|
||||
{#each data.tabs.filter((tab) => tab.type === 'playlist') as playlist}
|
||||
<div class="playlistTab-wrapper flex items-center gap-1">
|
||||
<button
|
||||
title={playlist.name}
|
||||
disabled={new URLSearchParams(data.url.search).get('playlist') === new URLSearchParams(playlist.pathname.split('?')[1]).get('playlist')}
|
||||
class="playlistTab relative aspect-square w-14 rounded-lg bg-cover bg-center transition-all"
|
||||
style="background-image: url({playlist.icon});"
|
||||
on:click={() => {
|
||||
calculateDirection(playlist)
|
||||
goto(playlist.pathname)
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
<span class="translate-x-3 overflow-clip text-ellipsis whitespace-nowrap rounded-md bg-slate-600 px-2 py-1 text-sm">{playlist.name}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each data.playlistTabs as playlist}
|
||||
<PlaylistTabComponent {playlist} disabled={new URLSearchParams(data.url.search).get('playlist') === playlist.id} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,18 +114,3 @@
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.navTab:not(:disabled) {
|
||||
color: rgb(163 163, 163);
|
||||
}
|
||||
.navTab:not(:disabled):hover {
|
||||
color: var(--lazuli-primary);
|
||||
}
|
||||
.playlistTab-wrapper:hover > span {
|
||||
display: block;
|
||||
}
|
||||
.playlistTab:not(:disabled):not(:hover) {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +1,83 @@
|
||||
import type { LayoutLoad } from './$types'
|
||||
|
||||
export interface Tab {
|
||||
type: 'nav' | 'playlist'
|
||||
pathname: string
|
||||
name: string
|
||||
icon: string
|
||||
button?: HTMLButtonElement
|
||||
}
|
||||
import type { NavTab } from '$lib/components/navbar/navTab.svelte'
|
||||
import type { PlaylistTab } from '$lib/components/navbar/playlistTab.svelte'
|
||||
|
||||
export const load: LayoutLoad = () => {
|
||||
const navTabs: Tab[] = [
|
||||
const navTabs: NavTab[] = [
|
||||
{
|
||||
type: 'nav',
|
||||
pathname: '/',
|
||||
name: 'Home',
|
||||
icon: 'fa-solid fa-house',
|
||||
},
|
||||
{
|
||||
type: 'nav',
|
||||
pathname: '/user',
|
||||
name: 'User',
|
||||
icon: 'fa-solid fa-user', // This would be a cool spot for a user-uploaded pfp
|
||||
},
|
||||
{
|
||||
type: 'nav',
|
||||
pathname: '/search',
|
||||
name: 'Search',
|
||||
icon: 'fa-solid fa-search',
|
||||
},
|
||||
{
|
||||
type: 'nav',
|
||||
pathname: '/library',
|
||||
name: 'Libray',
|
||||
icon: 'fa-solid fa-bars-staggered',
|
||||
},
|
||||
]
|
||||
|
||||
const playlistTabs: Tab[] = [
|
||||
const playlistTabs: PlaylistTab[] = [
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=AD:TRANCE 10',
|
||||
id: 'AD:TRANCE 10',
|
||||
name: 'AD:TRANCE 10',
|
||||
icon: 'https://www.diverse.direct/wp/wp-content/uploads/470_artwork.jpg',
|
||||
thumbnail: 'https://www.diverse.direct/wp/wp-content/uploads/470_artwork.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=Fionaredica',
|
||||
id: 'Fionaredica',
|
||||
name: 'Fionaredica',
|
||||
icon: 'https://f4.bcbits.com/img/a2436961975_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a2436961975_10.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=Machinate',
|
||||
id: 'Machinate',
|
||||
name: 'Machinate',
|
||||
icon: 'https://f4.bcbits.com/img/a3587136348_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a3587136348_10.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=MAGGOD',
|
||||
id: 'MAGGOD',
|
||||
name: 'MAGGOD',
|
||||
icon: 'https://f4.bcbits.com/img/a3641603617_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a3641603617_10.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=The Requiem',
|
||||
id: 'The Requiem',
|
||||
name: 'The Requiem',
|
||||
icon: 'https://f4.bcbits.com/img/a2458067285_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a2458067285_10.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
|
||||
id: 'IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
|
||||
name: 'IRREPARABLE HARDCORE IS BACK 2 -Horai Gekka-',
|
||||
icon: 'https://f4.bcbits.com/img/a1483629734_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a1483629734_10.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=妄殺オタクティクス',
|
||||
id: '妄殺オタクティクス',
|
||||
name: '妄殺オタクティクス',
|
||||
icon: 'https://f4.bcbits.com/img/a1653481367_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a1653481367_10.jpg',
|
||||
},
|
||||
{
|
||||
type: 'playlist',
|
||||
pathname: '/library?playlist=Collapse',
|
||||
id: 'Collapse',
|
||||
name: 'Collapse',
|
||||
icon: 'https://f4.bcbits.com/img/a0524413952_10.jpg',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a0524413952_10.jpg',
|
||||
},
|
||||
{
|
||||
id: 'Fleurix',
|
||||
name: 'Fleurix',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a1856993876_10.jpg',
|
||||
},
|
||||
{
|
||||
id: '天才失格 -No Longer Prodigy-',
|
||||
name: '天才失格 -No Longer Prodigy-',
|
||||
thumbnail: 'https://f4.bcbits.com/img/a2186643420_10.jpg',
|
||||
},
|
||||
]
|
||||
|
||||
return { tabs: navTabs.concat(playlistTabs) }
|
||||
return { navTabs, playlistTabs }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user