Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions web/src/components/HoldToConfirmButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';

export function HoldToConfirmButton({
onConfirm,
holdDurationMs = 2000,
children,
className = '',
disabled = false,
fillClass = 'bg-error/20',
...props
}) {
const [isHolding, setIsHolding] = useState(false);
const timerRef = useRef(null);

const startHold = useCallback((e) => {
// Only respond to primary button (left click) or touch
if (e.button !== undefined && e.button !== 0) return;
if (disabled) return;
setIsHolding(true);

// Optional: add a slight haptic feedback on mobile if supported
if (typeof globalThis.navigator?.vibrate === 'function') {
globalThis.navigator.vibrate(50);
}
}, [disabled]);

const cancelHold = useCallback(() => {
setIsHolding(false);
}, []);

useEffect(() => {
if (isHolding) {
timerRef.current = setTimeout(() => {
setIsHolding(false);
if (typeof globalThis.navigator?.vibrate === 'function') {
globalThis.navigator.vibrate([50, 50, 50]); // Success vibration
}
if (onConfirm) onConfirm();
}, holdDurationMs);
} else if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}

return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
}, [isHolding, holdDurationMs, onConfirm]);

// Context menu prevention to stop touch-and-hold from bringing up OS menus
const onContextMenu = useCallback((e) => {
if (isHolding || disabled) {
e.preventDefault();
return false;
}
}, [isHolding, disabled]);

return (
<button
{...props}
className={`relative overflow-hidden ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'select-none'}`}
onPointerDown={startHold}
onPointerUp={cancelHold}
onPointerLeave={cancelHold}
onPointerCancel={cancelHold}
onContextMenu={onContextMenu}
disabled={disabled}
style={{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: 'none'
}}
>
<div
className={`absolute inset-0 origin-left pointer-events-none rounded-[inherit] ${fillClass}`}
style={{
transform: isHolding ? 'scaleX(1)' : 'scaleX(0)',
transition: isHolding ? `transform ${holdDurationMs}ms linear` : 'transform 150ms ease-out',
willChange: 'transform'
}}
/>
{children}
</button>
);
}
37 changes: 37 additions & 0 deletions web/src/components/PageHeader.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBars } from '@fortawesome/free-solid-svg-icons/faBars';

export default function PageHeader({ title, actions, tabs, className = '', noStack = false }) {
const flexClasses = noStack
? 'flex flex-row items-center justify-between gap-4'
: 'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between';

return (
<div className={`flex flex-col ${tabs ? '-mb-2 lg:-mb-4' : ''} ${className}`}>
<div className={flexClasses}>
<div className="flex flex-row items-center gap-3">
<button
className="btn btn-ghost btn-circle md:hidden landscape:hidden -ml-3 text-base-content/70"
onClick={() => globalThis.dispatchEvent(new Event('open-mobile-nav'))}
aria-label="Open menu"
>
<FontAwesomeIcon icon={faBars} size="xl" />
</button>
<h1 className="text-2xl font-bold sm:text-3xl text-base-content tracking-tight">
{title}
</h1>
</div>
{actions && (
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
{actions}
</div>
)}
</div>
{tabs && (
<div className="mt-2 lg:mt-4">
{tabs}
</div>
)}
</div>
);
}
9 changes: 9 additions & 0 deletions web/src/components/PageLayout.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function PageLayout({ variant = 'wide', children, className = '' }) {
const widthClass = variant === 'narrow' ? 'mx-auto max-w-4xl w-full' : 'w-full';

return (
<div className={`${widthClass} flex flex-col gap-8 lg:gap-10 pb-20 ${className}`}>
{children}
</div>
);
}
181 changes: 181 additions & 0 deletions web/src/components/ProgressiveContent.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useState, useEffect, useRef } from 'preact/hooks';

// Module-level cache to store resolved modules.
// Key: load function itself, Value: Resolved component
const loaderCache = new Map();

/**
* Preloads a component. Can be called on tab hover/touch.
* @param {Function} loader - The dynamic import function (e.g. () => import('./MyComponent.jsx'))
*/
export function preloadComponent(loader) {
if (loaderCache.has(loader)) return;
// Trigger the dynamic import promise. Browser caches the evaluation/network chunk automatically.
loader().then(
(comp) => {
const resolved = comp.default || comp;
loaderCache.set(loader, resolved);
},
(err) => {
console.error('Failed to preload component:', err);
}
);
}

