mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
100
frontend/src/components/profile/scoring/FormatSettingsModal.jsx
Normal file
100
frontend/src/components/profile/scoring/FormatSettingsModal.jsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
206
frontend/src/components/settings/arrs/ArrTable.jsx
Normal file
206
frontend/src/components/settings/arrs/ArrTable.jsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user