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:
Sam Chau
2024-11-26 16:14:29 +10:30
committed by Sam Chau
parent 19c6be2f21
commit 9b1d69014a
40 changed files with 4572 additions and 1280 deletions

View 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;

View File

@@ -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;

View 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;

View 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

View File

@@ -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()} />

View 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;

View 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;

View 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;

View File

@@ -20,7 +20,7 @@ import {
statusLoadingMessages,
noChangesMessages,
getRandomMessage
} from '../../utils/messages';
} from '../../constants/messages';
const SettingsPage = () => {
const [settings, setSettings] = useState(null);

View File

@@ -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';

View File

@@ -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()}

View File

@@ -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} />

View File

@@ -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>
);
};

View File

@@ -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',

View File

@@ -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'>

View 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
};

View 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;