Connection management now id based rather than type based
This commit is contained in:
@@ -4,6 +4,7 @@ Big Problems:
|
|||||||
- Authentication for every other potential streaming service:
|
- Authentication for every other potential streaming service:
|
||||||
- YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html
|
- YouTube Music:? https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html
|
||||||
- Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization
|
- Spotify: https://developer.spotify.com/documentation/web-api/concepts/authorization
|
||||||
|
- Do I allow users to connect multiple accounts from the same service?
|
||||||
|
|
||||||
Little Problems:
|
Little Problems:
|
||||||
- Video and audio need to be kept in sync, accounting for buffering and latency.
|
- Video and audio need to be kept in sync, accounting for buffering and latency.
|
||||||
|
|||||||
52
src/lib/potentialServices.json
Normal file
52
src/lib/potentialServices.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"jellyfin": {
|
||||||
|
"displayName": "Jellyfin",
|
||||||
|
"type": ["streaming"],
|
||||||
|
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg"
|
||||||
|
},
|
||||||
|
"youtube-music": {
|
||||||
|
"displayName": "YouTube Music",
|
||||||
|
"type": ["streaming"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
|
||||||
|
},
|
||||||
|
"spotify": {
|
||||||
|
"displayName": "Spotify",
|
||||||
|
"type": ["streaming"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
|
||||||
|
},
|
||||||
|
"apple-music": {
|
||||||
|
"displayName": "Apple Music",
|
||||||
|
"type": ["streaming", "marketplace"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg"
|
||||||
|
},
|
||||||
|
"bandcamp": {
|
||||||
|
"displayName": "bandcamp",
|
||||||
|
"type": ["marketplace", "streaming"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg"
|
||||||
|
},
|
||||||
|
"soundcloud": {
|
||||||
|
"displayName": "SoundCloud",
|
||||||
|
"type": ["streaming"],
|
||||||
|
"icon": "https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg"
|
||||||
|
},
|
||||||
|
"lastfm": {
|
||||||
|
"displayName": "Last.fm",
|
||||||
|
"type": ["analytics"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg"
|
||||||
|
},
|
||||||
|
"plex": {
|
||||||
|
"displayName": "Plex",
|
||||||
|
"type": ["streaming"],
|
||||||
|
"icon": "https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg"
|
||||||
|
},
|
||||||
|
"deezer": {
|
||||||
|
"displayName": "deezer",
|
||||||
|
"type": ["streaming"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg"
|
||||||
|
},
|
||||||
|
"amazon-music": {
|
||||||
|
"displayName": "Amazon Music",
|
||||||
|
"type": ["streaming", "marketplace"],
|
||||||
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -5,10 +5,7 @@ const db = new Database('./src/lib/server/db/users.db', { verbose: console.info
|
|||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
|
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
|
||||||
const initUserConnectionsTable =
|
const initUserConnectionsTable =
|
||||||
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceName VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry DATETIME, FOREIGN KEY(userId) REFERENCES Users(id))'
|
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry INTEGER, connectionInfo TEXT, FOREIGN KEY(userId) REFERENCES Users(id))'
|
||||||
const initJellyfinAuthTable = 'CREATE TABLE IF NOT EXISTS JellyfinConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, accesstoken TEXT, serverid TEXT)'
|
|
||||||
const initYouTubeMusicConnectionsTable = ''
|
|
||||||
const initSpotifyConnectionsTable = ''
|
|
||||||
db.exec(initUsersTable)
|
db.exec(initUsersTable)
|
||||||
db.exec(initUserConnectionsTable)
|
db.exec(initUserConnectionsTable)
|
||||||
|
|
||||||
@@ -30,41 +27,29 @@ export class Users {
|
|||||||
export class UserConnections {
|
export class UserConnections {
|
||||||
static validServices = Object.keys(Services)
|
static validServices = Object.keys(Services)
|
||||||
|
|
||||||
static getConnections = (userId, serviceNames = null) => {
|
static getConnections = (userId) => {
|
||||||
if (!serviceNames) {
|
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ?`).all(userId)
|
||||||
const connections = db.prepare('SELECT * FROM UserConnections WHERE userId = ?').all(userId)
|
|
||||||
if (connections.length === 0) return null
|
|
||||||
return connections
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(serviceNames)) {
|
|
||||||
if (typeof serviceNames !== 'string') throw new Error('Service names must be a string or array of strings')
|
|
||||||
serviceNames = [serviceNames]
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceNames = serviceNames.filter((service) => this.validServices.includes(service))
|
|
||||||
|
|
||||||
const placeholders = serviceNames.map(() => '?').join(', ') // This is SQL-injection safe, the placeholders are just ?, ?, ?....
|
|
||||||
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ? AND serviceName IN (${placeholders})`).all(userId, ...serviceNames)
|
|
||||||
if (connections.length === 0) return null
|
if (connections.length === 0) return null
|
||||||
return connections
|
return connections
|
||||||
}
|
}
|
||||||
|
|
||||||
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
|
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
|
||||||
static setConnection = (userId, serviceName, accessToken, refreshToken = null, expiry = null) => {
|
static addConnection = (userId, serviceType, accessToken, options = {}) => {
|
||||||
if (!this.validServices.includes(serviceName)) throw new Error(`Service name ${serviceName} is invalid`)
|
const { refreshToken = null, expiry = null, connectionInfo = null } = options
|
||||||
|
|
||||||
const existingConnection = this.getConnections(userId, serviceName)
|
if (!this.validServices.includes(serviceType)) throw new Error(`Service name ${serviceType} is invalid`)
|
||||||
if (existingConnection) {
|
|
||||||
db.prepare('UPDATE UserConnections SET accessToken = ?, refreshToken = ?, expiry = ? WHERE userId = ? AND serviceName = ?').run(accessToken, refreshToken, expiry, userId, serviceName)
|
if (connectionInfo) JSON.parse(connectionInfo) // Aditional validation, if connectionInfo is not stringified valid json it will throw an error
|
||||||
} else {
|
|
||||||
db.prepare('INSERT INTO UserConnections(userId, serviceName, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?)').run(userId, serviceName, accessToken, refreshToken, expiry)
|
const commandInfo = db
|
||||||
}
|
.prepare('INSERT INTO UserConnections(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo) VALUES(?, ?, ?, ?, ?, ?)')
|
||||||
// return this.getConnections(userId, serviceName) <--- Uncomment this if want to return new connection data after update
|
.run(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo)
|
||||||
|
return commandInfo.lastInsertRowid
|
||||||
}
|
}
|
||||||
|
|
||||||
static deleteConnection = (userId, serviceName) => {
|
static deleteConnection = (userId, serviceId) => {
|
||||||
const info = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND serviceName = ?').run(userId, serviceName)
|
const commandInfo = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND id = ?').run(userId, serviceId)
|
||||||
if (!info.changes === 0) throw new Error(`User does not have connection: ${serviceName}`)
|
if (!commandInfo.changes === 0) throw new Error(`User does not have connection with id: ${serviceId}`)
|
||||||
|
return serviceId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,5 @@
|
|||||||
"displayName": "YouTube Music",
|
"displayName": "YouTube Music",
|
||||||
"type": ["streaming"],
|
"type": ["streaming"],
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
|
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg"
|
||||||
},
|
|
||||||
"spotify": {
|
|
||||||
"displayName": "Spotify",
|
|
||||||
"type": ["streaming"],
|
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function GET({ url, fetch }) {
|
|||||||
if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 })
|
if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 })
|
||||||
|
|
||||||
const data = await authResponse.json()
|
const data = await authResponse.json()
|
||||||
const requiredData = ['User', 'SessionInfo', 'AccessToken', 'ServerId']
|
const requiredData = ['User', 'AccessToken', 'ServerId']
|
||||||
|
|
||||||
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })
|
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ export async function PATCH({ request }) {
|
|||||||
const schema = Joi.object({
|
const schema = Joi.object({
|
||||||
userId: Joi.number().required(),
|
userId: Joi.number().required(),
|
||||||
connection: Joi.object({
|
connection: Joi.object({
|
||||||
serviceName: Joi.string().required(),
|
serviceType: Joi.string().required(),
|
||||||
accessToken: Joi.string().required(),
|
accessToken: Joi.string().required(),
|
||||||
|
refreshToken: Joi.string(),
|
||||||
|
expiry: Joi.number(),
|
||||||
|
connectionInfo: Joi.string(),
|
||||||
}).required(),
|
}).required(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -41,9 +44,10 @@ export async function PATCH({ request }) {
|
|||||||
const validation = schema.validate({ userId, connection })
|
const validation = schema.validate({ userId, connection })
|
||||||
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
||||||
|
|
||||||
UserConnections.setConnection(userId, connection.serviceName, connection.accessToken)
|
const { serviceType, accessToken, refreshToken, expiry, connectionInfo } = connection
|
||||||
|
const newConnectionId = UserConnections.addConnection(userId, serviceType, accessToken, { refreshToken, expiry, connectionInfo })
|
||||||
|
|
||||||
return new Response('Updated Connection')
|
return new Response(JSON.stringify({ id: newConnectionId }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('./$types').RequestHandler} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
@@ -51,7 +55,7 @@ export async function DELETE({ request }) {
|
|||||||
const schema = Joi.object({
|
const schema = Joi.object({
|
||||||
userId: Joi.number().required(),
|
userId: Joi.number().required(),
|
||||||
connection: Joi.object({
|
connection: Joi.object({
|
||||||
serviceName: Joi.string().required(),
|
serviceId: Joi.string().required(),
|
||||||
}).required(),
|
}).required(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -61,7 +65,7 @@ export async function DELETE({ request }) {
|
|||||||
const validation = schema.validate({ userId, connection })
|
const validation = schema.validate({ userId, connection })
|
||||||
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
||||||
|
|
||||||
UserConnections.deleteConnection(userId, connection.serviceName)
|
const deletedConnectionId = UserConnections.deleteConnection(userId, connection.serviceId)
|
||||||
|
|
||||||
return new Response('Deleted Connection')
|
return new Response(JSON.stringify({ id: deletedConnectionId }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ export const load = async ({ fetch, locals }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const connectionsData = await response.json()
|
const connectionData = await response.json()
|
||||||
if (connectionsData) {
|
const clientConnectionData = {}
|
||||||
const serviceNames = connectionsData.map((connection) => connection.serviceName)
|
connectionData?.forEach((connection) => {
|
||||||
return { existingConnections: serviceNames }
|
const { id, serviceType, connectionInfo } = connection
|
||||||
}
|
clientConnectionData[id] = {
|
||||||
|
serviceType,
|
||||||
|
connectionInfo: JSON.parse(connectionInfo),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { existingConnections: clientConnectionData }
|
||||||
} else {
|
} else {
|
||||||
const error = await response.text()
|
const error = await response.text()
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -42,23 +47,26 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jellyfinAuthData = await jellyfinAuthResponse.json()
|
const jellyfinAuthData = await jellyfinAuthResponse.json()
|
||||||
const jellyfinAccessToken = jellyfinAuthData.AccessToken
|
const { User, AccessToken, ServerId } = jellyfinAuthData
|
||||||
|
const connectionInfo = JSON.stringify({ User, ServerId })
|
||||||
const updateConnectionsResponse = await fetch('/api/user/connections', {
|
const updateConnectionsResponse = await fetch('/api/user/connections', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
apikey: SECRET_INTERNAL_API_KEY,
|
apikey: SECRET_INTERNAL_API_KEY,
|
||||||
userId: locals.userId,
|
userId: locals.userId,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ serviceName: 'jellyfin', accessToken: jellyfinAccessToken }),
|
body: JSON.stringify({ serviceType: 'jellyfin', accessToken: AccessToken, connectionInfo }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
||||||
|
|
||||||
return { message: 'Updated Jellyfin connection' }
|
const newConnectionData = await updateConnectionsResponse.json()
|
||||||
|
|
||||||
|
return { message: 'Added Jellyfin connection', newConnection: { id: newConnectionData.id, serviceType: 'jellyfin', connectionInfo: JSON.parse(connectionInfo) } }
|
||||||
},
|
},
|
||||||
deleteConnection: async ({ request, fetch, locals }) => {
|
deleteConnection: async ({ request, fetch, locals }) => {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const serviceName = formData.get('service')
|
const serviceId = formData.get('serviceId')
|
||||||
|
|
||||||
const deleteConnectionResponse = await fetch('/api/user/connections', {
|
const deleteConnectionResponse = await fetch('/api/user/connections', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -66,11 +74,13 @@ export const actions = {
|
|||||||
apikey: SECRET_INTERNAL_API_KEY,
|
apikey: SECRET_INTERNAL_API_KEY,
|
||||||
userId: locals.userId,
|
userId: locals.userId,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ serviceName }),
|
body: JSON.stringify({ serviceId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
||||||
|
|
||||||
return { message: 'Connection deleted' }
|
const deletedConnectionData = await deleteConnectionResponse.json()
|
||||||
|
|
||||||
|
return { message: 'Connection deleted', deletedConnection: { id: deletedConnectionData.id } }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,67 +7,10 @@
|
|||||||
import { newestAlert } from '$lib/stores/alertStore.js'
|
import { newestAlert } from '$lib/stores/alertStore.js'
|
||||||
import IconButton from '$lib/components/utility/iconButton.svelte'
|
import IconButton from '$lib/components/utility/iconButton.svelte'
|
||||||
import Toggle from '$lib/components/utility/toggle.svelte'
|
import Toggle from '$lib/components/utility/toggle.svelte'
|
||||||
import { onMount } from 'svelte'
|
|
||||||
|
|
||||||
export let data
|
export let data
|
||||||
let existingConnections = data?.existingConnections
|
let existingConnections = data.existingConnections
|
||||||
|
|
||||||
const testServices = {
|
|
||||||
jellyfin: {
|
|
||||||
displayName: 'Jellyfin',
|
|
||||||
type: ['streaming'],
|
|
||||||
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
|
|
||||||
},
|
|
||||||
'youtube-music': {
|
|
||||||
displayName: 'YouTube Music',
|
|
||||||
type: ['streaming'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
|
|
||||||
},
|
|
||||||
spotify: {
|
|
||||||
displayName: 'Spotify',
|
|
||||||
type: ['streaming'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg',
|
|
||||||
},
|
|
||||||
'apple-music': {
|
|
||||||
displayName: 'Apple Music',
|
|
||||||
type: ['streaming', 'marketplace'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/5/5f/Apple_Music_icon.svg',
|
|
||||||
},
|
|
||||||
bandcamp: {
|
|
||||||
displayName: 'bandcamp',
|
|
||||||
type: ['marketplace', 'streaming'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Bandcamp-button-bc-circle-aqua.svg',
|
|
||||||
},
|
|
||||||
soundcloud: {
|
|
||||||
displayName: 'SoundCloud',
|
|
||||||
type: ['streaming'],
|
|
||||||
icon: 'https://www.vectorlogo.zone/logos/soundcloud/soundcloud-icon.svg',
|
|
||||||
},
|
|
||||||
lastfm: {
|
|
||||||
displayName: 'Last.fm',
|
|
||||||
type: ['analytics'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/c/c4/Lastfm.svg',
|
|
||||||
},
|
|
||||||
plex: {
|
|
||||||
displayName: 'Plex',
|
|
||||||
type: ['streaming'],
|
|
||||||
icon: 'https://www.vectorlogo.zone/logos/plextv/plextv-icon.svg',
|
|
||||||
},
|
|
||||||
deezer: {
|
|
||||||
displayName: 'deezer',
|
|
||||||
type: ['streaming'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6b/Deezer_Icon.svg',
|
|
||||||
},
|
|
||||||
'amazon-music': {
|
|
||||||
displayName: 'Amazon Music',
|
|
||||||
type: ['streaming', 'marketplace'],
|
|
||||||
icon: 'https://upload.wikimedia.org/wikipedia/commons/9/92/Amazon_Music_logo.svg',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceAuthenticationMethods = {}
|
|
||||||
|
|
||||||
let formMode = null
|
|
||||||
const submitCredentials = ({ formData, action, cancel }) => {
|
const submitCredentials = ({ formData, action, cancel }) => {
|
||||||
switch (action.search) {
|
switch (action.search) {
|
||||||
case '?/authenticateJellyfin':
|
case '?/authenticateJellyfin':
|
||||||
@@ -88,9 +31,6 @@
|
|||||||
formData.append('deviceId', deviceId)
|
formData.append('deviceId', deviceId)
|
||||||
break
|
break
|
||||||
case '?/deleteConnection':
|
case '?/deleteConnection':
|
||||||
const connection = formData.get('service')
|
|
||||||
$newestAlert = ['info', `Delete ${connection}`]
|
|
||||||
cancel()
|
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
cancel()
|
cancel()
|
||||||
@@ -101,7 +41,19 @@
|
|||||||
case 'failure':
|
case 'failure':
|
||||||
return ($newestAlert = ['warning', result.data.message])
|
return ($newestAlert = ['warning', result.data.message])
|
||||||
case 'success':
|
case 'success':
|
||||||
formMode = null
|
modal = null
|
||||||
|
if (result.data?.newConnection) {
|
||||||
|
const { id, serviceType, connectionInfo } = result.data.newConnection
|
||||||
|
existingConnections[id] = {
|
||||||
|
serviceType,
|
||||||
|
connectionInfo,
|
||||||
|
}
|
||||||
|
existingConnections = existingConnections
|
||||||
|
} else if (result.data?.deletedConnection) {
|
||||||
|
const id = result.data.deletedConnection.id
|
||||||
|
delete existingConnections[id]
|
||||||
|
existingConnections = existingConnections
|
||||||
|
}
|
||||||
return ($newestAlert = ['success', result.data.message])
|
return ($newestAlert = ['success', result.data.message])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,57 +62,63 @@
|
|||||||
let modal
|
let modal
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="h-full">
|
<main>
|
||||||
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
|
<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>
|
<h1 class="py-2 text-xl">Add Connection</h1>
|
||||||
<div class="flex flex-wrap gap-2 pb-4">
|
<div class="flex flex-wrap gap-2 pb-4">
|
||||||
{#each Object.entries(testServices) as [serviceType, serviceData]}
|
{#each Object.entries(Services) as [serviceType, serviceData]}
|
||||||
{#if !existingConnections.includes(serviceType)}
|
<button
|
||||||
<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={() => (modal = JellyfinAuthBox)}>
|
class="bg-ne h-14 rounded-md"
|
||||||
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
|
style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));"
|
||||||
</button>
|
on:click={() => {
|
||||||
{/if}
|
if (serviceType === 'jellyfin') modal = JellyfinAuthBox
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{#if existingConnections}
|
<div class="grid gap-8">
|
||||||
<div class="grid gap-8">
|
{#each Object.entries(existingConnections) as [connectionId, connectionData]}
|
||||||
{#each existingConnections as connectionType}
|
{@const serviceData = Services[connectionData.serviceType]}
|
||||||
{@const service = Services[connectionType]}
|
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
|
||||||
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" in:fly={{ duration: 300, x: 50 }}>
|
<header class="flex h-20 items-center gap-4 p-4">
|
||||||
<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" />
|
||||||
<img src={service.icon} alt="{service.displayName} icon" class="aspect-square h-full p-1" />
|
<div>
|
||||||
<div>
|
{#if connectionData.serviceType === 'jellyfin'}
|
||||||
|
<div>{connectionData.connectionInfo.User.Name}</div>
|
||||||
|
{:else}
|
||||||
<div>Account Name</div>
|
<div>Account Name</div>
|
||||||
<div class="text-sm text-neutral-500">{service.displayName}</div>
|
{/if}
|
||||||
</div>
|
<div class="text-sm text-neutral-500">{serviceData.displayName}</div>
|
||||||
<div class="ml-auto h-8">
|
|
||||||
<IconButton on:click={() => (modal = `delete-${connectionType}`)}>
|
|
||||||
<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.toggleState)} />
|
|
||||||
<span>Enable Connection</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="ml-auto h-8">
|
||||||
{/each}
|
<IconButton on:click={() => (modal = `delete-${connectionId}`)}>
|
||||||
</div>
|
<i slot="icon" class="fa-solid fa-link-slash" />
|
||||||
{/if}
|
</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.toggleState)} />
|
||||||
|
<span>Enable Connection</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{#if modal}
|
{#if modal}
|
||||||
<form method="post" use:enhance={submitCredentials} transition:fly={{ duration: 300, y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
<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'}
|
{#if typeof modal === 'string'}
|
||||||
{@const connectionType = modal.replace('delete-', '')}
|
{@const connectionId = modal.replace('delete-', '')}
|
||||||
{@const service = Services[connectionType]}
|
{@const service = Services[existingConnections[connectionId].serviceType]}
|
||||||
<div class="rounded-lg bg-neutral-900 p-5">
|
<div class="rounded-lg bg-neutral-900 p-5">
|
||||||
<h1 class="pb-4 text-center">Delete {service.displayName}?</h1>
|
<h1 class="pb-4 text-center">Delete {service.displayName} connection?</h1>
|
||||||
<div class="flex w-60 justify-around">
|
<div class="flex w-60 justify-around">
|
||||||
<input type="hidden" name="service" value={connectionType} />
|
<input type="hidden" name="serviceId" value={connectionId} />
|
||||||
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click={() => (modal = null)}>Cancel</button>
|
<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>
|
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,23 +2,20 @@
|
|||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const dispatchClose = () => {
|
|
||||||
dispatch('close')
|
|
||||||
}
|
|
||||||
</script>
|
</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">
|
<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>
|
<h1 class="text-center text-4xl">Jellyfin Sign In</h1>
|
||||||
<div class="flex w-full flex-col gap-5">
|
<div class="flex w-full flex-col gap-5">
|
||||||
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
|
<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">
|
<div class="flex w-full flex-row gap-4">
|
||||||
<input type="text" name="username" autocomplete="off" placeholder="Username" class="border-jellyfin-blue h-10 w-full border-b-2 bg-transparent px-1 outline-none" />
|
<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="border-jellyfin-blue h-10 w-full border-b-2 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>
|
</div>
|
||||||
<div class="flex items-center justify-around text-lg">
|
<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={dispatchClose}>Cancel</button>
|
<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="bg-jellyfin-blue w-1/3 rounded py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user