PlexPlaylist_UI subtree merge

feat: Implement internationalization and rename project

Merge commit 'a745adc1ab02adbd17ed19574f47070f87eba50b'
This commit is contained in:
2025-12-09 05:19:21 +09:00
13 changed files with 569 additions and 143 deletions
@@ -1,3 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
import {
@@ -23,11 +24,12 @@ import {
History,
Eye
} from 'lucide-react';
import { useLanguage } from '../LanguageContext';
interface StrategyOption {
value: SyncStrategy;
label: string;
description: string;
labelKey: string;
descKey: string;
icon: React.ElementType;
color: string;
}
@@ -35,29 +37,29 @@ interface StrategyOption {
const STRATEGIES: StrategyOption[] = [
{
value: SyncStrategy.LOCAL_OVERWRITE,
label: 'Local Overwrite',
description: 'Local playlist completely overwrites Cloud. (No Diff)',
labelKey: 'strategies.localOverwrite.label',
descKey: 'strategies.localOverwrite.desc',
icon: ArrowRightCircle,
color: 'text-blue-400'
},
{
value: SyncStrategy.CLOUD_OVERWRITE,
label: 'Cloud Overwrite',
description: 'Cloud playlist completely overwrites Local. (No Diff)',
labelKey: 'strategies.cloudOverwrite.label',
descKey: 'strategies.cloudOverwrite.desc',
icon: ArrowLeftCircle,
color: 'text-green-400'
},
{
value: SyncStrategy.MERGE_LOCAL,
label: 'Two-way Merge (Local Priority)',
description: 'Merge both. Conflicts resolve to Local version.',
labelKey: 'strategies.mergeLocal.label',
descKey: 'strategies.mergeLocal.desc',
icon: GitMerge,
color: 'text-blue-300'
},
{
value: SyncStrategy.MERGE_CLOUD,
label: 'Two-way Merge (Cloud Priority)',
description: 'Merge both. Conflicts resolve to Cloud version.',
labelKey: 'strategies.mergeCloud.label',
descKey: 'strategies.mergeCloud.desc',
icon: GitMerge,
color: 'text-green-300'
}
@@ -116,6 +118,7 @@ interface MappingGroupEditorProps {
rightPlaceholder?: string;
leftInputClass?: string;
rightInputClass?: string;
t: (key: string) => string;
}
const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
@@ -126,10 +129,11 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
isLocked,
borderColor = "border-gray-700",
bgColor = "bg-gray-900/50",
leftPlaceholder = "Pattern",
rightPlaceholder = "Replace",
leftPlaceholder,
rightPlaceholder,
leftInputClass,
rightInputClass
rightInputClass,
t
}) => {
const handleAdd = () => {
@@ -162,7 +166,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
onClick={handleAdd}
disabled={isLocked}
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
title="Add Rule"
title={t('common.add')}
>
<Plus size={12} />
</button>
@@ -171,14 +175,14 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
<div className="flex-1 space-y-2 overflow-y-auto max-h-32 custom-scrollbar pr-1">
{rules.length === 0 ? (
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
No rules defined.
{t('mapping.noRules')}
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="flex items-center space-x-1 animate-in slide-in-from-left-1 duration-200">
<input
type="text"
placeholder={leftPlaceholder}
placeholder={leftPlaceholder || t('mapping.pattern')}
value={rule.search}
onChange={(e) => handleUpdate(rule.id, 'search', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${leftInputClass || defaultInputStyle}`}
@@ -186,7 +190,7 @@ const MappingGroupEditor: React.FC<MappingGroupEditorProps> = ({
<Link size={12} className="text-gray-600 flex-none opacity-50" />
<input
type="text"
placeholder={rightPlaceholder}
placeholder={rightPlaceholder || t('mapping.replace')}
value={rule.replace}
onChange={(e) => handleUpdate(rule.id, 'replace', e.target.value)}
className={`flex-1 min-w-0 border rounded px-1.5 py-1 text-xs focus:outline-none transition-colors ${rightInputClass || defaultInputStyle}`}
@@ -230,6 +234,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
syncState,
onSync
}) => {
const { t } = useLanguage();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -307,7 +312,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
const handleSelect = (strategy: StrategyOption) => {
if (isLocked) return;
onSelect(strategy.value, strategy.label);
onSelect(strategy.value, t(strategy.labelKey));
};
// --- Path Mapping Handlers ---
@@ -439,7 +444,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
title={`Current Strategy: ${selectedOption.label}`}
title={`Current Strategy: ${t(selectedOption.labelKey)}`}
>
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
@@ -465,7 +470,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1: Sync Strategy */}
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">{t('strategies.title')}</h3>
<div className="space-y-1">
{STRATEGIES.map((strategy) => (
<div
@@ -480,7 +485,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center space-x-3 overflow-hidden">
<strategy.icon size={18} className={strategy.color} />
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
{strategy.label}
{t(strategy.labelKey)}
</span>
</div>
@@ -488,7 +493,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="relative group/tooltip">
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
{strategy.description}
{t(strategy.descKey)}
</div>
</div>
@@ -504,7 +509,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 1.5: Backup Retention */}
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Backup Retention</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('backup.title')}</h3>
</div>
<div className="flex flex-col space-y-3">
@@ -514,8 +519,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<Archive size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">Enable Backups</span>
<span className="text-[10px] text-gray-500">Create a copy before changes</span>
<span className="text-sm font-medium text-gray-200">{t('backup.enable')}</span>
<span className="text-[10px] text-gray-500">{t('backup.enableDesc')}</span>
</div>
</div>
@@ -532,7 +537,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
<div className="flex items-center space-x-2">
<History size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">Max versions to keep:</span>
<span className="text-xs text-gray-400">{t('backup.maxVersions')}</span>
</div>
<div className="flex items-center space-x-2">
<input
@@ -543,7 +548,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
onChange={(e) => handleUpdateBackup('retentionCount', parseInt(e.target.value) || 1)}
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
/>
<span className="text-[10px] text-gray-600 italic">Oldest deleted automatically</span>
<span className="text-[10px] text-gray-600 italic">{t('backup.autoDelete')}</span>
</div>
</div>
</div>
@@ -558,7 +563,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveBackupClick}
@@ -569,7 +574,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
<span>{t('common.save')}</span>
</button>
</div>
</div>
@@ -578,14 +583,14 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 2: Path Mapping (Tabs + Grid) */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Path Mapping</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('mapping.title')}</h3>
</div>
{/* Tabs for Path Mapping Mode */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: PathMappingMode.SIMPLE, label: 'Simple Mapping', icon: Type },
{ id: PathMappingMode.REGEX, label: 'Regex Rules', icon: Code2 },
{ id: PathMappingMode.SIMPLE, label: t('mapping.simple'), icon: Type },
{ id: PathMappingMode.REGEX, label: t('mapping.regex'), icon: Code2 },
].map((tab) => (
<button
key={tab.id}
@@ -608,17 +613,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
// Simple Mode: Single Editor
<div className="animate-in fade-in duration-200">
<MappingGroupEditor
title="Path Mapping"
subtitle="Map Local paths to Cloud paths using simple string matching"
title={t('mapping.simpleTitle')}
subtitle={t('mapping.simpleSubtitle')}
rules={simpleRules}
onChange={updateSimpleGroup}
isLocked={isLocked}
borderColor={MAPPING_THEME.simple.borderColor}
bgColor={MAPPING_THEME.simple.bgColor}
leftPlaceholder="Local Path"
rightPlaceholder="Cloud Path"
leftPlaceholder={t('mapping.localPath')}
rightPlaceholder={t('mapping.cloudPath')}
leftInputClass={MAPPING_THEME.inputs.local}
rightInputClass={MAPPING_THEME.inputs.cloud}
t={t}
/>
</div>
) : (
@@ -626,44 +632,48 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-200">
{/* Row 1: Pre-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Pre-Processing (Before Sync)"
title={t('server.local')}
subtitle={t('mapping.regexPre')}
rules={regexRules.localPre}
onChange={(rules) => updateRegexGroup('localPre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Pre-Processing (Before Sync)"
title={t('server.cloud')}
subtitle={t('mapping.regexPre')}
rules={regexRules.remotePre}
onChange={(rules) => updateRegexGroup('remotePre', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
{/* Row 2: Post-Processing */}
<MappingGroupEditor
title="Local Playlist"
subtitle="Post-Processing (After Sync / Result)"
title={t('server.local')}
subtitle={t('mapping.regexPost')}
rules={regexRules.localPost}
onChange={(rules) => updateRegexGroup('localPost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.local.borderColor}
bgColor={MAPPING_THEME.local.bgColor}
t={t}
/>
<MappingGroupEditor
title="Remote Playlist"
subtitle="Post-Processing (After Sync / Result)"
title={t('server.cloud')}
subtitle={t('mapping.regexPost')}
rules={regexRules.remotePost}
onChange={(rules) => updateRegexGroup('remotePost', rules)}
isLocked={isLocked}
borderColor={MAPPING_THEME.remote.borderColor}
bgColor={MAPPING_THEME.remote.bgColor}
t={t}
/>
</div>
)}
@@ -679,7 +689,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveMappingClick}
@@ -690,7 +700,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save Rules</span>
<span>{t('mapping.saveRules')}</span>
</button>
</div>
</div>
@@ -698,15 +708,15 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{/* Section 3: Scheduled Tasks */}
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
<div className="flex items-center justify-between mb-3">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Scheduled Tasks</h3>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{t('schedule.title')}</h3>
</div>
{/* Tabs */}
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
{[
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
{ id: ScheduleMode.CRON, label: t('schedule.cron'), icon: Repeat },
{ id: ScheduleMode.DAILY, label: t('schedule.daily'), icon: Clock },
{ id: ScheduleMode.WEEKLY, label: t('schedule.weekly'), icon: Calendar },
].map((tab) => (
<button
key={tab.id}
@@ -729,7 +739,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Cron Schedule</span>
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableCron')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.CRON)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.CRON ? 'bg-plex-orange' : 'bg-gray-700'}`}
@@ -762,7 +772,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Daily Run</span>
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableDaily')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.DAILY ? 'bg-plex-orange' : 'bg-gray-700'}`}
@@ -788,7 +798,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<div className="flex flex-col animate-in fade-in duration-200">
{/* Top Row: Label + Switch */}
<div className="flex items-center justify-between mb-3 px-1">
<span className="text-xs text-gray-400 font-medium">Enable Weekly Run</span>
<span className="text-xs text-gray-400 font-medium">{t('schedule.enableWeekly')}</span>
<button
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localSchedule.mode === ScheduleMode.WEEKLY ? 'bg-plex-orange' : 'bg-gray-700'}`}
@@ -840,8 +850,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
<Eye size={16} />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-200">Watch Local Changes</span>
<span className="text-[10px] text-gray-500">Auto-sync when local playlist updates</span>
<span className="text-sm font-medium text-gray-200">{t('schedule.watchLocal')}</span>
<span className="text-[10px] text-gray-500">{t('schedule.watchDesc')}</span>
</div>
</div>
<button
@@ -863,7 +873,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
>
<RotateCcw size={12} />
<span>Revert</span>
<span>{t('common.revert')}</span>
</button>
<button
onClick={handleSaveScheduleClick}
@@ -874,7 +884,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
>
<Save size={12} />
<span>Save</span>
<span>{t('common.save')}</span>
</button>
</div>
</div>
@@ -896,18 +906,18 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
{isSyncing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Sync in Progress...</span>
<span>{t('strategies.syncing')}</span>
</>
) : (
<>
<Zap size={16} fill="currentColor" />
<span>Sync Now</span>
<span>{t('strategies.syncNow')}</span>
</>
)}
</button>
{(isMappingDirty || isBackupDirty) && (
<p className="text-[10px] text-plex-orange text-center mt-2">
Please save pending changes (Backups/Path Mapping) before syncing.
{t('strategies.saveWarning')}
</p>
)}
</div>
@@ -916,4 +926,4 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
);
};
export default StrategySelector;
export default StrategySelector;