mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-02-01 15:20:49 +01:00
feature: quality profile improvements (#9)
- refactored backend for general data endpoints - removed ID based files - overhauled quality profile creation - qualities, tags, scores, langauges, upgrades have all been added
This commit is contained in:
170
frontend/src/components/profile/CreateGroupModal.jsx
Normal file
170
frontend/src/components/profile/CreateGroupModal.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import Tooltip from '@ui/Tooltip';
|
||||
import {InfoIcon} from 'lucide-react';
|
||||
|
||||
const CreateGroupModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
availableQualities,
|
||||
onCreateGroup,
|
||||
editingGroup = null
|
||||
}) => {
|
||||
const [selectedQualities, setSelectedQualities] = useState([]);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && editingGroup) {
|
||||
setGroupName(editingGroup.name);
|
||||
setDescription(editingGroup.description || '');
|
||||
|
||||
// Set selected qualities from the editing group
|
||||
const existingQualities = editingGroup.qualities.map(quality => {
|
||||
// Find the quality in availableQualities to get the most up-to-date version
|
||||
return (
|
||||
availableQualities.find(q => q.id === quality.id) || quality
|
||||
);
|
||||
});
|
||||
setSelectedQualities(existingQualities);
|
||||
} else if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setGroupName('');
|
||||
setDescription('');
|
||||
setSelectedQualities([]);
|
||||
}
|
||||
}, [isOpen, editingGroup, availableQualities]);
|
||||
|
||||
const getValidationMessage = () => {
|
||||
if (!groupName) return 'Please enter a group name';
|
||||
if (selectedQualities.length === 0)
|
||||
return 'Select at least one quality';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (groupName && selectedQualities.length > 0) {
|
||||
const groupData = {
|
||||
// If editing, keep the same ID; otherwise generate new one
|
||||
id: editingGroup ? editingGroup.id : Date.now(),
|
||||
name: groupName,
|
||||
description,
|
||||
qualities: selectedQualities,
|
||||
// Preserve enabled state if editing, default to true for new groups
|
||||
enabled: editingGroup ? editingGroup.enabled : true,
|
||||
// Preserve radarr/sonarr settings if editing
|
||||
radarr: editingGroup?.radarr,
|
||||
sonarr: editingGroup?.sonarr
|
||||
};
|
||||
|
||||
onCreateGroup(groupData);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = groupName && selectedQualities.length > 0;
|
||||
|
||||
const isQualitySelected = quality => {
|
||||
return selectedQualities.some(sq => sq.id === quality.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={editingGroup ? 'Edit Quality Group' : 'Create Quality Group'}
|
||||
width='xl'
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Tooltip content={!isValid ? getValidationMessage() : null}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isValid}
|
||||
className='px-3 py-1.5 text-xs font-medium text-white bg-blue-600 dark:bg-blue-500 rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed'>
|
||||
{editingGroup ? 'Save Changes' : 'Save Group'}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex gap-2 p-3 text-xs bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
|
||||
<InfoIcon className='h-4 w-4 text-blue-600 dark:text-blue-400' />
|
||||
<p className='text-blue-700 dark:text-blue-300'>
|
||||
Groups allow you to combine multiple qualities that are
|
||||
considered equivalent. Items matching any quality in the
|
||||
group will be treated equally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-200'>
|
||||
Group Name
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={groupName}
|
||||
onChange={e => setGroupName(e.target.value)}
|
||||
className='mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2.5 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'
|
||||
placeholder='Enter group name'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-200'>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className='mt-1 block w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2.5 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'
|
||||
placeholder='Optional description for this quality group'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='block text-xs font-medium text-gray-700 dark:text-gray-200 mb-2'>
|
||||
Select Qualities
|
||||
</label>
|
||||
<div className='space-y-2 max-h-[400px] overflow-y-auto pr-2'>
|
||||
{availableQualities
|
||||
.filter(q => !('qualities' in q))
|
||||
.map(quality => (
|
||||
<div
|
||||
key={quality.id}
|
||||
onClick={() => {
|
||||
setSelectedQualities(prev =>
|
||||
isQualitySelected(quality)
|
||||
? prev.filter(
|
||||
q => q.id !== quality.id
|
||||
)
|
||||
: [...prev, quality]
|
||||
);
|
||||
}}
|
||||
className={`
|
||||
cursor-pointer rounded-lg border p-2.5 transition-all
|
||||
${
|
||||
isQualitySelected(quality)
|
||||
? 'border-blue-500 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}>
|
||||
<div className='flex-1'>
|
||||
<p className='text-xs font-medium text-gray-900 dark:text-gray-100'>
|
||||
{quality.name}
|
||||
</p>
|
||||
{quality.description && (
|
||||
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{quality.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateGroupModal;
|
||||
@@ -1,69 +1,216 @@
|
||||
import PropTypes from "prop-types";
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Copy, Globe2, Settings2, ArrowUpCircle} from 'lucide-react';
|
||||
|
||||
function unsanitize(text) {
|
||||
return text.replace(/\\:/g, ":").replace(/\\n/g, "\n");
|
||||
if (!text) return '';
|
||||
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function ProfileCard({ profile, onEdit, onClone, showDate, formatDate }) {
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm cursor-pointer hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-shadow"
|
||||
onClick={() => onEdit(profile)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-200">
|
||||
{unsanitize(profile.name)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClone(profile);
|
||||
}}
|
||||
className="relative group"
|
||||
>
|
||||
<img
|
||||
src="/clone.svg"
|
||||
alt="Clone"
|
||||
className="w-5 h-5 transition-transform transform group-hover:scale-125 group-hover:rotate-12 group-hover:-translate-y-1 group-hover:translate-x-1"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
|
||||
{unsanitize(profile.description)}
|
||||
</p>
|
||||
{showDate && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
|
||||
Modified: {formatDate(profile.date_modified)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap">
|
||||
{profile.tags &&
|
||||
profile.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 mb-1 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const ProfileCard = ({profile, onEdit, onClone, sortBy, formatDate}) => {
|
||||
console.log('sortBy:', sortBy); // Add this line
|
||||
console.log('profile dates:', profile.modified_date, profile.created_date);
|
||||
if (!profile || !profile.content) return null;
|
||||
|
||||
const {content} = profile;
|
||||
const activeCustomFormats = (content.custom_formats || []).filter(
|
||||
format => format.score !== 0
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-all cursor-pointer'
|
||||
onClick={() => onEdit(profile)}>
|
||||
<div className='flex flex-col p-6 gap-3'>
|
||||
{/* Header Section */}
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||
{unsanitize(content.name)}
|
||||
</h3>
|
||||
<div className='flex items-center gap-3'>
|
||||
{(sortBy === 'dateModified' ||
|
||||
sortBy === 'dateCreated') && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 shrink-0'>
|
||||
{sortBy === 'dateModified'
|
||||
? 'Modified'
|
||||
: 'Created'}
|
||||
:{' '}
|
||||
{formatDate(
|
||||
sortBy === 'dateModified'
|
||||
? profile.modified_date
|
||||
: profile.created_date
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onClone(profile);
|
||||
}}
|
||||
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors shrink-0'>
|
||||
<Copy className='w-5 h-5 text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Columns */}
|
||||
<div className='flex gap-6'>
|
||||
{/* Left Column: Main Content */}
|
||||
<div className='flex-1'>
|
||||
{/* Description */}
|
||||
{content.description && (
|
||||
<p className='text-gray-600 dark:text-gray-300 text-base leading-relaxed mb-4'>
|
||||
{unsanitize(content.description)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className='flex flex-wrap items-center gap-4 text-sm'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings2 className='w-4 h-4 text-gray-400 dark:text-gray-500' />
|
||||
<span className='text-gray-600 dark:text-gray-300'>
|
||||
{activeCustomFormats} format
|
||||
{activeCustomFormats !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe2 className='w-4 h-4 text-gray-400 dark:text-gray-500' />
|
||||
<span className='text-gray-600 dark:text-gray-300 capitalize'>
|
||||
{content.language || 'any'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{content.upgradesAllowed && (
|
||||
<span className='inline-flex items-center gap-1.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded text-xs border border-blue-200 dark:border-blue-800'>
|
||||
<ArrowUpCircle className='w-3.5 h-3.5' />
|
||||
Upgrades allowed
|
||||
</span>
|
||||
)}
|
||||
|
||||
{content.tags && content.tags.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{content.tags.map(tag => (
|
||||
<span
|
||||
key={`${profile.file_name}-${tag}`}
|
||||
className='bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 px-2.5 py-0.5 rounded text-sm'>
|
||||
{unsanitize(tag)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Qualities */}
|
||||
<div className='w-2/5 border-l border-gray-200 dark:border-gray-700 pl-6 flex flex-col gap-4'>
|
||||
{content.qualities &&
|
||||
content.qualities.map(quality =>
|
||||
quality.qualities ? (
|
||||
// Group quality - check if group name matches
|
||||
<div
|
||||
key={quality.name}
|
||||
className={`${
|
||||
quality.name ===
|
||||
content.upgrade_until?.name ||
|
||||
quality.qualities.some(
|
||||
q =>
|
||||
q.name ===
|
||||
content.upgrade_until?.name
|
||||
)
|
||||
? 'bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50'
|
||||
} rounded-lg p-3`}>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-200 mb-2'>
|
||||
{quality.name}
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{quality.qualities.map(
|
||||
subQuality => (
|
||||
<span
|
||||
key={subQuality.id}
|
||||
className={`bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-2 py-0.5 rounded text-xs inline-flex items-center ${
|
||||
subQuality.name ===
|
||||
content
|
||||
.upgrade_until
|
||||
?.name
|
||||
? 'bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50'
|
||||
}`}>
|
||||
{subQuality.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Individual quality - keep the same
|
||||
<div
|
||||
key={quality.id}
|
||||
className={`${
|
||||
quality.name ===
|
||||
content.upgrade_until?.name
|
||||
? 'bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'bg-gray-50 dark:bg-gray-700/50'
|
||||
} rounded-lg p-3`}>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-200'>
|
||||
{quality.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileCard.propTypes = {
|
||||
profile: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
date_modified: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onClone: PropTypes.func.isRequired,
|
||||
showDate: PropTypes.bool.isRequired,
|
||||
formatDate: PropTypes.func.isRequired,
|
||||
profile: PropTypes.shape({
|
||||
file_name: PropTypes.string,
|
||||
modified_date: PropTypes.string,
|
||||
created_date: PropTypes.string,
|
||||
content: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
upgrade_until: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}),
|
||||
qualities: PropTypes.arrayOf(
|
||||
PropTypes.oneOfType([
|
||||
// Quality group
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
qualities: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
})
|
||||
)
|
||||
}),
|
||||
// Individual quality
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
})
|
||||
])
|
||||
),
|
||||
custom_formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
score: PropTypes.number
|
||||
})
|
||||
),
|
||||
language: PropTypes.string
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onClone: PropTypes.func.isRequired,
|
||||
sortBy: PropTypes.string.isRequired,
|
||||
formatDate: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProfileCard;
|
||||
|
||||
194
frontend/src/components/profile/ProfileGeneralTab.jsx
Normal file
194
frontend/src/components/profile/ProfileGeneralTab.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Textarea from '../ui/TextArea';
|
||||
|
||||
const ProfileGeneralTab = ({
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
upgradesAllowed,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
onUpgradesAllowedChange,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
error
|
||||
}) => {
|
||||
const [newTag, setNewTag] = useState('');
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !tags.includes(newTag.trim())) {
|
||||
onAddTag(newTag.trim());
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{error && (
|
||||
<div className='bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-4 mb-6'>
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Profile Name
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Name of this profile. Import will use the same
|
||||
name
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col items-end space-y-1'>
|
||||
<label className='flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={upgradesAllowed}
|
||||
onChange={e =>
|
||||
onUpgradesAllowedChange(
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
className='rounded border-gray-300 dark:border-gray-600
|
||||
text-blue-500 focus:ring-blue-500
|
||||
h-4 w-4 cursor-pointer
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
<span>Upgrades Allowed</span>
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Allow automatic upgrades for this profile
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
value={name}
|
||||
onChange={e => onNameChange(e.target.value)}
|
||||
placeholder='Enter profile name'
|
||||
className='w-full rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Description
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Add any notes or details about this profile's
|
||||
purpose and configuration
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
placeholder='Enter a description for this profile'
|
||||
rows={4}
|
||||
className='w-full rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-1'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Tags
|
||||
</label>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Add tags to organize and categorize this profile
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex space-x-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={newTag}
|
||||
onChange={e => setNewTag(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder='Add a tag'
|
||||
className='w-full rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!newTag.trim()}
|
||||
className='px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-400 text-white rounded-md text-sm font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800'>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{tags.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2 rounded-md '>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className='inline-flex items-center p-1.5 rounded-md text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 group'>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => onRemoveTag(tag)}
|
||||
className='ml-1.5 hover:text-blue-900 dark:hover:text-blue-200 focus:outline-none'>
|
||||
<svg
|
||||
className='w-3.5 h-3.5 opacity-60 group-hover:opacity-100 transition-opacity'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex items-center justify-center h-[2.5rem] text-sm text-gray-500 dark:text-gray-400 rounded-md border border-dashed border-dark-border'>
|
||||
No tags added yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileGeneralTab.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
upgradesAllowed: PropTypes.bool.isRequired,
|
||||
onNameChange: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onUpgradesAllowedChange: PropTypes.func.isRequired,
|
||||
onAddTag: PropTypes.func.isRequired,
|
||||
onRemoveTag: PropTypes.func.isRequired,
|
||||
error: PropTypes.string
|
||||
};
|
||||
|
||||
export default ProfileGeneralTab;
|
||||
119
frontend/src/components/profile/ProfileLangaugesTab.jsx
Normal file
119
frontend/src/components/profile/ProfileLangaugesTab.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {InfoIcon} from 'lucide-react';
|
||||
|
||||
const LANGUAGES = [
|
||||
{id: 'any', name: 'Any', isSpecial: true},
|
||||
{id: 'original', name: 'Original', isSpecial: true},
|
||||
{id: 'arabic', name: 'Arabic'},
|
||||
{id: 'bengali', name: 'Bengali'},
|
||||
{id: 'bosnian', name: 'Bosnian'},
|
||||
{id: 'bulgarian', name: 'Bulgarian'},
|
||||
{id: 'catalan', name: 'Catalan'},
|
||||
{id: 'chinese', name: 'Chinese'},
|
||||
{id: 'croatian', name: 'Croatian'},
|
||||
{id: 'czech', name: 'Czech'},
|
||||
{id: 'danish', name: 'Danish'},
|
||||
{id: 'dutch', name: 'Dutch'},
|
||||
{id: 'english', name: 'English'},
|
||||
{id: 'estonian', name: 'Estonian'},
|
||||
{id: 'finnish', name: 'Finnish'},
|
||||
{id: 'flemish', name: 'Flemish'},
|
||||
{id: 'french', name: 'French'},
|
||||
{id: 'german', name: 'German'},
|
||||
{id: 'greek', name: 'Greek'},
|
||||
{id: 'hebrew', name: 'Hebrew'},
|
||||
{id: 'hindi', name: 'Hindi'},
|
||||
{id: 'hungarian', name: 'Hungarian'},
|
||||
{id: 'icelandic', name: 'Icelandic'},
|
||||
{id: 'indonesian', name: 'Indonesian'},
|
||||
{id: 'italian', name: 'Italian'},
|
||||
{id: 'japanese', name: 'Japanese'},
|
||||
{id: 'kannada', name: 'Kannada'},
|
||||
{id: 'korean', name: 'Korean'},
|
||||
{id: 'latvian', name: 'Latvian'},
|
||||
{id: 'lithuanian', name: 'Lithuanian'},
|
||||
{id: 'macedonian', name: 'Macedonian'},
|
||||
{id: 'malayalam', name: 'Malayalam'},
|
||||
{id: 'norwegian', name: 'Norwegian'},
|
||||
{id: 'persian', name: 'Persian'},
|
||||
{id: 'polish', name: 'Polish'},
|
||||
{id: 'portuguese', name: 'Portuguese'},
|
||||
{id: 'portuguese-brazil', name: 'Portuguese (Brazil)'},
|
||||
{id: 'romanian', name: 'Romanian'},
|
||||
{id: 'russian', name: 'Russian'},
|
||||
{id: 'serbian', name: 'Serbian'},
|
||||
{id: 'slovak', name: 'Slovak'},
|
||||
{id: 'slovenian', name: 'Slovenian'},
|
||||
{id: 'spanish', name: 'Spanish'},
|
||||
{id: 'spanish-latino', name: 'Spanish (Latino)'},
|
||||
{id: 'swedish', name: 'Swedish'},
|
||||
{id: 'tamil', name: 'Tamil'},
|
||||
{id: 'telugu', name: 'Telugu'},
|
||||
{id: 'thai', name: 'Thai'},
|
||||
{id: 'turkish', name: 'Turkish'},
|
||||
{id: 'ukrainian', name: 'Ukrainian'},
|
||||
{id: 'vietnamese', name: 'Vietnamese'}
|
||||
];
|
||||
|
||||
const ProfileLanguagesTab = ({selectedLanguage, onLanguageChange}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='h-full flex flex-col'>
|
||||
<div className='bg-white dark:bg-gray-800 pb-4'>
|
||||
<div className='grid grid-cols-[auto_1fr] gap-4 items-center'>
|
||||
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100 leading-tight'>
|
||||
Language Preference
|
||||
</h2>
|
||||
<p className='text-xs text-gray-600 dark:text-gray-400 leading-relaxed'>
|
||||
Select your preferred language for media content.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 space-y-4'>
|
||||
<div className='flex gap-2 p-3 text-xs bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg'>
|
||||
<InfoIcon className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
|
||||
<p className='text-blue-700 dark:text-blue-300'>
|
||||
Choose "Any" to accept all languages, or "Original" to
|
||||
prefer the original language of the content. Selecting a
|
||||
specific language will prioritize content in that
|
||||
language when available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='relative'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1'>
|
||||
Preferred Language
|
||||
</label>
|
||||
<select
|
||||
value={selectedLanguage}
|
||||
onChange={e => onLanguageChange(e.target.value)}
|
||||
className='scrollable mt-1 block w-64 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 dark:focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'
|
||||
style={{maxHeight: '200px', overflowY: 'auto'}}>
|
||||
{LANGUAGES.map(language => (
|
||||
<option
|
||||
key={language.id}
|
||||
value={language.id}
|
||||
className={
|
||||
language.isSpecial
|
||||
? 'font-semibold text-blue-600 dark:text-blue-400'
|
||||
: ''
|
||||
}>
|
||||
{language.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileLanguagesTab.propTypes = {
|
||||
selectedLanguage: PropTypes.string.isRequired,
|
||||
onLanguageChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProfileLanguagesTab;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,8 @@ import {useNavigate} from 'react-router-dom';
|
||||
import ProfileCard from './ProfileCard';
|
||||
import ProfileModal from './ProfileModal';
|
||||
import AddNewCard from '../ui/AddNewCard';
|
||||
import {getProfiles, getFormats, getGitStatus} from '../../api/api';
|
||||
import {getGitStatus} from '../../api/api';
|
||||
import {Profiles, CustomFormats} from '@api/data';
|
||||
import FilterMenu from '../ui/FilterMenu';
|
||||
import SortMenu from '../ui/SortMenu';
|
||||
import {Loader} from 'lucide-react';
|
||||
@@ -35,32 +36,6 @@ function ProfilePage() {
|
||||
fetchGitStatus();
|
||||
}, []);
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const fetchedProfiles = await getProfiles();
|
||||
setProfiles(fetchedProfiles);
|
||||
const tags = [
|
||||
...new Set(
|
||||
fetchedProfiles.flatMap(profile => profile.tags || [])
|
||||
)
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profiles:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const fetchedFormats = await getFormats();
|
||||
setFormats(fetchedFormats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching formats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitStatus = async () => {
|
||||
try {
|
||||
const result = await getGitStatus();
|
||||
@@ -79,6 +54,47 @@ function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const response = await Profiles.getAll();
|
||||
const profilesData = response.map(item => ({
|
||||
file_name: item.file_name,
|
||||
modified_date: item.modified_date,
|
||||
created_date: item.created_date,
|
||||
content: {
|
||||
...item.content,
|
||||
name: item.file_name.replace('.yml', '')
|
||||
}
|
||||
}));
|
||||
setProfiles(profilesData);
|
||||
const tags = [
|
||||
...new Set(
|
||||
profilesData.flatMap(profile => profile.content.tags || [])
|
||||
)
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profiles:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const response = await CustomFormats.getAll();
|
||||
const formatsData = response.map(item => ({
|
||||
id: item.content.name, // Use name as ID
|
||||
name: item.content.name,
|
||||
description: item.content.description || '',
|
||||
tags: item.content.tags || []
|
||||
}));
|
||||
setFormats(formatsData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching formats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (profile = null) => {
|
||||
const safeProfile = profile
|
||||
? {
|
||||
@@ -195,15 +211,15 @@ function ProfilePage() {
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4'>
|
||||
{sortedAndFilteredProfiles.map(profile => (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
key={profile.file_name}
|
||||
profile={profile}
|
||||
onEdit={() => handleOpenModal(profile)}
|
||||
onClone={handleCloneProfile}
|
||||
showDate={sortBy !== 'name'}
|
||||
formatDate={formatDate}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
|
||||
560
frontend/src/components/profile/ProfileQualitiesTab.jsx
Normal file
560
frontend/src/components/profile/ProfileQualitiesTab.jsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
restrictToVerticalAxis,
|
||||
restrictToParentElement
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {InfoIcon} from 'lucide-react';
|
||||
import Modal from '../ui/Modal';
|
||||
import CreateGroupModal from './CreateGroupModal';
|
||||
import QualityItem from './QualityItem';
|
||||
import QUALITIES from '../../constants/qualities';
|
||||
import Alert from '@ui/Alert';
|
||||
|
||||
const UpgradeSection = ({
|
||||
enabledQualities,
|
||||
selectedUpgradeQuality,
|
||||
onUpgradeQualityChange
|
||||
}) => {
|
||||
if (enabledQualities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='bg-gray-50 dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 mb-4'>
|
||||
<div className='grid grid-cols-[auto_1fr_auto] gap-4 items-center'>
|
||||
<h3 className='text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||
Upgrade Until
|
||||
</h3>
|
||||
<p className='text-xs text-gray-600 dark:text-gray-400'>
|
||||
Downloads will be upgraded until this quality is reached.
|
||||
Lower qualities will be upgraded, while higher qualities
|
||||
will be left unchanged.
|
||||
</p>
|
||||
<select
|
||||
className='w-48 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm'
|
||||
value={selectedUpgradeQuality?.id || ''}
|
||||
onChange={e => {
|
||||
const quality = enabledQualities.find(
|
||||
q => q.id === parseInt(e.target.value)
|
||||
);
|
||||
onUpgradeQualityChange(quality);
|
||||
}}>
|
||||
{enabledQualities.map(quality => (
|
||||
<option key={quality.id} value={quality.id}>
|
||||
{quality.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortableItem = ({quality, onToggle, onDelete, onEdit}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({
|
||||
id: quality.id
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
onClick={() => onToggle(quality)}
|
||||
data-group-id={quality.id}>
|
||||
<QualityItem
|
||||
quality={quality}
|
||||
isDragging={isDragging}
|
||||
listeners={listeners}
|
||||
attributes={attributes}
|
||||
style={style}
|
||||
onEdit={'qualities' in quality ? onEdit : undefined}
|
||||
onDelete={'qualities' in quality ? onDelete : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileQualitiesTab = ({
|
||||
enabledQualities,
|
||||
onQualitiesChange,
|
||||
upgradesAllowed,
|
||||
selectedUpgradeQuality,
|
||||
onSelectedUpgradeQualityChange,
|
||||
sortedQualities,
|
||||
onSortedQualitiesChange
|
||||
}) => {
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const [isCreateGroupModalOpen, setIsCreateGroupModalOpen] = useState(false);
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [groupToDelete, setGroupToDelete] = useState(null);
|
||||
const [editingGroup, setEditingGroup] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialLoad) {
|
||||
const needsUpdate = sortedQualities.some(quality => {
|
||||
if ('qualities' in quality) {
|
||||
const isEnabled = enabledQualities.some(eq =>
|
||||
quality.qualities.some(gq => gq.id === eq.id)
|
||||
);
|
||||
return quality.enabled !== isEnabled;
|
||||
}
|
||||
const isEnabled = enabledQualities.some(
|
||||
q => q.id === quality.id
|
||||
);
|
||||
return quality.enabled !== isEnabled;
|
||||
});
|
||||
|
||||
if (needsUpdate) {
|
||||
onSortedQualitiesChange(prev =>
|
||||
prev.map(quality => {
|
||||
if ('qualities' in quality) {
|
||||
return {
|
||||
...quality,
|
||||
enabled: enabledQualities.some(eq =>
|
||||
quality.qualities.some(
|
||||
gq => gq.id === eq.id
|
||||
)
|
||||
)
|
||||
};
|
||||
}
|
||||
return {
|
||||
...quality,
|
||||
enabled: enabledQualities.some(
|
||||
q => q.id === quality.id
|
||||
)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabledQualities.length === 0) {
|
||||
const defaultQuality = sortedQualities.find(q => q.id === 10);
|
||||
if (defaultQuality) {
|
||||
onSortedQualitiesChange(prev =>
|
||||
prev.map(q => (q.id === 10 ? {...q, enabled: true} : q))
|
||||
);
|
||||
onQualitiesChange([defaultQuality]);
|
||||
onSelectedUpgradeQualityChange?.(defaultQuality);
|
||||
if (!isInitialLoad) {
|
||||
Alert.info(
|
||||
'Bluray-1080p has been automatically selected as the default quality.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsInitialLoad(false);
|
||||
}, [
|
||||
enabledQualities,
|
||||
onQualitiesChange,
|
||||
onSelectedUpgradeQualityChange,
|
||||
isInitialLoad,
|
||||
sortedQualities,
|
||||
onSortedQualitiesChange
|
||||
]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8
|
||||
}
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
|
||||
const findNearestEnabledQuality = (qualities, currentQualityId) => {
|
||||
const currentIndex = qualities.findIndex(
|
||||
q => q.id === currentQualityId
|
||||
);
|
||||
|
||||
// Try to find the next enabled quality below
|
||||
for (let i = currentIndex + 1; i < qualities.length; i++) {
|
||||
if (qualities[i].enabled) return qualities[i];
|
||||
}
|
||||
|
||||
// If not found, try to find the nearest enabled quality above
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
if (qualities[i].enabled) return qualities[i];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleQualityToggle = quality => {
|
||||
if (!activeId) {
|
||||
const currentEnabledCount = sortedQualities.filter(
|
||||
q => q.enabled
|
||||
).length;
|
||||
|
||||
if (quality.enabled && currentEnabledCount <= 1) {
|
||||
Alert.error('At least one quality must be selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newQualities = sortedQualities.map(q => {
|
||||
if (q.id === quality.id) {
|
||||
return {
|
||||
...q,
|
||||
enabled: !q.enabled
|
||||
};
|
||||
}
|
||||
return q;
|
||||
});
|
||||
|
||||
onSortedQualitiesChange(newQualities);
|
||||
|
||||
// Update enabledQualities
|
||||
const allEnabledQualities = [];
|
||||
newQualities.forEach(q => {
|
||||
if (q.enabled) {
|
||||
if ('qualities' in q) {
|
||||
allEnabledQualities.push(...q.qualities);
|
||||
} else {
|
||||
allEnabledQualities.push(q);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onQualitiesChange(allEnabledQualities);
|
||||
|
||||
if (
|
||||
selectedUpgradeQuality &&
|
||||
!allEnabledQualities.find(
|
||||
q => q.id === selectedUpgradeQuality.id
|
||||
)
|
||||
) {
|
||||
const nearestQuality = findNearestEnabledQuality(
|
||||
newQualities,
|
||||
quality.id
|
||||
);
|
||||
|
||||
onSelectedUpgradeQualityChange?.(nearestQuality);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateOrUpdateGroup = groupData => {
|
||||
if (
|
||||
selectedUpgradeQuality &&
|
||||
!('qualities' in selectedUpgradeQuality)
|
||||
) {
|
||||
const qualityMovingToGroup = groupData.qualities.some(
|
||||
q => q.id === selectedUpgradeQuality.id
|
||||
);
|
||||
if (qualityMovingToGroup) {
|
||||
onSelectedUpgradeQualityChange({
|
||||
id: groupData.id,
|
||||
name: groupData.name,
|
||||
description: groupData.description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSortedQualitiesChange(prev => {
|
||||
// Remove the old group if we're editing
|
||||
let qualities = prev.filter(q => q.id !== editingGroup?.id);
|
||||
// Remove individual qualities that are now part of the group
|
||||
qualities = qualities.filter(
|
||||
q =>
|
||||
!groupData.qualities.find(
|
||||
selectedQ => selectedQ.id === q.id
|
||||
)
|
||||
);
|
||||
const newGroup = {
|
||||
...groupData,
|
||||
description: groupData.description || '',
|
||||
enabled: true
|
||||
};
|
||||
const newQualities = [newGroup, ...qualities];
|
||||
|
||||
return newQualities;
|
||||
});
|
||||
|
||||
const allEnabledQualities = [];
|
||||
sortedQualities.forEach(q => {
|
||||
if (q.enabled) {
|
||||
if ('qualities' in q) {
|
||||
allEnabledQualities.push(...q.qualities);
|
||||
} else {
|
||||
allEnabledQualities.push(q);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onQualitiesChange(allEnabledQualities);
|
||||
setEditingGroup(null);
|
||||
setIsCreateGroupModalOpen(false);
|
||||
};
|
||||
|
||||
const handleEditClick = group => {
|
||||
const groupQualities = group.qualities || [];
|
||||
const otherQualities = sortedQualities.filter(q => {
|
||||
if (!('qualities' in q)) return true;
|
||||
return q.id !== group.id;
|
||||
});
|
||||
|
||||
// Create a map of qualities that are available for selection
|
||||
const availableQualities = QUALITIES.map(originalQuality => {
|
||||
// If this quality is in the current group, use it
|
||||
const groupQuality = groupQualities.find(
|
||||
gq => gq.id === originalQuality.id
|
||||
);
|
||||
if (groupQuality) return groupQuality;
|
||||
|
||||
// If this quality is not in another group, make it available
|
||||
const isInOtherGroup = otherQualities.some(
|
||||
q =>
|
||||
'qualities' in q &&
|
||||
q.qualities.some(gq => gq.id === originalQuality.id)
|
||||
);
|
||||
|
||||
if (!isInOtherGroup) {
|
||||
return (
|
||||
otherQualities.find(q => q.id === originalQuality.id) ||
|
||||
originalQuality
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
setEditingGroup({
|
||||
...group,
|
||||
availableQualities,
|
||||
description: group.description || ''
|
||||
});
|
||||
setIsCreateGroupModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = group => {
|
||||
setGroupToDelete(group);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteGroup = group => {
|
||||
// Check if we're deleting the currently selected upgrade group
|
||||
if (selectedUpgradeQuality && selectedUpgradeQuality.id === group.id) {
|
||||
const firstQualityFromGroup = group.qualities[0];
|
||||
onSelectedUpgradeQualityChange(firstQualityFromGroup);
|
||||
}
|
||||
|
||||
onSortedQualitiesChange(prev => {
|
||||
const index = prev.findIndex(q => q.id === group.id);
|
||||
if (index === -1) return prev;
|
||||
const newQualities = [...prev];
|
||||
newQualities.splice(index, 1, ...group.qualities);
|
||||
return newQualities;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (groupToDelete) {
|
||||
handleDeleteGroup(groupToDelete);
|
||||
setIsDeleteModalOpen(false);
|
||||
setGroupToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = event => {
|
||||
setActiveId(event.active.id);
|
||||
};
|
||||
|
||||
const handleDragEnd = event => {
|
||||
const {active, over} = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
onSortedQualitiesChange(qualities => {
|
||||
const oldIndex = qualities.findIndex(q => q.id === active.id);
|
||||
const newIndex = qualities.findIndex(q => q.id === over.id);
|
||||
return arrayMove(qualities, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='h-full flex flex-col'>
|
||||
<div className='bg-white dark:bg-gray-800 pb-4'>
|
||||
<div className='grid grid-cols-[auto_1fr_auto] gap-4 items-center'>
|
||||
<h2 className='text-xl font-semibold text-gray-900 dark:text-gray-100 leading-tight'>
|
||||
Quality Rankings
|
||||
</h2>
|
||||
|
||||
<p className='text-xs text-gray-600 dark:text-gray-400 leading-relaxed'>
|
||||
Qualities higher in the list are more preferred even if
|
||||
not checked. Qualities within the same group are equal.
|
||||
Only checked qualities are wanted.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setIsCreateGroupModalOpen(true)}
|
||||
className='h-10 px-6 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center gap-2'>
|
||||
<InfoIcon className='w-4 h-4' />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{upgradesAllowed && (
|
||||
<UpgradeSection
|
||||
enabledQualities={sortedQualities.filter(q => q.enabled)}
|
||||
selectedUpgradeQuality={selectedUpgradeQuality}
|
||||
onUpgradeQualityChange={onSelectedUpgradeQualityChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[
|
||||
restrictToVerticalAxis,
|
||||
restrictToParentElement
|
||||
]}>
|
||||
<div className=''>
|
||||
<div className='space-y-2'>
|
||||
<SortableContext
|
||||
items={sortedQualities.map(q => q.id)}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
{sortedQualities.map(quality => (
|
||||
<SortableItem
|
||||
key={quality.id}
|
||||
quality={quality}
|
||||
onToggle={handleQualityToggle}
|
||||
onDelete={
|
||||
'qualities' in quality
|
||||
? handleDeleteClick
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
'qualities' in quality
|
||||
? handleEditClick
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<CreateGroupModal
|
||||
isOpen={isCreateGroupModalOpen}
|
||||
onClose={() => {
|
||||
setIsCreateGroupModalOpen(false);
|
||||
setEditingGroup(null);
|
||||
}}
|
||||
availableQualities={
|
||||
editingGroup?.availableQualities || sortedQualities
|
||||
}
|
||||
onCreateGroup={handleCreateOrUpdateGroup}
|
||||
editingGroup={editingGroup}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setGroupToDelete(null);
|
||||
}}
|
||||
title='Delete Quality Group'
|
||||
width='md'
|
||||
footer={
|
||||
<div className='flex justify-end space-x-3'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setGroupToDelete(null);
|
||||
}}
|
||||
className='px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700'>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDelete}
|
||||
className='px-3 py-1.5 text-xs font-medium text-white bg-red-600 dark:bg-red-500 rounded-md hover:bg-red-700 dark:hover:bg-red-600'>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
Are you sure you want to delete the quality group "
|
||||
{groupToDelete?.name}"? This action cannot be undone.
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileQualitiesTab.propTypes = {
|
||||
enabledQualities: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string
|
||||
})
|
||||
).isRequired,
|
||||
onQualitiesChange: PropTypes.func.isRequired,
|
||||
upgradesAllowed: PropTypes.bool,
|
||||
selectedUpgradeQuality: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string
|
||||
}),
|
||||
onSelectedUpgradeQualityChange: PropTypes.func,
|
||||
sortedQualities: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
enabled: PropTypes.bool,
|
||||
qualities: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string
|
||||
})
|
||||
)
|
||||
})
|
||||
).isRequired,
|
||||
onSortedQualitiesChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProfileQualitiesTab;
|
||||
416
frontend/src/components/profile/ProfileScoringTab.jsx
Normal file
416
frontend/src/components/profile/ProfileScoringTab.jsx
Normal file
@@ -0,0 +1,416 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Search} from 'lucide-react';
|
||||
import {SortDropdown} from '../ui/SortDropdown';
|
||||
import TabViewer from '../ui/TabViewer';
|
||||
|
||||
const ProfileScoringTab = ({
|
||||
formats,
|
||||
formatFilter,
|
||||
onFormatFilterChange,
|
||||
onScoreChange,
|
||||
formatSortKey,
|
||||
formatSortDirection,
|
||||
onFormatSort,
|
||||
tags,
|
||||
tagFilter,
|
||||
onTagFilterChange,
|
||||
tagScores,
|
||||
onTagScoreChange,
|
||||
tagSortKey,
|
||||
tagSortDirection,
|
||||
onTagSort,
|
||||
minCustomFormatScore,
|
||||
upgradeUntilScore,
|
||||
minScoreIncrement,
|
||||
onMinScoreChange,
|
||||
onUpgradeUntilScoreChange,
|
||||
onMinIncrementChange
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('formats');
|
||||
const [localFormatScores, setLocalFormatScores] = useState({});
|
||||
const [localTagScores, setLocalTagScores] = useState({});
|
||||
|
||||
const tabs = [
|
||||
{id: 'formats', label: 'Format Scoring'},
|
||||
{id: 'tags', label: 'Tag Scoring'},
|
||||
{id: 'upgrades', label: 'Upgrades'}
|
||||
];
|
||||
|
||||
// Filter formats based on search
|
||||
const filteredFormats = formats.filter(format =>
|
||||
format.name.toLowerCase().includes(formatFilter.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter tags based on search
|
||||
const filteredTags = tags.filter(tag =>
|
||||
tag.toLowerCase().includes(tagFilter.toLowerCase())
|
||||
);
|
||||
|
||||
// Handle local score changes
|
||||
const handleFormatScoreChange = (id, value) => {
|
||||
setLocalFormatScores(prev => ({...prev, [id]: value}));
|
||||
};
|
||||
|
||||
const handleTagScoreChange = (tag, value) => {
|
||||
setLocalTagScores(prev => ({...prev, [tag]: value}));
|
||||
};
|
||||
|
||||
// Handle blur events
|
||||
const handleFormatBlur = (id, currentScore) => {
|
||||
const localValue = localFormatScores[id];
|
||||
if (localValue === undefined) return;
|
||||
const numValue = localValue === '' ? 0 : parseInt(localValue);
|
||||
if (numValue !== currentScore) {
|
||||
onScoreChange(id, numValue);
|
||||
}
|
||||
setLocalFormatScores(prev => {
|
||||
const newState = {...prev};
|
||||
delete newState[id];
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagBlur = tag => {
|
||||
const localValue = localTagScores[tag];
|
||||
if (localValue === undefined) return;
|
||||
const currentScore = tagScores[tag] ?? 0; // Use nullish coalescing
|
||||
const numValue = localValue === '' ? 0 : parseInt(localValue);
|
||||
if (numValue !== currentScore) {
|
||||
onTagScoreChange(tag, numValue);
|
||||
}
|
||||
setLocalTagScores(prev => {
|
||||
const newState = {...prev};
|
||||
delete newState[tag];
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
// Sort formats
|
||||
const sortedFormats = [...filteredFormats].sort((a, b) => {
|
||||
if (formatSortKey === 'name') {
|
||||
return formatSortDirection === 'asc'
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name);
|
||||
} else if (formatSortKey === 'score') {
|
||||
return formatSortDirection === 'asc'
|
||||
? a.score - b.score
|
||||
: b.score - a.score;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Sort tags
|
||||
const sortedTags = [...filteredTags].sort((a, b) => {
|
||||
if (tagSortKey === 'name') {
|
||||
return tagSortDirection === 'asc'
|
||||
? a.localeCompare(b)
|
||||
: b.localeCompare(a);
|
||||
} else if (tagSortKey === 'score') {
|
||||
return tagSortDirection === 'asc'
|
||||
? (tagScores[a] || 0) - (tagScores[b] || 0)
|
||||
: (tagScores[b] || 0) - (tagScores[a] || 0);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Handle keydown to submit on enter
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'formats':
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex-1 relative'>
|
||||
<Search className='absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400' />
|
||||
<input
|
||||
type='text'
|
||||
value={formatFilter}
|
||||
onChange={e =>
|
||||
onFormatFilterChange(e.target.value)
|
||||
}
|
||||
placeholder='Search formats...'
|
||||
className='w-full pl-8 pr-3 py-1.5 text-xs rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||
/>
|
||||
</div>
|
||||
<SortDropdown
|
||||
options={[
|
||||
{key: 'name', label: 'Name'},
|
||||
{key: 'score', label: 'Score'}
|
||||
]}
|
||||
currentKey={formatSortKey}
|
||||
currentDirection={formatSortDirection}
|
||||
onSort={onFormatSort}
|
||||
/>
|
||||
</div>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedFormats.length === 0 ? (
|
||||
<div className='p-3 text-center text-xs text-gray-500'>
|
||||
No formats found
|
||||
</div>
|
||||
) : (
|
||||
sortedFormats.map(format => (
|
||||
<div
|
||||
key={format.id}
|
||||
className='flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-800/50'>
|
||||
<div className='flex items-center gap-2 min-w-0'>
|
||||
<span className='text-xs font-medium truncate'>
|
||||
{format.name}
|
||||
</span>
|
||||
<div className='flex gap-1'>
|
||||
{format.tags?.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className='px-1.5 py-0.5 text-[10px] rounded bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={
|
||||
localFormatScores[
|
||||
format.id
|
||||
] !== undefined
|
||||
? localFormatScores[
|
||||
format.id
|
||||
]
|
||||
: format.score
|
||||
}
|
||||
onChange={e =>
|
||||
handleFormatScoreChange(
|
||||
format.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={() =>
|
||||
handleFormatBlur(
|
||||
format.id,
|
||||
format.score
|
||||
)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-16 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'tags':
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex-1 relative'>
|
||||
<Search className='absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400' />
|
||||
<input
|
||||
type='text'
|
||||
value={tagFilter}
|
||||
onChange={e =>
|
||||
onTagFilterChange(e.target.value)
|
||||
}
|
||||
placeholder='Search tags...'
|
||||
className='w-full pl-8 pr-3 py-1.5 text-xs rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||
/>
|
||||
</div>
|
||||
<SortDropdown
|
||||
options={[
|
||||
{key: 'name', label: 'Name'},
|
||||
{key: 'score', label: 'Score'}
|
||||
]}
|
||||
currentKey={tagSortKey}
|
||||
currentDirection={tagSortDirection}
|
||||
onSort={onTagSort}
|
||||
/>
|
||||
</div>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||
{sortedTags.length === 0 ? (
|
||||
<div className='p-3 text-center text-xs text-gray-500'>
|
||||
No tags found
|
||||
</div>
|
||||
) : (
|
||||
sortedTags.map(tag => (
|
||||
<div
|
||||
key={tag}
|
||||
className='flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-800/50'>
|
||||
<span className='text-xs'>
|
||||
{tag}
|
||||
</span>
|
||||
<input
|
||||
type='number'
|
||||
value={
|
||||
localTagScores[tag] !==
|
||||
undefined
|
||||
? localTagScores[tag]
|
||||
: tagScores[tag] || 0
|
||||
}
|
||||
onChange={e =>
|
||||
handleTagScoreChange(
|
||||
tag,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={() =>
|
||||
handleTagBlur(tag)
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='w-16 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'upgrades':
|
||||
return (
|
||||
<div className='space-y-6 bg-white dark:bg-gray-800 rounded-lg p-3'>
|
||||
<div className='divide-y divide-gray-200 dark:divide-gray-700 space-y-4'>
|
||||
{/* Minimum Custom Format Score */}
|
||||
<div className='pt-4 first:pt-0'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Minimum Custom Format Score
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Minimum custom format score allowed
|
||||
to download
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={minCustomFormatScore}
|
||||
onChange={e =>
|
||||
onMinScoreChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='w-16 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade Until Score */}
|
||||
<div className='pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Upgrade Until Custom Format Score
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Once the quality cutoff is met or
|
||||
exceeded and this custom format
|
||||
score is reached, no more upgrades
|
||||
will be grabbed
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={upgradeUntilScore}
|
||||
onChange={e =>
|
||||
onUpgradeUntilScoreChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='w-16 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Minimum Score Increment */}
|
||||
<div className='pt-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Minimum Custom Format Score
|
||||
Increment
|
||||
</div>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Minimum required improvement of the
|
||||
custom format score between existing
|
||||
and new releases before considering
|
||||
an upgrade
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type='number'
|
||||
value={minScoreIncrement}
|
||||
onChange={e =>
|
||||
onMinIncrementChange(
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className='w-16 px-3 py-1 text-sm rounded border border-gray-700 dark:bg-gray-800 dark:text-gray-100 bg-gray-800 text-gray-100 [appearance:textfield]'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full space-y-4'>
|
||||
{/* Tab Navigation */}
|
||||
<div className='flex items-center'>
|
||||
<TabViewer
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileScoringTab.propTypes = {
|
||||
formats: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired, // Ensure id is required
|
||||
name: PropTypes.string.isRequired,
|
||||
score: PropTypes.number.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string)
|
||||
})
|
||||
).isRequired,
|
||||
formatFilter: PropTypes.string.isRequired,
|
||||
onFormatFilterChange: PropTypes.func.isRequired,
|
||||
onScoreChange: PropTypes.func.isRequired,
|
||||
formatSortKey: PropTypes.string.isRequired,
|
||||
formatSortDirection: PropTypes.string.isRequired,
|
||||
onFormatSort: PropTypes.func.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
tagFilter: PropTypes.string.isRequired,
|
||||
onTagFilterChange: PropTypes.func.isRequired,
|
||||
tagScores: PropTypes.object.isRequired,
|
||||
onTagScoreChange: PropTypes.func.isRequired,
|
||||
tagSortKey: PropTypes.string.isRequired,
|
||||
tagSortDirection: PropTypes.string.isRequired,
|
||||
onTagSort: PropTypes.func.isRequired,
|
||||
minCustomFormatScore: PropTypes.number.isRequired,
|
||||
upgradeUntilScore: PropTypes.number.isRequired,
|
||||
minScoreIncrement: PropTypes.number.isRequired,
|
||||
onMinScoreChange: PropTypes.func.isRequired,
|
||||
onUpgradeUntilScoreChange: PropTypes.func.isRequired,
|
||||
onMinIncrementChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ProfileScoringTab;
|
||||
124
frontend/src/components/profile/QualityItem.jsx
Normal file
124
frontend/src/components/profile/QualityItem.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import RadarrLogo from '@logo/Radarr.svg';
|
||||
import SonarrLogo from '@logo/Sonarr.svg';
|
||||
import {Pencil, Trash2} from 'lucide-react';
|
||||
|
||||
const QualityItem = ({
|
||||
quality,
|
||||
isDragging,
|
||||
listeners,
|
||||
attributes,
|
||||
style,
|
||||
onDelete,
|
||||
onEdit
|
||||
}) => {
|
||||
const isGroup = 'qualities' in quality;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative p-2.5 rounded-lg select-none cursor-grab active:cursor-grabbing
|
||||
border ${
|
||||
quality.enabled
|
||||
? 'border-blue-200 dark:border-blue-800'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
transition-colors duration-200
|
||||
${
|
||||
quality.enabled
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'bg-white dark:bg-gray-800'
|
||||
}
|
||||
hover:border-blue-500 dark:hover:border-blue-400
|
||||
${isDragging ? 'opacity-50' : ''}
|
||||
group
|
||||
`}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}>
|
||||
{/* Header Section */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
{/* Title and Description */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='text-xs font-medium text-gray-900 dark:text-gray-100'>
|
||||
{quality.name}
|
||||
</h3>
|
||||
{isGroup && quality.description && (
|
||||
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{quality.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions and Icons */}
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* App Icons */}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{quality.radarr && (
|
||||
<img
|
||||
src={RadarrLogo}
|
||||
className='w-3.5 h-3.5'
|
||||
alt='Radarr'
|
||||
/>
|
||||
)}
|
||||
{quality.sonarr && (
|
||||
<img
|
||||
src={SonarrLogo}
|
||||
className='w-3.5 h-3.5'
|
||||
alt='Sonarr'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit/Delete Actions */}
|
||||
{isGroup && (
|
||||
<div className='flex items-center gap-1 ml-1'>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onEdit(quality);
|
||||
}}
|
||||
className='hidden group-hover:flex items-center justify-center h-6 w-6 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 transition-all'>
|
||||
<Pencil className='w-3 h-3' />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(quality);
|
||||
}}
|
||||
className='hidden group-hover:flex items-center justify-center h-6 w-6 rounded-md text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 transition-all'>
|
||||
<Trash2 className='w-3 h-3' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Tags Section */}
|
||||
{isGroup && (
|
||||
<div className='mt-2 flex flex-wrap items-center gap-1'>
|
||||
{quality.qualities.map(q => (
|
||||
<span
|
||||
key={q.id}
|
||||
className='inline-flex px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'>
|
||||
{q.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-group Description */}
|
||||
{!isGroup && quality.description && (
|
||||
<p className='mt-0.5 text-xs text-gray-500 dark:text-gray-400'>
|
||||
{quality.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityItem;
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
statusLoadingMessages,
|
||||
noChangesMessages,
|
||||
getRandomMessage
|
||||
} from '../../utils/messages';
|
||||
} from '../../constants/messages';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const [settings, setSettings] = useState(null);
|
||||
|
||||
@@ -17,7 +17,7 @@ import ConflictTable from './ConflictTable';
|
||||
import CommitSection from './CommitMessage';
|
||||
import Modal from '../../ui/Modal';
|
||||
import Tooltip from '../../ui/Tooltip';
|
||||
import {getRandomMessage, noChangesMessages} from '../../../utils/messages';
|
||||
import {getRandomMessage, noChangesMessages} from '../../../constants/messages';
|
||||
import IconButton from '../../ui/IconButton';
|
||||
import {abortMerge, finalizeMerge} from '../../../api/api';
|
||||
import Alert from '../../ui/Alert';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../../../ui/Modal';
|
||||
import DiffCommit from './DiffCommit';
|
||||
import Tooltip from '../../../ui/Tooltip';
|
||||
import Alert from '../../../ui/Alert';
|
||||
import {getFormats, resolveConflict} from '../../../../api/api';
|
||||
@@ -47,21 +46,6 @@ const ResolveConflicts = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const parseKey = param => {
|
||||
return param
|
||||
.split('_')
|
||||
.map(
|
||||
word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const formatDate = dateString => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const renderTable = (title, headers, data, renderRow) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
@@ -93,7 +77,14 @@ const ResolveConflicts = ({
|
||||
};
|
||||
|
||||
const renderBasicFields = () => {
|
||||
const basicFields = ['name', 'description'];
|
||||
const basicFields = [
|
||||
'Description',
|
||||
'Language',
|
||||
'Minimum Custom Format Score',
|
||||
'Minimum Score Increment',
|
||||
'Upgrade Until Score',
|
||||
'Upgrades Allowed'
|
||||
];
|
||||
const conflicts = change.conflict_details.conflicting_parameters.filter(
|
||||
param => basicFields.includes(param.parameter)
|
||||
);
|
||||
@@ -111,19 +102,20 @@ const ResolveConflicts = ({
|
||||
conflicts,
|
||||
({parameter, local_value, incoming_value}) => (
|
||||
<tr key={parameter} className='border-t border-gray-600'>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{parseKey(parameter)}
|
||||
</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>{parameter}</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>{local_value}</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{incoming_value}
|
||||
</td>
|
||||
<td className='px-4 py-2.5'>
|
||||
<select
|
||||
value={conflictResolutions[parameter] || ''}
|
||||
value={
|
||||
conflictResolutions[parameter.toLowerCase()] ||
|
||||
''
|
||||
}
|
||||
onChange={e =>
|
||||
handleResolutionChange(
|
||||
parameter,
|
||||
parameter.toLowerCase(),
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
@@ -143,35 +135,12 @@ const ResolveConflicts = ({
|
||||
const renderCustomFormatConflicts = () => {
|
||||
if (change.type !== 'Quality Profile') return null;
|
||||
|
||||
const formatConflict =
|
||||
change.conflict_details.conflicting_parameters.find(
|
||||
param => param.parameter === 'custom_formats'
|
||||
const formatConflicts =
|
||||
change.conflict_details.conflicting_parameters.filter(param =>
|
||||
param.parameter.startsWith('Custom Format:')
|
||||
);
|
||||
|
||||
if (!formatConflict) return null;
|
||||
|
||||
const changedFormats = [];
|
||||
const localFormats = formatConflict.local_value;
|
||||
const incomingFormats = formatConflict.incoming_value;
|
||||
|
||||
// Compare and find changed scores
|
||||
localFormats.forEach(localFormat => {
|
||||
const incomingFormat = incomingFormats.find(
|
||||
f => f.id === localFormat.id
|
||||
);
|
||||
if (incomingFormat && incomingFormat.score !== localFormat.score) {
|
||||
changedFormats.push({
|
||||
id: localFormat.id,
|
||||
name:
|
||||
formatNames[localFormat.id] ||
|
||||
`Format ${localFormat.id}`,
|
||||
local_score: localFormat.score,
|
||||
incoming_score: incomingFormat.score
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (changedFormats.length === 0) return null;
|
||||
if (formatConflicts.length === 0) return null;
|
||||
|
||||
return renderTable(
|
||||
'Custom Format Conflicts',
|
||||
@@ -181,60 +150,54 @@ const ResolveConflicts = ({
|
||||
{label: 'Incoming Score', width: 'w-1/4'},
|
||||
{label: 'Resolution', width: 'w-1/4'}
|
||||
],
|
||||
changedFormats,
|
||||
({id, name, local_score, incoming_score}) => (
|
||||
<tr key={id} className='border-t border-gray-600'>
|
||||
<td className='px-4 py-2.5 text-gray-300'>{name}</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>{local_score}</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{incoming_score}
|
||||
</td>
|
||||
<td className='px-4 py-2.5'>
|
||||
<select
|
||||
value={
|
||||
conflictResolutions[`custom_format_${id}`] || ''
|
||||
}
|
||||
onChange={e =>
|
||||
handleResolutionChange(
|
||||
`custom_format_${id}`,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
|
||||
<option value='' disabled>
|
||||
Select
|
||||
</option>
|
||||
<option value='local'>Keep Local Score</option>
|
||||
<option value='incoming'>
|
||||
Accept Incoming Score
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
formatConflicts,
|
||||
({parameter, local_value, incoming_value}) => {
|
||||
const formatName = parameter.split(':')[1].trim();
|
||||
const resolutionKey = `custom_format_${formatName}`;
|
||||
|
||||
return (
|
||||
<tr key={parameter} className='border-t border-gray-600'>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{formatName}
|
||||
</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{local_value}
|
||||
</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{incoming_value}
|
||||
</td>
|
||||
<td className='px-4 py-2.5'>
|
||||
<select
|
||||
value={conflictResolutions[resolutionKey] || ''}
|
||||
onChange={e =>
|
||||
handleResolutionChange(
|
||||
resolutionKey,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
|
||||
<option value='' disabled>
|
||||
Select
|
||||
</option>
|
||||
<option value='local'>Keep Local Score</option>
|
||||
<option value='incoming'>
|
||||
Accept Incoming Score
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderTagConflicts = () => {
|
||||
const tagConflict = change.conflict_details.conflicting_parameters.find(
|
||||
param => param.parameter === 'tags'
|
||||
);
|
||||
const tagConflicts =
|
||||
change.conflict_details.conflicting_parameters.filter(param =>
|
||||
param.parameter.startsWith('Tags:')
|
||||
);
|
||||
|
||||
if (!tagConflict) return null;
|
||||
|
||||
const localTags = new Set(tagConflict.local_value);
|
||||
const incomingTags = new Set(tagConflict.incoming_value);
|
||||
const allTags = [...new Set([...localTags, ...incomingTags])];
|
||||
|
||||
const tagDiffs = allTags
|
||||
.filter(tag => localTags.has(tag) !== incomingTags.has(tag))
|
||||
.map(tag => ({
|
||||
tag,
|
||||
local_status: localTags.has(tag) ? 'present' : 'absent',
|
||||
incoming_status: incomingTags.has(tag) ? 'present' : 'absent'
|
||||
}));
|
||||
|
||||
if (tagDiffs.length === 0) return null;
|
||||
if (tagConflicts.length === 0) return null;
|
||||
|
||||
return renderTable(
|
||||
'Tag Conflicts',
|
||||
@@ -244,37 +207,42 @@ const ResolveConflicts = ({
|
||||
{label: 'Incoming Status', width: 'w-1/4'},
|
||||
{label: 'Resolution', width: 'w-1/4'}
|
||||
],
|
||||
tagDiffs,
|
||||
({tag, local_status, incoming_status}) => (
|
||||
<tr key={tag} className='border-t border-gray-600'>
|
||||
<td className='px-4 py-2.5 text-gray-300'>{tag}</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{local_status}
|
||||
</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{incoming_status}
|
||||
</td>
|
||||
<td className='px-4 py-2.5'>
|
||||
<select
|
||||
value={conflictResolutions[`tag_${tag}`] || ''}
|
||||
onChange={e =>
|
||||
handleResolutionChange(
|
||||
`tag_${tag}`,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
|
||||
<option value='' disabled>
|
||||
Select
|
||||
</option>
|
||||
<option value='local'>Keep Local Status</option>
|
||||
<option value='incoming'>
|
||||
Accept Incoming Status
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
tagConflicts,
|
||||
({parameter, local_value, incoming_value}) => {
|
||||
const tagName = parameter.split(':')[1].trim();
|
||||
const resolutionKey = `tag_${tagName}`;
|
||||
|
||||
return (
|
||||
<tr key={parameter} className='border-t border-gray-600'>
|
||||
<td className='px-4 py-2.5 text-gray-300'>{tagName}</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{local_value.toString()}
|
||||
</td>
|
||||
<td className='px-4 py-2.5 text-gray-300'>
|
||||
{incoming_value.toString()}
|
||||
</td>
|
||||
<td className='px-4 py-2.5'>
|
||||
<select
|
||||
value={conflictResolutions[resolutionKey] || ''}
|
||||
onChange={e =>
|
||||
handleResolutionChange(
|
||||
resolutionKey,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
|
||||
<option value='' disabled>
|
||||
Select
|
||||
</option>
|
||||
<option value='local'>Keep Local Status</option>
|
||||
<option value='incoming'>
|
||||
Accept Incoming Status
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -289,7 +257,7 @@ const ResolveConflicts = ({
|
||||
{label: 'Remote Version', width: 'w-1/4'},
|
||||
{label: 'Resolution', width: 'w-1/4'}
|
||||
],
|
||||
[change.conflict_details.conflicting_parameters[0]], // There's only one parameter for modify/delete
|
||||
[change.conflict_details.conflicting_parameters[0]],
|
||||
({parameter, local_value, incoming_value}) => (
|
||||
<tr key={parameter} className='border-t border-gray-600'>
|
||||
<td className='px-4 py-2.5 text-gray-300'>File</td>
|
||||
@@ -334,70 +302,37 @@ const ResolveConflicts = ({
|
||||
return !!conflictResolutions['file'];
|
||||
}
|
||||
|
||||
const requiredResolutions = [];
|
||||
// For all other conflicts, every parameter needs a resolution
|
||||
return change.conflict_details.conflicting_parameters.every(
|
||||
({parameter}) => {
|
||||
// Convert backend parameter name to resolution key format
|
||||
let resolutionKey = parameter;
|
||||
|
||||
// Basic fields
|
||||
change.conflict_details.conflicting_parameters
|
||||
.filter(param => ['name', 'description'].includes(param.parameter))
|
||||
.forEach(param => requiredResolutions.push(param.parameter));
|
||||
|
||||
// Custom formats (only for Quality Profiles)
|
||||
if (change.type === 'Quality Profile') {
|
||||
const formatConflict =
|
||||
change.conflict_details.conflicting_parameters.find(
|
||||
param => param.parameter === 'custom_formats'
|
||||
);
|
||||
|
||||
if (formatConflict) {
|
||||
const localFormats = formatConflict.local_value;
|
||||
const incomingFormats = formatConflict.incoming_value;
|
||||
|
||||
localFormats.forEach(localFormat => {
|
||||
const incomingFormat = incomingFormats.find(
|
||||
f => f.id === localFormat.id
|
||||
);
|
||||
if (
|
||||
incomingFormat &&
|
||||
incomingFormat.score !== localFormat.score
|
||||
) {
|
||||
requiredResolutions.push(
|
||||
`custom_format_${localFormat.id}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
const tagConflict = change.conflict_details.conflicting_parameters.find(
|
||||
param => param.parameter === 'tags'
|
||||
);
|
||||
|
||||
if (tagConflict) {
|
||||
const localTags = new Set(tagConflict.local_value);
|
||||
const incomingTags = new Set(tagConflict.incoming_value);
|
||||
const allTags = [...new Set([...localTags, ...incomingTags])];
|
||||
|
||||
allTags.forEach(tag => {
|
||||
if (localTags.has(tag) !== incomingTags.has(tag)) {
|
||||
requiredResolutions.push(`tag_${tag}`);
|
||||
// Check for known prefixes and convert appropriately
|
||||
if (parameter.startsWith('Custom Format: ')) {
|
||||
// Extract the format ID from something like "Custom Format: 123: Score"
|
||||
const formatId = parameter.split(': ')[1].split(':')[0];
|
||||
resolutionKey = `custom_format_${formatId}`;
|
||||
} else if (parameter.startsWith('Tags: ')) {
|
||||
// Extract just the tag name from "Tags: tagname"
|
||||
const tagName = parameter.split(': ')[1];
|
||||
resolutionKey = `tag_${tagName}`;
|
||||
} else {
|
||||
// Convert other parameters to lowercase for basic fields
|
||||
resolutionKey = parameter.toLowerCase();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return requiredResolutions.every(key => conflictResolutions[key]);
|
||||
return !!conflictResolutions[resolutionKey];
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleResolveConflicts = async () => {
|
||||
console.log('File path:', change.file_path);
|
||||
|
||||
const resolutions = {
|
||||
[change.file_path]: conflictResolutions
|
||||
};
|
||||
|
||||
console.log('Sending resolutions:', resolutions);
|
||||
|
||||
try {
|
||||
const resolutions = {
|
||||
[change.file_path]: conflictResolutions
|
||||
};
|
||||
|
||||
const result = await resolveConflict(resolutions);
|
||||
if (result.error) {
|
||||
Alert.warning(result.error);
|
||||
@@ -405,14 +340,13 @@ const ResolveConflicts = ({
|
||||
}
|
||||
|
||||
Alert.success('Successfully resolved conflicts');
|
||||
await fetchGitStatus(); // Add this to refresh the status
|
||||
onClose(); // Close the modal after successful resolution
|
||||
await fetchGitStatus();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
Alert.error(error.message || 'Failed to resolve conflicts');
|
||||
}
|
||||
};
|
||||
|
||||
// Title with status indicator
|
||||
const titleContent = (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<span className='text-lg font-bold'>
|
||||
@@ -443,10 +377,8 @@ const ResolveConflicts = ({
|
||||
width='5xl'>
|
||||
<div className='space-y-4'>
|
||||
{change.status === 'MODIFY_DELETE' ? (
|
||||
// For modify/delete conflicts, only show the file status
|
||||
renderModifyDeleteConflict()
|
||||
) : (
|
||||
// For regular conflicts, show all the existing sections
|
||||
<>
|
||||
{renderBasicFields()}
|
||||
{renderCustomFormatConflicts()}
|
||||
|
||||
@@ -25,12 +25,36 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
|
||||
}, []);
|
||||
|
||||
const parseKey = param => {
|
||||
// If the key contains colons, handle as a structured key
|
||||
if (param.includes(':')) {
|
||||
return param; // Already formatted from backend
|
||||
}
|
||||
|
||||
// For single term keys, handle compound terms specially
|
||||
const specialTerms = {
|
||||
minimumcustomformatscore: 'Minimum Custom Format Score',
|
||||
minscoreincrement: 'Minimum Score Increment',
|
||||
upgradeuntilscore: 'Upgrade Until Score',
|
||||
upgradesallowed: 'Upgrades Allowed',
|
||||
customformat: 'Custom Format',
|
||||
qualitygroup: 'Quality Group'
|
||||
};
|
||||
|
||||
// Check if it's a special term
|
||||
const lowerKey = param.toLowerCase();
|
||||
if (specialTerms[lowerKey]) {
|
||||
return specialTerms[lowerKey];
|
||||
}
|
||||
|
||||
// If the key already has spaces, preserve existing capitalization
|
||||
if (param.includes(' ')) {
|
||||
return param;
|
||||
}
|
||||
|
||||
// Default handling for simple keys - preserve original capitalization
|
||||
return param
|
||||
.split('_')
|
||||
.map(
|
||||
word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
)
|
||||
.map(word => word) // Keep original capitalization
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
@@ -208,7 +232,7 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={titleContent}
|
||||
width='5xl'>
|
||||
width='7xl'>
|
||||
<div className='space-y-4'>
|
||||
{change.commit_message && (
|
||||
<DiffCommit commitMessage={change.commit_message} />
|
||||
|
||||
@@ -281,13 +281,9 @@ const ViewCommits = ({isOpen, onClose, repoUrl, currentBranch}) => {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width='screen-xl'
|
||||
height='lg'>
|
||||
<div className='space-y-4'>
|
||||
<div className='overflow-y-auto max-h-[60vh]'>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
width='4xl'
|
||||
height='6xl'>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TabViewer from './TabViewer';
|
||||
|
||||
function Modal({
|
||||
const Modal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
tabs,
|
||||
level = 0,
|
||||
disableCloseOnOutsideClick = false,
|
||||
disableCloseOnEscape = false,
|
||||
width = 'auto',
|
||||
height = 'auto',
|
||||
maxHeight = '80vh'
|
||||
}) {
|
||||
maxHeight = '90vh'
|
||||
}) => {
|
||||
const modalRef = useRef();
|
||||
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !disableCloseOnEscape) {
|
||||
@@ -41,16 +45,16 @@ function Modal({
|
||||
|
||||
const widthClasses = {
|
||||
auto: 'w-auto max-w-[60%]',
|
||||
sm: 'w-[384px]', // 24rem
|
||||
md: 'w-[448px]', // 28rem
|
||||
lg: 'w-[512px]', // 32rem
|
||||
xl: 'w-[576px]', // 36rem
|
||||
'2xl': 'w-[672px]', // 42rem
|
||||
'3xl': 'w-[768px]', // 48rem
|
||||
'4xl': 'w-[896px]', // 56rem
|
||||
'5xl': 'w-[1024px]', // 64rem
|
||||
'6xl': 'w-[1152px]', // 72rem
|
||||
'7xl': 'w-[1280px]', // 80rem
|
||||
sm: 'w-[384px]',
|
||||
md: 'w-[448px]',
|
||||
lg: 'w-[512px]',
|
||||
xl: 'w-[576px]',
|
||||
'2xl': 'w-[672px]',
|
||||
'3xl': 'w-[768px]',
|
||||
'4xl': 'w-[896px]',
|
||||
'5xl': 'w-[1024px]',
|
||||
'6xl': 'w-[1152px]',
|
||||
'7xl': 'w-[1280px]',
|
||||
full: 'w-full',
|
||||
'screen-sm': 'w-screen-sm',
|
||||
'screen-md': 'w-screen-md',
|
||||
@@ -61,15 +65,15 @@ function Modal({
|
||||
|
||||
const heightClasses = {
|
||||
auto: 'h-auto',
|
||||
sm: 'h-[384px]', // 24rem
|
||||
md: 'h-[448px]', // 28rem
|
||||
lg: 'h-[512px]', // 32rem
|
||||
xl: 'h-[576px]', // 36rem
|
||||
'2xl': 'h-[672px]', // 42rem
|
||||
'3xl': 'h-[768px]', // 48rem
|
||||
'4xl': 'h-[896px]', // 56rem
|
||||
'5xl': 'h-[1024px]', // 64rem
|
||||
'6xl': 'h-[1152px]', // 72rem
|
||||
sm: 'h-[384px]',
|
||||
md: 'h-[448px]',
|
||||
lg: 'h-[512px]',
|
||||
xl: 'h-[576px]',
|
||||
'2xl': 'h-[672px]',
|
||||
'3xl': 'h-[768px]',
|
||||
'4xl': 'h-[896px]',
|
||||
'5xl': 'h-[1024px]',
|
||||
'6xl': 'h-[1152px]',
|
||||
full: 'h-full'
|
||||
};
|
||||
|
||||
@@ -84,53 +88,84 @@ function Modal({
|
||||
className={`fixed inset-0 bg-black transition-opacity duration-300 ease-out ${
|
||||
isOpen ? 'bg-opacity-50' : 'bg-opacity-0'
|
||||
}`}
|
||||
style={{zIndex: 1000 + level * 10}}></div>
|
||||
style={{zIndex: 1000 + level * 10}}
|
||||
/>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl
|
||||
min-w-[320px] min-h-[200px] ${widthClasses[width]} ${
|
||||
min-w-[320px] min-h-[200px] ${widthClasses[width]} ${
|
||||
heightClasses[height]
|
||||
}
|
||||
transition-all duration-300 ease-out transform
|
||||
${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
|
||||
overflow-visible`}
|
||||
transition-all duration-300 ease-out transform
|
||||
${isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}
|
||||
flex flex-col overflow-hidden`}
|
||||
style={{
|
||||
zIndex: 1001 + level * 10,
|
||||
maxHeight: maxHeight || '80vh'
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className='flex justify-between items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
|
||||
<h3 className='text-xl font-semibold dark:text-gray-200'>
|
||||
{title}
|
||||
</h3>
|
||||
{tabs && (
|
||||
<div className='ml-3'>
|
||||
<TabViewer
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
|
||||
className='ml-auto text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors'>
|
||||
<svg
|
||||
className='w-6 h-6'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'>
|
||||
viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M6 18L18 6M6 6l12 12'></path>
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className='p-6'>{children}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='flex-1 overflow-y-auto p-6 py-4'>
|
||||
{typeof children === 'function'
|
||||
? children(activeTab)
|
||||
: children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className='px-6 py-4 border-t border-gray-300 dark:border-gray-700'>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Modal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
|
||||
footer: PropTypes.node,
|
||||
tabs: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
})
|
||||
),
|
||||
level: PropTypes.number,
|
||||
disableCloseOnOutsideClick: PropTypes.bool,
|
||||
disableCloseOnEscape: PropTypes.bool,
|
||||
@@ -147,7 +182,6 @@ Modal.propTypes = {
|
||||
'6xl',
|
||||
'7xl',
|
||||
'full',
|
||||
'screen',
|
||||
'screen-sm',
|
||||
'screen-md',
|
||||
'screen-lg',
|
||||
|
||||
@@ -78,7 +78,7 @@ function Navbar({darkMode, setDarkMode}) {
|
||||
|
||||
return (
|
||||
<nav className='bg-gray-800 shadow-md'>
|
||||
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative'>
|
||||
<div className='max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 relative'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-8'>
|
||||
<h1 className='text-2xl font-bold text-white'>
|
||||
|
||||
61
frontend/src/components/ui/SortDropdown.jsx
Normal file
61
frontend/src/components/ui/SortDropdown.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {ArrowDown} from 'lucide-react';
|
||||
|
||||
export const SortDropdown = ({
|
||||
options,
|
||||
currentKey,
|
||||
currentDirection,
|
||||
onSort
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
const handleSort = key => {
|
||||
if (key === currentKey) {
|
||||
onSort(key, currentDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
onSort(key, 'desc');
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className='flex items-center space-x-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors'>
|
||||
<span>Sort</span>
|
||||
<ArrowDown size={14} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className='absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded shadow-lg z-10'>
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => handleSort(option.key)}
|
||||
className='block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'>
|
||||
{option.label}
|
||||
{currentKey === option.key && (
|
||||
<span className='float-right'>
|
||||
{currentDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SortDropdown.propTypes = {
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
currentKey: PropTypes.string.isRequired,
|
||||
currentDirection: PropTypes.string.isRequired,
|
||||
onSort: PropTypes.func.isRequired
|
||||
};
|
||||
63
frontend/src/components/ui/TabViewer.jsx
Normal file
63
frontend/src/components/ui/TabViewer.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, {useRef, useState, useEffect, useLayoutEffect} from 'react';
|
||||
|
||||
const TabViewer = ({tabs, activeTab, onTabChange}) => {
|
||||
const [tabOffset, setTabOffset] = useState(0);
|
||||
const [tabWidth, setTabWidth] = useState(0);
|
||||
const tabsRef = useRef({});
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const updateTabPosition = () => {
|
||||
if (tabsRef.current[activeTab]) {
|
||||
const tab = tabsRef.current[activeTab];
|
||||
setTabOffset(tab.offsetLeft);
|
||||
setTabWidth(tab.offsetWidth);
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updateTabPosition();
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(updateTabPosition);
|
||||
if (tabsRef.current[activeTab]) {
|
||||
resizeObserver.observe(tabsRef.current[activeTab]);
|
||||
}
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [activeTab]);
|
||||
|
||||
if (!tabs?.length) return null;
|
||||
|
||||
return (
|
||||
<div className='relative flex items-center'>
|
||||
{isInitialized && (
|
||||
<div
|
||||
className='absolute top-0 bottom-0 bg-gray-900 dark:bg-gray-900 rounded-md transition-all duration-300'
|
||||
style={{
|
||||
left: `${tabOffset}px`,
|
||||
width: `${tabWidth}px`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={el => (tabsRef.current[tab.id] = el)}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium relative z-10 transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'text-white'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabViewer;
|
||||
Reference in New Issue
Block a user