feat: Added the OverflowMarquee, implemented auto-scrolling text.
This commit is contained in:
+10
-3
@@ -15,6 +15,7 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f
|
||||
import ServerPanel from './components/ServerPanel';
|
||||
import StrategySelector from './components/StrategySelector';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import OverflowMarquee from './components/OverflowMarquee';
|
||||
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
|
||||
@@ -685,7 +686,9 @@ const App: React.FC = () => {
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${pathMappingInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{pathMappingInfo.label}</span>
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium ${pathMappingInfo.active ? 'text-blue-400' : 'text-gray-600'}`}>
|
||||
<pathMappingInfo.Icon size={12} strokeWidth={2.5} className="flex-shrink-0" />
|
||||
<span className="truncate">{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}</span>
|
||||
<OverflowMarquee>
|
||||
{pathMappingInfo.active ? pathMappingInfo.value : t('common.none')}
|
||||
</OverflowMarquee>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -694,7 +697,9 @@ const App: React.FC = () => {
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest transition-colors ${backupInfo.active ? 'text-plex-orange' : 'text-gray-500 group-hover/item:text-gray-400'}`}>{backupInfo.label}</span>
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium ${backupInfo.active ? 'text-indigo-400' : 'text-gray-600'}`}>
|
||||
<Archive size={12} strokeWidth={2.5} className="flex-shrink-0" />
|
||||
<span className="truncate">{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}</span>
|
||||
<OverflowMarquee>
|
||||
{backupInfo.active ? t('dashboard.retain', { count: backupSettings.retentionCount }) : backupInfo.value}
|
||||
</OverflowMarquee>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -713,7 +718,9 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
|
||||
<Clock size={12} strokeWidth={2.5} className="flex-shrink-0" />
|
||||
<span className="truncate">{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}</span>
|
||||
<OverflowMarquee>
|
||||
{scheduleInfo.active ? scheduleInfo.value : t('common.disabled')}
|
||||
</OverflowMarquee>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type OverflowMarqueeProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
title?: string;
|
||||
speedPxPerSec?: number;
|
||||
minDurationSec?: number;
|
||||
};
|
||||
|
||||
type MarqueeMetrics = {
|
||||
isOverflowing: boolean;
|
||||
overflowPx: number;
|
||||
durationSec: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SPEED_PX_PER_SEC = 24;
|
||||
const DEFAULT_MIN_DURATION_SEC = 4;
|
||||
|
||||
const OverflowMarquee: React.FC<OverflowMarqueeProps> = ({
|
||||
children,
|
||||
className,
|
||||
textClassName,
|
||||
title,
|
||||
speedPxPerSec = DEFAULT_SPEED_PX_PER_SEC,
|
||||
minDurationSec = DEFAULT_MIN_DURATION_SEC,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [metrics, setMetrics] = useState<MarqueeMetrics>({
|
||||
isOverflowing: false,
|
||||
overflowPx: 0,
|
||||
durationSec: minDurationSec,
|
||||
});
|
||||
|
||||
const fallbackTitle = useMemo(() => {
|
||||
if (title) return title;
|
||||
return typeof children === 'string' ? children : undefined;
|
||||
}, [children, title]);
|
||||
|
||||
const recompute = () => {
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
// Ensure we measure with current layout.
|
||||
const available = container.clientWidth;
|
||||
const content = text.scrollWidth;
|
||||
const overflowPx = Math.ceil(content - available);
|
||||
|
||||
if (overflowPx > 1) {
|
||||
const durationSec = Math.max(minDurationSec, overflowPx / Math.max(1, speedPxPerSec));
|
||||
setMetrics({ isOverflowing: true, overflowPx, durationSec });
|
||||
} else {
|
||||
// Avoid re-render loops if already not overflowing.
|
||||
setMetrics((prev) => (prev.isOverflowing ? { isOverflowing: false, overflowPx: 0, durationSec: minDurationSec } : prev));
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recompute();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [children]);
|
||||
|
||||
useEffect(() => {
|
||||
recompute();
|
||||
|
||||
const container = containerRef.current;
|
||||
const text = textRef.current;
|
||||
if (!container || !text) return;
|
||||
|
||||
const ro = new ResizeObserver(() => recompute());
|
||||
ro.observe(container);
|
||||
ro.observe(text);
|
||||
|
||||
const onResize = () => recompute();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const textStyle: React.CSSProperties | undefined = metrics.isOverflowing
|
||||
? ({
|
||||
['--marquee-distance' as any]: `${metrics.overflowPx}px`,
|
||||
['--marquee-duration' as any]: `${metrics.durationSec}s`,
|
||||
} satisfies React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={['overflow-marquee', className].filter(Boolean).join(' ')}
|
||||
title={fallbackTitle}
|
||||
>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={[
|
||||
'overflow-marquee__text',
|
||||
metrics.isOverflowing ? 'overflow-marquee__text--animate' : '',
|
||||
textClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={textStyle}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverflowMarquee;
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Playlist } from '../types';
|
||||
import { Disc3, Clock } from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import OverflowMarquee from './OverflowMarquee';
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist;
|
||||
@@ -12,8 +13,10 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
|
||||
return (
|
||||
<div className="group flex flex-col w-full p-2.5 bg-gray-800/60 rounded-md border border-gray-700/50 hover:bg-gray-700 hover:border-plex-orange/50 transition-all duration-200 cursor-pointer shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-200 truncate flex-1 mr-2 group-hover:text-white transition-colors">
|
||||
<h4 className="text-sm font-medium text-gray-200 flex-1 mr-2 group-hover:text-white transition-colors min-w-0">
|
||||
<OverflowMarquee>
|
||||
{playlist.title}
|
||||
</OverflowMarquee>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Playlist, ServerType, PlexServerConnection } from '../types';
|
||||
import PlaylistCard from './PlaylistCard';
|
||||
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import OverflowMarquee from './OverflowMarquee';
|
||||
|
||||
interface ServerPanelProps {
|
||||
type: ServerType;
|
||||
@@ -43,7 +44,12 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
||||
displayTitle = serverInfo.name || t('server.cloud');
|
||||
displaySubtitle = (
|
||||
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
|
||||
<span className="text-plex-orange truncate font-semibold">{serverInfo.libraryName}</span>
|
||||
<span className="text-plex-orange font-semibold min-w-0 max-w-full">
|
||||
<span className="block md:hidden truncate">{serverInfo.libraryName}</span>
|
||||
<OverflowMarquee className="hidden md:inline-block">
|
||||
{serverInfo.libraryName}
|
||||
</OverflowMarquee>
|
||||
</span>
|
||||
<span className="text-gray-600 hidden md:inline">•</span>
|
||||
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,41 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Overflow marquee (auto-scroll when truncated) */
|
||||
.overflow-marquee {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.overflow-marquee__text {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.overflow-marquee__text--animate {
|
||||
animation: overflow-marquee-scroll var(--marquee-duration, 6s) linear infinite;
|
||||
}
|
||||
|
||||
@keyframes overflow-marquee-scroll {
|
||||
0%, 12% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
70%, 86% {
|
||||
transform: translateX(calc(var(--marquee-distance, 0px) * -1));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overflow-marquee__text--animate {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user