Files
platform/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
T
pastilhasandClaude Opus 5 a894d64cb3 jellyfin panel app: browse, detail and player
adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.

the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:16:45 +00:00

175 lines
6.7 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router';
import type { LucideIcon } from 'lucide-react';
export type DockItem = {
label: string;
to: string;
color: string;
// Either a lucide glyph (rendered white on the coloured tile) or an image asset (e.g. an app favicon).
icon?: LucideIcon;
image?: string;
};
type DockProps = {
items: DockItem[];
className?: string;
// The content region the dock lives in. Reveal/hide is measured from ITS bottom edge, which the
// in-flow music dock shrinks when present — so the dock clears the music dock with no magic offset.
boundaryRef: React.RefObject<HTMLElement | null>;
};
const ICON_SIZE = 48;
const ICON_GAP = 24;
const DOCK_PADDING = 12;
const MAX_SCALE = 1.5;
const MAX_DISTANCE = 150;
const SHOW_THRESHOLD = 24;
const HIDE_THRESHOLD = 100;
const getScale = (mouseX: number | null, iconCenterX: number) => {
if (mouseX === null) return 1;
const distance = Math.abs(mouseX - iconCenterX);
if (distance > MAX_DISTANCE) return 1;
return 1 + (MAX_SCALE - 1) * Math.cos((distance / MAX_DISTANCE) * (Math.PI / 2));
};
export const Dock = ({ items, className, boundaryRef }: DockProps) => {
const [mouseX, setMouseX] = useState<number | null>(null);
const [visible, setVisible] = useState(false);
const dockRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to));
useEffect(() => {
const handleMouseMove = (ev: MouseEvent) => {
// Distance from the bottom of the content region (which the in-flow music dock shrinks when
// present) — so reveal/hide triggers just above wherever the region ends, above the music dock.
const bottom = boundaryRef.current?.getBoundingClientRect().bottom ?? window.innerHeight;
const distFromBottom = bottom - ev.clientY;
setVisible((prev) => (prev ? distFromBottom <= HIDE_THRESHOLD : distFromBottom <= SHOW_THRESHOLD));
if (distFromBottom <= HIDE_THRESHOLD) {
const rect = dockRef.current?.getBoundingClientRect();
if (rect) setMouseX(ev.clientX - rect.left);
} else {
setMouseX(null);
}
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, [boundaryRef]);
return (
<div
ref={dockRef}
className={`absolute left-1/2 z-5 items-end gap-2 md:gap-6 px-2 py-1.5 md:px-3 md:py-2 rounded-2xl border backdrop-blur-xl shadow-lg transition-transform duration-300 ease-in-out ${className ?? 'flex'}`}
style={{
backgroundColor: 'var(--dock-bg)',
borderColor: 'var(--dock-border)',
bottom: '16px',
transform: `translateX(-50%) translateY(${visible ? '0' : 'calc(100% + 24px)'})`,
}}
>
{items.map((item, index) => {
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
const scale = getScale(mouseX, iconCenter);
const active = isActive(item.to);
return (
<Link
key={item.to}
to={item.to}
className="group relative flex flex-col items-center"
style={{
transform: `scale(${scale})`,
transformOrigin: 'bottom center',
transition: 'transform 150ms ease-out',
}}
>
<span
className="absolute -top-9 px-2 py-1 rounded-md text-white text-xs whitespace-nowrap hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-150 pointer-events-none"
style={{ backgroundColor: 'var(--dock-tooltip-bg)' }}
>
{item.label}
</span>
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
style={{
background: item.color,
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-7 w-7 md:h-8 md:w-8 object-contain" />
) : (
item.icon && <item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
)}
</div>
{active && (
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
)}
</Link>
);
})}
</div>
);
};
import {
Home,
MessageCircle,
FileText,
FolderOpen,
Code,
LayoutGrid,
ScrollText,
FolderKanban,
Monitor,
Mail,
Globe,
MonitorSmartphone,
Workflow,
Music,
Activity,
Radio,
Network,
ArrowDownUp,
Bitcoin,
Receipt,
Images,
CalendarDays,
Contact,
Clapperboard,
} from 'lucide-react';
export const ALL_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
{ label: 'Calendar', to: '/calendar', icon: CalendarDays, color: '#3b82f6' },
{ label: 'Contacts', to: '/contacts', icon: Contact, color: '#0ea5e9' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
{ label: 'Photos', to: '/photos', icon: Images, color: '#10b981' },
{ label: 'Video', to: '/jellyfin', icon: Clapperboard, color: '#a855f7' },
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
{ label: 'Wallet', to: '/wallet', icon: Bitcoin, color: '#f7931a' },
{ label: 'Invoices', to: '/invoices', icon: Receipt, color: '#0891b2' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316' },
{ label: 'Browser', to: '/browser', icon: Globe, color: '#06b6d4' },
{ label: 'Desktop', to: '/desktop', icon: MonitorSmartphone, color: '#ec4899' },
{ label: 'Monitor', to: '/system-monitor', icon: Activity, color: '#0ea5e9' },
{ label: 'Activity', to: '/activity', icon: Radio, color: '#f59e0b' },
{ label: 'Dashboards', to: '/dashboards', icon: LayoutGrid, color: '#8b5cf6' },
];
export const DEFAULT_DOCK_PATHS = ['/', '/files', '/music', '/dashboards', '/chat'];