diff --git a/src/app/components/discordstatus.css b/src/app/components/discordstatus.css new file mode 100644 index 0000000..8a919cb --- /dev/null +++ b/src/app/components/discordstatus.css @@ -0,0 +1,76 @@ +.discord-status-compact { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.avatar-container { + position: relative; + display: inline-block; + width: 34px; + height: 34px; + flex-shrink: 0; +} + +.discord-avatar { + width: 100%; + height: 100%; + border-radius: 2px; + border: 1px solid var(--accent); + box-shadow: 2px 2px 0px var(--pink-accent); + object-fit: cover; +} + +.status-dot { + position: absolute; + bottom: -2px; + right: -2px; + width: 7px; + height: 7px; + border-radius: 50%; + box-shadow: 0 0 0 2px #fffdfd; +} +.status-dot.online { background-color: #a7f3d0; border: 1px solid #34d399; } +.status-dot.idle { background-color: #fef08a; border: 1px solid #facc15; } +.status-dot.dnd { background-color: #fecdd3; border: 1px solid #fb7185; } +.status-dot.offline { background-color: var(--text-dim); border: 1px solid var(--text-main); } + +.status-details { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.status-text { + font-size: 0.75rem; + color: var(--text-dim); + text-transform: lowercase; +} + +.status-text em { + font-style: italic; + font-weight: normal; + color: var(--text-title); +} + +.status-bubble-inline { + font-size: 0.7rem; + color: var(--text-main); + border-bottom: 1px dashed var(--border); + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.game-icon { + width: 24px; + height: 24px; + border-radius: 2px; + border: 1px solid var(--border); + box-shadow: 1px 1px 0px var(--pink-accent); + object-fit: cover; + flex-shrink: 0; +} \ No newline at end of file diff --git a/src/app/components/discordstatus.tsx b/src/app/components/discordstatus.tsx new file mode 100644 index 0000000..ca78cba --- /dev/null +++ b/src/app/components/discordstatus.tsx @@ -0,0 +1,179 @@ +import './discordstatus.css'; + +import { useEffect, useState } from "react"; + +interface LanyardResponse { + success: boolean; + data: LanyardData; +} + +interface LanyardData { + discord_status: 'online' | 'idle' | 'dnd' | 'offline'; + activities: DiscordActivity[]; + discord_user: DiscordUser; + active_on_discord_web: boolean; + active_on_discord_desktop: boolean; + active_on_discord_mobile: boolean; + active_on_discord_embedded: boolean; + active_on_discord_vr: boolean; + listening_to_spotify: boolean; + spotify: null | Record; + kv: Record; +} + +interface DiscordUser { + id: string; + username: string; + discriminator: string; + global_name: string; + display_name: string; + avatar: string; + bot: boolean; + public_flags: number; + avatar_decoration_data: null | any; + collectibles?: Record; + display_name_styles?: Record; + primary_guild?: Record; +} + +interface DiscordActivity { + id: string; + name: string; + type: number; + session_id?: string; + created_at: number; + state?: string; + details?: string; + timestamps?: { + start?: number; + end?: number; + }; + assets?: { + large_image?: string; + large_text?: string; + small_image?: string; + small_text?: string; + }; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + application_id?: string; + flags?: number; + platform?: string; +} + +function resolveDiscordAsset(image: string | undefined): string { + if (!image) return ""; + + if (image.startsWith("mp:external/")) { + const httpsIndex = image.indexOf("/https/"); + if (httpsIndex !== -1) { + const after = image.slice(httpsIndex + "/https/".length); + return `https://${after}`; + } + } + + if (/^\d+$/.test(image) || image.startsWith("spotify:")) return ""; + + return image; +} + +export interface DiscordStatusParams { + userId: string +} + +const STATUS_LABELS: Record = { + online: 'online', + idle: 'away', + dnd: 'busy', + offline: 'offline' +}; + +export function DiscordStatus({ userId }: DiscordStatusParams) { + const [presence, setPresence] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchRichPresence() { + try { + const response = await fetch(`https://api.lanyard.rest/v1/users/${userId}`); + const json: LanyardResponse = await response.json(); + if (json.success) { + setPresence(json.data); + } + } catch (error) { + console.error("Failed to fetch Lanyard presence:", error); + } finally { + setLoading(false); + } + } + + fetchRichPresence(); + const interval = setInterval(fetchRichPresence, 30000); + return () => clearInterval(interval); + }, []); + + if (loading) { + return

loading status...

; + } + + if (!presence) { + return

offline

; + } + + const customActivity = presence.activities.find(act => act.id === "custom"); + const customStatusText = customActivity + ? `${customActivity.emoji?.name || ''} ${customActivity.state || ''}`.trim() + : null; + + const gameActivity = presence.activities + .filter(act => act.type === 0) + .sort((a, b) => (b.assets ? 1 : 0) - (a.assets ? 1 : 0))[0] as DiscordActivity | undefined; + + const gameImage = gameActivity?.assets + ? resolveDiscordAsset(gameActivity.assets.small_image || gameActivity.assets.large_image) + : ""; + + return ( +
+
+ Discord avatar + +
+ +
+ + {gameActivity ? ( + <> + playing: {gameActivity.name.toLowerCase()} + + ) : ( + <> + currently: {STATUS_LABELS[presence.discord_status]} + + )} + + + {customStatusText && ( + + {customStatusText} + + )} +
+ + {gameImage && gameActivity && ( + {gameActivity.name} + )} +
+ ); +} \ No newline at end of file