/**
* ProgressiveContent Component
* Implements a state machine to prevent skeleton flashing and coordinate a smooth crossfade.
*/
export function ProgressiveContent({
loader,
skeleton: Skeleton,
loadingDelay = 0,
minDisplayDuration = 500,
isLoading = false,
children,
...componentProps
}) {
// A component is "ready" to show if its code is loaded (or not needed) AND data is not loading.
const isCodeLoadedInitially = !loader || loaderCache.has(loader);

const [status, setStatus] = useState(() => {
return (isCodeLoadedInitially && !isLoading) ? 'ready' : 'idle';
});

const [loadedComponent, setLoadedComponent] = useState(() => {
return loader && loaderCache.has(loader) ? loaderCache.get(loader) : null;
});

const loaderRef = useRef(loader);
const statusRef = useRef(status);
const skeletonShownAtRef = useRef(0);
const isLoadingRef = useRef(isLoading);

// Keep refs in sync
loaderRef.current = loader;
statusRef.current = status;
isLoadingRef.current = isLoading;

// 1. Code Loading Effect
useEffect(() => {
if (!loader) return;

const currentLoader = loader;
if (loaderCache.has(currentLoader)) {
setLoadedComponent(() => loaderCache.get(currentLoader));
return;
}

let isSubscribed = true;
currentLoader().then(
(module) => {
if (!isSubscribed || loaderRef.current !== currentLoader) return;
const resolvedComponent = module.default || module;
loaderCache.set(currentLoader, resolvedComponent);
setLoadedComponent(() => resolvedComponent);
},
(error) => {
console.error('ProgressiveContent failed to load chunk:', error);
if (isSubscribed && loaderRef.current === currentLoader) {
setStatus('error');
}
}
);
return () => {
isSubscribed = false;
};
}, [loader]);

// 2. State Machine Effect (Depends on Code loaded and Data loading)
useEffect(() => {
const currentLoader = loader;
const isCodeLoaded = !loader || loadedComponent !== null;
const isReadyToShow = isCodeLoaded && !isLoading;

if (statusRef.current === 'error') return;

if (isReadyToShow) {
if (statusRef.current === 'skeleton') {
const elapsed = Date.now() - skeletonShownAtRef.current;
const remaining = Math.max(0, minDisplayDuration - elapsed);

if (remaining > 0) {
const timeoutId = setTimeout(() => {
if (loaderRef.current === currentLoader && !isLoadingRef.current) {
setStatus('transitioning');
}
}, remaining);
return () => clearTimeout(timeoutId);
} else {
setStatus('transitioning');
}
} else if (statusRef.current === 'idle' || statusRef.current === 'loading') {
// Never showed the skeleton, show immediately without transition
setStatus('ready');
}
} else if (statusRef.current === 'ready' || statusRef.current === 'idle') {
// Need to show skeleton or loading
setStatus('loading');
const delayTimer = setTimeout(() => {
if (loaderRef.current === currentLoader && statusRef.current === 'loading') {
setStatus('skeleton');
skeletonShownAtRef.current = Date.now();
}
}, loadingDelay);
return () => clearTimeout(delayTimer);
}
}, [loadedComponent, isLoading, loader, loadingDelay, minDisplayDuration]);

// Handle transition completion (crossfade animation is 250ms)
useEffect(() => {
if (status === 'transitioning') {
const timer = setTimeout(() => {
setStatus('ready');
}, 250); // matches transition durations in CSS
return () => clearTimeout(timer);
}
}, [status]);

if (status === 'idle' || status === 'loading') {
// Render the skeleton but make it invisible so it occupies the exact same layout space
// and prevents layout shift (jank) when the skeleton or component loads
return (
<div className="opacity-0 pointer-events-none select-none transition-none">
<Skeleton />
</div>
);
}

if (status === 'error') {
return (
<div className="alert alert-error my-4">
<span>Failed to load component. Please check your connection or reload the page.</span>
</div>
);
}

if (status === 'skeleton') {
return <Skeleton />;
}

if (status === 'transitioning' && (loadedComponent || children)) {
const Component = loadedComponent;
return (
<div className="progressive-transition-container">
<div className="progressive-transition-fade-out">
<Skeleton />
</div>
<div className="progressive-transition-fade-in">
{Component ? <Component {...componentProps} /> : children}
</div>
</div>
);
}

if (status === 'ready' && (loadedComponent || children)) {
const Component = loadedComponent;
return Component ? <Component {...componentProps} /> : children;
}

return null;
}
53 changes: 53 additions & 0 deletions web/src/components/TabBar.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useLocation } from 'preact-iso';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

export default function TabBar({ tabs, activeTab, onTabChange, basePath, className = '' }) {
const location = useLocation();

const handleTabClick = (tabId, event) => {
if (basePath) {
event.preventDefault();
location.route(`${basePath}/${tabId}`);
} else if (onTabChange) {
onTabChange(tabId);
}
};

return (
<div className={`w-full overflow-x-auto scrollbar-none ${className}`}>
<div
role="tablist"
className="tabs tabs-border min-w-full px-1 flex-nowrap"
>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
const href = basePath ? `${basePath}/${tab.id}` : '#';

return (
<a
key={tab.id}
href={href}
onClick={(e) => handleTabClick(tab.id, e)}
onMouseEnter={() => tab.preload?.()}
onTouchStart={() => tab.preload?.()}
role="tab"
aria-selected={isActive}
aria-controls={`panel-${tab.id}`}
id={`tab-${tab.id}`}
className={`tab h-12 flex flex-row items-center justify-center gap-2 text-sm font-medium transition-all duration-150 whitespace-nowrap ${
isActive
? 'tab-active text-primary'
: 'text-base-content/60 hover:text-base-content/85'
}`}
>
{tab.icon && (
<FontAwesomeIcon icon={tab.icon} className="h-4 w-4 shrink-0" />
)}
{tab.label}
</a>
);
})}
</div>
</div>
);
}
Loading
Loading