Website Status

This commit is contained in:
2025-11-29 16:40:05 +00:00
parent e02fdf59f4
commit 1b356dd6aa
24 changed files with 1294 additions and 52 deletions
+208 -5
View File
@@ -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 @@
<div class="bar-right">
<!-- (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 -->
<div class="module theme-selector">
<button
@@ -227,12 +431,12 @@
</div>
</div>
<!-- Backdrop for theme dropdown only -->
{#if themeDropdownOpen}
<!-- Backdrop for dropdowns -->
{#if themeDropdownOpen || discordPopoverOpen}
<button
class="backdrop"
transition:fade={{ duration: 100 }}
onclick={() => { themeDropdownOpen = false; }}
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
aria-label="Close"
></button>
{/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}`}
>
<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>
{/each}
</div>
+5
View File
@@ -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 @@
<TuiCheckbox {line} inline={true} />
{:else if line.type === 'toggle'}
<TuiToggle {line} inline={true} />
{:else if line.type === 'group'}
<TuiGroup {line} inline={true} {onButtonClick} {onHoverButton} {onLinkClick} />
{:else if line.type === 'image' && showImage}
<div class="tui-image inline-image">
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
@@ -146,6 +149,8 @@
<TuiSelect {line} />
{:else if line.type === 'toggle'}
<TuiToggle {line} />
{:else if line.type === 'group'}
<TuiGroup {line} {onButtonClick} {onHoverButton} {onLinkClick} />
{:else}
<div class="tui-line {line.type}" class:complete id={line.id}>
{#if line.type === 'command' || line.type === 'prompt'}
+21 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { themeColors } from '$lib/stores/theme';
import type { TerminalLine } from './types';
import '$lib/assets/css/tui-card-grid.css';
@@ -8,7 +9,7 @@
$: cards = line.cards || [];
</script>
<div class="tui-card-grid">
<div class="tui-card-grid" style="--card-warning: {$themeColors.colorMap.warning};">
{#each cards as card}
<article class="tui-card" class:featured={card.featured}>
{#if card.image}
@@ -67,7 +68,7 @@
{/if}
{#if card.liveWarning}
<div class="warning">Demo may be unavailable</div>
<div class="warning">Demo may be unavailable</div>
{/if}
<div class="card-actions">
@@ -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 {
@@ -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;
}
}
</style>
+266
View 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>
+9
View File
@@ -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;
}
}
</style>
+15
View File
@@ -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%;
}
}
</style>
+9
View File
@@ -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;
}
}
</style>
+10 -1
View File
@@ -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
+2
View File
@@ -188,6 +188,8 @@ export function getLinePrefix(type: string): string {
return '✗ ';
case 'success':
return '✓ ';
case 'warning':
return '⚠ ';
case 'info':
return ' ';
default: