Trashed half my styling, this settings page is going to be a nightmare
This commit is contained in:
@@ -47,9 +47,3 @@ img {
|
||||
--jellyfin-blue: #00a4dc;
|
||||
--youtube-red: #ff0000;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,31 +10,33 @@
|
||||
export let disabled = false
|
||||
export let nav: NavTab
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let button: HTMLButtonElement
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="grid aspect-square w-full place-items-center transition-colors"
|
||||
{disabled}
|
||||
on:click={() => {
|
||||
dispatch('click')
|
||||
goto(nav.pathname)
|
||||
}}
|
||||
>
|
||||
<button bind:this={button} class="relative grid aspect-square w-full place-items-center transition-colors" {disabled} on: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>
|
||||
<div class="absolute left-0 top-1/2 h-0 w-[0.2rem] -translate-x-2 -translate-y-1/2 rounded-lg bg-white transition-all" />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button:disabled > div {
|
||||
height: 80%;
|
||||
}
|
||||
button:not(:disabled) {
|
||||
color: rgb(163 163, 163);
|
||||
}
|
||||
button:not(:disabled):hover {
|
||||
color: var(--lazuli-primary);
|
||||
}
|
||||
button:not(:disabled):hover > div {
|
||||
height: 40%;
|
||||
}
|
||||
</style>
|
||||
@@ -10,12 +10,24 @@
|
||||
export let disabled = false
|
||||
export let playlist: PlaylistTab
|
||||
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let button: HTMLButtonElement
|
||||
|
||||
type ButtonCenter = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const calculateCenter = (button: HTMLButtonElement): ButtonCenter => {
|
||||
const rect = button.getBoundingClientRect()
|
||||
const x = (rect.left + rect.right) / 2
|
||||
const y = (rect.top + rect.bottom) / 2
|
||||
return { x, y }
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -23,22 +35,17 @@
|
||||
bind:this={button}
|
||||
class="relative aspect-square w-full rounded-lg bg-cover bg-center transition-all"
|
||||
style="background-image: url({playlist.thumbnail});"
|
||||
on:mouseenter={() => dispatch('mouseenter', { ...calculateCenter(button), content: playlist.name })}
|
||||
on:mouseleave={() => dispatch('mouseleave')}
|
||||
on:click={() => {
|
||||
dispatch('click')
|
||||
goto(`/library?playlist=${playlist.id}`)
|
||||
}}
|
||||
>
|
||||
<span class="absolute left-full top-1/2 overflow-clip text-ellipsis whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-sm origin-left transition-transform duration-75">{playlist.name}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button:not(:disabled):not(:hover) {
|
||||
filter: brightness(50%);
|
||||
}
|
||||
span {
|
||||
transform: translateX(0.75rem) translateY(-50%) scale(0);
|
||||
}
|
||||
button:hover > span {
|
||||
transform: translateX(0.75rem) translateY(-50%) scale(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition'
|
||||
import { goto, beforeNavigate } from '$app/navigation'
|
||||
import { pageWidth } from '$lib/stores'
|
||||
import type { LayoutData } from './$types'
|
||||
import { onMount } from 'svelte'
|
||||
import NavTabComponent, { type NavTab } from '$lib/components/navbar/navTab.svelte'
|
||||
import PlaylistTabComponent, { type PlaylistTab } from '$lib/components/navbar/playlistTab.svelte'
|
||||
import NavTab from '$lib/components/navbar/navTab.svelte'
|
||||
import PlaylistTab from '$lib/components/navbar/playlistTab.svelte'
|
||||
|
||||
export let data: LayoutData
|
||||
|
||||
const pageTransitionTime: number = 200
|
||||
|
||||
let currentTabIndex = -1
|
||||
|
||||
type PageTransitionDirection = 1 | -1
|
||||
let directionMultiplier: PageTransitionDirection = 1
|
||||
|
||||
let indicatorBar: HTMLDivElement, tabList: HTMLDivElement
|
||||
|
||||
const inPathnameHeirarchy = (pathname: string, rootPathname: string): boolean => {
|
||||
return (pathname.startsWith(rootPathname) && rootPathname !== '/') || (pathname === '/' && rootPathname === '/')
|
||||
}
|
||||
let playlistTooltip: HTMLDivElement
|
||||
|
||||
// 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))
|
||||
|
||||
// if (newTabIndex < 0) indicatorBar.style.opacity = '0'
|
||||
|
||||
// const newTab = data.tabs[newTabIndex]
|
||||
// if (!newTab?.button) return
|
||||
|
||||
// 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`)
|
||||
|
||||
// if (directionMultiplier > 0) {
|
||||
// shiftTop()
|
||||
// setTimeout(shiftBottom, pageTransitionTime)
|
||||
// } else {
|
||||
// shiftBottom()
|
||||
// setTimeout(shiftTop, pageTransitionTime)
|
||||
// }
|
||||
|
||||
// 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)
|
||||
// })
|
||||
const setTooltip = (x: number, y: number, content: string): void => {
|
||||
const textWrapper = playlistTooltip.firstChild! as HTMLDivElement
|
||||
textWrapper.innerText = content
|
||||
playlistTooltip.style.display = 'block'
|
||||
playlistTooltip.style.left = `${x}px`
|
||||
playlistTooltip.style.top = `${y}px`
|
||||
}
|
||||
</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 w-20" bind:this={tabList}>
|
||||
<div class="flex flex-col gap-6">
|
||||
<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="flex flex-col gap-4">
|
||||
{#each data.navTabs as nav}
|
||||
<NavTabComponent {nav} disabled={inPathnameHeirarchy(data.url.pathname, nav.pathname)}/>
|
||||
<NavTab {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>
|
||||
<div class="flex flex-col gap-6 px-1">
|
||||
<div class="no-scrollbar flex flex-col gap-5 overflow-y-scroll px-1.5">
|
||||
{#each data.playlistTabs as playlist}
|
||||
<PlaylistTabComponent {playlist} disabled={new URLSearchParams(data.url.search).get('playlist') === playlist.id} />
|
||||
<PlaylistTab {playlist} on:mouseenter={(event) => setTooltip(event.detail.x, event.detail.y, event.detail.content)} on:mouseleave={() => (playlistTooltip.style.display = 'none')} />
|
||||
{/each}
|
||||
</div>
|
||||
<div bind:this={playlistTooltip} class="absolute hidden max-w-48 -translate-y-1/2 translate-x-10 whitespace-nowrap rounded bg-neutral-800 px-2 py-1.5 text-sm">
|
||||
<div class="overflow-clip text-ellipsis">PLAYLIST_NAME</div>
|
||||
<div class="overflow-clip text-ellipsis text-neutral-400">Playlist • {data.user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="no-scrollbar relative overflow-y-scroll">
|
||||
{#key data.url}
|
||||
<div
|
||||
in:fly={{ y: -50 * directionMultiplier, duration: pageTransitionTime, delay: pageTransitionTime }}
|
||||
out:fly={{ y: 50 * directionMultiplier, duration: pageTransitionTime }}
|
||||
class="absolute w-full px-[clamp(7rem,_5vw,_24rem)] pt-16"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
||||
<section class="no-scrollbar overflow-y-scroll px-[max(7rem,_5vw)] pt-16">
|
||||
<slot />
|
||||
</section>
|
||||
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
||||
<!-- <MiniPlayer displayMode={'horizontal'} /> -->
|
||||
@@ -91,15 +47,9 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full overflow-hidden">
|
||||
{#key data.url.pathname}
|
||||
<section
|
||||
in:fly={{ x: 200 * directionMultiplier, duration: pageTransitionTime, delay: pageTransitionTime }}
|
||||
out:fly={{ x: -200 * directionMultiplier, duration: pageTransitionTime }}
|
||||
class="no-scrollbar h-full overflow-y-scroll px-[5vw] pt-16"
|
||||
>
|
||||
<slot />
|
||||
</section>
|
||||
{/key}
|
||||
<section class="no-scrollbar h-full overflow-y-scroll px-[5vw] pt-16">
|
||||
<slot />
|
||||
</section>
|
||||
<footer class="fixed bottom-0 flex w-full flex-col items-center justify-center">
|
||||
<!-- <MiniPlayer displayMode={'vertical'} /> -->
|
||||
<!-- <NavbarFoot
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div id="test-box" class="h-[200vh]"></div>
|
||||
|
||||
<style>
|
||||
<!-- <style>
|
||||
#test-box {
|
||||
background: linear-gradient(to bottom, white, black);
|
||||
}
|
||||
</style>
|
||||
</style> -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import type { LayoutData } from '../$types'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { LayoutData } from '../$types'
|
||||
|
||||
export let data: LayoutData
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
</script>
|
||||
|
||||
<main class="h-full">
|
||||
<h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center">
|
||||
<h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center text-2xl">
|
||||
<IconButton on:click={() => history.back()}>
|
||||
<i slot="icon" class="fa-solid fa-arrow-left text-2xl" />
|
||||
<i slot="icon" class="fa-solid fa-arrow-left" />
|
||||
</IconButton>
|
||||
<span class="text-3xl">Account</span>
|
||||
<span>Account</span>
|
||||
</h1>
|
||||
<section class="px-[5vw]">
|
||||
<slot />
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
<script lang="ts">
|
||||
import NavMenu from './navMenu.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { LayoutServerData } from '../$types.js'
|
||||
|
||||
export let data: LayoutServerData
|
||||
|
||||
interface SettingRoute {
|
||||
pathname: string
|
||||
displayName: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const settingRoutes: SettingRoute[] = [
|
||||
{
|
||||
pathname: '/settings/connections',
|
||||
displayName: 'Connections',
|
||||
icon: 'fa-solid fa-circle-nodes',
|
||||
},
|
||||
{
|
||||
pathname: '/settings/devices',
|
||||
displayName: 'Devices',
|
||||
icon: 'fa-solid fa-mobile-screen',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<NavMenu />
|
||||
<nav class="h-full rounded-lg bg-neutral-950 p-6">
|
||||
<h1 class="flex h-6 justify-between text-neutral-400">
|
||||
<span>
|
||||
<i class="fa-solid fa-gear" />
|
||||
Settings
|
||||
</span>
|
||||
{#if data.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 settingRoutes as route}
|
||||
<li>
|
||||
{#if data.url.pathname === route.pathname}
|
||||
<div class="rounded-lg bg-neutral-500 px-3 py-1">
|
||||
<i class={route.icon} />
|
||||
{route.displayName}
|
||||
</div>
|
||||
{:else}
|
||||
<a href={route.pathname} 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>
|
||||
|
||||
110
src/routes/settings/connections/+page.server.ts
Normal file
110
src/routes/settings/connections/+page.server.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { fail } from '@sveltejs/kit'
|
||||
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
||||
import { Connections } from '$lib/server/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 { serverUrl, username, password, deviceId } = Object.fromEntries(formData)
|
||||
const serverUrlOrigin = new URL(serverUrl).origin
|
||||
|
||||
const jellyfinAuthResponse = await fetch('/api/jellyfin/auth', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: SECRET_INTERNAL_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ serverUrl: serverUrlOrigin, username, password, deviceId }),
|
||||
})
|
||||
|
||||
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: 'POST',
|
||||
headers: {
|
||||
apikey: SECRET_INTERNAL_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ serviceType: 'jellyfin', serviceUserId: jellyfinUserId, serviceUrl: serverUrlOrigin, 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 }
|
||||
},
|
||||
}
|
||||
137
src/routes/settings/connections/+page.svelte
Normal file
137
src/routes/settings/connections/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms'
|
||||
import { fly } from 'svelte/transition'
|
||||
import Services from '$lib/services.json'
|
||||
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
|
||||
import { newestAlert } from '$lib/stores'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import Toggle from '$lib/components/util/toggle.svelte'
|
||||
import type { SubmitFunction } from '@sveltejs/kit'
|
||||
|
||||
export let data
|
||||
let connectionProfiles = data.connectionProfiles
|
||||
|
||||
const submitCredentials: SubmitFunction = ({ formData, action, cancel }) => {
|
||||
switch (action.search) {
|
||||
case '?/authenticateJellyfin':
|
||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||
|
||||
if (!(serverUrl && username && password)) {
|
||||
$newestAlert = ['caution', 'All fields must be filled out']
|
||||
return cancel()
|
||||
}
|
||||
try {
|
||||
new URL(serverUrl.toString())
|
||||
} catch {
|
||||
$newestAlert = ['caution', 'Server URL is invalid']
|
||||
return cancel()
|
||||
}
|
||||
|
||||
// const deviceId = JellyfinUtils.getLocalDeviceUUID()
|
||||
// formData.append('deviceId', deviceId)
|
||||
break
|
||||
case '?/deleteConnection':
|
||||
break
|
||||
default:
|
||||
cancel()
|
||||
}
|
||||
|
||||
return async ({ result }) => {
|
||||
switch (result.type) {
|
||||
case 'failure':
|
||||
$newestAlert = ['warning', result.data.message]
|
||||
return
|
||||
case 'success':
|
||||
modal = null
|
||||
if (result.data?.newConnection) {
|
||||
const newConnection = result.data.newConnection
|
||||
connectionProfiles = [newConnection, ...connectionProfiles]
|
||||
|
||||
$newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`]
|
||||
return
|
||||
} 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
|
||||
|
||||
$newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
52
src/routes/settings/connections/jellyfinAuthBox.svelte
Normal file
52
src/routes/settings/connections/jellyfinAuthBox.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
</script>
|
||||
|
||||
<nav class="flex w-full flex-col divide-y-2 divide-neutral-700 rounded-lg bg-neutral-800 px-4 text-xl">
|
||||
<button class="flex items-center justify-between px-4 py-6">
|
||||
<span>
|
||||
<i class="fa-solid fa-circle-nodes mr-2" />
|
||||
Connections
|
||||
</span>
|
||||
<i class="fa-solid fa-arrow-right" />
|
||||
</button>
|
||||
<button class="flex items-center justify-between px-4 py-6">
|
||||
<span>
|
||||
<i class="fa-solid fa-mobile-screen mr-2" />
|
||||
Devices
|
||||
</span>
|
||||
<i class="fa-solid fa-arrow-right" />
|
||||
</button>
|
||||
</nav>
|
||||
Reference in New Issue
Block a user