6 Commits

Author SHA1 Message Date
Koha9 e1208420a0 feat: Add cht language support. 2025-12-14 03:52:35 +09:00
Koha9 575d1a7008 feat: Update week day representation and add localization support for weekdays 2025-12-14 03:14:58 +09:00
Koha9 4d3bb6cfd8 feat: Add chs language support. 2025-12-14 03:09:29 +09:00
Koha9 0629ffc3bc Merge branch 'main' into UI-internationalization 2025-12-14 01:06:56 +09:00
Koha9 a6f0d1c73c fix: CRLF error.
add CRLF to LF in Dockerfile.
2025-12-14 01:06:17 +09:00
Koha9 a7c3b544fa feat: Added the OverflowMarquee, implemented auto-scrolling text. 2025-12-14 01:04:26 +09:00
13 changed files with 546 additions and 13 deletions
+8
View File
@@ -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
+2 -1
View File
@@ -10,7 +10,8 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /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
+22 -3
View File
@@ -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>
</> </>
)} )}
+117
View File
@@ -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;
+5 -2
View File
@@ -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>
+7 -1
View File
@@ -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>
+6 -6
View File
@@ -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>
) )
})} })}
+35
View File
@@ -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">
{ {
+161
View File
@@ -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: '儲存媒體庫選擇失敗。',
},
};
+9
View File
@@ -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',
+9
View File
@@ -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',
+161
View File
@@ -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: '保存媒体库选择失败。',
},
};
+4
View File
@@ -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;