FINALLY, Beautiful connection types!
This commit is contained in:
39
src/app.d.ts
vendored
39
src/app.d.ts
vendored
@@ -4,7 +4,7 @@ declare global {
|
|||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user: Omit<User, 'password'>
|
user: Omit<User, 'passwordHash'>
|
||||||
}
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
@@ -21,25 +21,12 @@ declare global {
|
|||||||
interface User {
|
interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
password: string
|
passwordHash: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceType = 'jellyfin' | 'youtube-music'
|
type serviceType = 'jellyfin' | 'youtube-music'
|
||||||
|
|
||||||
type Service = Jellyfin.Service | YouTubeMusic.Service
|
type Connection<T extends serviceType> = T extends 'jellyfin' ? Jellyfin.Connection : T extends 'youtube-music' ? YouTubeMusic.Connection : never
|
||||||
|
|
||||||
type Tokens<T> = T extends Jellyfin.Service ? Jellyfin.Tokens : T extends YouTubeMusic.Service ? YouTubeMusic.Tokens : {}
|
|
||||||
|
|
||||||
// type ServiceTokenPair = [Jellyfin.Service, Jellyfin.Tokens] | [YouTubeMusic.Service, YouTubeMusic.Tokens]
|
|
||||||
|
|
||||||
interface BaseConnection<T> {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
type: T extends Jellyfin.Service ? 'jellyfin' : T extends YouTubeMusic.Service ? 'youtube-music' : serviceType
|
|
||||||
service: T extends undefined ? Service : T
|
|
||||||
}
|
|
||||||
|
|
||||||
type Connection<T extends Service = undefined> = BaseConnection<T> & Tokens<T>
|
|
||||||
|
|
||||||
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
||||||
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
|
// They are NOT meant to be stores for large amounts of data, i.e. Don't include the data for every single song the Playlist type.
|
||||||
@@ -94,16 +81,20 @@ declare global {
|
|||||||
// The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will
|
// The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will
|
||||||
// retrun the ServerName, it wont. This must be fetched from /System/Info.
|
// retrun the ServerName, it wont. This must be fetched from /System/Info.
|
||||||
// So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional)
|
// So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional)
|
||||||
interface Service {
|
interface Connection {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
type: 'jellyfin'
|
||||||
|
service: {
|
||||||
userId: string
|
userId: string
|
||||||
urlOrigin: string
|
urlOrigin: string
|
||||||
username?: string
|
username?: string
|
||||||
serverName?: string
|
serverName?: string
|
||||||
}
|
}
|
||||||
|
tokens: {
|
||||||
interface Tokens {
|
|
||||||
accessToken: string
|
accessToken: string
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
Name: string
|
Name: string
|
||||||
@@ -171,18 +162,22 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace YouTubeMusic {
|
namespace YouTubeMusic {
|
||||||
interface Service {
|
interface Connection {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
type: 'youtube-music'
|
||||||
|
service: {
|
||||||
userId: string
|
userId: string
|
||||||
username?: string
|
username?: string
|
||||||
profilePicture?: string
|
profilePicture?: string
|
||||||
}
|
}
|
||||||
|
tokens: {
|
||||||
interface Tokens {
|
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
expiry: number
|
expiry: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
|
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'password'>
|
const tokenData = jwt.verify(authToken, SECRET_JWT_KEY) as Omit<User, 'passwordHash'>
|
||||||
event.locals.user = tokenData
|
event.locals.user = tokenData
|
||||||
} catch {
|
} catch {
|
||||||
throw redirect(303, `/login?redirect=${urlpath}`)
|
throw redirect(303, `/login?redirect=${urlpath}`)
|
||||||
@@ -33,7 +33,7 @@ export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
|
|||||||
if (event.locals.user) {
|
if (event.locals.user) {
|
||||||
const expiredConnection = Connections.getExpiredConnections(event.locals.user.id)
|
const expiredConnection = Connections.getExpiredConnections(event.locals.user.id)
|
||||||
for (const connection of expiredConnection) {
|
for (const connection of expiredConnection) {
|
||||||
switch (connection.service.type) {
|
switch (connection.type) {
|
||||||
case 'youtube-music':
|
case 'youtube-music':
|
||||||
// Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS
|
// Again DON'T SHIP THIS, CLIENT SECRET SHOULD NOT BE EXPOSED TO USERS
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
@@ -41,13 +41,13 @@ export const handleFetch: HandleFetch = async ({ request, fetch, event }) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
||||||
client_secret: YOUTUBE_API_CLIENT_SECRET,
|
client_secret: YOUTUBE_API_CLIENT_SECRET,
|
||||||
refresh_token: connection.refreshToken as string,
|
refresh_token: connection.tokens.refreshToken as string,
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const { access_token, expires_in } = await response.json()
|
const { access_token, expires_in } = await response.json()
|
||||||
const newExpiry = Date.now() + expires_in * 1000
|
const newExpiry = Date.now() + expires_in * 1000
|
||||||
Connections.updateTokens(connection.id, access_token, connection.refreshToken, newExpiry)
|
Connections.updateTokens(connection.id, access_token, connection.tokens.refreshToken, newExpiry)
|
||||||
console.log('Refreshed YouTubeMusic access token')
|
console.log('Refreshed YouTubeMusic access token')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let mediaItem: MediaItem
|
export let mediaItem: MediaItem
|
||||||
|
|
||||||
import Services from '$lib/services.json'
|
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,22 +1,19 @@
|
|||||||
import Database, { SqliteError } from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { generateUUID } from '$lib/utils'
|
import { generateUUID } from '$lib/utils'
|
||||||
import { isValidURL } from '$lib/utils'
|
|
||||||
|
|
||||||
const db = new Database('./src/lib/server/users.db', { verbose: console.info })
|
const db = new Database('./src/lib/server/users.db', { verbose: console.info })
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
const initUsersTable = `CREATE TABLE IF NOT EXISTS Users(
|
const initUsersTable = `CREATE TABLE IF NOT EXISTS Users(
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
username VARCHAR(30) UNIQUE NOT NULL,
|
username VARCHAR(30) UNIQUE NOT NULL,
|
||||||
password VARCHAR(72) NOT NULL
|
passwordHash VARCHAR(72) NOT NULL
|
||||||
)`
|
)`
|
||||||
const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections(
|
const initConnectionsTable = `CREATE TABLE IF NOT EXISTS Connections(
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
userId VARCHAR(36) NOT NULL,
|
userId VARCHAR(36) NOT NULL,
|
||||||
type VARCHAR(36) NOT NULL,
|
type VARCHAR(36) NOT NULL,
|
||||||
service TEXT NOT NULL,
|
service TEXT,
|
||||||
accessToken TEXT NOT NULL,
|
tokens TEXT
|
||||||
refreshToken TEXT,
|
|
||||||
expiry NUMBER,
|
|
||||||
FOREIGN KEY(userId) REFERENCES Users(id)
|
FOREIGN KEY(userId) REFERENCES Users(id)
|
||||||
)`
|
)`
|
||||||
db.exec(initUsersTable), db.exec(initConnectionsTable)
|
db.exec(initUsersTable), db.exec(initConnectionsTable)
|
||||||
@@ -24,88 +21,87 @@ db.exec(initUsersTable), db.exec(initConnectionsTable)
|
|||||||
interface ConnectionsTableSchema {
|
interface ConnectionsTableSchema {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
type: string
|
type: serviceType
|
||||||
service: string
|
service?: string
|
||||||
accessToken: string
|
tokens?: string
|
||||||
refreshToken?: string
|
|
||||||
expiry?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Users {
|
export class Users {
|
||||||
static getUser = (id: string): User | null => {
|
static getUser = (id: string): User | null => {
|
||||||
const user = db.prepare('SELECT * FROM Users WHERE id = ?').get(id) as User | null
|
const user = db.prepare(`SELECT * FROM Users WHERE id = ?`).get(id) as User | null
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
static getUsername = (username: string): User | null => {
|
static getUsername = (username: string): User | null => {
|
||||||
const user = db.prepare('SELECT * FROM Users WHERE lower(username) = ?').get(username.toLowerCase()) as User | null
|
const user = db.prepare(`SELECT * FROM Users WHERE lower(username) = ?`).get(username.toLowerCase()) as User | null
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
static addUser = (username: string, hashedPassword: string): User | null => {
|
static addUser = (username: string, passwordHash: string): User | null => {
|
||||||
if (this.getUsername(username)) return null
|
if (this.getUsername(username)) return null
|
||||||
|
|
||||||
const userId = generateUUID()
|
const userId = generateUUID()
|
||||||
db.prepare('INSERT INTO Users(id, username, password) VALUES(?, ?, ?)').run(userId, username, hashedPassword)
|
db.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
|
||||||
return this.getUser(userId)!
|
return this.getUser(userId)!
|
||||||
}
|
}
|
||||||
|
|
||||||
static deleteUser = (id: string): void => {
|
static deleteUser = (id: string): void => {
|
||||||
const commandInfo = db.prepare('DELETE FROM Users WHERE id = ?').run(id)
|
const commandInfo = db.prepare(`DELETE FROM Users WHERE id = ?`).run(id)
|
||||||
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
|
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DBConnectionData<T extends serviceType> = T extends 'jellyfin'
|
||||||
|
? Omit<Jellyfin.Connection, 'service'> & { service: Pick<Jellyfin.Connection['service'], 'userId' | 'urlOrigin'> }
|
||||||
|
: T extends 'youtube-music'
|
||||||
|
? Omit<YouTubeMusic.Connection, 'service'> & { service: Pick<YouTubeMusic.Connection['service'], 'userId'> }
|
||||||
|
: never
|
||||||
|
|
||||||
export class Connections {
|
export class Connections {
|
||||||
static getConnection = (id: string): BaseConnection => {
|
static getConnection = (id: string): DBConnectionData<serviceType> => {
|
||||||
const { userId, service, accessToken, refreshToken, expiry } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema
|
const { userId, type, service, tokens } = db.prepare(`SELECT * FROM Connections WHERE id = ?`).get(id) as ConnectionsTableSchema
|
||||||
const connection: BaseConnection = { id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry }
|
const parsedService = service ? JSON.parse(service) : undefined
|
||||||
|
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
||||||
|
const connection: DBConnectionData<typeof type> = { id, userId, type, service: parsedService, tokens: parsedTokens }
|
||||||
return connection
|
return connection
|
||||||
}
|
}
|
||||||
|
|
||||||
static getUserConnections = (userId: string): Connection[] => {
|
static getUserConnections = (userId: string): DBConnectionData<serviceType>[] => {
|
||||||
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as ConnectionsTableSchema[]
|
const connectionRows = db.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as ConnectionsTableSchema[]
|
||||||
const connections: Connection[] = []
|
const connections: DBConnectionData<serviceType>[] = []
|
||||||
for (const row of connectionRows) {
|
for (const { id, type, service, tokens } of connectionRows) {
|
||||||
const { id, service, accessToken, refreshToken, expiry } = row
|
const parsedService = service ? JSON.parse(service) : undefined
|
||||||
connections.push({ id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry })
|
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
||||||
|
connections.push({ id, userId, type, service: parsedService, tokens: parsedTokens })
|
||||||
}
|
}
|
||||||
return connections
|
return connections
|
||||||
}
|
}
|
||||||
|
|
||||||
static addConnection = (userId: string, service: Service, accessToken?: string, refreshToken?: string, expiry?: number): Connection => {
|
static addConnection<T extends serviceType>(type: T, connectionData: Omit<DBConnectionData<T>, 'id' | 'type'>): DBConnectionData<T> {
|
||||||
const connectionId = generateUUID()
|
const connectionId = generateUUID()
|
||||||
const test: Connection = {
|
const { userId, service, tokens } = connectionData
|
||||||
id: 'test',
|
db.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, service, tokens)
|
||||||
userId: 'test',
|
return this.getConnection(connectionId) as DBConnectionData<T>
|
||||||
type: 'jellyfin',
|
|
||||||
service: {
|
|
||||||
userId: 'test',
|
|
||||||
urlOrigin: 'test',
|
|
||||||
},
|
|
||||||
accessToken: 'test',
|
|
||||||
}
|
|
||||||
// if (!isValidURL(service.urlOrigin)) throw new Error('Service does not have valid url')
|
|
||||||
db.prepare('INSERT INTO Connections(id, userId, service, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?, ?)').run(connectionId, userId, JSON.stringify(service), accessToken, refreshToken, expiry)
|
|
||||||
return this.getConnection(connectionId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static deleteConnection = (id: string): void => {
|
static deleteConnection = (id: string): void => {
|
||||||
const commandInfo = db.prepare('DELETE FROM Connections WHERE id = ?').run(id)
|
const commandInfo = db.prepare(`DELETE FROM Connections WHERE id = ?`).run(id)
|
||||||
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
|
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
|
||||||
}
|
}
|
||||||
|
|
||||||
static updateTokens = (id: string, accessToken: string, refreshToken?: string, expiry?: number): void => {
|
static updateTokens = (id: string, accessToken?: string, refreshToken?: string, expiry?: number): void => {
|
||||||
const commandInfo = db.prepare('UPDATE Connections SET accessToken = ?, refreshToken = ?, expiry = ? WHERE id = ?').run(accessToken, refreshToken, expiry, id)
|
const newTokens = { accessToken, refreshToken, expiry }
|
||||||
|
const commandInfo = db.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(newTokens, id)
|
||||||
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
|
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
|
||||||
}
|
}
|
||||||
|
|
||||||
static getExpiredConnections = (userId: string): Connection[] => {
|
static getExpiredConnections = (userId: string): DBConnectionData<serviceType>[] => {
|
||||||
const expiredRows = db.prepare('SELECT * FROM Connections WHERE userId = ? AND expiry < ?').all(userId, Date.now()) as ConnectionsTableSchema[]
|
const expiredRows = db.prepare(`SELECT * FROM Connections WHERE userId = ? AND json_extract(tokens, '$.expiry') < ?`).all(userId, Date.now()) as ConnectionsTableSchema[]
|
||||||
const connections: Connection[] = []
|
const connections: DBConnectionData<serviceType>[] = []
|
||||||
for (const row of expiredRows) {
|
for (const { id, userId, type, service, tokens } of expiredRows) {
|
||||||
const { id, userId, service, accessToken, refreshToken, expiry } = row
|
const parsedService = service ? JSON.parse(service) : undefined
|
||||||
connections.push({ id, userId, service: JSON.parse(service), accessToken, refreshToken, expiry })
|
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
|
||||||
|
connections.push({ id, userId, type, service: parsedService, tokens: parsedTokens })
|
||||||
}
|
}
|
||||||
return connections
|
return connections
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { google } from 'googleapis'
|
|
||||||
|
|
||||||
export class YouTubeMusic {}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"jellyfin": {
|
|
||||||
"displayName": "Jellyfin",
|
|
||||||
"type": ["streaming"],
|
|
||||||
"icon": "https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg",
|
|
||||||
"primaryColor": "--jellyfin-blue"
|
|
||||||
},
|
|
||||||
"youtube-music": {
|
|
||||||
"displayName": "YouTube Music",
|
|
||||||
"type": ["streaming"],
|
|
||||||
"icon": "https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg",
|
|
||||||
"primaryColor": "--youtube-red"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,20 @@
|
|||||||
|
import { google } from 'googleapis'
|
||||||
|
|
||||||
|
export const serviceData = {
|
||||||
|
jellyfin: {
|
||||||
|
displayName: 'Jellyfin',
|
||||||
|
type: ['streaming'],
|
||||||
|
icon: 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg',
|
||||||
|
primaryColor: '--jellyfin-blue',
|
||||||
|
},
|
||||||
|
'youtube-music': {
|
||||||
|
displayName: 'YouTube Music',
|
||||||
|
type: ['streaming'],
|
||||||
|
icon: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Youtube_Music_icon.svg',
|
||||||
|
primaryColor: '--youtube-red',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export class Jellyfin {
|
export class Jellyfin {
|
||||||
static audioPresets = (userId: string) => {
|
static audioPresets = (userId: string) => {
|
||||||
return {
|
return {
|
||||||
@@ -10,8 +27,28 @@ export class Jellyfin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static songFactory = (song: Jellyfin.Song, connection: Connection): Song => {
|
static fetchSerivceInfo = async (userId: string, urlOrigin: string, accessToken: string): Promise<Connection<'jellyfin'>['service']> => {
|
||||||
const { id, service } = connection
|
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
|
||||||
|
|
||||||
|
const userUrl = new URL(`Users/${userId}`, urlOrigin).href
|
||||||
|
const systemUrl = new URL('System/Info', urlOrigin).href
|
||||||
|
|
||||||
|
const userResponse = await fetch(userUrl, { headers: reqHeaders })
|
||||||
|
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
|
||||||
|
|
||||||
|
const userData: Jellyfin.User = await userResponse.json()
|
||||||
|
const systemData: Jellyfin.System = await systemResponse.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
urlOrigin,
|
||||||
|
username: userData.Name,
|
||||||
|
serverName: systemData.ServerName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static songFactory = (song: Jellyfin.Song, connection: Connection<'jellyfin'>): Song => {
|
||||||
|
const { id, type, service } = connection
|
||||||
const thumbnail = song.ImageTags?.Primary
|
const thumbnail = song.ImageTags?.Primary
|
||||||
? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href
|
? new URL(`Items/${song.Id}/Images/Primary`, service.urlOrigin).href
|
||||||
: song.AlbumPrimaryImageTag
|
: song.AlbumPrimaryImageTag
|
||||||
@@ -29,7 +66,7 @@ export class Jellyfin {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId: id,
|
connectionId: id,
|
||||||
serviceType: service.type,
|
serviceType: type,
|
||||||
type: 'song',
|
type: 'song',
|
||||||
id: song.Id,
|
id: song.Id,
|
||||||
name: song.Name,
|
name: song.Name,
|
||||||
@@ -42,8 +79,8 @@ export class Jellyfin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static albumFactory = (album: Jellyfin.Album, connection: Connection): Album => {
|
static albumFactory = (album: Jellyfin.Album, connection: Connection<'jellyfin'>): Album => {
|
||||||
const { id, service } = connection
|
const { id, type, service } = connection
|
||||||
const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined
|
const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, service.urlOrigin).href : undefined
|
||||||
|
|
||||||
const albumArtists = album.AlbumArtists
|
const albumArtists = album.AlbumArtists
|
||||||
@@ -60,7 +97,7 @@ export class Jellyfin {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId: id,
|
connectionId: id,
|
||||||
serviceType: service.type,
|
serviceType: type,
|
||||||
type: 'album',
|
type: 'album',
|
||||||
id: album.Id,
|
id: album.Id,
|
||||||
name: album.Name,
|
name: album.Name,
|
||||||
@@ -72,13 +109,13 @@ export class Jellyfin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection): Playlist => {
|
static playListFactory = (playlist: Jellyfin.Playlist, connection: Connection<'jellyfin'>): Playlist => {
|
||||||
const { id, service } = connection
|
const { id, type, service } = connection
|
||||||
const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined
|
const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, service.urlOrigin).href : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId: id,
|
connectionId: id,
|
||||||
serviceType: service.type,
|
serviceType: type,
|
||||||
type: 'playlist',
|
type: 'playlist',
|
||||||
id: playlist.Id,
|
id: playlist.Id,
|
||||||
name: playlist.Name,
|
name: playlist.Name,
|
||||||
@@ -87,13 +124,13 @@ export class Jellyfin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static artistFactory = (artist: Jellyfin.Artist, connection: Connection): Artist => {
|
static artistFactory = (artist: Jellyfin.Artist, connection: Connection<'jellyfin'>): Artist => {
|
||||||
const { id, service } = connection
|
const { id, type, service } = connection
|
||||||
const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined
|
const thumbnail = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, service.urlOrigin).href : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId: id,
|
connectionId: id,
|
||||||
serviceType: service.type,
|
serviceType: type,
|
||||||
type: 'artist',
|
type: 'artist',
|
||||||
id: artist.Id,
|
id: artist.Id,
|
||||||
name: artist.Name,
|
name: artist.Name,
|
||||||
@@ -101,3 +138,17 @@ export class Jellyfin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class YouTubeMusic {
|
||||||
|
static fetchServiceInfo = async (userId: string, accessToken: string): Promise<Connection<'youtube-music'>['service']> => {
|
||||||
|
const youtube = google.youtube('v3')
|
||||||
|
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: accessToken })
|
||||||
|
const userChannel = userChannelResponse.data.items![0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
username: userChannel.snippet?.title as string,
|
||||||
|
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit'
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
import { Jellyfin, YouTubeMusic } from '$lib/services'
|
||||||
import { Connections } from '$lib/server/users'
|
import { Connections } from '$lib/server/users'
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
|
const ids = url.searchParams.get('ids')?.replace(/\s/g, '').split(',')
|
||||||
if (!ids) return new Response('Missing ids query parameter', { status: 400 })
|
if (!ids) return new Response('Missing ids query parameter', { status: 400 })
|
||||||
|
|
||||||
const connections: Connection[] = []
|
const connections: Connection<serviceType>[] = []
|
||||||
for (const connectionId of ids) connections.push(Connections.getConnection(connectionId))
|
for (const connectionId of ids) {
|
||||||
|
const connection = Connections.getConnection(connectionId)
|
||||||
|
switch (connection.type) {
|
||||||
|
case 'jellyfin':
|
||||||
|
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken)
|
||||||
|
break
|
||||||
|
case 'youtube-music':
|
||||||
|
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ connections })
|
return Response.json({ connections })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit'
|
|
||||||
import { Connections } from '$lib/server/users'
|
|
||||||
import { google } from 'googleapis'
|
|
||||||
|
|
||||||
const youtubeInfo = async (connection: Connection): Promise<ConnectionInfo> => {
|
|
||||||
const youtube = google.youtube('v3')
|
|
||||||
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['snippet'], access_token: connection.accessToken })
|
|
||||||
const userChannel = userChannelResponse.data.items![0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
connectionId: connection.id,
|
|
||||||
serviceType: connection.service.type,
|
|
||||||
username: userChannel.snippet?.title as string,
|
|
||||||
profilePicture: userChannel.snippet?.thumbnails?.default?.url as string | undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jellyfinInfo = async (connection: Connection): Promise<ConnectionInfo> => {
|
|
||||||
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${connection.accessToken}"` })
|
|
||||||
|
|
||||||
const userUrl = new URL(`Users/${connection.service.userId}`, connection.service.urlOrigin).href
|
|
||||||
const systemUrl = new URL('System/Info', connection.service.urlOrigin).href
|
|
||||||
|
|
||||||
const userResponse = await fetch(userUrl, { headers: reqHeaders })
|
|
||||||
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
|
|
||||||
|
|
||||||
const userData: Jellyfin.User = await userResponse.json()
|
|
||||||
const systemData: Jellyfin.System = await systemResponse.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
connectionId: connection.id,
|
|
||||||
serviceType: 'jellyfin',
|
|
||||||
username: userData.Name,
|
|
||||||
serverName: systemData.ServerName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
|
||||||
const connectionId = params.connectionId!
|
|
||||||
const connection = Connections.getConnection(connectionId)
|
|
||||||
|
|
||||||
let info: ConnectionInfo
|
|
||||||
switch (connection.service.type) {
|
|
||||||
case 'jellyfin':
|
|
||||||
info = await jellyfinInfo(connection)
|
|
||||||
break
|
|
||||||
case 'youtube-music':
|
|
||||||
info = await youtubeInfo(connection)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ info })
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
import { Connections } from '$lib/server/users'
|
import { Connections } from '$lib/server/users'
|
||||||
|
import { Jellyfin, YouTubeMusic } from '$lib/services'
|
||||||
import type { RequestHandler } from '@sveltejs/kit'
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const userId = params.userId!
|
const userId = params.userId!
|
||||||
|
|
||||||
const connections = Connections.getUserConnections(userId)
|
const connections = Connections.getUserConnections(userId)
|
||||||
|
for (const connection of connections) {
|
||||||
|
switch (connection.type) {
|
||||||
|
case 'jellyfin':
|
||||||
|
connection.service = await Jellyfin.fetchSerivceInfo(connection.service.userId, connection.service.urlOrigin, connection.tokens.accessToken)
|
||||||
|
break
|
||||||
|
case 'youtube-music':
|
||||||
|
connection.service = await YouTubeMusic.fetchServiceInfo(connection.service.userId, connection.tokens.accessToken)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ connections })
|
return Response.json({ connections })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit'
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
||||||
import { Jellyfin } from '$lib/service-managers/jellyfin'
|
import { Jellyfin } from '$lib/services'
|
||||||
|
|
||||||
// This is temporary functionally for the sake of developing the app.
|
// This is temporary functionally for the sake of developing the app.
|
||||||
// In the future will implement more robust algorithm for offering recommendations
|
// In the future will implement more robust algorithm for offering recommendations
|
||||||
@@ -13,9 +13,9 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
|
|||||||
const recommendations: MediaItem[] = []
|
const recommendations: MediaItem[] = []
|
||||||
|
|
||||||
for (const connection of userConnections.connections) {
|
for (const connection of userConnections.connections) {
|
||||||
const { service, accessToken } = connection as Connection
|
const { type, service, tokens } = connection as Connection<serviceType>
|
||||||
|
|
||||||
switch (service.type) {
|
switch (type) {
|
||||||
case 'jellyfin':
|
case 'jellyfin':
|
||||||
const mostPlayedSongsSearchParams = new URLSearchParams({
|
const mostPlayedSongsSearchParams = new URLSearchParams({
|
||||||
SortBy: 'PlayCount',
|
SortBy: 'PlayCount',
|
||||||
@@ -26,7 +26,7 @@ export const GET: RequestHandler = async ({ params, fetch }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mostPlayedSongsURL = new URL(`/Users/${service.userId}/Items?${mostPlayedSongsSearchParams.toString()}`, service.urlOrigin).href
|
const mostPlayedSongsURL = new URL(`/Users/${service.userId}/Items?${mostPlayedSongsSearchParams.toString()}`, service.urlOrigin).href
|
||||||
const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${accessToken}"` })
|
const requestHeaders = new Headers({ Authorization: `MediaBrowser Token="${tokens.accessToken}"` })
|
||||||
|
|
||||||
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
|
const mostPlayedResponse = await fetch(mostPlayedSongsURL, { headers: requestHeaders })
|
||||||
const mostPlayedData = await mostPlayedResponse.json()
|
const mostPlayedData = await mostPlayedResponse.json()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const actions: Actions = {
|
|||||||
const user = Users.getUsername(username.toString())
|
const user = Users.getUsername(username.toString())
|
||||||
if (!user) return fail(400, { message: 'Invalid Username' })
|
if (!user) return fail(400, { message: 'Invalid Username' })
|
||||||
|
|
||||||
const passwordValid = await compare(password.toString(), user.password)
|
const passwordValid = await compare(password.toString(), user.passwordHash)
|
||||||
if (!passwordValid) return fail(400, { message: 'Invalid Password' })
|
if (!passwordValid) return fail(400, { message: 'Invalid Password' })
|
||||||
|
|
||||||
const authToken = jwt.sign({ id: user.id, username: user.username }, SECRET_JWT_KEY, { expiresIn: '100d' })
|
const authToken = jwt.sign({ id: user.id, username: user.username }, SECRET_JWT_KEY, { expiresIn: '100d' })
|
||||||
|
|||||||
@@ -43,13 +43,11 @@ export const actions: Actions = {
|
|||||||
return fail(400, { message: 'Could not reach Jellyfin server' })
|
return fail(400, { message: 'Could not reach Jellyfin server' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceData: Service = {
|
const newConnection = Connections.addConnection('jellyfin', {
|
||||||
type: 'jellyfin',
|
userId: locals.user.id,
|
||||||
userId: authData.User.Id,
|
service: { userId: authData.User.Id, urlOrigin: serverUrl.toString() },
|
||||||
urlOrigin: serverUrl.toString(),
|
tokens: { accessToken: authData.AccessToken },
|
||||||
}
|
})
|
||||||
|
|
||||||
const newConnection = Connections.addConnection(locals.user.id, serviceData, authData.AccessToken)
|
|
||||||
|
|
||||||
return { newConnection }
|
return { newConnection }
|
||||||
},
|
},
|
||||||
@@ -63,13 +61,11 @@ export const actions: Actions = {
|
|||||||
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
|
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! })
|
||||||
const userChannel = userChannelResponse.data.items![0]
|
const userChannel = userChannelResponse.data.items![0]
|
||||||
|
|
||||||
const serviceData: Service = {
|
const newConnection = Connections.addConnection('youtube-music', {
|
||||||
type: 'youtube-music',
|
userId: locals.user.id,
|
||||||
userId: userChannel.id!,
|
service: { userId: userChannel.id! },
|
||||||
urlOrigin: 'https://www.googleapis.com/youtube/v3',
|
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
||||||
}
|
})
|
||||||
|
|
||||||
const newConnection = Connections.addConnection(locals.user.id, serviceData, tokens.access_token!, tokens.refresh_token!, tokens.expiry_date!)
|
|
||||||
|
|
||||||
return { newConnection }
|
return { newConnection }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Services from '$lib/services.json'
|
import { serviceData } from '$lib/services'
|
||||||
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
|
import JellyfinAuthBox from './jellyfinAuthBox.svelte'
|
||||||
import { newestAlert } from '$lib/stores.js'
|
import { newestAlert } from '$lib/stores.js'
|
||||||
import type { PageServerData } from './$types.js'
|
import type { PageServerData } from './$types.js'
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||||
|
|
||||||
export let data: PageServerData
|
export let data: PageServerData
|
||||||
let connections: Connection[] = data.connections
|
let connections: Connection<serviceType>[] = data.connections
|
||||||
|
|
||||||
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
||||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
return ($newestAlert = ['warning', result.data?.message])
|
return ($newestAlert = ['warning', result.data?.message])
|
||||||
} else if (result.type === 'success') {
|
} else if (result.type === 'success') {
|
||||||
const newConnection: Connection = result.data!.newConnection
|
const newConnection: Connection<'jellyfin'> = result.data!.newConnection
|
||||||
connections = [...connections, newConnection]
|
connections = [...connections, newConnection]
|
||||||
|
|
||||||
newConnectionModal = null
|
newConnectionModal = null
|
||||||
return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`])
|
return ($newestAlert = ['success', `Added ${serviceData[newConnection.type].displayName}`])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
if (result.type === 'failure') {
|
if (result.type === 'failure') {
|
||||||
return ($newestAlert = ['warning', result.data?.message])
|
return ($newestAlert = ['warning', result.data?.message])
|
||||||
} else if (result.type === 'success') {
|
} else if (result.type === 'success') {
|
||||||
const newConnection: Connection = result.data!.newConnection
|
const newConnection: Connection<'youtube-music'> = result.data!.newConnection
|
||||||
connections = [...connections, newConnection]
|
connections = [...connections, newConnection]
|
||||||
return ($newestAlert = ['success', 'Added Youtube Music'])
|
return ($newestAlert = ['success', 'Added Youtube Music'])
|
||||||
}
|
}
|
||||||
@@ -84,12 +84,12 @@
|
|||||||
} else if (result.type === 'success') {
|
} else if (result.type === 'success') {
|
||||||
const id = result.data!.deletedConnectionId
|
const id = result.data!.deletedConnectionId
|
||||||
const indexToDelete = connections.findIndex((connection) => connection.id === id)
|
const indexToDelete = connections.findIndex((connection) => connection.id === id)
|
||||||
const serviceType = connections[indexToDelete].service.type
|
const serviceType = connections[indexToDelete].type
|
||||||
|
|
||||||
connections.splice(indexToDelete, 1)
|
connections.splice(indexToDelete, 1)
|
||||||
connections = connections
|
connections = connections
|
||||||
|
|
||||||
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
|
return ($newestAlert = ['success', `Deleted ${serviceData[serviceType].displayName}`])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +102,11 @@
|
|||||||
<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">
|
||||||
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
|
<button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
|
||||||
<img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
|
<img src={serviceData.jellyfin.icon} alt="{serviceData.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
|
||||||
</button>
|
</button>
|
||||||
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
|
<form method="post" action="?/youtubeMusicLogin" use:enhance={authenticateYouTube}>
|
||||||
<button class="add-connection-button h-14 rounded-md">
|
<button class="add-connection-button h-14 rounded-md">
|
||||||
<img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
|
<img src={serviceData['youtube-music'].icon} alt="{serviceData['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,47 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Services from '$lib/services.json'
|
import { serviceData } from '$lib/services'
|
||||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||||
import Toggle from '$lib/components/util/toggle.svelte'
|
import Toggle from '$lib/components/util/toggle.svelte'
|
||||||
import type { SubmitFunction } from '@sveltejs/kit'
|
import type { SubmitFunction } from '@sveltejs/kit'
|
||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
import { enhance } from '$app/forms'
|
import { enhance } from '$app/forms'
|
||||||
|
|
||||||
export let connection: Connection
|
export let connection: Connection<serviceType>
|
||||||
export let submitFunction: SubmitFunction
|
export let submitFunction: SubmitFunction
|
||||||
|
|
||||||
$: serviceData = Services[connection.service.type]
|
$: reactiveServiceData = serviceData[connection.type]
|
||||||
|
|
||||||
let showModal = false
|
let showModal = false
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
|
<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">
|
<header class="flex h-20 items-center gap-4 p-4">
|
||||||
<div class="h-full aspect-square p-1 relative">
|
<div class="relative aspect-square h-full p-1">
|
||||||
<img src={serviceData.icon} alt="{serviceData.displayName} icon" />
|
<img src={reactiveServiceData.icon} alt="{reactiveServiceData.displayName} icon" />
|
||||||
{#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'}
|
{#if 'profilePicture' in connection.service && typeof connection.service.profilePicture === 'string'}
|
||||||
<img src="{connection.service.profilePicture}" alt="" class="absolute h-5 aspect-square bottom-0 right-0 rounded-full"/>
|
<img src={connection.service.profilePicture} alt="" class="absolute bottom-0 right-0 aspect-square h-5 rounded-full" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>Username</div>
|
<div>Username</div>
|
||||||
<div class="text-sm text-neutral-500">
|
<div class="text-sm text-neutral-500">
|
||||||
{serviceData.displayName}
|
{reactiveServiceData.displayName}
|
||||||
{#if 'serverName' in connection.service}
|
{#if 'serverName' in connection.service}
|
||||||
- {connection.service.serverName}
|
- {connection.service.serverName}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative ml-auto h-8 flex flex-row-reverse gap-2">
|
<div class="relative ml-auto flex h-8 flex-row-reverse gap-2">
|
||||||
<IconButton halo={true} on:click={() => showModal = !showModal}>
|
<IconButton halo={true} on:click={() => (showModal = !showModal)}>
|
||||||
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" />
|
<i slot="icon" class="fa-solid fa-ellipsis-vertical text-xl text-neutral-500" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{#if showModal}
|
{#if showModal}
|
||||||
<form
|
<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">
|
||||||
use:enhance={submitFunction}
|
<button formaction="?/deleteConnection" class="whitespace-nowrap rounded-md px-3 py-2 hover:bg-neutral-800">
|
||||||
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="py-2 px-3 whitespace-nowrap hover:bg-neutral-800 rounded-md">
|
|
||||||
<i class="fa-solid fa-link-slash mr-1" />
|
<i class="fa-solid fa-link-slash mr-1" />
|
||||||
Delete Connection
|
Delete Connection
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user