Moved connections to user page, began search functionality
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { pageWidth } from '$lib/stores'
|
||||
import SearchBar from '$lib/components/util/searchBar.svelte'
|
||||
import type { LayoutData } from './$types'
|
||||
import NavTab from '$lib/components/navbar/navTab.svelte'
|
||||
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
||||
@@ -21,8 +22,8 @@
|
||||
</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 w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-16">
|
||||
<div class="h-full overflow-hidden">
|
||||
<div class="no-scrollbar fixed left-0 top-0 z-10 grid h-full w-20 grid-cols-1 grid-rows-[min-content_auto] gap-5 px-3 py-12">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each data.navTabs as nav}
|
||||
<NavTab {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)} />
|
||||
@@ -38,7 +39,10 @@
|
||||
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist • {data.user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="no-scrollbar overflow-y-scroll px-[max(7rem,_7vw)] pt-16">
|
||||
<section class="no-scrollbar overflow-y-scroll px-[max(7rem,_7vw)]">
|
||||
<div class="my-6 max-w-xl">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import type { PageServerLoad } from '../$types'
|
||||
import ytdl from 'ytdl-core'
|
||||
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {}
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||
const query = url.searchParams.get('query')
|
||||
if (query) {
|
||||
const searchResults: { searchResults: (Song | Album | Artist | Playlist)[] } = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => response.json())
|
||||
|
||||
return searchResults
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
<h1>Search Page</h1>
|
||||
<script lang="ts">
|
||||
import type { PageServerData } from './$types'
|
||||
|
||||
export let data: PageServerData
|
||||
</script>
|
||||
|
||||
{#if data.searchResults}
|
||||
{#each data.searchResults as searchResult}
|
||||
<div>{searchResult.name} - {searchResult.type}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { fail } from '@sveltejs/kit'
|
||||
import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||
import type { PageServerLoad, Actions } from './$types'
|
||||
import { DB } from '$lib/server/db'
|
||||
import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
|
||||
import { google } from 'googleapis'
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const connectionsResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
})
|
||||
|
||||
const userConnections = await connectionsResponse.json()
|
||||
|
||||
return { connections: userConnections.connections }
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
authenticateJellyfin: async ({ request, fetch, locals }) => {
|
||||
const formData = await request.formData()
|
||||
const { serverUrl, username, password, deviceId } = Object.fromEntries(formData)
|
||||
|
||||
if (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' })
|
||||
|
||||
const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch((error: JellyfinFetchError) => error)
|
||||
|
||||
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message })
|
||||
|
||||
const newConnectionId = DB.addConnectionInfo(locals.user.id, { type: 'jellyfin', serviceInfo: { userId: authData.User.Id, urlOrigin: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
||||
|
||||
const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
}).then((response) => {
|
||||
return response.json()
|
||||
})
|
||||
|
||||
return { newConnection: response.connections[0] }
|
||||
},
|
||||
youtubeMusicLogin: async ({ request, fetch, locals }) => {
|
||||
const formData = await request.formData()
|
||||
const { code } = Object.fromEntries(formData)
|
||||
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
|
||||
const { tokens } = await client.getToken(code.toString())
|
||||
|
||||
const youtube = google.youtube('v3')
|
||||
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
|
||||
const userChannel = userChannelResponse.data.items![0]
|
||||
|
||||
const newConnectionId = DB.addConnectionInfo(locals.user.id, {
|
||||
type: 'youtube-music',
|
||||
serviceInfo: { userId: userChannel.id! },
|
||||
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/connections?ids=${newConnectionId}`, {
|
||||
method: 'GET',
|
||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
||||
})
|
||||
|
||||
const responseData = await response.json()
|
||||
|
||||
return { newConnection: responseData.connections[0] }
|
||||
},
|
||||
deleteConnection: async ({ request }) => {
|
||||
const formData = await request.formData()
|
||||
const connectionId = formData.get('connectionId')!.toString()
|
||||
|
||||
DB.deleteConnectionInfo(connectionId)
|
||||
|
||||
return { deletedConnectionId: connectionId }
|
||||
},
|
||||
}
|
||||
@@ -2,8 +2,101 @@
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { LayoutData } from '../$types'
|
||||
import Services from '$lib/services.json'
|
||||
import JellyfinIcon from '$lib/static/jellyfin-icon.svg'
|
||||
import YouTubeMusicIcon from '$lib/static/youtube-music-icon.svg'
|
||||
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
|
||||
import { newestAlert } from '$lib/stores.js'
|
||||
import type { PageServerData } from './$types.js'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import { getDeviceUUID } from '$lib/utils'
|
||||
import { SvelteComponent, type ComponentType } from 'svelte'
|
||||
import ConnectionProfile from './connectionProfile.svelte'
|
||||
import { enhance } from '$app/forms'
|
||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||
|
||||
export let data: LayoutData
|
||||
export let data: PageServerData & LayoutData
|
||||
let connections: ConnectionInfo[] = data.connections
|
||||
|
||||
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||
|
||||
if (!(serverUrl && username && password)) {
|
||||
$newestAlert = ['caution', 'All fields must be filled out']
|
||||
return cancel()
|
||||
}
|
||||
try {
|
||||
formData.set('serverUrl', new URL(serverUrl.toString()).origin)
|
||||
} catch {
|
||||
$newestAlert = ['caution', 'Server URL is invalid']
|
||||
return cancel()
|
||||
}
|
||||
|
||||
const deviceId = getDeviceUUID()
|
||||
formData.append('deviceId', deviceId)
|
||||
|
||||
return ({ result }) => {
|
||||
if (result.type === 'failure') {
|
||||
return ($newestAlert = ['warning', result.data?.message])
|
||||
} else if (result.type === 'success') {
|
||||
const newConnection: ConnectionInfo = result.data!.newConnection
|
||||
connections = [...connections, newConnection]
|
||||
|
||||
newConnectionModal = null
|
||||
return ($newestAlert = ['success', `Added Jellyfin`])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authenticateYouTube: SubmitFunction = async ({ formData, cancel }) => {
|
||||
const googleLoginProcess = (): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// @ts-ignore (google variable is a global variable imported by html script tag)
|
||||
const client = google.accounts.oauth2.initCodeClient({
|
||||
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
||||
scope: 'https://www.googleapis.com/auth/youtube',
|
||||
ux_mode: 'popup',
|
||||
callback: (response: any) => {
|
||||
resolve(response.code)
|
||||
},
|
||||
})
|
||||
client.requestCode()
|
||||
})
|
||||
}
|
||||
|
||||
const code = await googleLoginProcess()
|
||||
if (!code) cancel()
|
||||
formData.append('code', code)
|
||||
|
||||
return ({ result }) => {
|
||||
if (result.type === 'failure') {
|
||||
return ($newestAlert = ['warning', result.data?.message])
|
||||
} else if (result.type === 'success') {
|
||||
const newConnection: ConnectionInfo = result.data!.newConnection
|
||||
connections = [...connections, newConnection]
|
||||
return ($newestAlert = ['success', 'Added Youtube Music'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const profileActions: SubmitFunction = ({ action, cancel }) => {
|
||||
return ({ result }) => {
|
||||
if (result.type === 'failure') {
|
||||
return ($newestAlert = ['warning', result.data?.message])
|
||||
} else if (result.type === 'success') {
|
||||
const id = result.data!.deletedConnectionId
|
||||
const indexToDelete = connections.findIndex((connection) => connection.id === id)
|
||||
const serviceType = connections[indexToDelete].type
|
||||
|
||||
connections.splice(indexToDelete, 1)
|
||||
connections = connections
|
||||
|
||||
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newConnectionModal: ComponentType<SvelteComponent<{ submitFunction: SubmitFunction }>> | null = null
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col gap-8">
|
||||
@@ -27,6 +120,35 @@
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<div>This is where things like history would go</div>
|
||||
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
|
||||
<h1 class="py-2 text-xl">Add Connection</h1>
|
||||
<div class="flex flex-wrap gap-2 pb-4">
|
||||
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
|
||||
<img src={JellyfinIcon} alt="Jellyfin icon" class="aspect-square h-full p-2" />
|
||||
</button>
|
||||
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
|
||||
<button class="add-connection-button h-14 rounded-md">
|
||||
<img src={YouTubeMusicIcon} alt="YouTube Music icon" class="aspect-square h-full p-2" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<div id="connection-profile-grid" class="grid gap-8">
|
||||
{#each connections as connection}
|
||||
<ConnectionProfile {connection} submitFunction={profileActions} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if newConnectionModal !== null}
|
||||
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.add-connection-button {
|
||||
background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));
|
||||
}
|
||||
#connection-profile-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import Services from '$lib/services.json'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import Toggle from '$lib/components/util/toggle.svelte'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { enhance } from '$app/forms'
|
||||
|
||||
export let connection: ConnectionInfo
|
||||
export let submitFunction: SubmitFunction
|
||||
|
||||
$: serviceData = Services[connection.type]
|
||||
|
||||
let showModal = false
|
||||
|
||||
const subHeaderItems: string[] = []
|
||||
if ('username' in connection.serviceInfo && connection.serviceInfo.username) subHeaderItems.push(connection.serviceInfo.username)
|
||||
if ('serverName' in connection.serviceInfo && connection.serviceInfo.serverName) subHeaderItems.push(connection.serviceInfo.serverName)
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
|
||||
<header class="flex h-20 items-center gap-4 p-4">
|
||||
<div class="relative aspect-square h-full p-1">
|
||||
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
|
||||
{#if 'profilePicture' in connection.serviceInfo && connection.serviceInfo.profilePicture}
|
||||
<img src={connection.serviceInfo.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div>{serviceData.displayName}</div>
|
||||
<div class="text-sm text-neutral-500">
|
||||
{subHeaderItems.join(' - ')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2">
|
||||
<IconButton halo={true} on:click={() => (showModal = !showModal)}>
|
||||
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" />
|
||||
</IconButton>
|
||||
{#if showModal}
|
||||
<form use:enhance={submitFunction} method="post" class="absolute right-0 top-full flex flex-col items-center justify-center gap-1 rounded-md bg-neutral-900 p-2 text-xs">
|
||||
<button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
|
||||
<i class="fa-solid fa-link-slash mr-1" />
|
||||
Delete Connection
|
||||
</button>
|
||||
<input type="hidden" value={connection.id} name="connectionId" />
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<hr class="mx-2 border-t-2 border-neutral-600" />
|
||||
<div class="p-4 text-sm text-neutral-400">
|
||||
<div class="grid grid-cols-[3rem_auto] gap-4">
|
||||
<Toggle on:toggled={(event) => console.log(event.detail.toggled)} />
|
||||
<span>Place for config</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
import { scale } from 'svelte/transition'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { enhance } from '$app/forms'
|
||||
|
||||
export let submitFunction: SubmitFunction
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<form method="post" use:enhance={submitFunction} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" transition:scale>
|
||||
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-925 px-8">
|
||||
<h1 class="text-center text-4xl">Jellyfin Sign In</h1>
|
||||
<div class="flex w-full flex-col gap-5">
|
||||
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
|
||||
<div class="flex w-full flex-row gap-4">
|
||||
<input type="text" name="username" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
|
||||
<input type="password" name="password" placeholder="Password" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-around text-lg">
|
||||
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-95" on:click|preventDefault={() => dispatch('close')}>Cancel</button>
|
||||
<button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-95" formaction="?/authenticateJellyfin">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
@property --gradient-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
--gradient-angle: 0deg;
|
||||
}
|
||||
100% {
|
||||
--gradient-angle: 360deg;
|
||||
}
|
||||
}
|
||||
#main-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.1rem;
|
||||
z-index: -1;
|
||||
background: conic-gradient(from var(--gradient-angle), var(--jellyfin-purple), var(--jellyfin-blue), var(--jellyfin-purple));
|
||||
border-radius: inherit;
|
||||
animation: rotation 15s linear infinite;
|
||||
filter: blur(0.5rem);
|
||||
}
|
||||
#cancel-button:hover {
|
||||
background-color: rgb(30 30 30);
|
||||
}
|
||||
#submit-button:hover {
|
||||
background-color: color-mix(in srgb, var(--jellyfin-blue) 80%, black);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user