feat(profiles): radarr/sonarr split functionality (#215)

- added option to set radarr/sonarr specific scores that profilarr's compiler will handle on import
- revise design for arr settings container - now styled as a table
- completely rewrote import module. Now uses connection pooling to reuse connections.
- fixed import progress bug where 1 failed format causes all other formats to be labelled as failed (even if they succeeded)
- fixed bug where on pull sync wasn't working
- improve styling for link / unlink database modals
- fixed issue where 0 score formats were removed in selective mode
This commit is contained in:
Samuel Chau
2025-08-11 01:51:51 +09:30
committed by GitHub
parent affb9cbf1f
commit d7d6b13e46
44 changed files with 3712 additions and 863 deletions

View File

@@ -33,7 +33,17 @@ export const pingService = async (url, apiKey, type) => {
export const saveArrConfig = async config => {
try {
const response = await axios.post(`/api/arr/config`, config, {
// Validate and auto-correct sync_interval if schedule method
const validatedConfig = {...config};
if (validatedConfig.sync_method === 'schedule' && validatedConfig.sync_interval) {
if (validatedConfig.sync_interval < 60) {
validatedConfig.sync_interval = 60;
} else if (validatedConfig.sync_interval > 43200) {
validatedConfig.sync_interval = 43200;
}
}
const response = await axios.post(`/api/arr/config`, validatedConfig, {
validateStatus: status => {
return (status >= 200 && status < 300) || status === 409;
}
@@ -54,7 +64,17 @@ export const saveArrConfig = async config => {
export const updateArrConfig = async (id, config) => {
try {
const response = await axios.put(`/api/arr/config/${id}`, config, {
// Validate and auto-correct sync_interval if schedule method
const validatedConfig = {...config};
if (validatedConfig.sync_method === 'schedule' && validatedConfig.sync_interval) {
if (validatedConfig.sync_interval < 60) {
validatedConfig.sync_interval = 60;
} else if (validatedConfig.sync_interval > 43200) {
validatedConfig.sync_interval = 43200;
}
}
const response = await axios.put(`/api/arr/config/${id}`, validatedConfig, {
validateStatus: status => {
return (status >= 200 && status < 300) || status === 409;
}

View File

@@ -1,77 +1,40 @@
import axios from 'axios';
const IMPORT_BASE_URL = '/api/import';
const API_URL = '/api/v2/import';
/**
* Import multiple formats to a specified arr instance
* @param {string|number} arr - The arr ID to import to
* @param {string[]} formatNames - Array of format file names to import
* @param {boolean} [all] - Whether to import all formats
* @returns {Promise<void>}
* Import formats or profiles to a specified arr instance
* @param {string|number} arrID - The arr config ID to import to
* @param {string} strategy - Either 'format' or 'profile'
* @param {string[]} filenames - Array of file names to import
* @returns {Promise<Object>} Import results
*/
export const importFormats = async (arr, formatNames, all = false) => {
export const importData = async (arrID, strategy, filenames) => {
try {
// Clean up format names by removing .yml if present
const cleanFormatNames = formatNames.map(name =>
name.endsWith('.yml') ? name.slice(0, -4) : name
// Clean filenames - remove .yml extension if present
const cleanFilenames = filenames.map(name =>
name.replace('.yml', '')
);
const response = await axios.post(`${IMPORT_BASE_URL}/format`, {
arrId: parseInt(arr, 10),
formatNames: cleanFormatNames,
all
const response = await axios.post(API_URL, {
arrID: parseInt(arrID, 10),
strategy: strategy,
filenames: cleanFilenames
});
if (!response.data.success) {
throw new Error(
response.data.message || 'Failed to import formats'
);
throw new Error(response.data.error || 'Import failed');
}
return response.data;
} catch (error) {
console.error('Error importing formats:', error);
throw (
error.response?.data?.message ||
error.message ||
'Failed to import formats'
);
console.error('Import error:', error);
throw error.response?.data?.error || error.message || 'Failed to import';
}
};
/**
* Import multiple profiles to a specified arr instance
* @param {string|number} arr - The arr ID to import to
* @param {string[]} profileNames - Array of profile file names to import
* @param {boolean} [all] - Whether to import all profiles
* @returns {Promise<void>}
*/
export const importProfiles = async (arr, profileNames, all = false) => {
try {
// Clean up profile names by removing .yml if present
const cleanProfileNames = profileNames.map(name =>
name.endsWith('.yml') ? name.slice(0, -4) : name
);
export const importFormats = (arrID, formatNames) =>
importData(arrID, 'format', formatNames);
const response = await axios.post(`${IMPORT_BASE_URL}/profile`, {
arrId: parseInt(arr, 10),
profileNames: cleanProfileNames,
all
});
if (!response.data.success) {
throw new Error(
response.data.message || 'Failed to import profiles'
);
}
return response.data;
} catch (error) {
console.error('Error importing profiles:', error);
throw (
error.response?.data?.message ||
error.message ||
'Failed to import profiles'
);
}
};
export const importProfiles = (arrID, profileNames) =>
importData(arrID, 'profile', profileNames);

View File

@@ -275,15 +275,24 @@ function FormatPage() {
}
};
const handleMassImport = async arr => {
const handleMassImport = async arrID => {
try {
const selectedFormats = Array.from(selectedItems).map(
index => filteredFormats[index]
);
const formatNames = selectedFormats.map(format => format.file_name);
await importFormats(arr, formatNames);
Alert.success('Formats imported successfully');
const result = await importFormats(arrID, formatNames);
if (result.status === 'partial') {
const { added, updated, failed } = result;
Alert.partial(
`Import partially successful:\n- ${added} added\n- ${updated} updated\n- ${failed} failed`
);
} else {
Alert.success('Formats imported successfully');
}
toggleSelectionMode();
} catch (error) {
console.error('Error importing formats:', error);

View File

@@ -67,9 +67,23 @@ const ProfileCard = ({
if (!profile || !profile.content) return null;
const {content} = profile;
const activeCustomFormats = (content.custom_formats || []).filter(
format => format.score !== 0
).length;
const activeCustomFormats = (() => {
const customFormats = content.custom_formats || [];
// Handle old format (array)
if (Array.isArray(customFormats)) {
return customFormats.filter(format => format.score !== 0).length;
}
// Handle new format (object with both/radarr/sonarr)
let totalActive = 0;
['both', 'radarr', 'sonarr'].forEach(appType => {
const appFormats = customFormats[appType] || [];
totalActive += appFormats.filter(format => format.score !== 0).length;
});
return totalActive;
})();
const handleClick = e => {
if (isSelectionMode) {
@@ -262,12 +276,36 @@ ProfileCard.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.string),
custom_formats: PropTypes.arrayOf(
custom_formats: PropTypes.oneOfType([
// Old format: array
PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
score: PropTypes.number
})
),
// New format: object with app-specific arrays
PropTypes.shape({
name: PropTypes.string,
score: PropTypes.number
both: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
score: PropTypes.number
})
),
radarr: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
score: PropTypes.number
})
),
sonarr: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
score: PropTypes.number
})
)
})
),
]),
language: PropTypes.string,
upgradesAllowed: PropTypes.bool,
qualities: PropTypes.arrayOf(

View File

@@ -35,8 +35,12 @@ function ProfileModal({
// Tags state
const [tags, setTags] = useState([]);
// Format scoring state
const [customFormats, setCustomFormats] = useState([]);
// Format scoring state - now app-specific
const [customFormats, setCustomFormats] = useState({
both: [],
radarr: [],
sonarr: []
});
const [formatTags, setFormatTags] = useState([]);
const [formatFilter, setFormatFilter] = useState('');
const [formatSortKey, setFormatSortKey] = useState('score');
@@ -76,14 +80,18 @@ function ProfileModal({
setError('');
setTags([]);
// Format scoring state
// Format scoring state - initialize all apps with all formats at score 0
const safeCustomFormats = formats.map(format => ({
id: format.name,
name: format.name,
score: 0,
tags: format.tags || []
}));
setCustomFormats(safeCustomFormats);
setCustomFormats({
both: [...safeCustomFormats],
radarr: [...safeCustomFormats],
sonarr: [...safeCustomFormats]
});
// Reset all other states to defaults
setUpgradesAllowed(false);
@@ -147,22 +155,36 @@ function ProfileModal({
setUpgradeUntilScore(Number(content.upgradeUntilScore || 0));
setMinScoreIncrement(Number(content.minScoreIncrement || 0));
// Custom formats setup
// Custom formats setup - handle backwards compatible format
const initialCustomFormats = content.custom_formats || [];
const safeCustomFormats = formats.map(format => ({
id: format.name,
name: format.name,
score:
initialCustomFormats.find(cf => cf.name === format.name)
?.score || 0,
tags: format.tags || []
}));
setCustomFormats(safeCustomFormats);
const initialCustomFormatsRadarr = content.custom_formats_radarr || [];
const initialCustomFormatsSonarr = content.custom_formats_sonarr || [];
setCustomFormats({
both: formats.map(format => ({
id: format.name,
name: format.name,
score: initialCustomFormats.find(cf => cf.name === format.name)?.score || 0,
tags: format.tags || []
})),
radarr: formats.map(format => ({
id: format.name,
name: format.name,
score: initialCustomFormatsRadarr.find(cf => cf.name === format.name)?.score || 0,
tags: format.tags || []
})),
sonarr: formats.map(format => ({
id: format.name,
name: format.name,
score: initialCustomFormatsSonarr.find(cf => cf.name === format.name)?.score || 0,
tags: format.tags || []
}))
});
// Format tags
const allTags = [
...new Set(
safeCustomFormats.flatMap(format => format.tags || [])
formats.flatMap(format => format.tags || [])
)
];
setFormatTags(allTags);
@@ -278,12 +300,16 @@ function ProfileModal({
score: 0,
tags: format.tags || []
}));
setCustomFormats(safeCustomFormats);
setCustomFormats({
both: [...safeCustomFormats],
radarr: [...safeCustomFormats],
sonarr: [...safeCustomFormats]
});
// Format tags
const allTags = [
...new Set(
safeCustomFormats.flatMap(format => format.tags || [])
formats.flatMap(format => format.tags || [])
)
];
setFormatTags(allTags);
@@ -345,62 +371,54 @@ function ProfileModal({
minScoreIncrement,
custom_formats: (() => {
// Check if selective mode is enabled
const selectiveMode = localStorage.getItem(
'formatSettingsSelectiveMode'
);
const useSelectiveMode =
selectiveMode !== null && JSON.parse(selectiveMode);
const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode');
const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode);
// Helper function to process formats for an app type
const processFormats = (appFormats, appType) => {
if (useSelectiveMode) {
try {
// Get the list of explicitly selected format IDs for this app
const selectedFormatIdsStr = localStorage.getItem(`selectedFormatIds_${appType}`);
const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : [];
if (useSelectiveMode) {
// In selective mode, save both:
// 1. Formats with non-zero scores as usual
// 2. Formats with zero score that have been explicitly selected in selectedFormatIds
// Get formats with non-zero scores
const nonZeroFormats = appFormats.filter(format => format.score !== 0);
try {
// Get the list of explicitly selected format IDs
const selectedFormatIdsStr =
localStorage.getItem('selectedFormatIds');
const selectedFormatIds = selectedFormatIdsStr
? JSON.parse(selectedFormatIdsStr)
: [];
// Get formats with non-zero scores
const nonZeroFormats = customFormats.filter(
format => format.score !== 0
);
// Get formats with zero scores that are explicitly selected
const explicitlySelectedZeroFormats =
customFormats.filter(
format =>
format.score === 0 &&
selectedFormatIds.includes(format.id)
// Get formats with zero scores that are explicitly selected
const explicitlySelectedZeroFormats = appFormats.filter(
format => format.score === 0 && selectedFormatIds.includes(format.id)
);
// Combine both lists
return [
...nonZeroFormats,
...explicitlySelectedZeroFormats
]
.sort((a, b) => {
// First sort by score (descending)
if (b.score !== a.score) {
return b.score - a.score;
}
// Then alphabetically for equal scores
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
} catch (e) {
// If there's any error parsing the selectedFormatIds, fall back to just non-zero scores
return customFormats
// Combine both lists
return [...nonZeroFormats, ...explicitlySelectedZeroFormats]
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
} catch (e) {
// Fallback to just non-zero scores
return appFormats
.filter(format => format.score !== 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
}
} else {
// Standard behavior - only include formats with non-zero scores
return appFormats
.filter(format => format.score !== 0)
.sort((a, b) => {
if (b.score !== a.score)
return b.score - a.score;
if (b.score !== a.score) return b.score - a.score;
return a.name.localeCompare(b.name);
})
.map(format => ({
@@ -408,24 +426,79 @@ function ProfileModal({
score: format.score
}));
}
} else {
// Standard behavior - only include formats with non-zero scores
return customFormats
.filter(format => format.score !== 0)
.sort((a, b) => {
// First sort by score (descending)
if (b.score !== a.score) {
return b.score - a.score;
}
// Then alphabetically for equal scores
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
}
};
// Always save "both" formats as the main custom_formats field for backwards compatibility
return processFormats(customFormats.both || [], 'both');
})(),
...((() => {
// Check if selective mode is enabled
const selectiveMode = localStorage.getItem('formatSettingsSelectiveMode');
const useSelectiveMode = selectiveMode !== null && JSON.parse(selectiveMode);
// Helper function to process formats for an app type
const processFormats = (appFormats, appType) => {
if (useSelectiveMode) {
try {
// Get the list of explicitly selected format IDs for this app
const selectedFormatIdsStr = localStorage.getItem(`selectedFormatIds_${appType}`);
const selectedFormatIds = selectedFormatIdsStr ? JSON.parse(selectedFormatIdsStr) : [];
// Get formats with non-zero scores
const nonZeroFormats = appFormats.filter(format => format.score !== 0);
// Get formats with zero scores that are explicitly selected
const explicitlySelectedZeroFormats = appFormats.filter(
format => format.score === 0 && selectedFormatIds.includes(format.id)
);
// Combine both lists
return [...nonZeroFormats, ...explicitlySelectedZeroFormats]
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
} catch (e) {
// Fallback to just non-zero scores
return appFormats
.filter(format => format.score !== 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
}
} else {
// Standard behavior - only include formats with non-zero scores
return appFormats
.filter(format => format.score !== 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.name.localeCompare(b.name);
})
.map(format => ({
name: format.name,
score: format.score
}));
}
};
// Always include app-specific formats as separate fields (empty arrays if no scores)
const radarrFormats = processFormats(customFormats.radarr || [], 'radarr');
const sonarrFormats = processFormats(customFormats.sonarr || [], 'sonarr');
return {
custom_formats_radarr: radarrFormats,
custom_formats_sonarr: sonarrFormats
};
})()),
qualities: sortedQualities
.filter(q => q.enabled)
.map(q => {
@@ -583,15 +656,38 @@ function ProfileModal({
)}
{activeTab === 'scoring' && (
<ProfileScoringTab
formats={customFormats}
customFormats={customFormats}
formatFilter={formatFilter}
onFormatFilterChange={setFormatFilter}
onScoreChange={(id, score) => {
setCustomFormats(prev =>
prev.map(f =>
onScoreChange={(appType, id, score) => {
setCustomFormats(prev => {
const newFormats = {...prev};
// If setting a non-zero score, handle conflicts
if (score !== 0) {
if (appType === 'both') {
// Setting in "both" clears radarr AND sonarr
newFormats.radarr = newFormats.radarr.map(f =>
f.id === id ? {...f, score: 0} : f
);
newFormats.sonarr = newFormats.sonarr.map(f =>
f.id === id ? {...f, score: 0} : f
);
} else {
// Setting in radarr/sonarr only clears "both"
newFormats.both = newFormats.both.map(f =>
f.id === id ? {...f, score: 0} : f
);
}
}
// Update the target app type
newFormats[appType] = newFormats[appType].map(f =>
f.id === id ? {...f, score} : f
)
);
);
return newFormats;
});
}}
formatSortKey={formatSortKey}
formatSortDirection={formatSortDirection}
@@ -600,24 +696,51 @@ function ProfileModal({
tagFilter={tagFilter}
onTagFilterChange={setTagFilter}
tagScores={tagScores}
onTagScoreChange={(tag, score) => {
onTagScoreChange={(appType, tag, score) => {
setTagScores(prev => ({
...prev,
[tag]: score
}));
setCustomFormats(prev =>
prev.map(format => {
if (
format.tags?.includes(tag)
) {
return {
...format,
score
};
setCustomFormats(prev => {
const newFormats = {...prev};
// If setting a non-zero score, handle conflicts for all formats with this tag
if (score !== 0) {
if (appType === 'both') {
// Setting in "both" clears radarr AND sonarr for formats with this tag
newFormats.radarr = newFormats.radarr.map(format => {
if (format.tags?.includes(tag)) {
return {...format, score: 0};
}
return format;
});
newFormats.sonarr = newFormats.sonarr.map(format => {
if (format.tags?.includes(tag)) {
return {...format, score: 0};
}
return format;
});
} else {
// Setting in radarr/sonarr only clears "both" for formats with this tag
newFormats.both = newFormats.both.map(format => {
if (format.tags?.includes(tag)) {
return {...format, score: 0};
}
return format;
});
}
}
// Update the target app type
newFormats[appType] = newFormats[appType].map(format => {
if (format.tags?.includes(tag)) {
return {...format, score};
}
return format;
})
);
});
return newFormats;
});
}}
tagSortKey={tagSortKey}
tagSortDirection={tagSortDirection}

View File

@@ -274,7 +274,7 @@ function ProfilePage() {
}
};
const handleMassImport = async arr => {
const handleMassImport = async arrID => {
try {
const selectedProfilesList = Array.from(selectedItems)
.map(index => filteredProfiles[index])
@@ -285,11 +285,20 @@ function ProfilePage() {
return;
}
await importProfiles(
arr,
const result = await importProfiles(
arrID,
selectedProfilesList.map(p => p.file_name)
);
Alert.success('Profiles imported successfully');
if (result.status === 'partial') {
const { added, updated, failed } = result;
Alert.partial(
`Import partially successful:\n- ${added} added\n- ${updated} updated\n- ${failed} failed`
);
} else {
Alert.success('Profiles imported successfully');
}
toggleSelectionMode();
} catch (error) {
console.error('Error importing profiles:', error);

View File

@@ -5,27 +5,27 @@ import useSearch from '@hooks/useSearch';
import AdvancedView from './AdvancedView';
import BasicView from './BasicView';
import FormatSelectorModal from './FormatSelectorModal';
import {ChevronDown, Settings, List, CheckSquare, Plus} from 'lucide-react';
import FormatSettingsModal from './FormatSettingsModal';
import {Settings, Plus, CheckSquare} from 'lucide-react';
import Tooltip from '@ui/Tooltip';
const FormatSettings = ({formats, onScoreChange}) => {
const FormatSettings = ({formats, onScoreChange, appType = 'both', activeApp, onAppChange}) => {
// Initialize state from localStorage, falling back to true if no value is stored
const [isAdvancedView, setIsAdvancedView] = useState(() => {
const stored = localStorage.getItem('formatSettingsView');
return stored === null ? true : JSON.parse(stored);
});
// Initialize selectiveMode from localStorage
// Initialize selectiveMode from localStorage (global setting)
const [showSelectiveMode, setShowSelectiveMode] = useState(() => {
const stored = localStorage.getItem('formatSettingsSelectiveMode');
return stored === null ? false : JSON.parse(stored);
});
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [availableFormats, setAvailableFormats] = useState([]);
const [selectedFormatIds, setSelectedFormatIds] = useState(() => {
try {
const stored = localStorage.getItem('selectedFormatIds');
const stored = localStorage.getItem(`selectedFormatIds_${appType}`);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
@@ -35,6 +35,9 @@ const FormatSettings = ({formats, onScoreChange}) => {
// Format selector modal state
const [isSelectorModalOpen, setIsSelectorModalOpen] = useState(false);
// Settings modal state
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
// Calculate which formats to display
const displayFormats = useMemo(() => {
if (showSelectiveMode) {
@@ -64,8 +67,8 @@ const FormatSettings = ({formats, onScoreChange}) => {
// Save selected format IDs to localStorage
useEffect(() => {
localStorage.setItem('selectedFormatIds', JSON.stringify(selectedFormatIds));
}, [selectedFormatIds]);
localStorage.setItem(`selectedFormatIds_${appType}`, JSON.stringify(selectedFormatIds));
}, [selectedFormatIds, appType]);
// Calculate available formats for selection (not already in use)
useEffect(() => {
@@ -126,22 +129,28 @@ const FormatSettings = ({formats, onScoreChange}) => {
// Pass the score change to parent
onScoreChange(formatId, score);
// If the score is changing from 0 to non-zero, we no longer need to track it
// as an explicitly selected format (it's tracked by virtue of its non-zero score)
const format = formats.find(f => f.id === formatId);
if (!format) return;
if (score !== 0) {
const format = formats.find(f => f.id === formatId);
if (format && format.score === 0 && selectedFormatIds.includes(formatId)) {
// If the score is changing from 0 to non-zero, we no longer need to track it
// as an explicitly selected format (it's tracked by virtue of its non-zero score)
if (format.score === 0 && selectedFormatIds.includes(formatId)) {
// Format was previously explicitly selected with zero score, but now has a non-zero score
// We can remove it from our explicit selection tracking
setSelectedFormatIds(prev => prev.filter(id => id !== formatId));
}
} else {
// If the score is changing to 0, we need to track it as explicitly selected
// so it remains visible in selective mode
if (format.score !== 0 && !selectedFormatIds.includes(formatId)) {
// Format was previously non-zero, but now is 0
// Add it to our explicit selection tracking
setSelectedFormatIds(prev => [...prev, formatId]);
}
}
};
// Toggle selective mode on/off
const toggleSelectiveMode = () => {
setShowSelectiveMode(prev => !prev);
};
// Open the format selector modal
const openFormatSelector = () => {
@@ -163,130 +172,43 @@ const FormatSettings = ({formats, onScoreChange}) => {
/>
<div className='flex gap-2'>
{/* View Mode Dropdown */}
<div className='relative flex'>
{/* Settings Button */}
<Tooltip content="Format settings" position="bottom">
<button
onClick={() => setIsDropdownOpen(prev => !prev)}
className='inline-flex items-center justify-between w-36 px-3 py-2 rounded-md border border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
aria-expanded={isDropdownOpen}
aria-haspopup='true'
onClick={() => setIsSettingsModalOpen(true)}
className='px-3 py-2 rounded-md border border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 flex items-center gap-2'
>
<span className='flex items-center gap-2'>
{isAdvancedView ? (
<>
<Settings
size={16}
className='text-gray-500 dark:text-gray-400'
/>
<span className='text-sm font-medium'>
Advanced
</span>
</>
) : (
<>
<List
size={16}
className='text-gray-500 dark:text-gray-400'
/>
<span className='text-sm font-medium'>
Basic
</span>
</>
)}
</span>
<ChevronDown
size={16}
className={`text-gray-500 dark:text-gray-400 transition-transform ${
isDropdownOpen ? 'transform rotate-180' : ''
}`}
/>
<Settings size={16} className='text-gray-500 dark:text-gray-400' />
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>Settings</span>
</button>
{isDropdownOpen && (
<>
<div
className='fixed inset-0'
onClick={() => setIsDropdownOpen(false)}
/>
<div className='absolute right-0 mt-12 w-36 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 z-10'>
<div>
<button
onClick={() => {
setIsAdvancedView(false);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm ${
!isAdvancedView
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
<div className='flex items-center gap-2'>
<List size={16} />
<span>Basic</span>
</div>
</button>
<button
onClick={() => {
setIsAdvancedView(true);
setIsDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm ${
isAdvancedView
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}>
<div className='flex items-center gap-2'>
<Settings size={16} />
<span>Advanced</span>
</div>
</button>
</div>
</div>
</>
)}
</div>
</Tooltip>
{/* Selective Mode with Format Selector */}
<div className="flex">
<button
onClick={toggleSelectiveMode}
className={`px-3 py-2 rounded-l-md border transition-colors flex items-center gap-1 ${
showSelectiveMode
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
: 'border-gray-300 bg-white hover:border-gray-400 dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
}`}
title={showSelectiveMode ? 'Hide unused formats' : 'Show all formats'}
>
<CheckSquare size={16} />
<span className='text-sm font-medium'>Selective</span>
</button>
{showSelectiveMode && (
<Tooltip
content="Select formats to include in your profile"
position="bottom"
{/* Selective Mode Toggle */}
<button
onClick={() => setShowSelectiveMode(prev => !prev)}
className={`px-3 py-2 rounded-md border transition-colors flex items-center gap-2 ${
showSelectiveMode
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
: 'border-gray-300 bg-white hover:border-gray-400 dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600'
}`}
title={showSelectiveMode ? 'Hide unused formats' : 'Show all formats'}
>
<CheckSquare size={16} />
<span className='text-sm font-medium'>Selective</span>
</button>
{/* Add Format Button (only show in selective mode) */}
{showSelectiveMode && (
<Tooltip content="Add formats to profile" position="bottom">
<button
onClick={openFormatSelector}
className="px-3 py-2 border rounded-md border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 flex items-center gap-2"
>
<button
onClick={openFormatSelector}
className="px-3 py-2 border rounded-r-md border-gray-300 bg-white hover:border-gray-400 transition-colors dark:bg-gray-800 dark:border-gray-700 dark:hover:border-gray-600 flex items-center gap-1 h-full -ml-[1px]"
>
<Plus size={16} />
<span className="text-sm font-medium">Add</span>
</button>
</Tooltip>
)}
{!showSelectiveMode && (
<Tooltip
content="Enable selective mode to add formats"
position="bottom"
>
<div className="px-3 py-2 border rounded-r-md bg-gray-100 border-gray-300 text-gray-400 dark:bg-gray-700 dark:border-gray-700 dark:text-gray-500 flex items-center gap-1 cursor-not-allowed h-full -ml-[1px]">
<Plus size={16} />
<span className="text-sm font-medium">Add</span>
</div>
</Tooltip>
)}
</div>
<Plus size={16} className='text-gray-500 dark:text-gray-400' />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Add</span>
</button>
</Tooltip>
)}
</div>
</div>
@@ -300,6 +222,16 @@ const FormatSettings = ({formats, onScoreChange}) => {
onFormatToggle={handleFormatToggle}
/>
{/* Settings Modal */}
<FormatSettingsModal
isOpen={isSettingsModalOpen}
onClose={() => setIsSettingsModalOpen(false)}
activeApp={activeApp}
onAppChange={onAppChange}
isAdvancedView={isAdvancedView}
onViewChange={setIsAdvancedView}
/>
{/* Format Display */}
{isAdvancedView ? (
<AdvancedView
@@ -329,7 +261,10 @@ FormatSettings.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
onScoreChange: PropTypes.func.isRequired
onScoreChange: PropTypes.func.isRequired,
appType: PropTypes.oneOf(['both', 'radarr', 'sonarr']),
activeApp: PropTypes.oneOf(['both', 'radarr', 'sonarr']).isRequired,
onAppChange: PropTypes.func.isRequired
};
export default FormatSettings;

View File

@@ -0,0 +1,100 @@
import React from 'react';
import PropTypes from 'prop-types';
import Modal from '@ui/Modal';
import TabViewer from '@ui/TabViewer';
const FormatSettingsModal = ({
isOpen,
onClose,
activeApp,
onAppChange,
isAdvancedView,
onViewChange
}) => {
const appTabs = [
{id: 'both', label: 'Both'},
{id: 'radarr', label: 'Radarr'},
{id: 'sonarr', label: 'Sonarr'}
];
const viewTabs = [
{id: 'basic', label: 'Basic'},
{id: 'advanced', label: 'Advanced'}
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Format Settings"
width="md"
level={1}
>
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-gray-200 mb-2">Instance Scoring</h3>
<p className="text-xs text-gray-400 mb-3">
Set scores for both apps by default, or override with app-specific scores. App-specific scores take precedence over default scores.
</p>
<div className="flex space-x-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
{[
{key: 'both', label: 'Both'},
{key: 'radarr', label: 'Radarr'},
{key: 'sonarr', label: 'Sonarr'}
].map(app => (
<button
key={app.key}
onClick={() => onAppChange(app.key)}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeApp === app.key
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
{app.label}
</button>
))}
</div>
</div>
<hr className="border-gray-700" />
<div>
<h3 className="text-sm font-medium text-gray-200 mb-2">Display Mode</h3>
<p className="text-xs text-gray-400 mb-3">
Choose how format scores are displayed and edited. Use Basic mode for a simple list view with sliders, Advanced mode for detailed A/V category grids.
</p>
<div className="flex space-x-1 bg-gray-100 dark:bg-gray-800 p-1 rounded-lg">
{[
{key: 'basic', label: 'Basic'},
{key: 'advanced', label: 'Advanced'}
].map(mode => (
<button
key={mode.key}
onClick={() => onViewChange(mode.key === 'advanced')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
(isAdvancedView ? 'advanced' : 'basic') === mode.key
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
{mode.label}
</button>
))}
</div>
</div>
</div>
</Modal>
);
};
FormatSettingsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
activeApp: PropTypes.oneOf(['both', 'radarr', 'sonarr']).isRequired,
onAppChange: PropTypes.func.isRequired,
isAdvancedView: PropTypes.bool.isRequired,
onViewChange: PropTypes.func.isRequired
};
export default FormatSettingsModal;

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import FormatSettings from './FormatSettings';
import UpgradeSettings from './UpgradeSettings';
const ProfileScoringTab = ({
formats,
customFormats,
onScoreChange,
minCustomFormatScore,
upgradeUntilScore,
@@ -15,6 +15,7 @@ const ProfileScoringTab = ({
upgradesAllowed,
onUpgradesAllowedChange
}) => {
const [activeApp, setActiveApp] = useState('both');
return (
<div className='w-full space-y-6'>
{/* Upgrade Settings Section */}
@@ -71,15 +72,17 @@ const ProfileScoringTab = ({
</h2>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Customize format scoring to prioritize your preferred downloads.
Use Basic mode for a simple list view with sliders, Advanced mode for
detailed A/V category grids, and Selective mode to display and manage
Selective mode allows you to display and manage
only formats you care about instead of all available formats.
</p>
</div>
<FormatSettings
formats={formats}
onScoreChange={onScoreChange}
formats={customFormats[activeApp] || []}
onScoreChange={(id, score) => onScoreChange(activeApp, id, score)}
appType={activeApp}
activeApp={activeApp}
onAppChange={setActiveApp}
/>
</div>
</div>
@@ -87,14 +90,32 @@ const ProfileScoringTab = ({
};
ProfileScoringTab.propTypes = {
formats: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
).isRequired,
customFormats: PropTypes.shape({
both: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
),
radarr: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
),
sonarr: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
score: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.string)
})
)
}).isRequired,
onScoreChange: PropTypes.func.isRequired,
minCustomFormatScore: PropTypes.number.isRequired,
upgradeUntilScore: PropTypes.number.isRequired,

View File

@@ -1,153 +0,0 @@
import React from 'react';
import {Clock, ArrowUpDown, BarChart} from 'lucide-react';
import RadarrLogo from '@logo/Radarr.svg';
import SonarrLogo from '@logo/Sonarr.svg';
const ArrCard = ({
title,
type,
sync_percentage = 0,
last_sync_time,
sync_method,
sync_interval,
tags = [],
data_to_sync = {},
import_as_unique,
onClick
}) => {
// Format last sync time
const formatLastSync = timestamp => {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
return date.toLocaleString();
};
// Get sync method display
const getSyncMethodDisplay = () => {
switch (sync_method) {
case 'pull':
return 'On Pull';
case 'schedule':
return `Scheduled (${sync_interval}m)`;
case 'manual':
return 'Manual';
default:
return 'Unknown';
}
};
const syncMethodDisplay = getSyncMethodDisplay();
return (
<div
onClick={onClick}
className='bg-gradient-to-br from-gray-800 to-gray-900 rounded-lg border border-gray-700
shadow-xl hover:shadow-2xl hover:border-blue-500/50 transition-all duration-200
cursor-pointer overflow-hidden group'>
<div className='p-4 space-y-4'>
{/* Header with Logo, Title, and Tags */}
<div className='flex items-start justify-between'>
<div className='flex items-center space-x-3'>
<div className='p-2 rounded-lg group-hover:bg-blue-500/20 transition-colors'>
<img
src={
type === 'radarr' ? RadarrLogo : SonarrLogo
}
className='w-5 h-5'
alt={type === 'radarr' ? 'Radarr' : 'Sonarr'}
/>
</div>
<div>
<h3 className='font-medium text-gray-100'>
{title}
</h3>
<div className='flex items-center mt-1 text-sm text-gray-400'>
<ArrowUpDown className='w-3.5 h-3.5 mr-1.5' />
{syncMethodDisplay}
</div>
</div>
</div>
<div className='flex flex-wrap gap-1 justify-end'>
{tags.map((tag, index) => (
<span
key={index}
className='px-2 py-0.5 text-xs font-medium rounded-full
bg-blue-500/20 text-blue-300 border border-blue-500/20'>
{tag}
</span>
))}
{import_as_unique && (
<span
className='px-2 py-0.5 text-xs font-medium rounded-full
bg-green-500/20 text-green-300 border border-green-500/20
flex items-center'>
Unique
</span>
)}
</div>
</div>
{/* Sync Progress */}
<div className='space-y-2'>
<div className='flex items-center justify-between text-sm'>
<span className='text-gray-400 flex items-center'>
<BarChart className='w-3.5 h-3.5 mr-1.5' />
Sync Progress
</span>
<span className='font-medium text-gray-300'>
{sync_percentage}%
</span>
</div>
<div className='w-full bg-gray-700/50 rounded-full h-1.5'>
<div
className='bg-blue-500 h-1.5 rounded-full transition-all duration-300'
style={{
width: `${Math.max(
0,
Math.min(100, sync_percentage)
)}%`
}}
/>
</div>
</div>
{/* Sync Details */}
<div className='grid grid-cols-2 gap-3 pt-2 border-t border-gray-700/50'>
<div className='text-sm'>
<div className='flex items-center text-gray-400 mb-1'>
<Clock className='w-3.5 h-3.5 mr-1.5' />
Last Sync
</div>
<div className='text-gray-300'>
{formatLastSync(last_sync_time)}
</div>
</div>
{/* Profiles Section */}
{data_to_sync?.profiles &&
data_to_sync.profiles.length > 0 && (
<div className='text-sm'>
<div className='text-gray-400 mb-1'>
Profiles
</div>
<div className='flex flex-wrap gap-1'>
{data_to_sync.profiles.map(
(profile, index) => (
<span
key={index}
className='px-1.5 py-0.5 text-xs rounded
bg-gray-700/50 text-gray-300'>
{profile}
</span>
)
)}
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default ArrCard;

View File

@@ -1,9 +1,9 @@
import React, {useState, useEffect} from 'react';
import {Loader} from 'lucide-react';
import ArrModal from './ArrModal';
import ArrCard from './ArrCard';
import AddButton from '@ui/DataBar/AddButton';
import {getArrConfigs} from '@api/arr';
import ArrTable from './ArrTable';
import {getArrConfigs, deleteArrConfig} from '@api/arr';
import {toast} from 'react-toastify';
const ArrContainer = () => {
const [showModal, setShowModal] = useState(false);
@@ -73,29 +73,10 @@ const ArrContainer = () => {
{error}
</div>
)}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3'>
{arrs.map(arrConfig => (
<ArrCard
key={arrConfig.id}
title={arrConfig.name}
type={arrConfig.type}
sync_method={arrConfig.sync_method}
sync_interval={arrConfig.sync_interval}
sync_percentage={arrConfig.sync_percentage}
last_sync_time={arrConfig.last_sync_time}
tags={arrConfig.tags}
data_to_sync={arrConfig.data_to_sync}
import_as_unique={arrConfig.import_as_unique}
onClick={() => handleEditArr(arrConfig)}
/>
))}
</div>
<AddButton
onClick={handleAddArr}
label='Add New App'
top='5vh'
left='75vw'
<ArrTable
arrs={arrs}
onAddArr={handleAddArr}
onEditArr={handleEditArr}
/>
<ArrModal

View File

@@ -32,7 +32,8 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
showSyncConfirm,
setShowSyncConfirm,
handleManualSync,
isInitialSyncing
isInitialSyncing,
handleSyncIntervalBlur
} = useArrModal({isOpen, onSubmit, editingArr});
const arrTypes = [
@@ -363,11 +364,16 @@ const ArrModal = ({isOpen, onClose, onSubmit, editingArr}) => {
id='sync_interval'
value={formData.sync_interval}
onChange={handleInputChange}
onBlur={handleSyncIntervalBlur}
className={inputClasses('sync_interval')}
placeholder='Enter interval in minutes'
min='1'
placeholder='Enter interval in minutes (60-43200)'
min='60'
max='43200'
required
/>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Minimum: 1 hour (60 minutes), Maximum: 1 month (43200 minutes)
</p>
{errors.sync_interval && (
<p className='text-xs text-red-500 mt-1'>
{errors.sync_interval}

View File

@@ -0,0 +1,206 @@
import React from 'react';
import {Plus, Clock, ArrowUpDown, BarChart, Tag, Edit2, Trash2, Check, X} from 'lucide-react';
import RadarrLogo from '@logo/Radarr.svg';
import SonarrLogo from '@logo/Sonarr.svg';
const ArrTable = ({arrs, onAddArr, onEditArr, onDeleteArr}) => {
const formatLastSync = timestamp => {
if (!timestamp) return 'Never';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
const getSyncMethodDisplay = (method, interval) => {
switch (method) {
case 'pull':
return 'On Pull';
case 'schedule':
return `Every ${interval}m`;
case 'manual':
return 'Manual';
default:
return 'Unknown';
}
};
return (
<div className='rounded-lg border border-gray-700 overflow-hidden'>
<table className='w-full'>
<thead>
<tr className='bg-gray-800 border-b border-gray-700'>
<th className='py-3 px-6 text-left text-gray-400 font-medium w-8'>
Type
</th>
<th className='py-3 px-6 text-left text-gray-400 font-medium'>
Name
</th>
<th className='py-3 px-6 text-left text-gray-400 font-medium'>
<div className='flex items-center'>
<ArrowUpDown className='w-3.5 h-3.5 mr-1.5' />
Sync Method
</div>
</th>
<th className='py-3 px-6 text-left text-gray-400 font-medium'>
<div className='flex items-center'>
<BarChart className='w-3.5 h-3.5 mr-1.5' />
Progress
</div>
</th>
<th className='py-3 px-6 text-left text-gray-400 font-medium'>
<div className='flex items-center'>
<Clock className='w-3.5 h-3.5 mr-1.5' />
Last Sync
</div>
</th>
<th className='py-3 px-6 text-left text-gray-400 font-medium'>
<div className='flex items-center'>
<Tag className='w-3.5 h-3.5 mr-1.5' />
Tags
</div>
</th>
<th className='py-3 px-6 text-left text-gray-400 font-medium'>
Sync Data
</th>
<th className='py-3 px-6 text-center text-gray-400 font-medium'>
Unique
</th>
<th className='py-3 px-6 text-right text-gray-400 font-medium'>
Actions
</th>
</tr>
</thead>
<tbody>
{arrs.map((arr, index) => (
<tr
key={arr.id}
className='border-b border-gray-700 hover:bg-gray-800/50 transition-colors cursor-pointer'
onClick={() => onEditArr(arr)}>
<td className='py-4 px-6'>
<img
src={arr.type === 'radarr' ? RadarrLogo : SonarrLogo}
className='w-5 h-5'
alt={arr.type === 'radarr' ? 'Radarr' : 'Sonarr'}
/>
</td>
<td className='py-4 px-6'>
<span className='text-gray-100 font-medium'>{arr.name}</span>
</td>
<td className='py-4 px-6'>
<span className='text-gray-300 text-sm'>
{getSyncMethodDisplay(arr.sync_method, arr.sync_interval)}
</span>
</td>
<td className='py-4 px-6'>
{arr.sync_method === 'manual' ? (
<span className='text-gray-500 text-sm'>N/A</span>
) : (
<div className='flex items-center space-x-2'>
<div className='w-24 bg-gray-700/50 rounded-full h-1.5'>
<div
className='bg-blue-500 h-1.5 rounded-full transition-all duration-300'
style={{
width: `${Math.max(
0,
Math.min(100, arr.sync_percentage || 0)
)}%`
}}
/>
</div>
<span className='text-gray-400 text-sm'>
{arr.sync_percentage || 0}%
</span>
</div>
)}
</td>
<td className='py-4 px-6'>
<span className='text-gray-300 text-sm'>
{arr.sync_method === 'manual' ? 'Manual' : formatLastSync(arr.last_sync_time)}
</span>
</td>
<td className='py-4 px-6'>
<div className='flex flex-wrap gap-1'>
{arr.tags && arr.tags.length > 0 ? (
arr.tags.map((tag, tagIndex) => (
<span
key={tagIndex}
className='px-2 py-0.5 text-xs font-medium rounded-full
bg-blue-500/20 text-blue-300 border border-blue-500/20'>
{tag}
</span>
))
) : (
<span className='text-gray-500 text-sm'>None</span>
)}
</div>
</td>
<td className='py-4 px-6'>
<div className='text-sm text-gray-300'>
{arr.data_to_sync?.profiles && arr.data_to_sync.profiles.length > 0 &&
arr.data_to_sync?.customFormats && arr.data_to_sync.customFormats.length > 0 ? (
<span>{arr.data_to_sync.profiles.length} profiles, {arr.data_to_sync.customFormats.length} formats</span>
) : arr.data_to_sync?.profiles && arr.data_to_sync.profiles.length > 0 ? (
<span>{arr.data_to_sync.profiles.length} profile{arr.data_to_sync.profiles.length !== 1 ? 's' : ''}</span>
) : arr.data_to_sync?.customFormats && arr.data_to_sync.customFormats.length > 0 ? (
<span>{arr.data_to_sync.customFormats.length} format{arr.data_to_sync.customFormats.length !== 1 ? 's' : ''}</span>
) : (
<span className='text-gray-500'>None</span>
)}
</div>
</td>
<td className='py-4 px-6 text-center'>
{arr.import_as_unique ? (
<Check className='w-4 h-4 text-green-500 inline-block' />
) : (
<X className='w-4 h-4 text-gray-500 inline-block' />
)}
</td>
<td className='py-4 px-6'>
<div className='flex items-center justify-end space-x-2'>
<button
onClick={e => {
e.stopPropagation();
onEditArr(arr);
}}
className='p-1.5 hover:bg-gray-700 rounded transition-colors'>
<Edit2 className='w-4 h-4 text-gray-400 hover:text-blue-400' />
</button>
{onDeleteArr && (
<button
onClick={e => {
e.stopPropagation();
onDeleteArr(arr.id);
}}
className='p-1.5 hover:bg-gray-700 rounded transition-colors'>
<Trash2 className='w-4 h-4 text-gray-400 hover:text-red-400' />
</button>
)}
</div>
</td>
</tr>
))}
<tr
className='hover:bg-gray-800/50 transition-colors cursor-pointer'
onClick={onAddArr}>
<td colSpan='9' className='py-5 text-center'>
<div className='flex items-center justify-center space-x-2 text-gray-400 hover:text-blue-400 transition-colors'>
<Plus className='w-5 h-5' />
<span className='font-medium'>Add New App</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
);
};
export default ArrTable;

View File

@@ -1,7 +1,6 @@
import React from 'react';
import {Link, Loader} from 'lucide-react';
import GithubIcon from '@logo/GitHub.svg';
import Tooltip from '@ui/Tooltip';
const EmptyRepo = ({onLinkRepo, loadingAction}) => {
return (
@@ -28,21 +27,17 @@ const EmptyRepo = ({onLinkRepo, loadingAction}) => {
or any external database to get started.
</p>
</div>
<Tooltip content='Link Repository'>
<button
onClick={onLinkRepo}
className={`flex items-center px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all duration-200 ease-in-out text-sm font-medium ${
loadingAction ? 'opacity-50 cursor-not-allowed' : ''
}`}
disabled={loadingAction !== ''}>
{loadingAction === 'link_repo' ? (
<Loader size={16} className='animate-spin mr-2' />
) : (
<Link size={16} className='mr-2' />
)}
Link Repository
</button>
</Tooltip>
<button
onClick={onLinkRepo}
className='inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium'
disabled={loadingAction !== ''}>
{loadingAction === 'link_repo' ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Link className="w-4 h-4 text-blue-500" />
)}
<span>Link Repository</span>
</button>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, {useState} from 'react';
import Modal from '@ui/Modal';
import {Loader} from 'lucide-react';
import {Loader, GitBranch, Link} from 'lucide-react';
import {cloneRepo} from '@api/api';
import Alert from '@ui/Alert';
@@ -82,40 +82,34 @@ const LinkRepo = ({isOpen, onClose, onSubmit}) => {
isOpen={isOpen}
onClose={onClose}
title='Link Git Repository'
width='2xl'
footer={
<div className='flex justify-end'>
<button
className='bg-blue-600 text-white px-4 py-2 rounded border border-blue-600 hover:bg-blue-700 transition-colors flex items-center text-sm'
disabled={loading}
onClick={handleSubmit}>
{loading ? (
<>
<Loader
size={16}
className='animate-spin mr-2'
/>
Linking...
</>
) : (
'Link'
)}
</button>
width='2xl'>
<div className='flex items-center py-2'>
<div className='relative flex-1'>
<input
type='text'
value={gitRepo}
onChange={e => setGitRepo(e.target.value)}
className='w-full pl-10 pr-3 py-2 text-sm rounded-l-lg rounded-r-none
border border-r-0 border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700
text-gray-900 dark:text-white
focus:outline-none focus:border-gray-400 dark:focus:border-gray-500
placeholder-gray-400 dark:placeholder-gray-500 transition-colors'
placeholder='https://github.com/your-repo'
/>
<GitBranch className='absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-gray-500' size={18} />
</div>
}>
<div className='space-y-2'>
<input
type='text'
value={gitRepo}
onChange={e => setGitRepo(e.target.value)}
className='w-full px-3 py-2 text-sm rounded
border border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-700
text-gray-900 dark:text-white
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
placeholder-gray-400 dark:placeholder-gray-500'
placeholder='https://github.com/your-repo'
/>
<button
className='inline-flex items-center gap-2 px-4 py-2 text-sm rounded-l-none rounded-r-lg bg-gray-800 border border-gray-700 text-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed group'
disabled={loading}
onClick={handleSubmit}>
{loading ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Link className="w-4 h-4 text-blue-500 group-hover:scale-110 transition-transform" />
)}
<span>{loading ? 'Linking...' : 'Link'}</span>
</button>
</div>
</Modal>
);

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import Modal from '../../../ui/Modal';
import { Unlink } from 'lucide-react';
const UnlinkRepo = ({ isOpen, onClose, onSubmit }) => {
const [removeFiles, setRemoveFiles] = useState(false);
@@ -12,33 +13,45 @@ const UnlinkRepo = ({ isOpen, onClose, onSubmit }) => {
};
return (
<Modal isOpen={isOpen} onClose={onClose} title='Unlink Repository' size='sm'>
<Modal
isOpen={isOpen}
onClose={onClose}
title='Unlink Repository'
width='md'
footer={
<div className='flex justify-end'>
<button
className='inline-flex items-center gap-2 px-4 py-2 rounded bg-gray-800 border border-gray-700 text-gray-200 hover:bg-gray-700 transition-colors text-sm font-medium'
onClick={handleUnlink}
>
<Unlink className="w-4 h-4 text-red-500" />
<span>Unlink</span>
</button>
</div>
}>
<div className='space-y-4'>
<p className='text-gray-700 dark:text-gray-300 text-sm'>
Are you sure you want to unlink the repository?
<p className='text-sm text-gray-600 dark:text-gray-400'>
This will disconnect your repository from Profilarr. You will need to re-link it to sync configuration files again.
</p>
<div className='flex justify-between items-center mt-4'>
<div className='flex items-center'>
<div className='bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4'>
<label className='flex items-start space-x-3 cursor-pointer'>
<input
type='checkbox'
id='removeFiles'
checked={removeFiles}
onChange={() => setRemoveFiles(!removeFiles)}
className='form-checkbox h-4 w-4 text-red-600 transition duration-150 ease-in-out'
className='mt-0.5 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500 dark:bg-gray-700'
/>
<label
htmlFor='removeFiles'
className='ml-2 text-gray-700 dark:text-gray-300 text-sm'
>
Also remove repository files
</label>
</div>
<button
className='px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors duration-200 ease-in-out text-xs'
onClick={handleUnlink}
>
Unlink
</button>
<div>
<span className='text-sm font-medium text-gray-900 dark:text-gray-100'>
Delete local repository files
</span>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
This will permanently remove all cloned repository files from your system. This action cannot be undone.
</p>
</div>
</label>
</div>
</div>
</Modal>

View File

@@ -40,7 +40,15 @@ const Alert = {
progressClassName: `${baseStyles.progressClassName} bg-blue-300`,
...options
});
},
partial: (message, options = {}) => {
toast.warn(message, { // Using warn icon for partial success
...baseStyles,
className: `${baseStyles.className} bg-yellow-500 text-white`,
progressClassName: `${baseStyles.progressClassName} bg-yellow-200`,
...options
});
}
};
export default Alert;
export default Alert;

View File

@@ -118,7 +118,7 @@ const Modal = ({
ref={modalRef}
className={`relative bg-gradient-to-br from-gray-50 to-gray-100
dark:from-gray-800 dark:to-gray-900 rounded-lg shadow-xl
min-w-[320px] min-h-[200px] ${widthClasses[width]} ${
min-w-[320px] ${widthClasses[width]} ${
heightClasses[height]
}
${isClosing
@@ -150,7 +150,7 @@ const Modal = ({
)}
<button
onClick={handleClose}
className='text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
className={`text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors ${!tabs ? 'ml-auto' : ''}`}>
<svg
className='w-6 h-6'
fill='none'
@@ -167,7 +167,7 @@ const Modal = ({
</div>
{/* Content */}
<div className='flex-1 overflow-y-auto p-6 py-4 text-gray-900 dark:text-gray-200'>
<div className={`flex-1 overflow-y-auto px-6 py-4 text-gray-900 dark:text-gray-200`}>
{typeof children === 'function'
? children(activeTab)
: children}

View File

@@ -47,9 +47,8 @@ const Tooltip = ({content, children}) => {
zIndex: 99999
}}
className='pointer-events-none'>
<div className='bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap'>
<div className='bg-gray-700 border border-gray-500 text-white text-xs rounded py-1 px-2 whitespace-nowrap shadow-lg'>
{content}
<div className='absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2' />
</div>
</div>,
document.body

View File

@@ -110,10 +110,10 @@ export const useArrModal = ({isOpen, onSubmit, editingArr}) => {
if (
formData.sync_method === 'schedule' &&
(!formData.sync_interval || formData.sync_interval < 1)
(!formData.sync_interval || formData.sync_interval < 60 || formData.sync_interval > 43200)
) {
newErrors.sync_interval =
'Please enter a valid interval (minimum 1 minute)';
'Sync interval must be between 60 minutes (1 hour) and 43200 minutes (1 month)';
}
// Safely check data_to_sync structure
@@ -302,7 +302,7 @@ export const useArrModal = ({isOpen, onSubmit, editingArr}) => {
// Handle other input changes
return {
...prev,
[id]: id === 'sync_interval' ? parseInt(value) || 0 : value
[id]: id === 'sync_interval' ? (value === '' ? '' : parseInt(value) || 0) : value
};
});
@@ -363,6 +363,26 @@ export const useArrModal = ({isOpen, onSubmit, editingArr}) => {
}
};
const handleSyncIntervalBlur = () => {
setFormData(prev => {
if (prev.sync_method === 'schedule') {
let interval = prev.sync_interval === '' ? 0 : parseInt(prev.sync_interval) || 0;
// Auto-correct to min/max if out of range
if (interval < 60) {
interval = 60;
} else if (interval > 43200) {
interval = 43200;
}
// Always set to a number on blur
return {
...prev,
sync_interval: interval
};
}
return prev;
});
};
return {
formData,
availableData,
@@ -387,6 +407,7 @@ export const useArrModal = ({isOpen, onSubmit, editingArr}) => {
showSyncConfirm,
setShowSyncConfirm,
handleManualSync,
isInitialSyncing
isInitialSyncing,
handleSyncIntervalBlur
};
};