diff --git a/package.json b/package.json index dbae9a1..3f9b4a5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@threlte/core": "^8.3.0", "@types/three": "^0.181.0", "cors": "^2.8.5", + "discord.js": "^14.25.1", "dotenv": "^17.2.3", "express": "^5.1.0", "hotkeys-js": "^4.0.0-beta.7", diff --git a/src/lib/assets/css/navbar-waybar.css b/src/lib/assets/css/navbar-waybar.css index fe033ab..67ffb7d 100644 --- a/src/lib/assets/css/navbar-waybar.css +++ b/src/lib/assets/css/navbar-waybar.css @@ -110,7 +110,7 @@ /* Workspaces */ .workspaces { - gap: 0.25rem; + gap: 0.15rem; background: transparent; padding: 0; } @@ -118,7 +118,7 @@ .workspace { display: flex; align-items: center; - gap: 0.35rem; + gap: 0.20rem; padding: 0.35rem 0.4rem; color: var(--bar-muted); text-decoration: none; @@ -413,3 +413,396 @@ .mobile-mode-btn:active { transform: scale(0.98); } + +/* Battery styles */ +.module.battery { + background: transparent; +} + +.battery-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + background: var(--bar-bg-module); + border: 1px solid var(--bar-border); + border-radius: 6px; + color: var(--bar-text); + cursor: default; + font-family: inherit; + font-size: 0.8rem; + transition: all 0.12s ease; +} + +.battery-btn:hover { + background: color-mix(in srgb, var(--bar-primary) 12%, transparent); + border-color: var(--bar-primary); +} + +.battery-level { + font-weight: 600; + color: var(--bar-text); +} + +.battery-alert { + color: var(--bar-warning); +} + +/* Low battery visuals */ +.battery-btn.low { + border-color: color-mix(in srgb, var(--bar-warning) 60%, var(--bar-border)); + /* animation: battery-pulse 1.6s infinite ease-in-out; */ + box-shadow: 0 0 0 0 rgba(0,0,0,0); +} + +@keyframes battery-pulse { + 0% { box-shadow: 0 0 0 0 rgba(0,0,0,0); } + 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--bar-warning) 12%, transparent); } + 100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); } +} + +/* Charging visuals (green) - overrides low state */ +.battery-btn.charging { + border-color: color-mix(in srgb, var(--bar-success) 60%, var(--bar-border)); + background: color-mix(in srgb, var(--bar-success) 8%, var(--bar-bg-module)); + animation: none; +} + +.battery-level.charging { + color: var(--bar-success); +} + +/* Discord status module */ +.module.discord-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + background: var(--bar-bg-module); + border: 1px solid var(--bar-border); + border-radius: 6px; + color: var(--bar-text); + font-size: 0.85rem; +} + +.discord-status .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + background: var(--bar-muted); + box-shadow: 0 0 0 2px rgba(0,0,0,0.04) inset; +} + +.discord-status .status-dot.online { background: var(--bar-success); } +.discord-status .status-dot.idle { background: var(--bar-warning); } +.discord-status .status-dot.dnd { background: var(--bar-error); } +.discord-status .status-dot.offline { background: var(--bar-muted); opacity: 0.7; } + +.discord-status .status-text { + color: var(--bar-text); + font-weight: 600; + font-size: 0.85rem; +} + +/* Keep the status on a single line and truncate if too long */ +.module.discord-status { + min-width: 0; +} + +.discord-status .status-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + max-width: 9rem; /* desktop default */ + vertical-align: middle; +} + +@media (max-width: 768px) { + /* tighter max width on mobile to keep everything on one line */ + .discord-status .status-text { + max-width: 6rem; + } + .module.discord-status { + padding: 0.2rem 0.45rem; + } +} + +/* Discord status button */ +.discord-status-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0; + background: transparent; + border: none; + color: inherit; + font-family: inherit; + font-size: inherit; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.discord-status-btn:hover { + opacity: 0.85; +} + +/* Discord Popover */ +.module.discord-status { + position: relative; +} + +.discord-popover { + position: absolute; + top: calc(100% + 0.5rem); + right: -0.5rem; + width: 320px; + background: var(--bar-bg); + border: 1px solid var(--bar-border); + border-radius: 12px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + z-index: 1002; + overflow: hidden; +} + +.popover-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: color-mix(in srgb, var(--bar-primary) 8%, var(--bar-bg)); +} + +.popover-avatar { + position: relative; + flex-shrink: 0; + color: var(--bar-muted); + width: 48px; + height: 48px; +} + +.popover-avatar .avatar-img { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; +} + +.popover-status-dot { + position: absolute; + bottom: 0; + right: 0; + width: 14px; + height: 14px; + border-radius: 50%; + border: 3px solid color-mix(in srgb, var(--bar-primary) 8%, var(--bar-bg)); + background: var(--bar-muted); +} + +.popover-status-dot.online { background: var(--bar-success); } +.popover-status-dot.idle { background: var(--bar-warning); } +.popover-status-dot.dnd { background: var(--bar-error); } +.popover-status-dot.offline { background: var(--bar-muted); opacity: 0.7; } + +.popover-user-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.popover-name-row { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.popover-username { + font-weight: 700; + font-size: 1rem; + color: var(--bar-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.popover-name-row { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.popover-username { + font-weight: 700; + font-size: 1rem; + color: var(--bar-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: calc(100% - 92px); +} + +.popover-clan-tag { + display: inline-flex; + align-items: center; + justify-content: center; + height: 20px; + padding: 0 0.5rem; + background: color-mix(in srgb, var(--bar-primary) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--bar-primary) 30%, transparent); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + color: var(--bar-primary); + text-transform: uppercase; + letter-spacing: 0.02em; + line-height: 1; + flex-shrink: 0; +} + +/* .popover-clan-badge { + width: 36px; + height: 20px; + object-fit: contain; + border-radius: 4px; + flex-shrink: 0; + background: transparent; + border: none; + box-shadow: none; +} */ + +.popover-clan-tag { + display: inline-flex; + align-items: center; + justify-content: center; + height: 22px; /* slightly taller to match larger text */ + padding: 0 0.6rem; + background: color-mix(in srgb, var(--bar-primary) 12%, transparent); + border-radius: 4px; + font-size: 0.9rem; /* a bit larger than the badge */ + font-weight: 700; + color: var(--bar-primary); + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1; + flex-shrink: 0; + margin-left: 0.25rem; +} + +.popover-clan-badge { + width: 15px; /* slightly smaller than tag text */ + height: 15px; + object-fit: contain; + border-radius: 4px; + flex-shrink: 0; + background: transparent; + border: none; + box-shadow: none; + margin-right: 0.35rem; +} + +/* Activities section */ +.popover-activities { + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.activity-card { + background: var(--bar-bg-module); + border-radius: 8px; + padding: 0.75rem; +} + +.activity-type-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--bar-muted); + margin-bottom: 0.5rem; + font-weight: 600; +} + +.activity-content { + display: flex; + gap: 0.75rem; +} + +.activity-images { + position: relative; + flex-shrink: 0; + width: 60px; + height: 60px; +} + +.activity-large-image { + width: 60px; + height: 60px; + border-radius: 8px; + object-fit: cover; +} + +.activity-small-image { + position: absolute; + bottom: -4px; + right: -4px; + width: 24px; + height: 24px; + border-radius: 50%; + border: 3px solid var(--bar-bg-module); + object-fit: cover; +} + +.activity-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + justify-content: center; +} + +.activity-name { + font-weight: 600; + font-size: 0.85rem; + color: var(--bar-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.activity-details { + font-size: 0.75rem; + color: var(--bar-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* No activity state */ +.popover-no-activity { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 1.5rem; + color: var(--bar-muted); + font-size: 0.8rem; +} + +/* Mobile adjustments for popover */ +@media (max-width: 768px) { + .discord-popover { + position: fixed; + top: var(--navbar-height); + right: 0.5rem; + left: 0.5rem; + width: auto; + } +} + diff --git a/src/lib/assets/css/tui-body.css b/src/lib/assets/css/tui-body.css index d0cdce9..b8c3f64 100644 --- a/src/lib/assets/css/tui-body.css +++ b/src/lib/assets/css/tui-body.css @@ -246,3 +246,33 @@ .tui-body::-webkit-scrollbar-thumb:hover { background: var(--terminal-muted); } + +/* Mobile: stack inline groups vertically */ +@media (max-width: 768px) { + .tui-inline-group { + flex-direction: column; + flex-wrap: nowrap; + align-items: stretch; + } + + .tui-inline-group:has(.inline-image) { + flex-wrap: nowrap; + flex-direction: column; + } + + .tui-inline-group .inline-image img { + max-width: 100% !important; + width: 100%; + } + + /* TuiGroup inline on mobile should stack */ + .tui-group.inline { + flex-direction: column; + align-items: stretch; + } + + .tui-group.inline .inline-image img { + max-width: 100% !important; + width: 100%; + } +} diff --git a/src/lib/assets/css/tui-button.css b/src/lib/assets/css/tui-button.css index 071726f..9d6283c 100644 --- a/src/lib/assets/css/tui-button.css +++ b/src/lib/assets/css/tui-button.css @@ -58,3 +58,11 @@ vertical-align: middle; margin: 0 0.15em; } + +/* Mobile: inline buttons become full-width */ +@media (max-width: 768px) { + .tui-button.inline { + width: 100%; + display: flex; + } +} diff --git a/src/lib/assets/css/tui-card-grid.css b/src/lib/assets/css/tui-card-grid.css index 2e780e3..acd9a64 100644 --- a/src/lib/assets/css/tui-card-grid.css +++ b/src/lib/assets/css/tui-card-grid.css @@ -164,7 +164,7 @@ text-transform: lowercase; } -.warning { +.tui-card-grid .warning { font-size: 0.6rem; color: #f9e2af; padding: 0.2rem 0.35rem; diff --git a/src/lib/components/NavbarWaybar.svelte b/src/lib/components/NavbarWaybar.svelte index 21379d7..20ea776 100644 --- a/src/lib/components/NavbarWaybar.svelte +++ b/src/lib/components/NavbarWaybar.svelte @@ -12,6 +12,32 @@ let mobileMenuOpen = $state(false); let currentTime = $state(new Date()); + // Discord presence from layout data + let discordStatus = $state('offline'); + let discordDisplayName: string | null = $state(null); + let discordActivities: any[] = $state([]); + let discordAvatarUrl: string | null = $state(null); + let discordGuildTag: string | null = $state(null); + let guildTagBadgeImage: string | null = $state(null); + let discordPopoverOpen = $state(false); + + $effect(() => { + discordStatus = $page.data?.status ?? 'offline'; + discordDisplayName = $page.data?.displayName ?? null; + discordAvatarUrl = $page.data?.avatarUrl ?? null; + discordGuildTag = $page.data?.guildTag ?? null; + guildTagBadgeImage = $page.data?.guildTagBadgeImage ?? null; + // Filter out Custom Status (type 4) + discordActivities = ($page.data?.activities ?? []).filter((a: any) => a.type !== 4); + }); + + // Derived: first displayable activity (if any) + let currentActivity = $derived(discordActivities.length > 0 ? discordActivities[0] : null); + + // Battery state (computed from EST time) + let batteryLevel = $state(50); + let batteryCharging = $state(false); + // Derived values (Svelte 5 runes) let currentPath = $derived($page.url.pathname); @@ -22,6 +48,51 @@ // Update time every second timeInterval = setInterval(() => { currentTime = new Date(); + + // Compute battery using America/New_York timezone (DST-aware) + function zoneEpochFromMs(ms: number, timeZone: string) { + const date = new Date(ms); + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + const parts = fmt.formatToParts(date).reduce((acc: any, p: any) => { + acc[p.type] = p.value; + return acc; + }, {}); + const iso = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}Z`; + return Date.parse(iso); + } + + function computeBatteryFromMs(ms: number) { + const dischargeMs = 5 * 3600 * 1000; // 5 hours + const chargeMs = 0.5 * 3600 * 1000; // 30 minutes + const cycleMs = dischargeMs + chargeMs; // 5.5 hours + // position in cycle using America/New_York wall-clock epoch (DST-aware) + const estMs = zoneEpochFromMs(ms, 'America/New_York'); + let pos = estMs % cycleMs; + if (pos < 0) pos += cycleMs; + if (pos < dischargeMs) { + // discharging: 100 -> 0 + const pct = 100 - (pos / dischargeMs) * 100; + return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: false }; + } else { + // charging: 0 -> 100 + const chargePos = pos - dischargeMs; + const pct = (chargePos / chargeMs) * 100; + return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: true }; + } + } + + const { level, charging } = computeBatteryFromMs(Date.now()); + batteryLevel = level; + batteryCharging = charging; }, 1000); }); @@ -72,6 +143,22 @@ if (event.key === 'Escape') { themeDropdownOpen = false; mobileMenuOpen = false; + discordPopoverOpen = false; + } + } + + function toggleDiscordPopover() { + discordPopoverOpen = !discordPopoverOpen; + } + + function getActivityTypeLabel(type: number): string { + switch (type) { + case 0: return 'Playing'; + case 1: return 'Streaming'; + case 2: return 'Listening to'; + case 3: return 'Watching'; + case 5: return 'Competing in'; + default: return ''; } } @@ -184,6 +271,123 @@
+ +
+ + + {#if discordPopoverOpen} +
+ +
+
+ {#if discordAvatarUrl} + + {:else} + + {/if} + +
+ + +
+ + +
+ + + {#if discordActivities.length > 0} +
+ {#each discordActivities as activity} +
+
{getActivityTypeLabel(activity.type)}
+
+ {#if activity.assets?.large_image} +
+ + {#if activity.assets?.small_image} + + {/if} +
+ {/if} +
+ {activity.name} + {#if activity.details} + {activity.details} + {/if} +
+
+
+ {/each} +
+ {:else} +
+ + No current activity +
+ {/if} +
+ {/if} +
+ + +
+ +
+
- - {#if themeDropdownOpen} + + {#if themeDropdownOpen || discordPopoverOpen} {/if} @@ -320,10 +524,9 @@ class="mobile-theme-btn" class:active={$colorTheme === option.value} onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }} - title={`Press T+${i+1} to switch to ${option.label}`} > - {i+1}. {option.label} + {option.label} [{i+1}] {/each}
diff --git a/src/lib/components/tui/TuiBody.svelte b/src/lib/components/tui/TuiBody.svelte index ac71631..acb20f7 100644 --- a/src/lib/components/tui/TuiBody.svelte +++ b/src/lib/components/tui/TuiBody.svelte @@ -16,6 +16,7 @@ import TuiRadio from './TuiRadio.svelte'; import TuiSelect from './TuiSelect.svelte'; import TuiToggle from './TuiToggle.svelte'; + import TuiGroup from './TuiGroup.svelte'; import type { DisplayedLine } from './types'; import '$lib/assets/css/tui-body.css'; @@ -85,6 +86,8 @@ {:else if line.type === 'toggle'} + {:else if line.type === 'group'} + {:else if line.type === 'image' && showImage}
{line.imageAlt @@ -146,6 +149,8 @@ {:else if line.type === 'toggle'} + {:else if line.type === 'group'} + {:else}
{#if line.type === 'command' || line.type === 'prompt'} diff --git a/src/lib/components/tui/TuiCardGrid.svelte b/src/lib/components/tui/TuiCardGrid.svelte index 39c3e5a..e6a3fb5 100644 --- a/src/lib/components/tui/TuiCardGrid.svelte +++ b/src/lib/components/tui/TuiCardGrid.svelte @@ -1,5 +1,6 @@ -
+
{#each cards as card}
{#if card.image} @@ -67,7 +68,7 @@ {/if} {#if card.liveWarning} -
⚠ Demo may be unavailable
+
Demo may be unavailable
{/if}
@@ -268,12 +269,25 @@ } */ .warning { - font-size: 0.6rem; - color: #f9e2af; - padding: 0.2rem 0.35rem; - background: rgba(249, 226, 175, 0.1); - border-radius: 3px; + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.75rem; + color: var(--card-warning, #b8860b); + padding: 0.3rem 0.6rem; + background: color-mix(in srgb, var(--card-warning, #b8860b) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--card-warning, #b8860b) 20%, transparent); + border-radius: 6px; width: fit-content; + font-weight: 600; + box-shadow: 0 1px 0 rgba(0,0,0,0.02) inset; + } + + .warning:before { + content: '⚠'; + display: inline-block; + font-size: 0.9rem; + line-height: 1; } .card-actions { diff --git a/src/lib/components/tui/TuiCheckbox.svelte b/src/lib/components/tui/TuiCheckbox.svelte index fe0d004..6ee2a7c 100644 --- a/src/lib/components/tui/TuiCheckbox.svelte +++ b/src/lib/components/tui/TuiCheckbox.svelte @@ -129,4 +129,13 @@ .tui-checkbox.disabled .checkbox-label { color: var(--terminal-muted); } + + /* Mobile: inline checkboxes become full-width */ + @media (max-width: 768px) { + .tui-checkbox.inline { + display: flex; + width: 100%; + margin: 0.35rem 0; + } + } diff --git a/src/lib/components/tui/TuiGroup.svelte b/src/lib/components/tui/TuiGroup.svelte new file mode 100644 index 0000000..101a2ab --- /dev/null +++ b/src/lib/components/tui/TuiGroup.svelte @@ -0,0 +1,266 @@ + + +
+ {#each parsedChildren as parsed, idx} + {@const child = parsed.line} + {@const visibleSegments = parsed.segments} + {@const childInline = child.inline !== false} + + {#if child.type === 'image'} +
+ {child.imageAlt + {#if child.content} + {child.content} + {/if} +
+ {:else if child.type === 'button'} + + {:else if child.type === 'link'} + onLinkClick(idx)} /> + {:else if child.type === 'tooltip'} + + {:else if child.type === 'progress'} + + {:else if child.type === 'input'} + + {:else if child.type === 'checkbox'} + + {:else if child.type === 'toggle'} + + {:else if child.type === 'header'} + + + {#each visibleSegments as segment} + {#if segment.icon} + + {:else if getSegmentStyle(segment)} + {segment.text} + {:else} + {segment.text} + {/if} + {/each} + + {:else if child.type === 'blank'} + + {:else if child.type === 'command' || child.type === 'prompt'} + + + {user.username}@{user.hostname} + :~$ + + + {#each visibleSegments as segment} + {#if segment.icon} + + {:else if getSegmentStyle(segment)} + {segment.text} + {:else} + {segment.text} + {/if} + {/each} + + + {:else} + + {getLinePrefix(child.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each} + + {/if} + {/each} +
+ + diff --git a/src/lib/components/tui/TuiInput.svelte b/src/lib/components/tui/TuiInput.svelte index 6b09521..246a21b 100644 --- a/src/lib/components/tui/TuiInput.svelte +++ b/src/lib/components/tui/TuiInput.svelte @@ -195,4 +195,13 @@ color: #f38ba8; font-size: 0.8rem; } + + /* Mobile: inline inputs become full-width */ + @media (max-width: 768px) { + .tui-input.inline { + display: flex; + width: 100%; + margin: 0.5rem 0; + } + } diff --git a/src/lib/components/tui/TuiProgress.svelte b/src/lib/components/tui/TuiProgress.svelte index f4230c3..73e2f49 100644 --- a/src/lib/components/tui/TuiProgress.svelte +++ b/src/lib/components/tui/TuiProgress.svelte @@ -156,4 +156,19 @@ vertical-align: middle; margin: 0 0.15em; } + + /* Mobile: inline progress becomes full-width */ + @media (max-width: 768px) { + .tui-progress.inline { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + margin: 0.5rem 0; + } + + .tui-progress.inline .progress-bar { + min-width: 100%; + } + } diff --git a/src/lib/components/tui/TuiToggle.svelte b/src/lib/components/tui/TuiToggle.svelte index f7c934c..a3d68bf 100644 --- a/src/lib/components/tui/TuiToggle.svelte +++ b/src/lib/components/tui/TuiToggle.svelte @@ -173,4 +173,13 @@ color: var(--toggle-color); transform: translateX(0.5rem); } + + /* Mobile: inline toggles become full-width */ + @media (max-width: 768px) { + .tui-toggle.inline { + display: flex; + width: 100%; + margin: 0.35rem 0; + } + } diff --git a/src/lib/components/tui/types.ts b/src/lib/components/tui/types.ts index fdde6d7..a9a854e 100644 --- a/src/lib/components/tui/types.ts +++ b/src/lib/components/tui/types.ts @@ -5,7 +5,8 @@ export type LineType = | 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning' | 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link' | 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid' - | 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle'; + | 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle' + | 'group'; // Option type for radio and select components export interface FormOption { @@ -117,6 +118,14 @@ export interface TerminalLine { toggleOnLabel?: string; toggleOffLabel?: string; toggleShowLabels?: boolean; + // For group type - contains child lines rendered together + children?: TerminalLine[]; + // Group layout direction + groupDirection?: 'row' | 'column'; + // Group alignment + groupAlign?: 'start' | 'center' | 'end'; + // Group gap + groupGap?: string; } // Pre-parsed line with segments ready for rendering diff --git a/src/lib/components/tui/utils.ts b/src/lib/components/tui/utils.ts index 3a32851..1e78a75 100644 --- a/src/lib/components/tui/utils.ts +++ b/src/lib/components/tui/utils.ts @@ -188,6 +188,8 @@ export function getLinePrefix(type: string): string { return '✗ '; case 'success': return '✓ '; + case 'warning': + return '⚠ '; case 'info': return '› '; default: diff --git a/src/lib/config/terminal.ts b/src/lib/config/terminal.ts index 9858751..9b7d193 100644 --- a/src/lib/config/terminal.ts +++ b/src/lib/config/terminal.ts @@ -134,7 +134,7 @@ export const pageSpeedSettings: Record = { // 'models': 'instant', // No typing animation // 'hackathons': 0.5, // Custom: 2x faster than normal 'home': 'fast', - 'portfolio': 'fast', + 'portfolio': 'instant', 'models': 'fast', 'projects': 'fast', 'components': 'fast' diff --git a/src/lib/config/user.ts b/src/lib/config/user.ts index 38f809c..ea92bf6 100644 --- a/src/lib/config/user.ts +++ b/src/lib/config/user.ts @@ -10,7 +10,7 @@ export const user = { title: 'Engineering Student', email: 'sirblob0@gmail.com', location: 'USA (EAST)', - bio: `Hi, I am Sir Blob — a engineer who loves making things. ` + + bio: `A engineer who loves making things. ` + `I build fun coding projects, participate in game jams and hackathons, and enjoy games like Minecraft and Pokémon TCG Live. ` + `I'm interested in Open Source, Game Development, Embedded Systems, and AI/ML.`, diff --git a/src/lib/discord/fetchUserImages.ts b/src/lib/discord/fetchUserImages.ts new file mode 100644 index 0000000..e8b1f4d --- /dev/null +++ b/src/lib/discord/fetchUserImages.ts @@ -0,0 +1,146 @@ +export enum ImageSize { + USER_AVATAR = 32, + USER_DECORATION = 64, + SERVER_TAG = 32, + ACTIVITY_LARGE = 128, + ACTIVITY_SMALL = 64, + EMOJI = 32, +} + +export type Activity = { + name?: string; + type?: number; + details?: string | null; + url?: string | null; + application_id?: string | null; + assets?: { large_image?: string | null; small_image?: string | null }; + emoji?: { id?: string; animated?: boolean } | null; +}; + +export type Data = { + discord_user: { + id: string; + avatar?: string | null; + discriminator?: string | null; + primary_guild?: { identity_guild_id?: string; badge?: string } | null; + avatar_decoration_data?: { asset?: string } | null; + }; + activities: Activity[]; + spotify?: { album_art_url?: string } | null; +}; + +export type ProfileSettings = { + optimized?: boolean; + animatedDecoration?: string | boolean; + ignoreAppId?: string[]; + theme?: string | null; +}; + +async function encodeBase64(url: string, _size?: ImageSize, _theme?: string | null) { + try { + const res = await fetch(url); + if (!res.ok) return url; + const buf = await res.arrayBuffer(); + const bytes = new Uint8Array(buf); + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]); + } + const b64 = typeof globalThis.btoa === "function" ? globalThis.btoa(binary) : Buffer.from(binary, "binary").toString("base64"); + // try to infer mime + const ext = url.split("?")[0].split(".").pop()?.toLowerCase() ?? "png"; + const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/png"; + return `data:${mime};base64,${b64}`; + } catch (err) { + return url; + } +} + +export async function fetchUserImages(data: Data, settings: ProfileSettings) { + let avatar: string; + let avatarDecoration: string | null = null; + let clanBadge: string | null = null; + let assetLargeImage: string | null = null; + let assetSmallImage: string | null = null; + let userEmoji: string | null = null; + let albumCover: string | null = null; + + const avatarExtension = + data.discord_user.avatar && data.discord_user.avatar.startsWith("a_") && !settings.optimized + ? "gif" + : "webp"; + + const statusExtension: string = data.activities[0]?.emoji?.animated && !settings.optimized ? "gif" : "webp"; + + const userStatus: Activity | undefined = data.activities[0] && data.activities[0].type === 4 ? data.activities[0] : undefined; + + const activities = data.activities + .filter((activity) => activity.type === 0) + .filter((activity) => !settings.ignoreAppId?.includes(activity.application_id ?? "")); + const activity: Activity | undefined = activities.length > 0 ? activities[0] : undefined; + + if (data.discord_user.avatar) { + avatar = await encodeBase64( + `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}.${avatarExtension}?size=${avatarExtension === "gif" ? "64" : "256"}`, + ImageSize.USER_AVATAR + ); + } else { + avatar = await encodeBase64( + `https://cdn.discordapp.com/embed/avatars/${data.discord_user.discriminator === "0" ? Number(BigInt(data.discord_user.id) >> BigInt(22)) % 6 : Number(data.discord_user.discriminator ?? "0") % 5}.png?size=128`, + ImageSize.USER_AVATAR + ); + } + + if (data.discord_user.primary_guild && data.discord_user.primary_guild.identity_guild_id && data.discord_user.primary_guild.badge) { + clanBadge = await encodeBase64( + `https://cdn.discordapp.com/clan-badges/${data.discord_user.primary_guild.identity_guild_id}/${data.discord_user.primary_guild.badge}.png?size=32`, + ImageSize.SERVER_TAG + ); + } + + if (data.discord_user.avatar_decoration_data?.asset) { + avatarDecoration = await encodeBase64( + `https://cdn.discordapp.com/avatar-decoration-presets/${data.discord_user.avatar_decoration_data.asset}.png?size=64&passthrough=${settings.animatedDecoration || "false"}`, + ImageSize.USER_DECORATION + ); + } + + if (activity?.assets?.large_image) + assetLargeImage = await encodeBase64( + activity.assets?.large_image!.startsWith("mp:external/") + ? `https://media.discordapp.net/${activity.assets!.large_image!.replace("mp:", "")}` + : `https://cdn.discordapp.com/app-assets/${activity.application_id}/${activity.assets!.large_image}.webp`, + ImageSize.ACTIVITY_LARGE, + settings.theme || null + ); + + if (activity?.assets?.small_image) + assetSmallImage = await encodeBase64( + activity.assets.small_image!.startsWith("mp:external/") + ? `https://media.discordapp.net/${activity.assets.small_image!.replace("mp:", "")}` + : `https://cdn.discordapp.com/app-assets/${activity.application_id}/${activity.assets.small_image}.webp`, + ImageSize.ACTIVITY_SMALL, + settings.theme || null + ); + + if (userStatus?.emoji?.id) + userEmoji = await encodeBase64( + `https://cdn.discordapp.com/emojis/${userStatus.emoji.id}.${statusExtension}?size=32`, + ImageSize.EMOJI + ); + + if (data.spotify?.album_art_url) albumCover = await encodeBase64(data.spotify.album_art_url, ImageSize.ACTIVITY_LARGE); + + return { + avatar, + clanBadge, + avatarDecoration, + assetLargeImage, + assetSmallImage, + userEmoji, + albumCover, + }; +} + +export default fetchUserImages; diff --git a/src/lib/discordbot.ts b/src/lib/discordbot.ts new file mode 100644 index 0000000..fc571ed --- /dev/null +++ b/src/lib/discordbot.ts @@ -0,0 +1,106 @@ +import { Client, Partials, GatewayIntentBits, ActivityType } from "discord.js"; + +import { DISCORD_TOKEN, DISCORD_USER_ID, DISCORD_GUILD_ID } from "$env/static/private"; + +class Bot { + private client: Client; + + // Normalize a discord.js Activity object into a simple POJO + private mapActivity(a: any) { + const type = typeof a.type === 'number' ? a.type : (ActivityType[a.type] ?? a.type); + const application_id = a.applicationId ?? a.application_id ?? null; + + const rawLarge = a.assets?.largeImage ?? a.assets?.large_image ?? null; + const rawSmall = a.assets?.smallImage ?? a.assets?.small_image ?? null; + + const formatAsset = (raw: string | null) => { + if (!raw) return null; + if (raw.startsWith('mp:external/')) { + return `https://media.discordapp.net/${raw.replace(/^mp:/, '')}`; + } + if (/^https?:\/\//.test(raw)) return raw; + if (application_id) return `https://cdn.discordapp.com/app-assets/${application_id}/${raw}.webp`; + return raw; + }; + + return { + name: a.name ?? null, + type, + details: a.details ?? a.state ?? null, + url: a.url ?? null, + application_id, + assets: { + large_image: formatAsset(rawLarge), + small_image: formatAsset(rawSmall) + }, + emoji: a.emoji ? { id: a.emoji.id ?? null, animated: !!a.emoji.animated } : null + }; + } + + constructor() { + // Need presence and members intents to read user presence/activity + this.client = new Client({ + partials: [Partials.User], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildPresences + ] + }); + + this.client.login(DISCORD_TOKEN).catch((error) => { + console.error("Failed to login to Discord:", error); + }); + + this.client.once("clientReady", async () => { + console.log(`Logged in as ${this.client.user?.tag}`); + }); + } + + getClient(): Client { + return this.client; + } + + /** + * Look up the user's presence specifically in the given guild. + * Returns null if guild/member/presence not available. + */ + async getUserActivityInGuild(guildId: string = DISCORD_GUILD_ID, userId: string = DISCORD_USER_ID) { + try { + // Fetch guild from cache or API + let guild = this.client.guilds.cache.get(guildId) || await this.client.guilds.fetch(guildId).catch(() => null); + if (!guild) return null; + + // Fetch member (will populate presence if available) + const member = await guild.members.fetch(userId).catch(() => null); + if (!member) return null; + + const presence = member.presence; + const user = member.user; + + // Build avatar URL + const avatarHash = user.avatar; + const avatarUrl = avatarHash + ? `https://cdn.discordapp.com/avatars/${user.id}/${avatarHash}.${avatarHash.startsWith('a_') ? 'gif' : 'webp'}?size=128` + : `https://cdn.discordapp.com/embed/avatars/${(BigInt(user.id) >> BigInt(22)) % BigInt(6)}.png`; + + // Get guild/clan tag if available + const guildTag = (user as any).primaryGuild?.tag ?? null; + const guildTagBadgeImage = `https://cdn.discordapp.com/clan-badges/${(user as any).primaryGuild?.identityGuildId}/${(user as any).primaryGuild?.badge}.png?size=32`; + + if (!presence) return { guildId, status: 'offline', activities: [], displayName: member.displayName, avatarUrl, guildTag, guildTagBadgeImage }; + + const status = presence.status; + const activities = (presence.activities || []).map((a: any) => this.mapActivity(a)); + + return { guildId, status, activities, displayName: member.displayName, avatarUrl, guildTag, guildTagBadgeImage }; + } catch (err) { + return null; + } + } +} + +export const bot = new Bot(); + +// Re-export image helper for convenience +export { fetchUserImages } from "./discord/fetchUserImages"; \ No newline at end of file diff --git a/src/lib/pages/home.ts b/src/lib/pages/home.ts index 6834cfd..3d36a2e 100644 --- a/src/lib/pages/home.ts +++ b/src/lib/pages/home.ts @@ -13,32 +13,26 @@ export const lines: TerminalLine[] = [ { type: 'output', content: '(&dim,red)███(&)(&dim,orange)███(&)(&dim,yellow)███(&)(&dim,green)███(&)(&dim,cyan)███(&)(&dim,blue)███(&)(&dim,magenta)███(&)(&dim,pink)███(&)', inline: true }, { type: 'blank', content: '' }, - { type: 'header', content: `Welcome to ${user.displayname}'s Portfolio` }, + { type: 'header', content: `HI, I'm ${user.displayname}` }, { type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 120, inline: true }, { type: 'output', content: `(&muted)${user.bio}(&)`, inline: true }, { type: 'divider', content: 'NAVIGATION' }, // Interactive navigation buttons - ...navigation.map(nav => ({ + ...navigation.map((nav, i) => ({ type: 'button' as const, - content: nav.name, + content: nav.name + ` (&text, bold)[${i+1}](&)`, icon: nav.icon === '📁' ? 'mdi:folder' : nav.icon === '🎨' ? 'mdi:palette' : 'mdi:trophy', style: 'primary' as const, href: nav.path, inline: true })), + { type: 'divider', content: 'Website Keybinds' }, { type: 'output', content: '(&orange)Toggle Light/Dark Mode(&) (&text, bold)(Alt/Option+B)(&)' , inline: true }, - { type: 'output', content: '(&muted)•(&)' , inline: true }, - { type: 'output', content: '(&orange)Select Theme(&) (&text, bold)(Alt/Option+T + [#])(&)' , inline: true }, - { type: 'output', content: '(&muted)•(&)' , inline: true }, - { type: 'output', content: '(&orange)Skip typing animation(&) (&text, bold)(Y)(&)' , inline: true }, - { type: 'output', content: '(&muted)•(&)' , inline: true }, - { type: 'output', content: '(&orange)Navigate to page(&) (&text, bold)(Ctrl/Cmd+ [#])(&)' , inline: true }, - { type: 'blank', content: '' }, - // list navigation with numeric shortcuts - { type: 'output', content: '(&text, bold)Pages -(&)', inline: true }, - ...navigation.map((nav, i) => ({ type: 'output' as const, content: `(&blue)${nav.name}(&) (&text, bold)[${i+1}](&)`, inline: true })), + { type: 'output', content: '(&muted)•(&) (&orange)Select Theme(&) (&text, bold)(Alt/Option+T + [#])(&)' , inline: true }, + { type: 'output', content: '(&muted)•(&) (&orange)Skip typing animation(&) (&text, bold)(Y)(&)' , inline: true }, + { type: 'output', content: '(&muted)•(&) (&orange)Navigate to page(&) (&text, bold)(Ctrl/Cmd+ [#])(&)' , inline: true }, { type: 'blank', content: '' }, ]; \ No newline at end of file diff --git a/src/lib/pages/portfolio.ts b/src/lib/pages/portfolio.ts index 4ff3402..d21b260 100644 --- a/src/lib/pages/portfolio.ts +++ b/src/lib/pages/portfolio.ts @@ -6,26 +6,35 @@ export const lines: TerminalLine[] = [ // Header command { type: 'command', content: 'cat ~/about.md' }, { type: 'blank', content: '' }, - + // Avatar image - { type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 150 }, - { type: 'blank', content: '' }, + { type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 180, inline: true }, + + { + type: 'group', + content: '', + groupDirection: 'column', + groupAlign: 'start', + inline: true, + children: [ + // User info + { type: 'header', content: `(&primary,bold)${user.name}(&)` }, + { type: 'info', content: `(&accent)${user.title}(&)` }, + { type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` }, + { type: 'output', content: `(&muted)${user.bio}(&)` }, + ] + }, - // User info - { type: 'header', content: `(&primary,bold)${user.name}(&)` }, - { type: 'info', content: `(&accent)${user.title}(&)` }, - { type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` }, - { type: 'output', content: `(&muted)${user.bio}(&)` }, { type: 'blank', content: '' }, - { type: 'output', content: `(&primary, bold)Links >(&)`, inline: true }, - { type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true }, - { type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, inline: true }, - { type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true }, - { type: 'blank', content: '' }, - + { type: 'group', content: '', groupAlign: 'start', groupGap: '1rem', + children: [ + { type: 'output', content: `(&primary, bold)Links >(&)`, inline: true }, + { type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true }, + { type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, inline: true }, + { type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true }, + ] + }, { type: 'divider', content: 'CONTACT', id: 'contact' }, - { type: 'blank', content: '' }, - // Contact buttons - dynamically generated from socials array ...user.socials.map(social => ({ type: 'button' as const, @@ -45,7 +54,6 @@ export const lines: TerminalLine[] = [ { type: 'blank', content: '' }, { type: 'divider', content: 'SKILLS', id: 'skills' }, - { type: 'blank', content: '' }, // Skills as TUI sections @@ -81,9 +89,9 @@ export const lines: TerminalLine[] = [ // Interests { type: 'info', content: '(&accent,bold)▸ Interests(&)' }, - { type: 'output', content: ' ' + skills.interests.map(s => `(&muted)${s}(&)`).join(' (&muted)•(&) ') }, - + { type: 'output', content: ' ' + skills.interests.map(s => `(&muted)${s}(&)`).join(' (&muted)•(&) ') }, { type: 'blank', content: '' }, + { type: 'divider', content: 'PROJECTS', id: 'projects' }, { type: 'blank', content: '' }, diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..add3c31 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,16 @@ +import type { LayoutServerLoad } from './$types'; + +import { bot } from '$lib/discordbot'; + +export const load: LayoutServerLoad = async () => { + const info = await bot.getUserActivityInGuild().catch(() => null); + + return { + status: info?.status ?? 'offline', + activities: info?.activities ?? [], + displayName: info?.displayName ?? null, + avatarUrl: info?.avatarUrl ?? null, + guildTag: info?.guildTag ?? null, + guildTagBadgeImage: info?.guildTagBadgeImage ?? null + }; +}; \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 66a63c9..359c1d0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -20,7 +20,7 @@ diff --git a/src/routes/models/+page.svelte b/src/routes/models/+page.svelte index 2bb7fd3..9851901 100644 --- a/src/routes/models/+page.svelte +++ b/src/routes/models/+page.svelte @@ -44,7 +44,6 @@ { type: 'blank', content: '' }, { type: 'divider', content: 'VIEWER' }, - { type: 'blank', content: '' }, // Interactive model buttons ...glbModels.map(model => ({