Big layout improvements, started on miniplayer

This commit is contained in:
Eclypsed
2024-01-16 02:45:27 -05:00
parent fd08867628
commit a6c65ce0cf
33 changed files with 351 additions and 176 deletions
@@ -0,0 +1,68 @@
<script>
import IconButton from '$lib/components/utility/iconButton.svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
const settingRoutes = {
connections: {
displayName: 'Connections',
uri: '/settings/connections',
icon: 'fa-solid fa-circle-nodes',
},
// devices: {
// displayName: 'Devices',
// uri: '/settings/devices',
// icon: 'fa-solid fa-mobile-screen',
// },
}
</script>
<main class="mx-auto grid h-full max-w-screen-xl gap-8 p-8">
<nav class="h-full rounded-lg p-6">
<h1 class="flex h-6 justify-between text-neutral-400">
<span>
<i class="fa-solid fa-gear" />
Settings
</span>
{#if $page.url.pathname.split('/').at(-1) !== 'settings'}
<IconButton on:click={() => goto('/settings')}>
<i slot="icon" class="fa-solid fa-caret-left" />
</IconButton>
{/if}
</h1>
<ol class="ml-2 mt-4 flex flex-col gap-3 border-2 border-transparent border-l-neutral-500 px-2">
{#each Object.values(settingRoutes) as route}
<li>
{#if $page.url.pathname === route.uri}
<div class="rounded-lg bg-neutral-500 px-3 py-1">
<i class={route.icon} />
{route.displayName}
</div>
{:else}
<a href={route.uri} class="block rounded-lg px-3 py-1 opacity-50 hover:bg-neutral-700">
<i class={route.icon} />
{route.displayName}
</a>
{/if}
</li>
{/each}
</ol>
</nav>
<div class="relative h-full overflow-y-scroll rounded-lg">
<slot />
</div>
</main>
<style>
main {
grid-template-columns: 20rem auto;
}
nav {
background-color: rgba(82, 82, 82, 0.25);
}
i {
text-align: center;
width: 1rem;
margin-right: 0.2rem;
}
</style>
@@ -0,0 +1,13 @@
<!-- This is template for reference -->
<script>
</script>
<section></section>
<style>
section {
border-radius: 0.5rem;
background-color: rgba(82, 82, 82, 0.25);
height: 24rem;
}
</style>
@@ -0,0 +1,111 @@
import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import { UserConnections } from '$lib/server/db/users'
const createProfile = async (connectionData) => {
const { id, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry } = connectionData
switch (serviceType) {
case 'jellyfin':
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
const systemUrl = new URL('System/Info', serviceUrl).href
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData = await userResponse.json()
const systemData = await systemResponse.json()
return {
connectionId: id,
serviceType,
userId: serviceUserId,
username: userData?.Name,
serviceUrl: serviceUrl,
serverName: systemData?.ServerName,
}
default:
return null
}
}
/** @type {import('./$types').PageServerLoad} */
export const load = async ({ fetch, locals }) => {
const response = await fetch(`/api/user/connections?userId=${locals.userId}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
const allConnections = await response.json()
const connectionProfiles = []
if (allConnections) {
for (const connection of allConnections) {
const connectionProfile = await createProfile(connection)
connectionProfiles.push(connectionProfile)
}
}
return { connectionProfiles }
}
/** @type {import('./$types').Actions}} */
export const actions = {
authenticateJellyfin: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const queryParams = new URLSearchParams()
for (let field of formData) {
const [key, value] = field
queryParams.append(key, value)
}
const jellyfinAuthResponse = await fetch(`/api/jellyfin/auth?${queryParams.toString()}`, {
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
})
if (!jellyfinAuthResponse.ok) {
const jellyfinAuthError = await jellyfinAuthResponse.text()
return fail(jellyfinAuthResponse.status, { message: jellyfinAuthError })
}
const jellyfinAuthData = await jellyfinAuthResponse.json()
const accessToken = jellyfinAuthData.AccessToken
const jellyfinUserId = jellyfinAuthData.User.Id
const updateConnectionsResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
method: 'PATCH',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
body: JSON.stringify({ serviceType: 'jellyfin', serviceUserId: jellyfinUserId, serviceUrl: formData.get('serverUrl'), accessToken }),
})
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection = await updateConnectionsResponse.json()
const newConnectionData = UserConnections.getConnection(newConnection.id)
const jellyfinProfile = await createProfile(newConnectionData)
return { newConnection: jellyfinProfile }
},
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const connectionId = formData.get('connectionId')
const deleteConnectionResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
method: 'DELETE',
headers: {
apikey: SECRET_INTERNAL_API_KEY,
},
body: JSON.stringify({ connectionId }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { deletedConnectionId: connectionId }
},
}
@@ -0,0 +1,134 @@
<script>
import { enhance } from '$app/forms'
import { fly } from 'svelte/transition'
import { JellyfinUtils } from '$lib/utils/utils'
import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/utils/stores.js'
import IconButton from '$lib/components/utility/iconButton.svelte'
import Toggle from '$lib/components/utility/toggle.svelte'
export let data
let connectionProfiles = data.connectionProfiles
const submitCredentials = ({ formData, action, cancel }) => {
switch (action.search) {
case '?/authenticateJellyfin':
const { serverUrl, username, password } = Object.fromEntries(formData)
if (!(serverUrl && username && password)) {
cancel()
return ($newestAlert = ['caution', 'All fields must be filled out'])
}
try {
new URL(serverUrl)
} catch {
cancel()
return ($newestAlert = ['caution', 'Server URL is invalid'])
}
const deviceId = JellyfinUtils.getLocalDeviceUUID()
formData.append('deviceId', deviceId)
break
case '?/deleteConnection':
break
default:
cancel()
}
return async ({ result }) => {
switch (result.type) {
case 'failure':
return ($newestAlert = ['warning', result.data.message])
case 'success':
modal = null
if (result.data?.newConnection) {
const newConnection = result.data.newConnection
connectionProfiles = [newConnection, ...connectionProfiles]
return ($newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`])
} else if (result.data?.deletedConnectionId) {
const id = result.data.deletedConnectionId
const indexToDelete = connectionProfiles.findIndex((profile) => profile.connectionId === id)
const serviceType = connectionProfiles[indexToDelete].serviceType
connectionProfiles.splice(indexToDelete, 1)
connectionProfiles = connectionProfiles
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
}
}
}
}
let modal
</script>
<main>
<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">
{#each Object.entries(Services) as [serviceType, serviceData]}
<button
class="bg-ne h-14 rounded-md"
style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));"
on:click={() => {
if (serviceType === 'jellyfin') modal = JellyfinAuthBox
}}
>
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button>
{/each}
</div>
</section>
<div class="grid gap-8">
{#each connectionProfiles as connectionProfile}
{@const serviceData = Services[connectionProfile.serviceType]}
<section class="overflow-hidden 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">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div>
<div>{connectionProfile?.username ? connectionProfile.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{#if connectionProfile.serviceType === 'jellyfin' && connectionProfile?.serverName}
- {connectionProfile.serverName}
{/if}
</div>
</div>
<div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionProfile.connectionId}`)}>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</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>Enable Connection</span>
</div>
</div>
</section>
{/each}
</div>
{#if modal}
<form method="post" use:enhance={submitCredentials} transition:fly={{ y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{#if typeof modal === 'string'}
{@const connectionId = modal.replace('delete-', '')}
{@const connection = connectionProfiles.find((profile) => profile.connectionId === connectionId)}
{@const serviceData = Services[connection.serviceType]}
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {serviceData.displayName} connection?</h1>
<div class="flex w-60 justify-around">
<input type="hidden" name="connectionId" value={connectionId} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault={() => (modal = null)}>Cancel</button>
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
</div>
</div>
{:else}
<svelte:component this={modal} on:close={() => (modal = null)} />
{/if}
</form>
{/if}
</main>
@@ -0,0 +1,52 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 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-[97%]" 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-[97%]" formaction="?/authenticateJellyfin">Submit</button>
</div>
</div>
<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>
@@ -0,0 +1,12 @@
{
"connectionId": "Database id of the respective connection",
"serviceType": "Type of service of the respective connection",
"userId": "The UUID of the account [req]",
"username": "The username of the account [req]",
"email": "Email asscociated w/ account [opt]",
"serviceUrl": "Id of source server [req]",
"serverName": "Name of source server [jellyfin]",
"V POTENTIAL": "TBD V",
"connectionEnabled": "[Toggle] boolean; enables/disables pulling data from the respective connection"
}