Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1208420a0 | |||
| 575d1a7008 | |||
| 4d3bb6cfd8 | |||
| 0629ffc3bc | |||
| a6f0d1c73c | |||
| a7c3b544fa | |||
| 5c6b0b0444 | |||
| b1c9fa5f8e |
@@ -0,0 +1,8 @@
|
|||||||
|
# Normalize line endings to avoid CRLF issues in Linux containers
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Shell scripts must be LF for correct shebang parsing
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# PowerShell scripts are typically CRLF on Windows
|
||||||
|
*.ps1 text eol=crlf
|
||||||
+7
-1
@@ -6,9 +6,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends build-essential \
|
&& apt-get install -y --no-install-recommends build-essential tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \
|
||||||
|
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
@@ -17,4 +21,6 @@ COPY frontend ./frontend
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ PlexPlaylistSync 是一个用于同步 Plex 播放列表和本地 `.m3u`/`.m3u8`
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 配置容器时区
|
||||||
|
|
||||||
|
本项目支持通过 `docker-compose.yml` 的环境变量 `TZ` 配置容器运行时区(需要使用有效的 IANA 时区名,例如 `Asia/Shanghai`、`UTC`、`America/New_York`)。
|
||||||
|
|
||||||
|
- 临时指定(当前终端会话生效):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TZ=UTC docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- 或在项目根目录创建 `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`。
|
- 默认会以 `--reload` 模式启动,监听本地 8080 端口,可在浏览器访问 `http://localhost:8080`。
|
||||||
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
|
- 通过 `./app/config.json` 保存的 Plex 配置信息会在主机和容器间共享,便于调试时保留登录 token 等数据。
|
||||||
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
|
- 如需自定义端口或其他参数,可在 `docker-compose.yml` 中调整。
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ services:
|
|||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PYTHONDONTWRITEBYTECODE=1
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
|
- TZ=${TZ:-Asia/Tokyo}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Configure timezone inside the container if TZ is provided.
|
||||||
|
# This avoids relying on host mounts like /etc/localtime, which are awkward on Windows.
|
||||||
|
if [ "${TZ:-}" != "" ]; then
|
||||||
|
ZONEINFO="/usr/share/zoneinfo/${TZ}"
|
||||||
|
if [ -e "$ZONEINFO" ]; then
|
||||||
|
ln -snf "$ZONEINFO" /etc/localtime
|
||||||
|
echo "$TZ" > /etc/timezone
|
||||||
|
else
|
||||||
|
echo "[entrypoint] Warning: TZ='$TZ' not found at $ZONEINFO; keeping existing timezone." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
+22
-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>
|
||||||
@@ -743,6 +750,18 @@ const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Español
|
Español
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
|
||||||
|
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
|
||||||
|
>
|
||||||
|
简体中文
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
|
||||||
|
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
|
||||||
|
>
|
||||||
|
繁體中文
|
||||||
|
</button>
|
||||||
</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">
|
||||||
|
<OverflowMarquee>
|
||||||
{playlist.title}
|
{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>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const STRATEGIES: StrategyOption[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
const WEEK_DAY_INDEXES = [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
// Color Theme Variables for Mapping Editors
|
// Color Theme Variables for Mapping Editors
|
||||||
const MAPPING_THEME = {
|
const MAPPING_THEME = {
|
||||||
@@ -818,12 +818,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
|
|
||||||
{/* Middle Row: Full Width Capsules */}
|
{/* Middle Row: Full Width Capsules */}
|
||||||
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
|
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
{WEEK_DAYS.map((day, index) => {
|
{WEEK_DAY_INDEXES.map((dayIndex) => {
|
||||||
const isSelected = localSchedule.weeklyDays.includes(index);
|
const isSelected = localSchedule.weeklyDays.includes(dayIndex);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={dayIndex}
|
||||||
onClick={() => toggleWeekDay(index)}
|
onClick={() => toggleWeekDay(dayIndex)}
|
||||||
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
|
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
|
||||||
first:rounded-l-lg last:rounded-r-lg
|
first:rounded-l-lg last:rounded-r-lg
|
||||||
${isSelected
|
${isSelected
|
||||||
@@ -832,7 +832,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{day}
|
{t(`schedule.weekdaysNarrow.${dayIndex}`)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
export const cht = {
|
||||||
|
app: {
|
||||||
|
title: 'PlexSync',
|
||||||
|
manager: '管理',
|
||||||
|
footer: '© {year} PMS Playlist Sync。已連線至 Docker 後端。',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
save: '儲存',
|
||||||
|
cancel: '取消',
|
||||||
|
revert: '還原',
|
||||||
|
delete: '刪除',
|
||||||
|
done: '完成',
|
||||||
|
loading: '載入中…',
|
||||||
|
refresh: '重新整理',
|
||||||
|
close: '關閉',
|
||||||
|
none: '無',
|
||||||
|
disabled: '已停用',
|
||||||
|
add: '新增',
|
||||||
|
switchLanguage: '切換語言',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
local: '本機伺服器',
|
||||||
|
cloud: '雲端伺服器',
|
||||||
|
playlists: '{count} 個播放清單',
|
||||||
|
notConnected: '未連線',
|
||||||
|
connectionFailed: '連線失敗',
|
||||||
|
connecting: '連線中…',
|
||||||
|
waiting: '等待中…',
|
||||||
|
syncing: '同步中…',
|
||||||
|
noPlaylists: '找不到播放清單。',
|
||||||
|
cancelRefresh: '取消重新整理',
|
||||||
|
refreshPlaylists: '重新整理播放清單',
|
||||||
|
},
|
||||||
|
playlist: {
|
||||||
|
trackCount: '曲目數',
|
||||||
|
lastUpdated: '上次更新',
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
mapping: '路徑對應',
|
||||||
|
backup: '備份',
|
||||||
|
autoSync: '自動同步',
|
||||||
|
watch: '監看',
|
||||||
|
watchModeActive: '監看模式:啟用',
|
||||||
|
watchModeDisabled: '監看模式:停用',
|
||||||
|
notSet: '未設定',
|
||||||
|
retain: '保留:{count}',
|
||||||
|
keep: '保留 {count}',
|
||||||
|
connected: '已連線至 Plex',
|
||||||
|
disconnected: '未連線',
|
||||||
|
synchronizing: 'SYNCHRONIZING',
|
||||||
|
syncComplete: 'SYNC COMPLETE',
|
||||||
|
},
|
||||||
|
strategies: {
|
||||||
|
title: '同步策略',
|
||||||
|
localOverwrite: {
|
||||||
|
label: '本機覆寫',
|
||||||
|
desc: '本機播放清單完全覆寫雲端。(無 Diff)',
|
||||||
|
},
|
||||||
|
cloudOverwrite: {
|
||||||
|
label: '雲端覆寫',
|
||||||
|
desc: '雲端播放清單完全覆寫本機。(無 Diff)',
|
||||||
|
},
|
||||||
|
mergeLocal: {
|
||||||
|
label: '雙向合併(本機優先)',
|
||||||
|
desc: '合併兩端。衝突以本機版本為準。',
|
||||||
|
},
|
||||||
|
mergeCloud: {
|
||||||
|
label: '雙向合併(雲端優先)',
|
||||||
|
desc: '合併兩端。衝突以雲端版本為準。',
|
||||||
|
},
|
||||||
|
syncNow: '立即同步',
|
||||||
|
syncing: '同步進行中…',
|
||||||
|
saveWarning: '同步前請先儲存待處理的變更(備份/路徑對應)。',
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
title: '路徑對應',
|
||||||
|
simple: '簡易對應',
|
||||||
|
regex: 'Regex 規則',
|
||||||
|
simpleTitle: '路徑對應',
|
||||||
|
simpleSubtitle: '使用簡單字串比對將本機路徑對應到雲端路徑',
|
||||||
|
regexPre: '前處理(同步前)',
|
||||||
|
regexPost: '後處理(同步後 / 結果)',
|
||||||
|
localPath: '本機路徑',
|
||||||
|
cloudPath: '雲端路徑',
|
||||||
|
pattern: '模式',
|
||||||
|
replace: '取代',
|
||||||
|
saveRules: '儲存規則',
|
||||||
|
noRules: '尚未定義規則。',
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
title: '備份保留',
|
||||||
|
enable: '啟用備份',
|
||||||
|
enableDesc: '變更前建立副本',
|
||||||
|
maxVersions: '保留的最大版本數:',
|
||||||
|
noAutoDelete: '不自動刪除',
|
||||||
|
autoDelete: '自動刪除最舊版本',
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
title: '排程任務',
|
||||||
|
cron: 'Cron',
|
||||||
|
daily: '每日',
|
||||||
|
weekly: '每週',
|
||||||
|
weekdaysNarrow: {
|
||||||
|
0: '日',
|
||||||
|
1: '一',
|
||||||
|
2: '二',
|
||||||
|
3: '三',
|
||||||
|
4: '四',
|
||||||
|
5: '五',
|
||||||
|
6: '六',
|
||||||
|
},
|
||||||
|
enableCron: '啟用 Cron 排程',
|
||||||
|
enableDaily: '啟用每日執行',
|
||||||
|
enableWeekly: '啟用每週執行',
|
||||||
|
watchLocal: '監看本機變更',
|
||||||
|
watchDesc: '本機播放清單更新時自動同步',
|
||||||
|
schedule: '排程',
|
||||||
|
notConfigured: '尚未設定',
|
||||||
|
today: '今天',
|
||||||
|
tomorrow: '明天',
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
titleConnected: '伺服器已連線',
|
||||||
|
titleConnect: '連線 Plex 伺服器',
|
||||||
|
serverDetails: '伺服器詳細資訊',
|
||||||
|
authentication: '驗證',
|
||||||
|
protocol: '通訊協定',
|
||||||
|
address: 'IP 位址或網域',
|
||||||
|
port: '連接埠',
|
||||||
|
token: 'X-Plex-Token(選填)',
|
||||||
|
username: '使用者名稱 / 電子郵件',
|
||||||
|
password: '密碼',
|
||||||
|
advanced: '進階選項',
|
||||||
|
timeout: '連線逾時(秒)',
|
||||||
|
connectBtn: '連線伺服器',
|
||||||
|
connecting: '連線中…',
|
||||||
|
connectedSuccess: '連線成功',
|
||||||
|
selectLibrary: '選擇要同步的媒體庫',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
localRefreshCancelled: '已取消本機重新整理。',
|
||||||
|
cloudRefreshCancelled: '已取消雲端重新整理。',
|
||||||
|
strategySaved: '已儲存選擇的策略「{strategy}」。',
|
||||||
|
strategySaveFailed: '儲存同步策略失敗。',
|
||||||
|
mappingSaved: '已儲存路徑對應規則。',
|
||||||
|
mappingSaveFailed: '儲存路徑對應規則失敗。',
|
||||||
|
backupSaved: '已儲存備份設定。',
|
||||||
|
backupFailed: '儲存備份設定失敗。',
|
||||||
|
scheduleDisabled: '已停用排程任務。',
|
||||||
|
scheduleEmpty: '已停用排程任務(Cron 為空)。',
|
||||||
|
scheduleStarted: '排程任務更新成功。',
|
||||||
|
scheduleFailed: '更新排程失敗。',
|
||||||
|
syncFailed: '同步失敗。請檢查連線。',
|
||||||
|
backgroundSyncSuccess: '背景同步已成功完成。',
|
||||||
|
backgroundSyncFailed: '背景同步失敗:{error}',
|
||||||
|
librarySwitched: '媒體庫已切換為 {library}',
|
||||||
|
connectedTo: '已成功連線到 {name}',
|
||||||
|
connectionCancelled: '使用者已取消連線。',
|
||||||
|
librarySaveFailed: '儲存媒體庫選擇失敗。',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -100,6 +100,15 @@ export const en = {
|
|||||||
cron: 'Cron',
|
cron: 'Cron',
|
||||||
daily: 'Daily',
|
daily: 'Daily',
|
||||||
weekly: 'Weekly',
|
weekly: 'Weekly',
|
||||||
|
weekdaysNarrow: {
|
||||||
|
0: 'S',
|
||||||
|
1: 'M',
|
||||||
|
2: 'T',
|
||||||
|
3: 'W',
|
||||||
|
4: 'T',
|
||||||
|
5: 'F',
|
||||||
|
6: 'S',
|
||||||
|
},
|
||||||
enableCron: 'Enable Cron Schedule',
|
enableCron: 'Enable Cron Schedule',
|
||||||
enableDaily: 'Enable Daily Run',
|
enableDaily: 'Enable Daily Run',
|
||||||
enableWeekly: 'Enable Weekly Run',
|
enableWeekly: 'Enable Weekly Run',
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ export const es = {
|
|||||||
cron: 'Cron',
|
cron: 'Cron',
|
||||||
daily: 'Diario',
|
daily: 'Diario',
|
||||||
weekly: 'Semanal',
|
weekly: 'Semanal',
|
||||||
|
weekdaysNarrow: {
|
||||||
|
0: 'D',
|
||||||
|
1: 'L',
|
||||||
|
2: 'M',
|
||||||
|
3: 'X',
|
||||||
|
4: 'J',
|
||||||
|
5: 'V',
|
||||||
|
6: 'S',
|
||||||
|
},
|
||||||
enableCron: 'Habilitar Cron',
|
enableCron: 'Habilitar Cron',
|
||||||
enableDaily: 'Habilitar Ejecución Diaria',
|
enableDaily: 'Habilitar Ejecución Diaria',
|
||||||
enableWeekly: 'Habilitar Ejecución Semanal',
|
enableWeekly: 'Habilitar Ejecución Semanal',
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
export const zh = {
|
||||||
|
app: {
|
||||||
|
title: 'PlexSync',
|
||||||
|
manager: '管理',
|
||||||
|
footer: '© {year} PMS Playlist Sync。已连接到 Docker 后端。',
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
save: '保存',
|
||||||
|
cancel: '取消',
|
||||||
|
revert: '恢复',
|
||||||
|
delete: '删除',
|
||||||
|
done: '完成',
|
||||||
|
loading: '加载中...',
|
||||||
|
refresh: '刷新',
|
||||||
|
close: '关闭',
|
||||||
|
none: '无',
|
||||||
|
disabled: '已禁用',
|
||||||
|
add: '添加',
|
||||||
|
switchLanguage: '切换语言',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
local: '本地服务器',
|
||||||
|
cloud: '云端服务器',
|
||||||
|
playlists: '{count} 个播放列表',
|
||||||
|
notConnected: '未连接',
|
||||||
|
connectionFailed: '连接失败',
|
||||||
|
connecting: '正在连接...',
|
||||||
|
waiting: '等待中...',
|
||||||
|
syncing: '同步中...',
|
||||||
|
noPlaylists: '未找到播放列表。',
|
||||||
|
cancelRefresh: '取消刷新',
|
||||||
|
refreshPlaylists: '刷新播放列表',
|
||||||
|
},
|
||||||
|
playlist: {
|
||||||
|
trackCount: '曲目数',
|
||||||
|
lastUpdated: '最近更新',
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
mapping: '路径映射',
|
||||||
|
backup: '备份',
|
||||||
|
autoSync: '自动同步',
|
||||||
|
watch: '监听',
|
||||||
|
watchModeActive: '监听模式:启用',
|
||||||
|
watchModeDisabled: '监听模式:禁用',
|
||||||
|
notSet: '未设置',
|
||||||
|
retain: '保留:{count}',
|
||||||
|
keep: '保留 {count}',
|
||||||
|
connected: '已连接 Plex',
|
||||||
|
disconnected: '未连接',
|
||||||
|
synchronizing: 'SYNCHRONIZING',
|
||||||
|
syncComplete: 'SYNC COMPLETE',
|
||||||
|
},
|
||||||
|
strategies: {
|
||||||
|
title: '同步策略',
|
||||||
|
localOverwrite: {
|
||||||
|
label: '本地覆盖',
|
||||||
|
desc: '本地播放列表完全覆盖云端。(无 Diff)',
|
||||||
|
},
|
||||||
|
cloudOverwrite: {
|
||||||
|
label: '云端覆盖',
|
||||||
|
desc: '云端播放列表完全覆盖本地。(无 Diff)',
|
||||||
|
},
|
||||||
|
mergeLocal: {
|
||||||
|
label: '双向合并(本地优先)',
|
||||||
|
desc: '合并两端。冲突以本地版本为准。',
|
||||||
|
},
|
||||||
|
mergeCloud: {
|
||||||
|
label: '双向合并(云端优先)',
|
||||||
|
desc: '合并两端。冲突以云端版本为准。',
|
||||||
|
},
|
||||||
|
syncNow: '立即同步',
|
||||||
|
syncing: '同步进行中...',
|
||||||
|
saveWarning: '同步前请先保存待处理的更改(备份/路径映射)。',
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
title: '路径映射',
|
||||||
|
simple: '简单映射',
|
||||||
|
regex: '正则规则',
|
||||||
|
simpleTitle: '路径映射',
|
||||||
|
simpleSubtitle: '使用简单字符串匹配将本地路径映射到云端路径',
|
||||||
|
regexPre: '预处理(同步前)',
|
||||||
|
regexPost: '后处理(同步后 / 结果)',
|
||||||
|
localPath: '本地路径',
|
||||||
|
cloudPath: '云端路径',
|
||||||
|
pattern: '模式',
|
||||||
|
replace: '替换',
|
||||||
|
saveRules: '保存规则',
|
||||||
|
noRules: '尚未定义规则。',
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
title: '备份保留',
|
||||||
|
enable: '启用备份',
|
||||||
|
enableDesc: '在更改前创建副本',
|
||||||
|
maxVersions: '保留的最大版本数:',
|
||||||
|
noAutoDelete: '不自动删除',
|
||||||
|
autoDelete: '自动删除最旧版本',
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
title: '定时任务',
|
||||||
|
cron: 'Cron',
|
||||||
|
daily: '每日',
|
||||||
|
weekly: '每周',
|
||||||
|
weekdaysNarrow: {
|
||||||
|
0: '日',
|
||||||
|
1: '一',
|
||||||
|
2: '二',
|
||||||
|
3: '三',
|
||||||
|
4: '四',
|
||||||
|
5: '五',
|
||||||
|
6: '六',
|
||||||
|
},
|
||||||
|
enableCron: '启用 Cron 计划',
|
||||||
|
enableDaily: '启用每日运行',
|
||||||
|
enableWeekly: '启用每周运行',
|
||||||
|
watchLocal: '监听本地更改',
|
||||||
|
watchDesc: '本地播放列表更新时自动同步',
|
||||||
|
schedule: '计划',
|
||||||
|
notConfigured: '未配置',
|
||||||
|
today: '今天',
|
||||||
|
tomorrow: '明天',
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
titleConnected: '服务器已连接',
|
||||||
|
titleConnect: '连接 Plex 服务器',
|
||||||
|
serverDetails: '服务器详情',
|
||||||
|
authentication: '认证',
|
||||||
|
protocol: '协议',
|
||||||
|
address: 'IP 地址或域名',
|
||||||
|
port: '端口',
|
||||||
|
token: 'X-Plex-Token(可选)',
|
||||||
|
username: '用户名 / 邮箱',
|
||||||
|
password: '密码',
|
||||||
|
advanced: '高级选项',
|
||||||
|
timeout: '连接超时(秒)',
|
||||||
|
connectBtn: '连接服务器',
|
||||||
|
connecting: '连接中...',
|
||||||
|
connectedSuccess: '连接成功',
|
||||||
|
selectLibrary: '选择要同步的媒体库',
|
||||||
|
},
|
||||||
|
toasts: {
|
||||||
|
localRefreshCancelled: '本地刷新已取消。',
|
||||||
|
cloudRefreshCancelled: '云端刷新已取消。',
|
||||||
|
strategySaved: '已保存选择的策略“{strategy}”。',
|
||||||
|
strategySaveFailed: '保存同步策略失败。',
|
||||||
|
mappingSaved: '已保存路径映射规则。',
|
||||||
|
mappingSaveFailed: '保存路径映射规则失败。',
|
||||||
|
backupSaved: '已保存备份设置。',
|
||||||
|
backupFailed: '保存备份设置失败。',
|
||||||
|
scheduleDisabled: '已禁用定时任务。',
|
||||||
|
scheduleEmpty: '已禁用定时任务(Cron 为空)。',
|
||||||
|
scheduleStarted: '定时任务更新成功。',
|
||||||
|
scheduleFailed: '更新定时任务失败。',
|
||||||
|
syncFailed: '同步失败。请检查连接。',
|
||||||
|
backgroundSyncSuccess: '后台同步已成功完成。',
|
||||||
|
backgroundSyncFailed: '后台同步失败:{error}',
|
||||||
|
librarySwitched: '媒体库已切换为 {library}',
|
||||||
|
connectedTo: '已成功连接到 {name}',
|
||||||
|
connectionCancelled: '用户已取消连接。',
|
||||||
|
librarySaveFailed: '保存媒体库选择失败。',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { en } from './locales/en';
|
import { en } from './locales/en';
|
||||||
import { es } from './locales/es';
|
import { es } from './locales/es';
|
||||||
|
import { zh as chs } from './locales/zh';
|
||||||
|
import { cht } from './locales/cht';
|
||||||
|
|
||||||
export const translations = {
|
export const translations = {
|
||||||
en,
|
en,
|
||||||
es,
|
es,
|
||||||
|
chs,
|
||||||
|
cht,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Language = keyof typeof translations;
|
export type Language = keyof typeof translations;
|
||||||
|
|||||||
Reference in New Issue
Block a user