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 ServerPanel from './components/ServerPanel';
|
||||||
import StrategySelector from './components/StrategySelector';
|
import StrategySelector from './components/StrategySelector';
|
||||||
import ConnectionModal from './components/ConnectionModal';
|
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 { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
|
||||||
import { useLanguage } from './LanguageContext';
|
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>
|
<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'}`}>
|
<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" />
|
<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>
|
||||||
</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>
|
<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'}`}>
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -713,7 +718,9 @@ const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-1.5 text-xs font-medium mt-0.5 ${scheduleInfo.active ? 'text-green-400' : 'text-gray-600'}`}>
|
<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" />
|
<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>
|
</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 { Playlist } from '../types';
|
||||||
import { Disc3, Clock } from 'lucide-react';
|
import { Disc3, Clock } from 'lucide-react';
|
||||||
import { useLanguage } from '../LanguageContext';
|
import { useLanguage } from '../LanguageContext';
|
||||||
|
import OverflowMarquee from './OverflowMarquee';
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
interface PlaylistCardProps {
|
||||||
playlist: Playlist;
|
playlist: Playlist;
|
||||||
@@ -12,8 +13,10 @@ const PlaylistCard: React.FC<PlaylistCardProps> = ({ playlist }) => {
|
|||||||
return (
|
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="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">
|
<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">
|
||||||
{playlist.title}
|
<OverflowMarquee>
|
||||||
|
{playlist.title}
|
||||||
|
</OverflowMarquee>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Playlist, ServerType, PlexServerConnection } from '../types';
|
|||||||
import PlaylistCard from './PlaylistCard';
|
import PlaylistCard from './PlaylistCard';
|
||||||
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
|
import { RefreshCw, Server, Cloud, WifiOff, X } from 'lucide-react';
|
||||||
import { useLanguage } from '../LanguageContext';
|
import { useLanguage } from '../LanguageContext';
|
||||||
|
import OverflowMarquee from './OverflowMarquee';
|
||||||
|
|
||||||
interface ServerPanelProps {
|
interface ServerPanelProps {
|
||||||
type: ServerType;
|
type: ServerType;
|
||||||
@@ -43,7 +44,12 @@ const ServerPanel: React.FC<ServerPanelProps> = ({ type, playlists, isLoading, o
|
|||||||
displayTitle = serverInfo.name || t('server.cloud');
|
displayTitle = serverInfo.name || t('server.cloud');
|
||||||
displaySubtitle = (
|
displaySubtitle = (
|
||||||
<div className="flex items-center text-xs text-gray-300 font-medium space-x-1.5 truncate mt-0.5 md:mt-0">
|
<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-600 hidden md:inline">•</span>
|
||||||
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
|
<span className="text-gray-500 font-mono text-[10px] hidden md:inline">{serverInfo.ip}:{serverInfo.port}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,41 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #6b7280;
|
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>
|
</style>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user