mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-14 12:46:05 +00:00
Add sidebar toggle button with collapse/expand functionality (#175)
This commit is contained in:
parent
cf7ada95e3
commit
acf91e228d
7 changed files with 187 additions and 109 deletions
|
|
@ -62,7 +62,6 @@ export class VibeTunnelApp extends LitElement {
|
|||
@state() private isAuthenticated = false;
|
||||
@state() private sidebarCollapsed = this.loadSidebarState();
|
||||
@state() private sidebarWidth = this.loadSidebarWidth();
|
||||
@state() private userInitiatedSessionChange = false;
|
||||
@state() private isResizing = false;
|
||||
@state() private mediaState: MediaQueryState = responsiveObserver.getCurrentState();
|
||||
@state() private showLogLink = false;
|
||||
|
|
@ -70,6 +69,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
private initialLoadComplete = false;
|
||||
private responsiveObserverInitialized = false;
|
||||
private initialRenderComplete = false;
|
||||
private sidebarAnimationReady = false;
|
||||
|
||||
private hotReloadWs: WebSocket | null = null;
|
||||
private errorTimeoutId: number | null = null;
|
||||
|
|
@ -95,6 +95,10 @@ export class VibeTunnelApp extends LitElement {
|
|||
// Mark initial render as complete after a microtask to ensure DOM is settled
|
||||
Promise.resolve().then(() => {
|
||||
this.initialRenderComplete = true;
|
||||
// Enable sidebar animations after a short delay to prevent initial load animations
|
||||
setTimeout(() => {
|
||||
this.sidebarAnimationReady = true;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -140,6 +144,12 @@ export class VibeTunnelApp extends LitElement {
|
|||
this.showFileBrowser = true;
|
||||
}
|
||||
|
||||
// Handle Cmd+B / Ctrl+B to toggle sidebar
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
this.handleToggleSidebar();
|
||||
}
|
||||
|
||||
// Handle Escape to close the session and return to list view
|
||||
if (
|
||||
e.key === 'Escape' &&
|
||||
|
|
@ -213,18 +223,10 @@ export class VibeTunnelApp extends LitElement {
|
|||
const url = new URL(window.location.href);
|
||||
const sessionId = url.searchParams.get('session');
|
||||
if (sessionId) {
|
||||
// Try to find the session and navigate to it
|
||||
const session = this.sessions.find((s) => s.id === sessionId);
|
||||
if (session) {
|
||||
this.userInitiatedSessionChange = false;
|
||||
this.selectedSessionId = sessionId;
|
||||
this.currentView = 'session';
|
||||
|
||||
// Update page title with session name
|
||||
const sessionName = session.name || session.command.join(' ');
|
||||
console.log('[App] Setting title from checkUrlParams:', sessionName);
|
||||
document.title = `${sessionName} - VibeTunnel`;
|
||||
}
|
||||
// Always navigate to the session view if a session ID is provided
|
||||
logger.log(`Navigating to session ${sessionId} from URL after auth`);
|
||||
this.selectedSessionId = sessionId;
|
||||
this.currentView = 'session';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,20 +352,14 @@ export class VibeTunnelApp extends LitElement {
|
|||
document.title = `VibeTunnel - ${sessionCount} Session${sessionCount !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Check if currently selected session still exists after refresh
|
||||
// Don't redirect away from session view during loadSessions
|
||||
// The session-view component will handle missing sessions
|
||||
if (this.selectedSessionId && this.currentView === 'session') {
|
||||
const sessionExists = this.sessions.find((s) => s.id === this.selectedSessionId);
|
||||
if (!sessionExists) {
|
||||
// Session no longer exists, redirect to dashboard
|
||||
logger.warn(
|
||||
`Selected session ${this.selectedSessionId} no longer exists, redirecting to dashboard`
|
||||
`Selected session ${this.selectedSessionId} not found in current sessions list, but keeping session view`
|
||||
);
|
||||
this.selectedSessionId = null;
|
||||
this.currentView = 'list';
|
||||
// Clear the session param from URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('session');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
|
|
@ -637,8 +633,6 @@ export class VibeTunnelApp extends LitElement {
|
|||
mediaStateIsMobile: this.mediaState.isMobile,
|
||||
});
|
||||
|
||||
this.userInitiatedSessionChange = true;
|
||||
|
||||
// Check if View Transitions API is supported
|
||||
if ('startViewTransition' in document && typeof document.startViewTransition === 'function') {
|
||||
// Debug: Check what elements have view-transition-name before transition
|
||||
|
|
@ -837,19 +831,17 @@ export class VibeTunnelApp extends LitElement {
|
|||
private loadSidebarState(): boolean {
|
||||
try {
|
||||
const saved = localStorage.getItem('sidebarCollapsed');
|
||||
// Default to false (expanded) on desktop, true (collapsed) on mobile
|
||||
// Use window.innerWidth for initial load since mediaState might not be initialized yet
|
||||
const isMobile = window.innerWidth < BREAKPOINTS.MOBILE;
|
||||
|
||||
// Force expanded on desktop regardless of localStorage for better UX
|
||||
const result = isMobile ? (saved !== null ? saved === 'true' : true) : false;
|
||||
// Respect saved state if it exists, otherwise default based on device type
|
||||
const result = saved !== null ? saved === 'true' : isMobile;
|
||||
|
||||
logger.debug('Loading sidebar state:', {
|
||||
savedValue: saved,
|
||||
windowWidth: window.innerWidth,
|
||||
mobileBreakpoint: BREAKPOINTS.MOBILE,
|
||||
isMobile,
|
||||
forcedDesktopExpanded: !isMobile,
|
||||
hasSavedState: saved !== null,
|
||||
resultingState: result ? 'collapsed' : 'expanded',
|
||||
});
|
||||
|
||||
|
|
@ -994,26 +986,17 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
if (sessionId) {
|
||||
// Check if we have sessions loaded
|
||||
if (this.sessions.length === 0 && this.isAuthenticated) {
|
||||
// Sessions not loaded yet, load them first
|
||||
await this.loadSessions();
|
||||
}
|
||||
// Always navigate to the session view if a session ID is provided
|
||||
// The session-view component will handle loading and error cases
|
||||
logger.log(`Navigating to session ${sessionId} from URL`);
|
||||
this.selectedSessionId = sessionId;
|
||||
this.currentView = 'session';
|
||||
|
||||
// Now check if the session exists
|
||||
const session = this.sessions.find((s) => s.id === sessionId);
|
||||
if (session) {
|
||||
this.selectedSessionId = sessionId;
|
||||
this.currentView = 'session';
|
||||
} else {
|
||||
// Session not found, go to list view
|
||||
logger.warn(`Session ${sessionId} not found in sessions list`);
|
||||
this.selectedSessionId = null;
|
||||
this.currentView = 'list';
|
||||
// Clear the session param from URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('session');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
// Load sessions in the background if not already loaded
|
||||
if (this.sessions.length === 0 && this.isAuthenticated) {
|
||||
this.loadSessions().catch((error) => {
|
||||
logger.error('Error loading sessions:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.selectedSessionId = null;
|
||||
|
|
@ -1114,18 +1097,14 @@ export class VibeTunnelApp extends LitElement {
|
|||
|
||||
const baseClasses = 'bg-dark-bg-secondary border-r border-dark-border flex flex-col';
|
||||
const isMobile = this.mediaState.isMobile;
|
||||
const transitionClass =
|
||||
this.initialRenderComplete && !isMobile
|
||||
? this.userInitiatedSessionChange
|
||||
? 'sidebar-transition'
|
||||
: ''
|
||||
: '';
|
||||
// Only apply transition class when animations are ready (not during initial load)
|
||||
const transitionClass = this.sidebarAnimationReady && !isMobile ? 'sidebar-transition' : '';
|
||||
const mobileClasses = isMobile ? 'absolute left-0 top-0 bottom-0 z-30 flex' : transitionClass;
|
||||
|
||||
const collapsedClasses = this.sidebarCollapsed
|
||||
? isMobile
|
||||
? 'hidden mobile-sessions-sidebar collapsed'
|
||||
: 'sm:w-0 sm:overflow-hidden sm:translate-x-0 flex'
|
||||
: 'sm:overflow-hidden sm:translate-x-0 flex'
|
||||
: isMobile
|
||||
? 'overflow-visible sm:translate-x-0 flex mobile-sessions-sidebar expanded'
|
||||
: 'overflow-visible sm:translate-x-0 flex';
|
||||
|
|
@ -1134,12 +1113,18 @@ export class VibeTunnelApp extends LitElement {
|
|||
}
|
||||
|
||||
private get sidebarStyles(): string {
|
||||
if (!this.showSplitView || this.sidebarCollapsed) {
|
||||
const isMobile = this.mediaState.isMobile;
|
||||
return this.showSplitView && this.sidebarCollapsed && !isMobile ? 'width: 0px;' : '';
|
||||
if (!this.showSplitView) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isMobile = this.mediaState.isMobile;
|
||||
|
||||
if (this.sidebarCollapsed) {
|
||||
// Hide completely on both desktop and mobile
|
||||
return 'width: 0px;';
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
if (isMobile) {
|
||||
return `width: calc(100vw - ${SIDEBAR.MOBILE_RIGHT_MARGIN}px);`;
|
||||
}
|
||||
|
|
@ -1282,6 +1267,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
@open-settings=${this.handleOpenSettings}
|
||||
@logout=${this.handleLogout}
|
||||
@navigate-to-list=${this.handleNavigateToList}
|
||||
@toggle-sidebar=${this.handleToggleSidebar}
|
||||
></app-header>
|
||||
<div class="${this.showSplitView ? 'flex-1 overflow-y-auto' : 'flex-1'} bg-dark-bg-secondary">
|
||||
<session-list
|
||||
|
|
@ -1290,6 +1276,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
.hideExited=${this.hideExited}
|
||||
.selectedSessionId=${this.selectedSessionId}
|
||||
.compactMode=${showSplitView}
|
||||
.collapsed=${this.sidebarCollapsed}
|
||||
.authClient=${authClient}
|
||||
@session-killed=${this.handleSessionKilled}
|
||||
@refresh=${this.handleRefresh}
|
||||
|
|
@ -1336,6 +1323,7 @@ export class VibeTunnelApp extends LitElement {
|
|||
.disableFocusManagement=${this.hasActiveOverlay}
|
||||
@navigate-to-list=${this.handleNavigateToList}
|
||||
@toggle-sidebar=${this.handleToggleSidebar}
|
||||
@create-session=${this.handleCreateSession}
|
||||
@session-status-changed=${this.handleSessionStatusChanged}
|
||||
></session-view>
|
||||
`
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
* @fires clean-exited-sessions - When clean exited button is clicked
|
||||
* @fires open-file-browser - When browse button is clicked
|
||||
* @fires logout - When logout is clicked
|
||||
* @fires toggle-sidebar - When sidebar toggle button is clicked
|
||||
*/
|
||||
import { html, LitElement } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
|
@ -60,6 +61,7 @@ export class AppHeader extends LitElement {
|
|||
@open-settings=${this.forwardEvent}
|
||||
@logout=${this.forwardEvent}
|
||||
@navigate-to-list=${this.forwardEvent}
|
||||
@toggle-sidebar=${this.forwardEvent}
|
||||
></sidebar-header>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { repeat } from 'lit/directives/repeat.js';
|
|||
import type { Session } from '../../shared/types.js';
|
||||
import type { AuthClient } from '../services/auth-client.js';
|
||||
import './session-card.js';
|
||||
import { formatSessionDuration } from '../../shared/utils/time.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import { formatPathForDisplay } from '../utils/path-utils.js';
|
||||
|
||||
|
|
@ -238,14 +239,40 @@ export class SessionList extends LitElement {
|
|||
? html`
|
||||
<!-- Compact list item for sidebar -->
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary ${
|
||||
class="group flex items-center gap-3 p-3 rounded-md cursor-pointer transition-all hover:bg-dark-bg-tertiary hover:shadow-md ${
|
||||
session.id === this.selectedSessionId
|
||||
? 'bg-dark-bg-tertiary border border-accent-green shadow-sm'
|
||||
: 'border border-transparent'
|
||||
: 'border border-transparent hover:border-dark-border'
|
||||
}"
|
||||
@click=${() =>
|
||||
this.handleSessionSelect({ detail: session } as CustomEvent)}
|
||||
>
|
||||
<!-- Activity indicator on the left -->
|
||||
<div
|
||||
class="w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
session.status === 'running'
|
||||
? session.activityStatus?.specificStatus
|
||||
? 'bg-status-warning' // Claude active - orange, no pulse
|
||||
: session.activityStatus?.isActive
|
||||
? 'bg-status-success' // Generic active
|
||||
: 'bg-status-success ring-1 ring-status-success' // Idle (outline)
|
||||
: 'bg-status-warning'
|
||||
}"
|
||||
title="${
|
||||
session.status === 'running' && session.activityStatus
|
||||
? session.activityStatus.specificStatus
|
||||
? `Active: ${session.activityStatus.specificStatus.app}`
|
||||
: session.activityStatus.isActive
|
||||
? 'Active'
|
||||
: 'Idle'
|
||||
: session.status
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Divider line -->
|
||||
<div class="w-px h-8 bg-dark-border"></div>
|
||||
|
||||
<!-- Session content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="text-sm font-mono text-accent-green truncate"
|
||||
|
|
@ -290,32 +317,26 @@ export class SessionList extends LitElement {
|
|||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full ${
|
||||
session.status === 'running'
|
||||
? session.activityStatus?.specificStatus
|
||||
? 'bg-accent-green animate-pulse' // Claude active
|
||||
: session.activityStatus?.isActive
|
||||
? 'bg-status-success' // Generic active
|
||||
: 'bg-status-success ring-1 ring-status-success' // Idle (outline)
|
||||
: 'bg-status-warning'
|
||||
}"
|
||||
title="${
|
||||
session.status === 'running' && session.activityStatus
|
||||
? session.activityStatus.specificStatus
|
||||
? `Active: ${session.activityStatus.specificStatus.app}`
|
||||
: session.activityStatus.isActive
|
||||
? 'Active'
|
||||
: 'Idle'
|
||||
: session.status
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- Right side: duration and close button -->
|
||||
<div class="relative flex items-center flex-shrink-0">
|
||||
<!-- Session duration (hidden on group hover on desktop) -->
|
||||
<div class="text-xs text-dark-text-muted font-mono transition-opacity ${
|
||||
'ontouchstart' in window ? '' : 'group-hover:opacity-0'
|
||||
}">
|
||||
${session.startedAt ? formatSessionDuration(session.startedAt) : ''}
|
||||
</div>
|
||||
|
||||
<!-- Close button (overlaps duration on hover) -->
|
||||
${
|
||||
session.status === 'running' || session.status === 'exited'
|
||||
? html`
|
||||
<button
|
||||
class="btn-ghost text-status-error p-1 rounded hover:bg-dark-bg"
|
||||
class="btn-ghost text-status-error p-1 rounded transition-opacity absolute right-0 ${
|
||||
'ontouchstart' in window
|
||||
? 'opacity-100 static ml-2'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
} hover:bg-dark-bg hover:shadow-sm"
|
||||
@click=${async (e: Event) => {
|
||||
e.stopPropagation();
|
||||
// Kill the session
|
||||
|
|
|
|||
|
|
@ -496,6 +496,16 @@ export class SessionView extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
private handleCreateSession() {
|
||||
// Dispatch event to create a new session
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('create-session', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleSessionExit(e: Event) {
|
||||
const customEvent = e as CustomEvent;
|
||||
logger.log('session exit event received', customEvent.detail);
|
||||
|
|
@ -1042,6 +1052,7 @@ export class SessionView extends LitElement {
|
|||
.widthTooltip=${this.getWidthTooltip()}
|
||||
.onBack=${() => this.handleBack()}
|
||||
.onSidebarToggle=${() => this.handleSidebarToggle()}
|
||||
.onCreateSession=${() => this.handleCreateSession()}
|
||||
.onOpenFileBrowser=${() => this.handleOpenFileBrowser()}
|
||||
.onOpenImagePicker=${() => this.handleOpenFilePicker()}
|
||||
.onMaxWidthToggle=${() => this.handleMaxWidthToggle()}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export class SessionHeader extends LitElement {
|
|||
@property({ type: Function }) onBack?: () => void;
|
||||
@property({ type: Function }) onSidebarToggle?: () => void;
|
||||
@property({ type: Function }) onOpenFileBrowser?: () => void;
|
||||
@property({ type: Function }) onCreateSession?: () => void;
|
||||
@property({ type: Function }) onOpenImagePicker?: () => void;
|
||||
@property({ type: Function }) onMaxWidthToggle?: () => void;
|
||||
@property({ type: Function }) onWidthSelect?: (width: number) => void;
|
||||
|
|
@ -76,35 +77,40 @@ export class SessionHeader extends LitElement {
|
|||
return html`
|
||||
<!-- Compact Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 border-b border-dark-border text-sm min-w-0 bg-dark-bg-secondary"
|
||||
style="padding-top: max(0.5rem, env(safe-area-inset-top)); padding-left: max(0.75rem, env(safe-area-inset-left)); padding-right: max(0.75rem, env(safe-area-inset-right));"
|
||||
class="flex items-center justify-between border-b border-dark-border text-sm min-w-0 bg-dark-bg-secondary p-3"
|
||||
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top))); padding-left: max(0.75rem, env(safe-area-inset-left)); padding-right: max(0.75rem, env(safe-area-inset-right));"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<!-- Mobile Hamburger Menu Button (only on phones, only when session is shown) -->
|
||||
<!-- Sidebar Toggle and Create Session Buttons (shown when sidebar is collapsed) -->
|
||||
${
|
||||
this.showSidebarToggle && this.sidebarCollapsed
|
||||
? html`
|
||||
<button
|
||||
class="sm:hidden bg-dark-bg-tertiary border border-dark-border rounded-lg p-1 font-mono text-accent-green transition-all duration-300 hover:bg-dark-bg hover:border-accent-green flex-shrink-0"
|
||||
@click=${() => this.onSidebarToggle?.()}
|
||||
title="Show sessions"
|
||||
>
|
||||
<!-- Hamburger menu icon -->
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="bg-dark-bg-tertiary border border-dark-border rounded-lg p-1.5 font-mono text-dark-text-muted transition-all duration-300 hover:text-dark-text hover:bg-dark-bg hover:border-accent-green flex-shrink-0"
|
||||
@click=${() => this.onSidebarToggle?.()}
|
||||
title="Show sidebar (⌘B)"
|
||||
aria-label="Show sidebar"
|
||||
aria-expanded="false"
|
||||
aria-controls="sidebar"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Right chevron icon to expand sidebar -->
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Create Session button -->
|
||||
<button
|
||||
class="bg-dark-bg-tertiary border border-accent-green text-accent-green rounded-lg p-1.5 font-mono transition-all duration-300 hover:bg-accent-green hover:text-dark-bg flex-shrink-0"
|
||||
@click=${() => this.onCreateSession?.()}
|
||||
title="Create New Session (⌘K)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,24 @@ export class SidebarHeader extends HeaderBase {
|
|||
style="padding-top: max(0.75rem, calc(0.75rem + env(safe-area-inset-top)));"
|
||||
>
|
||||
<!-- Compact layout for sidebar -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Title and logo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Toggle button -->
|
||||
<button
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer group"
|
||||
class="p-2 text-dark-text-muted hover:text-dark-text rounded-lg hover:bg-dark-bg-tertiary transition-all duration-200 flex-shrink-0"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent('toggle-sidebar'))}
|
||||
title="Collapse sidebar (⌘B)"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-expanded="true"
|
||||
aria-controls="sidebar"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Title and logo with flex-grow for centering -->
|
||||
<button
|
||||
class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer group flex-grow"
|
||||
title="Go to home"
|
||||
@click=${this.handleHomeClick}
|
||||
>
|
||||
|
|
@ -41,7 +55,7 @@ export class SidebarHeader extends HeaderBase {
|
|||
</button>
|
||||
|
||||
<!-- Action buttons group -->
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<!-- Notification button -->
|
||||
<notification-status
|
||||
@open-settings=${() => this.dispatchEvent(new CustomEvent('open-settings'))}
|
||||
|
|
|
|||
36
web/src/shared/utils/time.ts
Normal file
36
web/src/shared/utils/time.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Formats a duration in milliseconds to a human-readable string
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates duration from a start time to now
|
||||
*/
|
||||
export function getDurationFromStart(startTime: string): number {
|
||||
const start = new Date(startTime).getTime();
|
||||
const now = Date.now();
|
||||
return now - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats session duration for display
|
||||
*/
|
||||
export function formatSessionDuration(startedAt: string): string {
|
||||
const duration = getDurationFromStart(startedAt);
|
||||
return formatDuration(duration);
|
||||
}
|
||||
Loading…
Reference in a new issue