Website Status
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"@threlte/core": "^8.3.0",
|
"@threlte/core": "^8.3.0",
|
||||||
"@types/three": "^0.181.0",
|
"@types/three": "^0.181.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"hotkeys-js": "^4.0.0-beta.7",
|
"hotkeys-js": "^4.0.0-beta.7",
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
/* Workspaces */
|
/* Workspaces */
|
||||||
.workspaces {
|
.workspaces {
|
||||||
gap: 0.25rem;
|
gap: 0.15rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
.workspace {
|
.workspace {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.20rem;
|
||||||
padding: 0.35rem 0.4rem;
|
padding: 0.35rem 0.4rem;
|
||||||
color: var(--bar-muted);
|
color: var(--bar-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -413,3 +413,396 @@
|
|||||||
.mobile-mode-btn:active {
|
.mobile-mode-btn:active {
|
||||||
transform: scale(0.98);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,3 +246,33 @@
|
|||||||
.tui-body::-webkit-scrollbar-thumb:hover {
|
.tui-body::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--terminal-muted);
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,3 +58,11 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin: 0 0.15em;
|
margin: 0 0.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile: inline buttons become full-width */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tui-button.inline {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.tui-card-grid .warning {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
color: #f9e2af;
|
color: #f9e2af;
|
||||||
padding: 0.2rem 0.35rem;
|
padding: 0.2rem 0.35rem;
|
||||||
|
|||||||
@@ -12,6 +12,32 @@
|
|||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
let currentTime = $state(new Date());
|
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)
|
// Derived values (Svelte 5 runes)
|
||||||
let currentPath = $derived($page.url.pathname);
|
let currentPath = $derived($page.url.pathname);
|
||||||
|
|
||||||
@@ -22,6 +48,51 @@
|
|||||||
// Update time every second
|
// Update time every second
|
||||||
timeInterval = setInterval(() => {
|
timeInterval = setInterval(() => {
|
||||||
currentTime = new Date();
|
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);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +143,22 @@
|
|||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
themeDropdownOpen = false;
|
themeDropdownOpen = false;
|
||||||
mobileMenuOpen = 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 @@
|
|||||||
<div class="bar-right">
|
<div class="bar-right">
|
||||||
<!-- (No system modules — minimal Waybar look) -->
|
<!-- (No system modules — minimal Waybar look) -->
|
||||||
|
|
||||||
|
<!-- Discord status with popover -->
|
||||||
|
<div class="module discord-status">
|
||||||
|
<button
|
||||||
|
class="discord-status-btn"
|
||||||
|
onclick={toggleDiscordPopover}
|
||||||
|
title={discordDisplayName ? `${discordDisplayName} — ${discordStatus}` : `Discord: ${discordStatus}`}
|
||||||
|
aria-expanded={discordPopoverOpen}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="status-dot"
|
||||||
|
class:online={discordStatus === 'online'}
|
||||||
|
class:idle={discordStatus === 'idle'}
|
||||||
|
class:dnd={discordStatus === 'dnd'}
|
||||||
|
class:offline={discordStatus === 'offline'}
|
||||||
|
></span>
|
||||||
|
{#if discordDisplayName}
|
||||||
|
<span class="status-text">{discordDisplayName}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if discordPopoverOpen}
|
||||||
|
<div
|
||||||
|
class="discord-popover"
|
||||||
|
transition:fly={{ y: -10, duration: 150 }}
|
||||||
|
>
|
||||||
|
<!-- Header with avatar and name -->
|
||||||
|
<div class="popover-header">
|
||||||
|
<div class="popover-avatar">
|
||||||
|
{#if discordAvatarUrl}
|
||||||
|
<img src={discordAvatarUrl} alt="" class="avatar-img" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon="mdi:account-circle" width="48" />
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="popover-status-dot"
|
||||||
|
class:online={discordStatus === 'online'}
|
||||||
|
class:idle={discordStatus === 'idle'}
|
||||||
|
class:dnd={discordStatus === 'dnd'}
|
||||||
|
class:offline={discordStatus === 'offline'}
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="popover-user-info">
|
||||||
|
<div class="popover-name-row">
|
||||||
|
<span class="popover-username">{discordDisplayName ?? 'Unknown'}</span>
|
||||||
|
{#if discordGuildTag}
|
||||||
|
<span class="popover-clan-tag">
|
||||||
|
<img src={guildTagBadgeImage} alt={discordGuildTag ?? 'Guild'} class="popover-clan-badge" />
|
||||||
|
{discordGuildTag}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="popover-status-label">{discordStatus}</span>
|
||||||
|
</div>
|
||||||
|
<button class="popover-close" onclick={() => discordPopoverOpen = false} aria-label="Close">
|
||||||
|
<Icon icon="mdi:close" width="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="popover-divider"></div>
|
||||||
|
|
||||||
|
<!-- Activities -->
|
||||||
|
{#if discordActivities.length > 0}
|
||||||
|
<div class="popover-activities">
|
||||||
|
{#each discordActivities as activity}
|
||||||
|
<div class="activity-card">
|
||||||
|
<div class="activity-type-label">{getActivityTypeLabel(activity.type)}</div>
|
||||||
|
<div class="activity-content">
|
||||||
|
{#if activity.assets?.large_image}
|
||||||
|
<div class="activity-images">
|
||||||
|
<img
|
||||||
|
src={activity.assets.large_image}
|
||||||
|
alt=""
|
||||||
|
class="activity-large-image"
|
||||||
|
/>
|
||||||
|
{#if activity.assets?.small_image}
|
||||||
|
<img
|
||||||
|
src={activity.assets.small_image}
|
||||||
|
alt=""
|
||||||
|
class="activity-small-image"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="activity-info">
|
||||||
|
<span class="activity-name">{activity.name}</span>
|
||||||
|
{#if activity.details}
|
||||||
|
<span class="activity-details">{activity.details}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="popover-no-activity">
|
||||||
|
<Icon icon="mdi:sleep" width="24" />
|
||||||
|
<span>No current activity</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Battery module (EST-based simulated battery cycle) -->
|
||||||
|
<div class="module battery" title="Battery">
|
||||||
|
<button
|
||||||
|
class="battery-btn"
|
||||||
|
aria-label={`Battery ${batteryLevel}% ${batteryCharging ? 'charging' : 'discharging'}`}
|
||||||
|
class:low={batteryLevel < 20 && !batteryCharging}
|
||||||
|
class:charging={batteryCharging}
|
||||||
|
>
|
||||||
|
<Icon icon={getBatteryIcon(batteryLevel, batteryCharging)} width="16" />
|
||||||
|
<span class="battery-level" class:battery-alert={batteryLevel < 20 && !batteryCharging} class:charging={batteryCharging}>{batteryLevel}%</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Theme selector -->
|
<!-- Theme selector -->
|
||||||
<div class="module theme-selector">
|
<div class="module theme-selector">
|
||||||
<button
|
<button
|
||||||
@@ -227,12 +431,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backdrop for theme dropdown only -->
|
<!-- Backdrop for dropdowns -->
|
||||||
{#if themeDropdownOpen}
|
{#if themeDropdownOpen || discordPopoverOpen}
|
||||||
<button
|
<button
|
||||||
class="backdrop"
|
class="backdrop"
|
||||||
transition:fade={{ duration: 100 }}
|
transition:fade={{ duration: 100 }}
|
||||||
onclick={() => { themeDropdownOpen = false; }}
|
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -320,10 +524,9 @@
|
|||||||
class="mobile-theme-btn"
|
class="mobile-theme-btn"
|
||||||
class:active={$colorTheme === option.value}
|
class:active={$colorTheme === option.value}
|
||||||
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
|
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
|
||||||
title={`Press T+${i+1} to switch to ${option.label}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon={getThemeIcon(option.value)} width="18" />
|
<Icon icon={getThemeIcon(option.value)} width="18" />
|
||||||
<span class="theme-label"><span class="theme-number">{i+1}.</span> {option.label}</span>
|
<span class="theme-label">{option.label} <span class="theme-number">[{i+1}]</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import TuiRadio from './TuiRadio.svelte';
|
import TuiRadio from './TuiRadio.svelte';
|
||||||
import TuiSelect from './TuiSelect.svelte';
|
import TuiSelect from './TuiSelect.svelte';
|
||||||
import TuiToggle from './TuiToggle.svelte';
|
import TuiToggle from './TuiToggle.svelte';
|
||||||
|
import TuiGroup from './TuiGroup.svelte';
|
||||||
import type { DisplayedLine } from './types';
|
import type { DisplayedLine } from './types';
|
||||||
import '$lib/assets/css/tui-body.css';
|
import '$lib/assets/css/tui-body.css';
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@
|
|||||||
<TuiCheckbox {line} inline={true} />
|
<TuiCheckbox {line} inline={true} />
|
||||||
{:else if line.type === 'toggle'}
|
{:else if line.type === 'toggle'}
|
||||||
<TuiToggle {line} inline={true} />
|
<TuiToggle {line} inline={true} />
|
||||||
|
{:else if line.type === 'group'}
|
||||||
|
<TuiGroup {line} inline={true} {onButtonClick} {onHoverButton} {onLinkClick} />
|
||||||
{:else if line.type === 'image' && showImage}
|
{:else if line.type === 'image' && showImage}
|
||||||
<div class="tui-image inline-image">
|
<div class="tui-image inline-image">
|
||||||
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
|
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
|
||||||
@@ -146,6 +149,8 @@
|
|||||||
<TuiSelect {line} />
|
<TuiSelect {line} />
|
||||||
{:else if line.type === 'toggle'}
|
{:else if line.type === 'toggle'}
|
||||||
<TuiToggle {line} />
|
<TuiToggle {line} />
|
||||||
|
{:else if line.type === 'group'}
|
||||||
|
<TuiGroup {line} {onButtonClick} {onHoverButton} {onLinkClick} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="tui-line {line.type}" class:complete id={line.id}>
|
<div class="tui-line {line.type}" class:complete id={line.id}>
|
||||||
{#if line.type === 'command' || line.type === 'prompt'}
|
{#if line.type === 'command' || line.type === 'prompt'}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
|
import { themeColors } from '$lib/stores/theme';
|
||||||
import type { TerminalLine } from './types';
|
import type { TerminalLine } from './types';
|
||||||
import '$lib/assets/css/tui-card-grid.css';
|
import '$lib/assets/css/tui-card-grid.css';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
$: cards = line.cards || [];
|
$: cards = line.cards || [];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tui-card-grid">
|
<div class="tui-card-grid" style="--card-warning: {$themeColors.colorMap.warning};">
|
||||||
{#each cards as card}
|
{#each cards as card}
|
||||||
<article class="tui-card" class:featured={card.featured}>
|
<article class="tui-card" class:featured={card.featured}>
|
||||||
{#if card.image}
|
{#if card.image}
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if card.liveWarning}
|
{#if card.liveWarning}
|
||||||
<div class="warning">⚠ Demo may be unavailable</div>
|
<div class="warning">Demo may be unavailable</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
@@ -268,12 +269,25 @@
|
|||||||
} */
|
} */
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
font-size: 0.6rem;
|
display: inline-flex;
|
||||||
color: #f9e2af;
|
align-items: center;
|
||||||
padding: 0.2rem 0.35rem;
|
gap: 0.45rem;
|
||||||
background: rgba(249, 226, 175, 0.1);
|
font-size: 0.75rem;
|
||||||
border-radius: 3px;
|
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;
|
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 {
|
.card-actions {
|
||||||
|
|||||||
@@ -129,4 +129,13 @@
|
|||||||
.tui-checkbox.disabled .checkbox-label {
|
.tui-checkbox.disabled .checkbox-label {
|
||||||
color: var(--terminal-muted);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
266
src/lib/components/tui/TuiGroup.svelte
Normal file
266
src/lib/components/tui/TuiGroup.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { getSegmentStyle, getLinePrefix, getSegmentsUpToChar, parseColorText, getPlainText } from './utils';
|
||||||
|
import { user } from '$lib/config';
|
||||||
|
import { themeColors } from '$lib/stores/theme';
|
||||||
|
import TuiButton from './TuiButton.svelte';
|
||||||
|
import TuiLink from './TuiLink.svelte';
|
||||||
|
import TuiProgress from './TuiProgress.svelte';
|
||||||
|
import TuiTooltip from './TuiTooltip.svelte';
|
||||||
|
import TuiInput from './TuiInput.svelte';
|
||||||
|
import TuiCheckbox from './TuiCheckbox.svelte';
|
||||||
|
import TuiToggle from './TuiToggle.svelte';
|
||||||
|
import type { TerminalLine } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
line: TerminalLine;
|
||||||
|
inline?: boolean;
|
||||||
|
onButtonClick?: (idx: number) => void;
|
||||||
|
onHoverButton?: (idx: number) => void;
|
||||||
|
onLinkClick?: (idx: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
line,
|
||||||
|
inline = false,
|
||||||
|
onButtonClick = () => {},
|
||||||
|
onHoverButton = () => {},
|
||||||
|
onLinkClick = () => {}
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Get colorMap from current theme
|
||||||
|
const colorMap = $derived($themeColors.colorMap);
|
||||||
|
|
||||||
|
// Parse children with current colorMap
|
||||||
|
const parsedChildren = $derived(
|
||||||
|
(line.children || []).map(child => {
|
||||||
|
const segments = parseColorText(child.content, colorMap);
|
||||||
|
return {
|
||||||
|
line: child,
|
||||||
|
segments,
|
||||||
|
plainText: getPlainText(segments)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Style for the group container
|
||||||
|
const groupStyle = $derived(() => {
|
||||||
|
const styles: string[] = [];
|
||||||
|
if (line.groupDirection === 'column') {
|
||||||
|
styles.push('flex-direction: column');
|
||||||
|
}
|
||||||
|
if (line.groupAlign) {
|
||||||
|
const alignMap = { start: 'flex-start', center: 'center', end: 'flex-end' };
|
||||||
|
styles.push(`align-items: ${alignMap[line.groupAlign] || 'flex-start'}`);
|
||||||
|
}
|
||||||
|
if (line.groupGap) {
|
||||||
|
styles.push(`gap: ${line.groupGap}`);
|
||||||
|
}
|
||||||
|
return styles.join('; ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="tui-group"
|
||||||
|
class:inline
|
||||||
|
style={groupStyle()}
|
||||||
|
id={line.id}
|
||||||
|
>
|
||||||
|
{#each parsedChildren as parsed, idx}
|
||||||
|
{@const child = parsed.line}
|
||||||
|
{@const visibleSegments = parsed.segments}
|
||||||
|
{@const childInline = child.inline !== false}
|
||||||
|
|
||||||
|
{#if child.type === 'image'}
|
||||||
|
<div class="tui-image" class:inline-image={childInline}>
|
||||||
|
<img
|
||||||
|
src={child.image}
|
||||||
|
alt={child.imageAlt || 'Image'}
|
||||||
|
style="max-width: {child.imageWidth || 300}px"
|
||||||
|
/>
|
||||||
|
{#if child.content}
|
||||||
|
<span class="image-caption">{child.content}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if child.type === 'button'}
|
||||||
|
<TuiButton line={child} index={idx} selected={false} onClick={onButtonClick} onHover={onHoverButton} inline={childInline} />
|
||||||
|
{:else if child.type === 'link'}
|
||||||
|
<TuiLink line={child} onClick={() => onLinkClick(idx)} />
|
||||||
|
{:else if child.type === 'tooltip'}
|
||||||
|
<TuiTooltip line={child} />
|
||||||
|
{:else if child.type === 'progress'}
|
||||||
|
<TuiProgress line={child} inline={childInline} />
|
||||||
|
{:else if child.type === 'input'}
|
||||||
|
<TuiInput line={child} inline={childInline} />
|
||||||
|
{:else if child.type === 'checkbox'}
|
||||||
|
<TuiCheckbox line={child} inline={childInline} />
|
||||||
|
{:else if child.type === 'toggle'}
|
||||||
|
<TuiToggle line={child} inline={childInline} />
|
||||||
|
{:else if child.type === 'header'}
|
||||||
|
<span class="content header-text">
|
||||||
|
<Icon icon="mdi:pound" width="14" class="header-icon" />
|
||||||
|
{#each visibleSegments as segment}
|
||||||
|
{#if segment.icon}
|
||||||
|
<Icon icon={segment.icon} width="16" class="inline-icon" />
|
||||||
|
{:else if getSegmentStyle(segment)}
|
||||||
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
|
{:else}
|
||||||
|
{segment.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{:else if child.type === 'blank'}
|
||||||
|
<!-- Empty for blank -->
|
||||||
|
{:else if child.type === 'command' || child.type === 'prompt'}
|
||||||
|
<span class="group-line {child.type}">
|
||||||
|
<span class="prompt">
|
||||||
|
<span class="user">{user.username}</span><span class="at">@</span><span class="host">{user.hostname}</span>
|
||||||
|
<span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
|
||||||
|
</span>
|
||||||
|
<span class="content">
|
||||||
|
{#each visibleSegments as segment}
|
||||||
|
{#if segment.icon}
|
||||||
|
<Icon icon={segment.icon} width="14" class="inline-icon" />
|
||||||
|
{:else if getSegmentStyle(segment)}
|
||||||
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
|
{:else}
|
||||||
|
{segment.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="group-line {child.type}">
|
||||||
|
{getLinePrefix(child.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tui-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
animation: lineSlideIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-group.inline {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line.output {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line.info {
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line.success {
|
||||||
|
color: #a6e3a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line.error {
|
||||||
|
color: #f38ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line.warning {
|
||||||
|
color: #f9e2af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-line.command,
|
||||||
|
.group-line.prompt {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt .user {
|
||||||
|
color: var(--terminal-user);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt .at {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt .host {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt .separator {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt .path {
|
||||||
|
color: var(--terminal-path);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt .symbol {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-image img {
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lineSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: inline groups become vertical stacked */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tui-group.inline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-group.inline .tui-image img {
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -195,4 +195,13 @@
|
|||||||
color: #f38ba8;
|
color: #f38ba8;
|
||||||
font-size: 0.8rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -156,4 +156,19 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin: 0 0.15em;
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -173,4 +173,13 @@
|
|||||||
color: var(--toggle-color);
|
color: var(--toggle-color);
|
||||||
transform: translateX(0.5rem);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export type LineType =
|
|||||||
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
||||||
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
||||||
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid'
|
| '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
|
// Option type for radio and select components
|
||||||
export interface FormOption {
|
export interface FormOption {
|
||||||
@@ -117,6 +118,14 @@ export interface TerminalLine {
|
|||||||
toggleOnLabel?: string;
|
toggleOnLabel?: string;
|
||||||
toggleOffLabel?: string;
|
toggleOffLabel?: string;
|
||||||
toggleShowLabels?: boolean;
|
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
|
// Pre-parsed line with segments ready for rendering
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export function getLinePrefix(type: string): string {
|
|||||||
return '✗ ';
|
return '✗ ';
|
||||||
case 'success':
|
case 'success':
|
||||||
return '✓ ';
|
return '✓ ';
|
||||||
|
case 'warning':
|
||||||
|
return '⚠ ';
|
||||||
case 'info':
|
case 'info':
|
||||||
return '› ';
|
return '› ';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
|
|||||||
// 'models': 'instant', // No typing animation
|
// 'models': 'instant', // No typing animation
|
||||||
// 'hackathons': 0.5, // Custom: 2x faster than normal
|
// 'hackathons': 0.5, // Custom: 2x faster than normal
|
||||||
'home': 'fast',
|
'home': 'fast',
|
||||||
'portfolio': 'fast',
|
'portfolio': 'instant',
|
||||||
'models': 'fast',
|
'models': 'fast',
|
||||||
'projects': 'fast',
|
'projects': 'fast',
|
||||||
'components': 'fast'
|
'components': 'fast'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const user = {
|
|||||||
title: 'Engineering Student',
|
title: 'Engineering Student',
|
||||||
email: 'sirblob0@gmail.com',
|
email: 'sirblob0@gmail.com',
|
||||||
location: 'USA (EAST)',
|
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 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.`,
|
`I'm interested in Open Source, Game Development, Embedded Systems, and AI/ML.`,
|
||||||
|
|
||||||
|
|||||||
146
src/lib/discord/fetchUserImages.ts
Normal file
146
src/lib/discord/fetchUserImages.ts
Normal file
@@ -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;
|
||||||
106
src/lib/discordbot.ts
Normal file
106
src/lib/discordbot.ts
Normal file
@@ -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";
|
||||||
@@ -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: 'output', content: '(&dim,red)███(&)(&dim,orange)███(&)(&dim,yellow)███(&)(&dim,green)███(&)(&dim,cyan)███(&)(&dim,blue)███(&)(&dim,magenta)███(&)(&dim,pink)███(&)', inline: true },
|
||||||
|
|
||||||
{ type: 'blank', content: '' },
|
{ 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: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 120, inline: true },
|
||||||
{ type: 'output', content: `(&muted)${user.bio}(&)`, inline: true },
|
{ type: 'output', content: `(&muted)${user.bio}(&)`, inline: true },
|
||||||
|
|
||||||
{ type: 'divider', content: 'NAVIGATION' },
|
{ type: 'divider', content: 'NAVIGATION' },
|
||||||
|
|
||||||
// Interactive navigation buttons
|
// Interactive navigation buttons
|
||||||
...navigation.map(nav => ({
|
...navigation.map((nav, i) => ({
|
||||||
type: 'button' as const,
|
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',
|
icon: nav.icon === '📁' ? 'mdi:folder' : nav.icon === '🎨' ? 'mdi:palette' : 'mdi:trophy',
|
||||||
style: 'primary' as const,
|
style: 'primary' as const,
|
||||||
href: nav.path,
|
href: nav.path,
|
||||||
inline: true
|
inline: true
|
||||||
})),
|
})),
|
||||||
|
|
||||||
{ type: 'divider', content: 'Website Keybinds' },
|
{ type: 'divider', content: 'Website Keybinds' },
|
||||||
{ type: 'output', content: '(&orange)Toggle Light/Dark Mode(&) (&text, bold)(Alt/Option+B)(&)' , inline: true },
|
{ type: 'output', content: '(&orange)Toggle Light/Dark Mode(&) (&text, bold)(Alt/Option+B)(&)' , inline: true },
|
||||||
{ type: 'output', content: '(&muted)•(&)' , inline: true },
|
{ type: 'output', content: '(&muted)•(&) (&orange)Select Theme(&) (&text, bold)(Alt/Option+T + [#])(&)' , inline: true },
|
||||||
{ type: 'output', content: '(&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)•(&)' , inline: true },
|
{ type: 'output', content: '(&muted)•(&) (&orange)Navigate to page(&) (&text, bold)(Ctrl/Cmd+ [#])(&)' , 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: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
];
|
];
|
||||||
@@ -8,24 +8,33 @@ export const lines: TerminalLine[] = [
|
|||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
// Avatar image
|
// Avatar image
|
||||||
{ type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 150 },
|
{ type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 180, inline: true },
|
||||||
{ type: 'blank', content: '' },
|
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'column',
|
||||||
|
groupAlign: 'start',
|
||||||
|
inline: true,
|
||||||
|
children: [
|
||||||
// User info
|
// User info
|
||||||
{ type: 'header', content: `(&primary,bold)${user.name}(&)` },
|
{ type: 'header', content: `(&primary,bold)${user.name}(&)` },
|
||||||
{ type: 'info', content: `(&accent)${user.title}(&)` },
|
{ type: 'info', content: `(&accent)${user.title}(&)` },
|
||||||
{ type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` },
|
{ type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` },
|
||||||
{ type: 'output', content: `(&muted)${user.bio}(&)` },
|
{ type: 'output', content: `(&muted)${user.bio}(&)` },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
{ type: 'group', content: '', groupAlign: 'start', groupGap: '1rem',
|
||||||
|
children: [
|
||||||
{ type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
|
{ type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
|
||||||
{ type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, 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#skills", content: `(&bg-orange,black)Skills(&)`, inline: true },
|
||||||
{ type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
|
{ type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
|
||||||
{ type: 'blank', content: '' },
|
]
|
||||||
|
},
|
||||||
{ type: 'divider', content: 'CONTACT', id: 'contact' },
|
{ type: 'divider', content: 'CONTACT', id: 'contact' },
|
||||||
{ type: 'blank', content: '' },
|
|
||||||
|
|
||||||
// Contact buttons - dynamically generated from socials array
|
// Contact buttons - dynamically generated from socials array
|
||||||
...user.socials.map(social => ({
|
...user.socials.map(social => ({
|
||||||
type: 'button' as const,
|
type: 'button' as const,
|
||||||
@@ -45,7 +54,6 @@ export const lines: TerminalLine[] = [
|
|||||||
|
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
||||||
{ type: 'blank', content: '' },
|
|
||||||
|
|
||||||
// Skills as TUI sections
|
// Skills as TUI sections
|
||||||
|
|
||||||
@@ -82,8 +90,8 @@ export const lines: TerminalLine[] = [
|
|||||||
// Interests
|
// Interests
|
||||||
{ type: 'info', content: '(&accent,bold)▸ 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: 'blank', content: '' },
|
||||||
|
|
||||||
{ type: 'divider', content: 'PROJECTS', id: 'projects' },
|
{ type: 'divider', content: 'PROJECTS', id: 'projects' },
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
|
|||||||
16
src/routes/+layout.server.ts
Normal file
16
src/routes/+layout.server.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home-container {
|
.home-container {
|
||||||
padding: 2rem 1rem;
|
padding: 1rem 1rem;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@
|
|||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
{ type: 'divider', content: 'VIEWER' },
|
{ type: 'divider', content: 'VIEWER' },
|
||||||
{ type: 'blank', content: '' },
|
|
||||||
|
|
||||||
// Interactive model buttons
|
// Interactive model buttons
|
||||||
...glbModels.map(model => ({
|
...glbModels.map(model => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user