chore(internationalisation): extract literal strings into message defintions for i18n

This commit is contained in:
Tom Wheeler
2026-01-14 09:55:35 +13:00
parent e8b40272e1
commit cac92296ed
64 changed files with 1887 additions and 1203 deletions
@@ -65,6 +65,15 @@ const messages = defineMessages({
noCollections: 'No collections available',
overseerrMode: 'Overseerr',
directMode: 'Direct',
yes: 'Yes',
no: 'No',
defaultSort: 'Default',
reverseSort: 'Reverse',
randomSort: 'Random',
firstSeason: 'First',
latestSeason: 'Latest',
airingSeason: 'Airing',
noCollectionsSelected: 'No collections selected',
});
interface BulkEditModalProps {
@@ -471,7 +480,7 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
// Save changes
const handleSave = async () => {
if (selectedIds.size === 0) {
addToast('No collections selected', {
addToast(intl.formatMessage(messages.noCollectionsSelected), {
appearance: 'error',
autoDismiss: true,
});
@@ -1397,8 +1406,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1426,8 +1439,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1455,8 +1472,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1498,8 +1519,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1517,9 +1542,15 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="default">Default</option>
<option value="reverse">Reverse</option>
<option value="random">Random</option>
<option value="default">
{intl.formatMessage(messages.defaultSort)}
</option>
<option value="reverse">
{intl.formatMessage(messages.reverseSort)}
</option>
<option value="random">
{intl.formatMessage(messages.randomSort)}
</option>
<option value="imdb_rating_desc">IMDb </option>
<option value="imdb_rating_asc">IMDb </option>
</select>
@@ -1568,8 +1599,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1593,8 +1628,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1618,8 +1657,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1643,8 +1686,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1699,9 +1746,15 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="first">First</option>
<option value="latest">Latest</option>
<option value="airing">Airing</option>
<option value="first">
{intl.formatMessage(messages.firstSeason)}
</option>
<option value="latest">
{intl.formatMessage(messages.latestSeason)}
</option>
<option value="airing">
{intl.formatMessage(messages.airingSeason)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1822,8 +1875,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
<td className="px-3 py-2">
@@ -1847,8 +1904,12 @@ const BulkEditModal: React.FC<BulkEditModalProps> = ({
className="w-full rounded border border-gray-600 bg-stone-700 px-2 py-1 text-xs text-white"
>
<option value="">-</option>
<option value="true">Yes</option>
<option value="false">No</option>
<option value="true">
{intl.formatMessage(messages.yes)}
</option>
<option value="false">
{intl.formatMessage(messages.no)}
</option>
</select>
</td>
</tr>
@@ -17,6 +17,7 @@ const messages = defineMessages({
exclusionRemoveError: 'Failed to remove exclusion',
loading: 'Loading exclusions...',
close: 'Close',
loadError: 'Failed to load exclusions',
});
interface EnrichedMovie {
@@ -136,7 +137,7 @@ const ExclusionsModal = ({ onCancel }: ExclusionsModalProps) => {
{error && (
<div className="flex h-96 items-center justify-center text-red-500">
Failed to load exclusions
{intl.formatMessage(messages.loadError)}
</div>
)}
@@ -25,6 +25,8 @@ const messages = defineMessages({
selectTag: 'Select tag...',
loadingTags: 'Loading tags...',
selectInstanceFirst: 'Select an instance first',
loadInstancesError: 'Failed to load instances. Please try again.',
loadTagsError: 'Failed to load tags. Please try again.',
});
interface ArrTagConfigSectionProps {
@@ -181,7 +183,7 @@ const ArrTagConfigSection = ({
/>
{instanceError && (
<p className="mt-1 text-xs text-red-400">
Failed to load instances. Please try again.
{intl.formatMessage(messages.loadInstancesError)}
</p>
)}
</div>
@@ -249,7 +251,7 @@ const ArrTagConfigSection = ({
/>
{tagsError && (
<p className="mt-1 text-xs text-red-400">
Failed to load tags. Please try again.
{intl.formatMessage(messages.loadTagsError)}
</p>
)}
</div>
@@ -101,6 +101,13 @@ const messages = defineMessages({
noTagOptions: 'No tags.',
selectOverseerrRadarrTags: 'Radarr Tags (Movies)',
selectOverseerrSonarrTags: 'Sonarr Tags (TV Shows)',
contentProcessing: 'Content Processing',
enableProcessingForApproval:
'Enable movie or TV processing above to configure auto-approval options.',
enableProcessingForOverseerr:
'Enable movie or TV processing above to configure server options.',
enableProcessingForDirect:
'Enable movie or TV processing above to configure server and profile options.',
});
interface AutoRequestSectionProps {
@@ -423,7 +430,7 @@ const AutoRequestSection = ({
{/* Media Type Processing Options */}
<div className="mb-6">
<div className="mb-3 text-sm font-medium text-gray-200">
Content Processing
{intl.formatMessage(messages.contentProcessing)}
</div>
<div className="space-y-3">
{/* Movies - only show if library supports movies */}
@@ -850,8 +857,7 @@ const AutoRequestSection = ({
{/* Ensure at least one child exists to avoid empty div */}
{!values.searchMissingMovies && !values.searchMissingTV && (
<div className="text-sm text-gray-400">
Enable movie or TV processing above to configure
auto-approval options.
{intl.formatMessage(messages.enableProcessingForApproval)}
</div>
)}
</>
@@ -1267,8 +1273,9 @@ const AutoRequestSection = ({
{/* Show message if no processing options are enabled */}
{!values.searchMissingMovies && !values.searchMissingTV && (
<div className="text-sm text-gray-400">
Enable movie or TV processing above to configure server
options.
{intl.formatMessage(
messages.enableProcessingForOverseerr
)}
</div>
)}
</div>
@@ -1662,8 +1669,7 @@ const AutoRequestSection = ({
{/* Show message if no processing options are enabled */}
{!values.searchMissingMovies && !values.searchMissingTV && (
<div className="text-sm text-gray-400">
Enable movie or TV processing above to configure server and
profile options.
{intl.formatMessage(messages.enableProcessingForDirect)}
</div>
)}
</div>
@@ -10,8 +10,9 @@ const messages = defineMessages({
'Automatically exclude items that exist in other collections. Items from selected collections will be removed from this collection during sync. Note: Exclusions only apply if the excluded collection is active in Plex.',
enableExclusion: 'Enable collection exclusion',
selectCollections: 'Select collections to exclude items from',
noCollectionsAvailable: 'No other collections available for exclusion',
collectionPlaceholder: 'Select collections...',
collectionsSelected:
'{count, plural, one {# collection selected for exclusion} other {# collections selected for exclusion}}',
});
interface CollectionExclusionSectionProps {
@@ -209,9 +210,9 @@ const CollectionExclusionSection: React.FC<CollectionExclusionSectionProps> = ({
/>
{selectedExclusions.length > 0 && (
<div className="mt-2 text-xs text-gray-400">
{selectedExclusions.length} collection
{selectedExclusions.length !== 1 ? 's' : ''} selected for
exclusion
{intl.formatMessage(messages.collectionsSelected, {
count: selectedExclusions.length,
})}
</div>
)}
</div>
@@ -31,6 +31,22 @@ const messages = defineMessages({
collectionSubtype: 'Collection Sub-Type',
selectSource: 'Select Source...',
selectSubtype: 'Select sub-type...',
comingSoonVolumesWarning:
'Coming Soon requires media volume mounts for placeholder creation',
seeSetupGuide: 'See setup guide',
minimumItems: 'Minimum Items',
minimumItemsHelp:
'Only create if this person has at least this many items (default: 5, minimum allowed: 2)',
useSeparator: 'Use Separator',
useSeparatorHelp:
'Create a simple separator collection to group your auto {type} collections.',
separatorTitle: 'Separator Title',
separatorTitleHelp:
'Defaults to {defaultTitle}. This title is used for the separator collection and poster.',
actorCollections: 'Actor Collections',
directorCollections: 'Director Collections',
numberOfDays: 'Number of Days',
minimumPlayCount: 'Minimum Play Count',
});
interface SubtypeOption {
@@ -539,15 +555,14 @@ const CollectionTypeSection = ({
<Alert
title={
<>
Coming Soon requires media volume mounts for placeholder creation
-{' '}
{intl.formatMessage(messages.comingSoonVolumesWarning)} -{' '}
<a
href="https://agregarr.org/docs/coming-soon-volumes"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-500 hover:text-blue-400"
>
See setup guide
{intl.formatMessage(messages.seeSetupGuide)}
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
</>
@@ -565,7 +580,7 @@ const CollectionTypeSection = ({
htmlFor="personMinimumItems"
className="mb-2 block text-sm text-gray-300"
>
Minimum Items
{intl.formatMessage(messages.minimumItems)}
</label>
<Field
type="number"
@@ -577,8 +592,7 @@ const CollectionTypeSection = ({
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="mt-1 text-xs text-gray-400">
Only create if this person has at least this many items
(default: 5, minimum allowed: 2)
{intl.formatMessage(messages.minimumItemsHelp)}
</p>
</div>
<div className="rounded-md border border-gray-500/20 bg-transparent p-4 md:col-span-2">
@@ -588,12 +602,12 @@ const CollectionTypeSection = ({
htmlFor="useSeparator"
className="text-sm font-medium text-gray-300"
>
Use Separator
{intl.formatMessage(messages.useSeparator)}
</label>
<p className="text-xs text-gray-400">
Create a simple separator collection to group your auto{' '}
{values.subtype === 'actors' ? 'actor' : 'director'}{' '}
collections.
{intl.formatMessage(messages.useSeparatorHelp, {
type: values.subtype === 'actors' ? 'actor' : 'director',
})}
</p>
</div>
<Field
@@ -626,7 +640,8 @@ const CollectionTypeSection = ({
htmlFor="separatorTitle"
className="mb-2 block text-sm text-gray-300"
>
Separator Title <span className="text-red-500">*</span>
{intl.formatMessage(messages.separatorTitle)}{' '}
<span className="text-red-500">*</span>
</label>
<Field
type="text"
@@ -640,11 +655,13 @@ const CollectionTypeSection = ({
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="mt-1 text-xs text-gray-400">
Defaults to{' '}
{values.subtype === 'actors'
? 'Actor Collections'
: 'Director Collections'}
. This title is used for the separator collection and poster.
{intl.formatMessage(messages.separatorTitleHelp, {
defaultTitle: intl.formatMessage(
values.subtype === 'actors'
? messages.actorCollections
: messages.directorCollections
),
})}
</p>
</div>
)}
@@ -659,7 +676,8 @@ const CollectionTypeSection = ({
htmlFor="customDays"
className="mb-2 block text-sm text-gray-300"
>
Number of Days <span className="text-red-500">*</span>
{intl.formatMessage(messages.numberOfDays)}{' '}
<span className="text-red-500">*</span>
</label>
<Field
type="number"
@@ -676,7 +694,8 @@ const CollectionTypeSection = ({
htmlFor="minimumPlays"
className="mb-2 block text-sm text-gray-300"
>
Minimum Play Count <span className="text-red-500">*</span>
{intl.formatMessage(messages.minimumPlayCount)}{' '}
<span className="text-red-500">*</span>
</label>
<Field
type="number"
@@ -12,10 +12,19 @@ const messages = defineMessages({
customAnilistListUrl: 'Custom AniList List URL',
fetchTitle: 'Validate',
fetching: 'Fetching...',
fetchedTitle: 'Fetched Title',
enterUrl: 'Enter URL...',
urlRequired: 'URL is required for custom lists',
validUrl: 'Please enter a valid URL',
traktUrlExamples:
'Examples: https://trakt.tv/users/username/lists/listname or https://trakt.tv/lists/official/jurassic-park-collection',
tmdbUrlExamples:
'Examples: Collection (https://www.themoviedb.org/collection/12345), List (https://www.themoviedb.org/list/310), Network (https://www.themoviedb.org/network/213), Company (https://www.themoviedb.org/company/7505/movie or /tv)',
imdbUrlExamples:
'Examples: List (https://www.imdb.com/list/ls123456789/) or Watchlist (https://www.imdb.com/user/ur12345678/watchlist)',
letterboxdListUrlExample:
'Example: https://letterboxd.com/username/list/listname/',
letterboxdWatchlistUrl: 'Letterboxd Watchlist URL',
letterboxdWatchlistHelp: 'Enter the full URL to your Letterboxd watchlist.',
anilistUrlExample:
'Example: https://anilist.co/animelist/listname or https://anilist.co/user/username/animelist/listname',
mdblistUrlExample: 'Example: https://mdblist.com/lists/username/list-name',
});
interface CustomUrlSectionProps {
@@ -156,8 +165,7 @@ const CustomUrlSection = ({
</p>
)}
<p className="mt-1 text-xs text-gray-400">
Examples: https://trakt.tv/users/username/lists/listname or
https://trakt.tv/lists/official/jurassic-park-collection
{intl.formatMessage(messages.traktUrlExamples)}
</p>
</div>
);
@@ -206,10 +214,7 @@ const CustomUrlSection = ({
</p>
)}
<p className="mt-1 text-xs text-gray-400">
Examples: Collection (https://www.themoviedb.org/collection/12345),
List (https://www.themoviedb.org/list/310), Network
(https://www.themoviedb.org/network/213), Company
(https://www.themoviedb.org/company/7505/movie or /tv)
{intl.formatMessage(messages.tmdbUrlExamples)}
</p>
</div>
);
@@ -258,8 +263,7 @@ const CustomUrlSection = ({
</p>
)}
<p className="mt-1 text-xs text-gray-400">
Examples: List (https://www.imdb.com/list/ls123456789/) or Watchlist
(https://www.imdb.com/user/ur12345678/watchlist)
{intl.formatMessage(messages.imdbUrlExamples)}
</p>
</div>
);
@@ -306,7 +310,7 @@ const CustomUrlSection = ({
className="mt-1 text-sm text-red-500"
/>
<p className="mt-1 text-xs text-gray-400">
Example: https://letterboxd.com/username/list/listname/
{intl.formatMessage(messages.letterboxdListUrlExample)}
</p>
</div>
);
@@ -317,7 +321,8 @@ const CustomUrlSection = ({
htmlFor="letterboxdCustomListUrl"
className="mb-2 block text-sm text-gray-300"
>
Letterboxd Watchlist URL <span className="text-red-500">*</span>
{intl.formatMessage(messages.letterboxdWatchlistUrl)}{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<Field
@@ -348,7 +353,7 @@ const CustomUrlSection = ({
className="mt-1 text-sm text-red-500"
/>
<p className="mt-1 text-xs text-gray-400">
Enter the full URL to your Letterboxd watchlist.
{intl.formatMessage(messages.letterboxdWatchlistHelp)}
</p>
</div>
);
@@ -393,8 +398,7 @@ const CustomUrlSection = ({
className="mt-1 text-sm text-red-500"
/>
<p className="mt-1 text-xs text-gray-400">
Example: https://anilist.co/animelist/listname or
https://anilist.co/user/username/animelist/listname
{intl.formatMessage(messages.anilistUrlExample)}
</p>
</div>
);
@@ -438,7 +442,7 @@ const CustomUrlSection = ({
className="mt-1 text-sm text-red-500"
/>
<p className="mt-1 text-xs text-gray-400">
Example: https://mdblist.com/lists/username/list-name
{intl.formatMessage(messages.mdblistUrlExample)}
</p>
</div>
);
@@ -7,6 +7,15 @@ const messages = defineMessages({
librarySelection: 'Library Selection',
selectLibraries: 'Select Libraries',
allLibraries: 'All Libraries',
analyzingListContent: 'Analyzing list content to detect media types...',
bothMediaTypes: 'List contains both Movies and TV Shows.',
movies: 'Movies',
tvShows: 'TV Shows',
detectedSingleMediaType:
'Detected {mediaTypeLabel} only. {oppositeTypeLabel} collections will be empty until matching content is added.',
noLibrariesSelected: 'No libraries selected',
librarySelectionHelper:
'Select the libraries where collections should be created',
});
interface LibraryCheckboxDropdownProps {
@@ -179,7 +188,7 @@ const LibrarySelectionSection = ({
// Show loading state if currently detecting
if (isDetectingMediaType) {
return {
message: 'Analyzing list content to detect media types...',
message: intl.formatMessage(messages.analyzingListContent),
type: 'info',
};
}
@@ -187,7 +196,7 @@ const LibrarySelectionSection = ({
// Show success message if both types detected
if (detectedMediaType === 'both' || detectedMediaType === 'mixed') {
return {
message: 'List contains both Movies and TV Shows.',
message: intl.formatMessage(messages.bothMediaTypes),
type: 'success',
};
}
@@ -195,12 +204,19 @@ const LibrarySelectionSection = ({
// Show warning if specific media type detected
if (detectedMediaType === 'movie' || detectedMediaType === 'tv') {
const mediaTypeLabel =
detectedMediaType === 'movie' ? 'Movies' : 'TV Shows';
detectedMediaType === 'movie'
? intl.formatMessage(messages.movies)
: intl.formatMessage(messages.tvShows);
const oppositeTypeLabel =
detectedMediaType === 'movie' ? 'TV Shows' : 'Movies';
detectedMediaType === 'movie'
? intl.formatMessage(messages.tvShows)
: intl.formatMessage(messages.movies);
return {
message: `Detected ${mediaTypeLabel} only. ${oppositeTypeLabel} collections will be empty until matching content is added.`,
message: intl.formatMessage(messages.detectedSingleMediaType, {
mediaTypeLabel,
oppositeTypeLabel,
}),
type: 'warning',
};
}
@@ -228,7 +244,7 @@ const LibrarySelectionSection = ({
{values.libraryIds && Array.isArray(values.libraryIds) ? (
values.libraryIds.includes('all') ? (
<span className="font-medium text-orange-300">
All Libraries
{intl.formatMessage(messages.allLibraries)}
</span>
) : (
values.libraryIds.map((id: string, index: number) => {
@@ -250,7 +266,7 @@ const LibrarySelectionSection = ({
})()
) : (
<span className="italic text-gray-500">
No libraries selected
{intl.formatMessage(messages.noLibrariesSelected)}
</span>
)}
</div>
@@ -306,7 +322,7 @@ const LibrarySelectionSection = ({
{/* Helper text */}
<p className="mt-2 text-xs text-gray-400">
Select the libraries where collections should be created
{intl.formatMessage(messages.librarySelectionHelper)}
</p>
{/* Note: Warning for "both" media type removed - no longer supported */}
@@ -32,17 +32,14 @@ const messages = defineMessages({
networksPlatform: 'Streaming Platform',
selectCountry: 'Select country...',
selectPlatform: 'Select platform...',
loadingCountries: 'Loading countries...',
loadingPlatforms: 'Loading platforms...',
customUrl: 'Custom URL',
customUrlPlaceholder: 'Enter custom list URL',
timePeriod: 'Time Period',
customDays: 'Number of Days',
minimumPlays: 'Minimum Play Count',
comingSoonDays: 'Days to Look Ahead',
combineMode: 'Combine Mode',
addSource: 'Add Source',
removeSource: 'Remove',
validateUrl: 'Validate URL',
validatingUrl: 'Validating...',
urlValid: 'Valid',
@@ -59,6 +56,36 @@ const messages = defineMessages({
selectTag: 'Select tag...',
loadingTags: 'Loading tags...',
selectInstanceFirst: 'Select an instance first',
loadPlatformsError: 'Failed to load platforms. Please try again.',
loadInstancesError: 'Failed to load instances. Please try again.',
loadTagsError: 'Failed to load tags. Please try again.',
sources: 'Sources ({count})',
noSourcesConfigured:
'No sources configured. Click Add Source to get started.',
sourceNumber: 'Source {number}',
remove: 'Remove',
overseerrRequests: 'Overseerr Requests',
tautulliStatistics: 'Tautulli Statistics',
traktLists: 'Trakt Lists',
letterboxdLists: 'Letterboxd Lists',
tmdbLists: 'TMDB Lists',
imdbLists: 'IMDb Lists',
mdblistLists: 'MDBList Lists',
networks: 'Networks',
streamingOriginals: 'Streaming Originals',
radarrTags: 'Radarr Tags',
sonarrTags: 'Sonarr Tags',
anilist: 'AniList',
myAnimeList: 'MyAnimeList',
comingSoon: 'Coming Soon',
contains: 'Contains: {types}',
global: 'Global',
daily: 'Daily',
weekly: 'Weekly',
monthly: 'Monthly',
allTime: 'All Time',
detectedContentTypes: 'Detected content types: {types}',
disabledMixedContent: '(Disabled - mixed content detected)',
});
interface SubtypeOption {
@@ -134,7 +161,7 @@ const NetworksPlatformSelect = ({
</Field>
{platformsError && (
<p className="mt-1 text-xs text-red-400">
Failed to load platforms. Please try again.
{intl.formatMessage(messages.loadPlatformsError)}
</p>
)}
</>
@@ -297,7 +324,7 @@ const ArrTagSelect = ({
</Field>
{instanceError && (
<p className="mt-1 text-xs text-red-400">
Failed to load instances. Please try again.
{intl.formatMessage(messages.loadInstancesError)}
</p>
)}
</div>
@@ -341,7 +368,7 @@ const ArrTagSelect = ({
</Field>
{tagsError && (
<p className="mt-1 text-xs text-red-400">
Failed to load tags. Please try again.
{intl.formatMessage(messages.loadTagsError)}
</p>
)}
</div>
@@ -921,12 +948,12 @@ const MultiSourceConfigSection = ({
<div className="space-y-6">
<div className="space-y-4">
<h4 className="text-md font-medium text-gray-100">
Sources ({sources.length})
{intl.formatMessage(messages.sources, { count: sources.length })}
</h4>
{sources.length === 0 && (
<div className="py-6 text-center text-gray-400">
No sources configured. Click Add Source to get started.
{intl.formatMessage(messages.noSourcesConfigured)}
</div>
)}
@@ -937,7 +964,9 @@ const MultiSourceConfigSection = ({
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-gray-200">
Source {index + 1}
{intl.formatMessage(messages.sourceNumber, {
number: index + 1,
})}
</h5>
{sources.length > 1 && (
<Button
@@ -945,7 +974,7 @@ const MultiSourceConfigSection = ({
buttonType="danger"
buttonSize="sm"
>
Remove
{intl.formatMessage(messages.remove)}
</Button>
)}
</div>
@@ -999,20 +1028,48 @@ const MultiSourceConfigSection = ({
<option value="">
{intl.formatMessage(messages.selectSource)}
</option>
<option value="overseerr">Overseerr Requests</option>
<option value="tautulli">Tautulli Statistics</option>
<option value="trakt">Trakt Lists</option>
<option value="letterboxd">Letterboxd Lists</option>
<option value="tmdb">TMDB Lists</option>
<option value="imdb">IMDb Lists</option>
<option value="mdblist">MDBList Lists</option>
<option value="networks">Networks</option>
<option value="originals">Streaming Originals</option>
<option value="radarrtag">Radarr Tags</option>
<option value="sonarrtag">Sonarr Tags</option>
<option value="anilist">AniList</option>
<option value="myanimelist">MyAnimeList</option>
<option value="comingsoon">Coming Soon</option>
<option value="overseerr">
{intl.formatMessage(messages.overseerrRequests)}
</option>
<option value="tautulli">
{intl.formatMessage(messages.tautulliStatistics)}
</option>
<option value="trakt">
{intl.formatMessage(messages.traktLists)}
</option>
<option value="letterboxd">
{intl.formatMessage(messages.letterboxdLists)}
</option>
<option value="tmdb">
{intl.formatMessage(messages.tmdbLists)}
</option>
<option value="imdb">
{intl.formatMessage(messages.imdbLists)}
</option>
<option value="mdblist">
{intl.formatMessage(messages.mdblistLists)}
</option>
<option value="networks">
{intl.formatMessage(messages.networks)}
</option>
<option value="originals">
{intl.formatMessage(messages.streamingOriginals)}
</option>
<option value="radarrtag">
{intl.formatMessage(messages.radarrTags)}
</option>
<option value="sonarrtag">
{intl.formatMessage(messages.sonarrTags)}
</option>
<option value="anilist">
{intl.formatMessage(messages.anilist)}
</option>
<option value="myanimelist">
{intl.formatMessage(messages.myAnimeList)}
</option>
<option value="comingsoon">
{intl.formatMessage(messages.comingSoon)}
</option>
</Field>
{/* API Key Warning for this source */}
@@ -1176,7 +1233,9 @@ const MultiSourceConfigSection = ({
</p>
{validation.contentTypes.length > 0 && (
<p className="text-xs text-green-300">
Contains: {validation.contentTypes.join(', ')}
{intl.formatMessage(messages.contains, {
types: validation.contentTypes.join(', '),
})}
</p>
)}
</div>
@@ -1263,7 +1322,9 @@ const MultiSourceConfigSection = ({
</option>
{/* Global option - always available */}
<option value="global">Global</option>
<option value="global">
{intl.formatMessage(messages.global)}
</option>
{/* Separator */}
<option disabled style={{ borderTop: '1px solid #4a5568' }}>
@@ -1328,10 +1389,18 @@ const MultiSourceConfigSection = ({
);
}}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="all">All Time</option>
<option value="daily">
{intl.formatMessage(messages.daily)}
</option>
<option value="weekly">
{intl.formatMessage(messages.weekly)}
</option>
<option value="monthly">
{intl.formatMessage(messages.monthly)}
</option>
<option value="all">
{intl.formatMessage(messages.allTime)}
</option>
</Field>
</div>
)}
@@ -1391,7 +1460,7 @@ const MultiSourceConfigSection = ({
<div className="flex justify-end pt-4">
<Button onClick={addSource} buttonSize="sm">
Add Source
{intl.formatMessage(messages.addSource)}
</Button>
</div>
</div>
@@ -1421,8 +1490,9 @@ const MultiSourceConfigSection = ({
{intl.formatMessage(messages.mixedContentWarning)}
</p>
<p className="mt-1 text-xs text-orange-300">
Detected content types:{' '}
{mixedContentInfo.allContentTypes.join(', ')}
{intl.formatMessage(messages.detectedContentTypes, {
types: mixedContentInfo.allContentTypes.join(', '),
})}
</p>
</div>
</div>
@@ -1453,7 +1523,7 @@ const MultiSourceConfigSection = ({
{option.label}
{option.disabled && (
<span className="ml-2 text-xs text-orange-500">
(Disabled - mixed content detected)
{intl.formatMessage(messages.disabledMixedContent)}
</span>
)}
</div>
@@ -21,6 +21,9 @@ const messages = defineMessages({
selectPlatform: 'Select platform...',
loadingCountries: 'Loading countries...',
loadingPlatforms: 'Loading platforms...',
global: 'Global',
loadCountriesError: 'Failed to load countries. Please try again.',
loadPlatformsError: 'Failed to load platforms. Please try again.',
});
interface NetworksConfigSectionProps {
@@ -108,7 +111,7 @@ const NetworksConfigSection = ({
<option value="">{intl.formatMessage(messages.selectCountry)}</option>
{/* Global option - always available */}
<option value="global">Global</option>
<option value="global">{intl.formatMessage(messages.global)}</option>
{/* Separator */}
<option disabled style={{ borderTop: '1px solid #4a5568' }}>
@@ -133,7 +136,7 @@ const NetworksConfigSection = ({
</Field>
{countriesError && (
<p className="mt-1 text-xs text-red-400">
Failed to load countries. Please try again.
{intl.formatMessage(messages.loadCountriesError)}
</p>
)}
</div>
@@ -181,7 +184,7 @@ const NetworksConfigSection = ({
</Field>
{platformsError && (
<p className="mt-1 text-xs text-red-400">
Failed to load platforms. Please try again.
{intl.formatMessage(messages.loadPlatformsError)}
</p>
)}
</div>
@@ -21,13 +21,10 @@ const messages = defineMessages({
invalidUrl: 'Please enter a valid URL',
downloadingPoster: 'Downloading poster...',
posterDownloadError: 'Failed to download poster from URL',
deletePoster: 'Delete Poster',
noPosterAvailable: 'No posters available',
uploading: 'Uploading...',
generating: 'Generating...',
deleting: 'Deleting...',
posterUploadSuccess: 'Poster uploaded successfully',
posterGenerateSuccess: 'Poster generated successfully',
posterDeleteSuccess: 'Poster deleted successfully',
posterUploadError: 'Failed to upload poster',
posterGenerateError: 'Failed to generate poster',
@@ -38,6 +35,7 @@ const messages = defineMessages({
'This poster is currently being used by the following collections:',
deleteAnyway: 'Delete Anyway',
cancel: 'Cancel',
forLibrary: 'for {libraryName}',
});
interface Poster {
@@ -430,7 +428,7 @@ const PosterSelectionPopover: React.FC<PosterSelectionPopoverProps> = ({
{intl.formatMessage(messages.selectPoster)}
{libraryName && (
<span className="ml-1 text-xs font-normal text-stone-400">
for {libraryName}
{intl.formatMessage(messages.forLibrary, { libraryName })}
</span>
)}
</h3>
@@ -7,24 +7,11 @@ import useSWR from 'swr';
import PosterSelectionPopover from './PosterSelectionPopover';
const messages = defineMessages({
customPoster: 'Custom Poster',
customPosters: 'Posters',
addPoster: 'Add Poster',
addPosters: 'Add Posters',
uploading: 'Uploading poster...',
remove: 'Remove',
posterSize: '1000x1500px',
posterUploadHelp:
'Upload a custom poster image for this collection (JPEG, PNG, or WebP, max 10MB). Poster will be applied to Plex during the next collection sync.',
posterUploadHelpMulti:
'Upload custom poster images for each selected library. Posters will be applied to Plex collections during the next sync.',
posterRemoveConfirm: 'Poster will be removed on next collection sync',
posterUploadSuccess:
'Poster uploaded successfully. Will be applied on next collection sync.',
posterUploadErrorSize: 'File size must be less than 10MB',
posterUploadErrorType: 'Only JPEG, PNG, and WebP files are allowed',
posterUploadErrorGeneric: 'Upload failed',
posterUploadErrorNetwork: 'Network error occurred',
autoPoster: 'Auto-generate Collection posters',
autoPosterHelp:
'Automatically generate posters using the collection name during sync. Uncheck to manually upload custom posters instead.',
@@ -32,7 +19,6 @@ const messages = defineMessages({
applyOverlaysDuringSyncHelp:
'Apply overlays to collection items immediately after sync completes. Otherwise, overlays will be applied during the regular overlays sync job.',
selectTemplate: 'Select Template',
defaultTemplate: 'Default Template',
templateHelp: 'Choose a template for auto-generated posters.',
useTmdbFranchisePoster: 'Use TMDB Franchise Poster',
useTmdbFranchisePosterHelp:
@@ -40,6 +26,7 @@ const messages = defineMessages({
hideIndividualItems: 'Hide Individual Items in Collection',
hideIndividualItemsHelp:
'Hide the individual movies in this franchise collection. Only the collection itself will be shown in the Library tab. If an item appears in another collection it will still be visible in the Library tab.',
selectLibrariesFirst: 'Select libraries first to upload custom posters.',
});
interface Library {
@@ -228,7 +215,7 @@ const PosterUploadSection = ({
if (selectedLibraryIds.length === 0) {
return (
<div className="label-tip">
Select libraries first to upload custom posters.
{intl.formatMessage(messages.selectLibrariesFirst)}
</div>
);
}
@@ -7,6 +7,12 @@ import type {
import { Field, type FormikErrors, type FormikTouched } from 'formik';
import type React from 'react';
import { memo, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
previewUnavailable: 'Preview unavailable',
preview: 'Preview:',
});
interface FetchedTitles {
[key: string]: string;
@@ -58,6 +64,7 @@ const TemplatePreviewItem = memo(function TemplatePreviewItem({
subtype?: string;
customDays?: number;
}) {
const intl = useIntl();
const { preview, loading, error } = useTemplatePreview({
template,
mediaType,
@@ -67,7 +74,12 @@ const TemplatePreviewItem = memo(function TemplatePreviewItem({
});
if (loading) return <span className="text-gray-400">Loading...</span>;
if (error) return <span className="text-gray-500">Preview unavailable</span>;
if (error)
return (
<span className="text-gray-500">
{intl.formatMessage(messages.previewUnavailable)}
</span>
);
return (
<span className="text-gray-300">
@@ -88,6 +100,7 @@ const TemplateSection = ({
isVisible = true,
libraries = [],
}: TemplateSectionProps) => {
const intl = useIntl();
// Memoize the template-relevant values to prevent unnecessary API calls
const templateRelevantValues = useMemo(() => {
const effectiveSubtype =
@@ -240,7 +253,9 @@ const TemplateSection = ({
{/* Template preview */}
<div className="mt-3 rounded-md border border-gray-600 bg-stone-800 p-3">
<h5 className="mb-2 text-sm font-medium text-gray-200">Preview:</h5>
<h5 className="mb-2 text-sm font-medium text-gray-200">
{intl.formatMessage(messages.preview)}
</h5>
<div className="text-sm text-gray-300">
{(() => {
// Get the actual template being used (same logic as dropdown)
@@ -5,14 +5,7 @@ import { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
customTheme: 'Custom Theme Music',
addTheme: 'Add Theme',
uploading: 'Uploading theme...',
remove: 'Remove',
play: 'Play',
pause: 'Pause',
themeUploadHelp:
'Upload a custom theme music file for this collection (MP3, WAV, FLAC, OGG, AAC, or M4A, max 10MB). Theme will be applied to Plex during the next collection sync.',
themeUploadHelpMulti:
'Upload custom theme music files for each selected library. Themes will be applied to Plex collections during the next sync.',
themeRemoveConfirm: 'Theme will be removed on next collection sync',
@@ -23,6 +16,7 @@ const messages = defineMessages({
'Only MP3, WAV, FLAC, OGG, AAC, and M4A files are allowed',
themeUploadErrorGeneric: 'Upload failed',
themeUploadErrorNetwork: 'Network error occurred',
selectLibrariesFirst: 'Select libraries first to upload custom themes.',
});
interface Library {
@@ -224,7 +218,7 @@ const ThemeUploadSection = ({
if (selectedLibraryIds.length === 0) {
return (
<div className="label-tip">
Select libraries first to upload custom themes.
{intl.formatMessage(messages.selectLibrariesFirst)}
</div>
);
}
@@ -14,7 +14,6 @@ type VisibilityConfig = {
};
const messages = defineMessages({
timeRestrictions: 'Time Restrictions',
alwaysActive: 'Always Active',
removeFromPlex: 'Remove from Plex when inactive',
dateRangesTitle: 'Date Ranges',
@@ -31,9 +30,6 @@ const messages = defineMessages({
'When the collection is inactive (outside time restrictions), control where it appears in Plex.',
customSyncSchedule: 'Custom Sync Schedule',
customSyncEnabled: 'Enable custom sync timing',
customSyncInterval: 'Sync every (hours)',
customSyncHelp:
'Override the default sync schedule for this collection. This will also cycle the list for Random Lists and Multi-Source Collections in "Cycle Lists" mode. Note: Manual sync always works regardless of schedule timing.',
customSyncPreset: 'Schedule',
customSyncCustomCron: 'Custom cron expression',
customSyncCustomCronHelp:
@@ -45,6 +41,10 @@ const messages = defineMessages({
'Uncheck to set a specific date/time for the sync cycle to be based off (e.g. "Best Movies This Year" collection that should be updated on January 1st). You can still use Manual Sync to populate the collection before the scheduled time.',
customSyncStartDate: 'Start date',
customSyncStartTime: 'Start time',
to: 'to',
customCronExpression: 'Custom Cron Expression',
dateFormatHint: 'DD-MM format',
timeFormatHint: 'HH:MM format (e.g., 09:00, 23:30)',
});
interface DateRange {
@@ -286,7 +286,9 @@ const TimeRestrictionsSection = ({
className="w-20 text-sm"
maxLength={5}
/>
<span className="text-gray-400">to</span>
<span className="text-gray-400">
{intl.formatMessage(messages.to)}
</span>
<input
type="text"
placeholder="DD-MM"
@@ -439,7 +441,9 @@ const TimeRestrictionsSection = ({
{preset.label}
</option>
))}
<option value="custom">Custom Cron Expression</option>
<option value="custom">
{intl.formatMessage(messages.customCronExpression)}
</option>
</Field>
<p className="mt-2 text-xs text-gray-400">
{intl.formatMessage(messages.customSyncPresetHelp)}
@@ -503,7 +507,7 @@ const TimeRestrictionsSection = ({
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="mt-1 text-xs text-gray-400">
DD-MM format
{intl.formatMessage(messages.dateFormatHint)}
</p>
</div>
<div>
@@ -519,7 +523,7 @@ const TimeRestrictionsSection = ({
className="w-full rounded-md border border-stone-500 bg-stone-700 px-3 py-2 text-white focus:border-orange-500 focus:outline-none focus:ring-2 focus:ring-orange-500"
/>
<p className="mt-1 text-xs text-gray-400">
HH:MM format (e.g., 09:00, 23:30)
{intl.formatMessage(messages.timeFormatHint)}
</p>
</div>
</div>
@@ -10,14 +10,17 @@ type VisibilityConfig = {
};
const messages = defineMessages({
visibility: 'Visibility',
visibilityDescription: 'Control where this collection appears in Plex',
usersHome: 'Users Home',
usersHomeDescription: 'Show on user home screens',
serverOwnerHome: 'Server Owner Home',
serverOwnerHomeDescription: 'Show on server owner home screen',
libraryRecommended: 'Library Recommended',
libraryRecommendedDescription: 'Show in library recommended section',
userRequestCollectionsRestricted:
"Individual user request collections are restricted to Library Tab Only visibility due a Plex bug that doesn't respect label restrictions on the Home/Recommended screens. TMDB Franchise Collections and Plex Library Auto Director Collections are hidden so that you don't clog up your home/recommended screens.",
serverOwnerOnlyRestricted:
"Server owner request collections can only appear on the server owner's home screen.",
noVisibilityHubWarning:
'No visibility options selected. Hub will be completely hidden.',
noVisibilityCollectionWarning:
'No visibility options selected. Collection will only appear in library tab.',
});
interface VisibilitySectionProps {
@@ -79,19 +82,14 @@ const VisibilitySection = ({
{/* Show restriction notice for collections restricted to library only */}
{restrictToLibraryOnly && (
<div className="mb-3 rounded border border-orange-500/20 bg-orange-500/10 p-3 text-sm text-orange-300">
Individual user request collections are restricted to Library Tab Only
visibility due a Plex bug that doesn&apos;t respect label restrictions
on the Home/Recommended screens. TMDB Franchise Collections and Plex
Library Auto Director Collections are hidden so that you don&apos;t
clog up your home/recommended screens.
{intl.formatMessage(messages.userRequestCollectionsRestricted)}
</div>
)}
{/* Show restriction notice for overseerr server owner collections */}
{restrictToServerOwnerOnly && (
<div className="mb-3 rounded border border-green-500/20 bg-green-500/10 p-3 text-sm text-green-300">
Server owner request collections can only appear on the server
owner&apos;s home screen.
{intl.formatMessage(messages.serverOwnerOnlyRestricted)}
</div>
)}
@@ -170,17 +168,10 @@ const VisibilitySection = ({
!visibilityConfig?.serverOwnerHome &&
!visibilityConfig?.libraryRecommended && (
<div className="mt-3 rounded border border-orange-500/20 bg-orange-500/10 p-2 text-xs text-orange-300">
{isDefaultPlexHub ? (
<>
No visibility options selected. Hub will be completely
hidden.
</>
) : (
<>
No visibility options selected. Collection will only appear
in library tab.
</>
)}
{' '}
{isDefaultPlexHub
? intl.formatMessage(messages.noVisibilityHubWarning)
: intl.formatMessage(messages.noVisibilityCollectionWarning)}
</div>
)}
</div>
@@ -5,13 +5,7 @@ import { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
customWallpaper: 'Custom Wallpaper',
addWallpaper: 'Add Wallpaper',
uploading: 'Uploading wallpaper...',
remove: 'Remove',
wallpaperSize: '1920x1080px (landscape)',
wallpaperUploadHelp:
'Upload a custom wallpaper/art image for this collection (JPEG, PNG, or WebP, max 10MB). Wallpaper will be applied to Plex during the next collection sync.',
wallpaperUploadHelpMulti:
'Upload custom wallpaper images for each selected library. Wallpapers will be applied to Plex collections during the next sync.',
wallpaperRemoveConfirm: 'Wallpaper will be removed on next collection sync',
@@ -21,6 +15,7 @@ const messages = defineMessages({
wallpaperUploadErrorType: 'Only JPEG, PNG, and WebP files are allowed',
wallpaperUploadErrorGeneric: 'Upload failed',
wallpaperUploadErrorNetwork: 'Network error occurred',
selectLibrariesFirst: 'Select libraries first to upload custom wallpapers.',
});
interface Library {
@@ -182,7 +177,7 @@ const WallpaperUploadSection = ({
if (selectedLibraryIds.length === 0) {
return (
<div className="label-tip">
Select libraries first to upload custom wallpapers.
{intl.formatMessage(messages.selectLibrariesFirst)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,18 @@ import Spinner from '@app/assets/spinner.svg';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import axios from 'axios';
import React, { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
lastSyncFailed: 'Last sync failed',
collectionsNeedingSync:
'{count, plural, one {# collection pending} other {# collections pending}}',
lastSync: 'Last sync: {time}',
nextSync: 'Next sync: {time}',
noSyncYet: 'No sync yet',
});
interface GlobalSyncStatusResponse {
running: boolean;
currentStage?: string;
@@ -27,6 +37,7 @@ const GlobalSyncStatus: React.FC<GlobalSyncStatusProps> = ({
onSyncStart,
onSyncComplete,
}) => {
const intl = useIntl();
const { data: syncStatus, mutate } = useSWR<GlobalSyncStatusResponse>(
'/api/v1/collections/sync/status',
(url: string) => axios.get(url).then((res) => res.data),
@@ -154,7 +165,9 @@ const GlobalSyncStatus: React.FC<GlobalSyncStatusProps> = ({
{!syncStatus?.running && syncStatus?.globalSyncError && (
<div className="flex items-center space-x-1">
<ExclamationTriangleIcon className="h-3 w-3" />
<span title={syncStatus.globalSyncError}>Last sync failed</span>
<span title={syncStatus.globalSyncError}>
{intl.formatMessage(messages.lastSyncFailed)}
</span>
</div>
)}
@@ -163,8 +176,9 @@ const GlobalSyncStatus: React.FC<GlobalSyncStatusProps> = ({
!syncStatus?.globalSyncError &&
(syncStatus?.collectionsNeedingSync || 0) > 0 && (
<span>
{syncStatus.collectionsNeedingSync} collection
{syncStatus.collectionsNeedingSync === 1 ? '' : 's'} pending
{intl.formatMessage(messages.collectionsNeedingSync, {
count: syncStatus.collectionsNeedingSync,
})}
</span>
)}
@@ -175,11 +189,17 @@ const GlobalSyncStatus: React.FC<GlobalSyncStatusProps> = ({
syncStatus?.lastGlobalSyncAt && (
<div className="flex flex-col">
<span>
Last sync: {formatRelativeTime(syncStatus.lastGlobalSyncAt)}
{intl.formatMessage(messages.lastSync, {
time: formatRelativeTime(syncStatus.lastGlobalSyncAt),
})}
</span>
{/* Next Sync Time - Only show if we have a next sync time */}
{syncStatus?.nextSyncAt && (
<span>Next sync: {formatFutureTime(syncStatus.nextSyncAt)}</span>
<span>
{intl.formatMessage(messages.nextSync, {
time: formatFutureTime(syncStatus.nextSyncAt),
})}
</span>
)}
</div>
)}
@@ -190,10 +210,14 @@ const GlobalSyncStatus: React.FC<GlobalSyncStatusProps> = ({
(syncStatus?.collectionsNeedingSync || 0) === 0 &&
!syncStatus?.lastGlobalSyncAt && (
<div className="flex flex-col">
<span>No sync yet</span>
<span>{intl.formatMessage(messages.noSyncYet)}</span>
{/* Next Sync Time - Only show if we have a next sync time */}
{syncStatus?.nextSyncAt && (
<span>Next sync: {formatFutureTime(syncStatus.nextSyncAt)}</span>
<span>
{intl.formatMessage(messages.nextSync, {
time: formatFutureTime(syncStatus.nextSyncAt),
})}
</span>
)}
</div>
)}
@@ -16,11 +16,8 @@ import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
previewCollection: 'Preview Collection',
loadingPreview: 'Loading preview...',
errorLoadingPreview: 'Failed to load preview',
noItems: 'No items found',
inLibrary: 'In Library',
missing: 'Missing',
placeholder: 'Placeholder',
downloadViaRadarr: 'Download via Radarr',
downloadViaSonarr: 'Download via Sonarr',
@@ -37,6 +34,7 @@ const messages = defineMessages({
itemExcluded: 'Item excluded from all collections',
excludeError: 'Failed to exclude item',
viewExclusions: 'View Exclusions',
noPoster: 'No Poster',
});
interface PreviewItem {
@@ -683,7 +681,7 @@ const PreviewCollectionModal = ({
) : (
<div className="flex h-full w-full items-center justify-center bg-gray-800">
<span className="text-xs text-gray-500">
No Poster
{intl.formatMessage(messages.noPoster)}
</span>
</div>
)}
@@ -8,7 +8,6 @@ import useSWR from 'swr';
const messages = defineMessages({
radarrOptions: 'Radarr Download Options',
radarrOptionsDescription: 'Configure download settings for this movie',
selectServer: 'Select Server',
selectProfile: 'Select Quality Profile',
selectRootFolder: 'Select Root Folder',
@@ -9,14 +9,11 @@ import useSWR from 'swr';
const messages = defineMessages({
selectSeasons: 'Select Seasons',
selectSeasonsDescription: 'Choose which seasons to download',
season: 'Season',
seasonnumber: 'Season {number}',
download: 'Download',
cancel: 'Cancel',
loadingSeasons: 'Loading seasons...',
selectAll: 'Select All',
deselectAll: 'Deselect All',
selectServer: 'Select Server',
selectProfile: 'Select Quality Profile',
selectRootFolder: 'Select Root Folder',
@@ -13,6 +13,17 @@ import {
XMarkIcon,
} from '@heroicons/react/24/solid';
import type React from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
plexDefault: 'Plex Default',
preExisting: 'Pre-Existing',
items: 'Items: {maxItems}',
grabMissingItems: 'Grab Missing Items',
timeRestrictionsSet: 'Time Restrictions Set',
unwatched: 'Unwatched',
createPlaceholders: 'Create Placeholders',
});
// This file contains shared badge and UI components used across LibraryCollectionGroup and AllCollectionsView
@@ -460,11 +471,14 @@ export const getTypeLabel = (type: string): string => {
};
// Collection Type Badge - for Plex Default Hubs
export const PlexDefaultBadge: React.FC = () => (
<Badge badgeType="default" className="text-xs">
Plex Default
</Badge>
);
export const PlexDefaultBadge: React.FC = () => {
const intl = useIntl();
return (
<Badge badgeType="default" className="text-xs">
{intl.formatMessage(messages.plexDefault)}
</Badge>
);
};
// Collection Type Badge - for Pre-Existing Collections
interface PreExistingBadgeProps {
@@ -473,18 +487,21 @@ interface PreExistingBadgeProps {
export const PreExistingBadge: React.FC<PreExistingBadgeProps> = ({
withBorder = false,
}) => (
<Badge
badgeType={withBorder ? 'default' : 'warning'}
className={
withBorder
? '!border !border-orange-500 !bg-stone-600/20 text-xs !text-stone-300'
: 'text-xs'
}
>
Pre-Existing
</Badge>
);
}) => {
const intl = useIntl();
return (
<Badge
badgeType={withBorder ? 'default' : 'warning'}
className={
withBorder
? '!border !border-orange-500 !bg-stone-600/20 text-xs !text-stone-300'
: 'text-xs'
}
>
{intl.formatMessage(messages.preExisting)}
</Badge>
);
};
// Enhanced Source & Subtype Badge
interface SourceSubtypeBadgeProps {
@@ -519,12 +536,13 @@ export const ItemCountBadge: React.FC<ItemCountBadgeProps> = ({
maxItems,
onBadgeClick,
}) => {
const intl = useIntl();
// Easter egg handling for maxItems === 69
if (maxItems === 69 && onBadgeClick) {
return (
<button type="button" onClick={onBadgeClick} className="cursor-pointer">
<Badge badgeType="success" className="!bg-opacity-40">
Items: {maxItems}
{intl.formatMessage(messages.items, { maxItems })}
</Badge>
</button>
);
@@ -532,7 +550,7 @@ export const ItemCountBadge: React.FC<ItemCountBadgeProps> = ({
return (
<Badge badgeType="default" className="!bg-opacity-30">
Items: {maxItems}
{intl.formatMessage(messages.items, { maxItems })}
</Badge>
);
};
@@ -547,13 +565,14 @@ export const MissingItemsBadge: React.FC<MissingItemsBadgeProps> = ({
searchMissingMovies,
searchMissingTV,
}) => {
const intl = useIntl();
const hasGrabMissing = searchMissingMovies || searchMissingTV;
if (!hasGrabMissing) return null;
return (
<Badge badgeType="default" className="!bg-opacity-30">
Grab Missing Items
{intl.formatMessage(messages.grabMissingItems)}
</Badge>
);
};
@@ -568,11 +587,12 @@ interface TimeRestrictionsBadgeProps {
export const TimeRestrictionsBadge: React.FC<TimeRestrictionsBadgeProps> = ({
timeRestriction,
}) => {
const intl = useIntl();
if (!timeRestriction || timeRestriction.alwaysActive) return null;
return (
<Badge badgeType="default" className="!bg-opacity-30">
Time Restrictions Set
{intl.formatMessage(messages.timeRestrictionsSet)}
</Badge>
);
};
@@ -604,11 +624,12 @@ interface UnwatchedBadgeProps {
export const UnwatchedBadge: React.FC<UnwatchedBadgeProps> = ({
showUnwatchedOnly,
}) => {
const intl = useIntl();
if (!showUnwatchedOnly) return null;
return (
<Badge badgeType="default" className="!bg-opacity-30">
Unwatched
{intl.formatMessage(messages.unwatched)}
</Badge>
);
};
@@ -621,11 +642,12 @@ interface PlaceholdersBadgeProps {
export const PlaceholdersBadge: React.FC<PlaceholdersBadgeProps> = ({
createPlaceholdersForMissing,
}) => {
const intl = useIntl();
if (!createPlaceholdersForMissing) return null;
return (
<Badge badgeType="default" className="!bg-opacity-30">
Create Placeholders
{intl.formatMessage(messages.createPlaceholders)}
</Badge>
);
};
@@ -46,7 +46,6 @@ const messages = defineMessages({
allCollectionsTitle: 'All Collections',
allCollectionsDescription:
'Complete list of all Agregarr Collections, Default Plex Hubs, and Pre-existing Collections.',
loading: 'Loading collections...',
noCollections: 'No collections found.',
agregarrCollections: 'Agregarr Collections',
plexHubs: 'Plex Hubs',
@@ -57,6 +56,13 @@ const messages = defineMessages({
nameAZ: 'Name (A-Z)',
nameZA: 'Name (Z-A)',
bulkEdit: 'Bulk Edit',
errorLoadingCollections: 'Error Loading Collections',
errorLoadingDescription:
'Failed to load collection data. Please try refreshing the page.',
ofTotal: 'of {total}',
sortType: 'Type',
sortLibrary: 'Library',
titleWillUpdate: 'Title will be updated on Collection Sync',
});
// Interfaces for clean collection data display - no conversion needed
@@ -289,10 +295,10 @@ const AllCollectionsView: React.FC = () => {
return (
<div className="text-center">
<h3 className="text-lg font-medium text-red-400">
Error Loading Collections
{intl.formatMessage(messages.errorLoadingCollections)}
</h3>
<p className="mt-2 text-gray-500">
Failed to load collection data. Please try refreshing the page.
{intl.formatMessage(messages.errorLoadingDescription)}
</p>
</div>
);
@@ -541,7 +547,9 @@ const AllCollectionsView: React.FC = () => {
filteredAndSortedCollections.length && (
<span className="text-gray-500">
{' '}
of {allCollections.length}
{intl.formatMessage(messages.ofTotal, {
total: allCollections.length,
})}
</span>
)}
</p>
@@ -593,10 +601,18 @@ const AllCollectionsView: React.FC = () => {
onChange={(e) => setSortType(e.target.value)}
className="rounded-md border border-gray-600 bg-stone-700 px-3 py-1 text-sm text-white focus:border-orange-400 focus:ring-2 focus:ring-orange-400"
>
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
<option value="type">Type</option>
<option value="library">Library</option>
<option value="name-asc">
{intl.formatMessage(messages.nameAZ)}
</option>
<option value="name-desc">
{intl.formatMessage(messages.nameZA)}
</option>
<option value="type">
{intl.formatMessage(messages.sortType)}
</option>
<option value="library">
{intl.formatMessage(messages.sortLibrary)}
</option>
</select>
</div>
</div>
@@ -665,7 +681,9 @@ const AllCollectionsView: React.FC = () => {
<div className="mb-2">
<h5 className="text-base font-medium text-white">
{collection.name === 'DYNAMIC_RANDOM_TITLE' ? (
<em>Title will be updated on Collection Sync</em>
<em>
{intl.formatMessage(messages.titleWillUpdate)}
</em>
) : isCollection &&
originalCollectionConfig?.type === 'plex' &&
(originalCollectionConfig?.subtype === 'directors' ||
@@ -39,7 +39,7 @@ import axios from 'axios';
import { useRouter } from 'next/router';
// ID generation is now handled by the backend using sequential numbers
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
@@ -47,6 +47,43 @@ const messages = defineMessages({
collectionConfigSaved: 'Collection configuration saved successfully!',
collectionConfigError: 'Failed to save collection configuration.',
collectionConfigDeleted: 'Collection configuration deleted successfully!',
addCollection: 'Add Collection',
cleanUpMissingCollections: 'Clean Up Missing Collections ({count})',
changeSchedule: 'Change Schedule',
placeholderWarning:
'You have at least one collection with <strong>Create placeholders for missing items</strong> enabled. The following default hubs are enabled but will show placeholder items:',
disableVisibilityFilteredHub:
'Disable visibility on this default hub (you have a <strong>{filteredHubType}</strong> filtered hub)',
createFilteredHub:
'Create a <strong>{filteredHubType}</strong> filtered hub to exclude placeholders',
home: 'Home',
recommended: 'Recommended',
library: 'Library',
orderingExplanation:
'Collections in <strong>Home & Recommended</strong> share the same ordering (controls Plex home screen position), while <strong>Library</strong> has independent ordering for library tabs.',
clickToSeeInactive: 'Click here to see inactive Collections',
failedLoadPlexLibraries:
'Failed to load Plex libraries. Please check your Plex connection.',
noCollectionsFound: 'No Collections found. Click Discover to get started!',
usersHomeUnlocked: 'Users Home collections unlocked!',
failedUnlockUsersHome: 'Failed to unlock Users Home collections',
collectionsSyncStarted: 'Collections sync started successfully!',
failedStartSync: 'Failed to start collections sync. Please try again.',
hubConfigSaved: 'Hub configuration saved successfully!',
failedSaveHubConfig: 'Failed to save hub configuration.',
preExistingConfigSaved:
'Pre-existing collection configuration saved successfully!',
failedSavePreExistingConfig:
'Failed to save pre-existing collection configuration.',
collectionNotFound: 'Collection not found',
lastCollectionDeleted: 'Last collection deleted - final cleanup completed.',
failedSaveCollectionConfig: 'Failed to save collection configuration',
failedCleanupMissing: 'Failed to cleanup missing collections',
collectionPromoted: 'Collection promoted to top section successfully!',
failedPromoteCollection: 'Failed to promote collection',
collectionMovedToAlphabetical:
'Collection moved to alphabetical section successfully!',
failedDemoteCollection: 'Failed to demote collection',
});
interface HubIssue {
@@ -374,14 +411,14 @@ const CollectionSettings = ({
})
.then(() => {
revalidate();
addToast('Users Home collections unlocked! 🏠✨', {
addToast(`${intl.formatMessage(messages.usersHomeUnlocked)} 🏠✨`, {
autoDismiss: true,
appearance: 'success',
});
setBadgeClickCount(0);
})
.catch(() => {
addToast('Failed to unlock Users Home collections', {
addToast(intl.formatMessage(messages.failedUnlockUsersHome), {
autoDismiss: true,
appearance: 'error',
});
@@ -662,14 +699,14 @@ const CollectionSettings = ({
refreshSyncStatus();
}
addToast('Collections sync started successfully!', {
addToast(intl.formatMessage(messages.collectionsSyncStarted), {
autoDismiss: true,
appearance: 'success',
});
// Clear starting state after a short delay to allow real status to come through
setTimeout(() => setSyncStarting(false), 2000);
} catch (error) {
addToast('Failed to start collections sync. Please try again.', {
addToast(intl.formatMessage(messages.failedStartSync), {
autoDismiss: true,
appearance: 'error',
});
@@ -895,14 +932,14 @@ const CollectionSettings = ({
};
await axios.put(`/api/v1/defaulthubs/${config.id}/settings`, payload);
await revalidateDefaultHubs();
addToast('Hub configuration saved successfully!', {
addToast(intl.formatMessage(messages.hubConfigSaved), {
autoDismiss: true,
appearance: 'success',
});
setShowHubForm(false);
setEditingHubConfig(null);
} catch (error) {
addToast('Failed to save hub configuration.', {
addToast(intl.formatMessage(messages.failedSaveHubConfig), {
autoDismiss: true,
appearance: 'error',
});
@@ -977,14 +1014,14 @@ const CollectionSettings = ({
};
await axios.put(`/api/v1/preexisting/${config.id}/settings`, payload);
await revalidatePreExisting();
addToast('Pre-existing collection configuration saved successfully!', {
addToast(intl.formatMessage(messages.preExistingConfigSaved), {
autoDismiss: true,
appearance: 'success',
});
setShowPreExistingForm(false);
setEditingPreExistingConfig(null);
} catch (error) {
addToast('Failed to save pre-existing collection configuration.', {
addToast(intl.formatMessage(messages.failedSavePreExistingConfig), {
autoDismiss: true,
appearance: 'error',
});
@@ -1178,7 +1215,7 @@ const CollectionSettings = ({
(c: CollectionFormConfig) => c.id === configId
);
if (!configToDelete) {
addToast('Collection not found', {
addToast(intl.formatMessage(messages.collectionNotFound), {
autoDismiss: true,
appearance: 'error',
});
@@ -1236,7 +1273,7 @@ const CollectionSettings = ({
webAppUrl: data.webAppUrl,
});
addToast('Last collection deleted - final cleanup completed.', {
addToast(intl.formatMessage(messages.lastCollectionDeleted), {
autoDismiss: true,
appearance: 'success',
});
@@ -1348,7 +1385,7 @@ const CollectionSettings = ({
// Save the single updated hub config
await saveIndividualConfigs([updatedHubConfigs[existingHubIndex]]);
addToast('Hub configuration saved successfully!', {
addToast(intl.formatMessage(messages.hubConfigSaved), {
autoDismiss: true,
appearance: 'success',
});
@@ -1357,7 +1394,7 @@ const CollectionSettings = ({
revalidateAll();
} catch (error) {
addToast('Failed to save hub configuration.', {
addToast(intl.formatMessage(messages.failedSaveHubConfig), {
autoDismiss: true,
appearance: 'error',
});
@@ -1475,7 +1512,7 @@ const CollectionSettings = ({
setShowConfigForm(false);
setEditingConfig(null);
} catch (error) {
addToast('Failed to save collection configuration', {
addToast(intl.formatMessage(messages.failedSaveCollectionConfig), {
autoDismiss: true,
appearance: 'error',
});
@@ -1680,7 +1717,7 @@ const CollectionSettings = ({
}
);
} catch (error) {
addToast('Failed to cleanup missing collections', {
addToast(intl.formatMessage(messages.failedCleanupMissing), {
autoDismiss: true,
appearance: 'error',
});
@@ -1705,12 +1742,12 @@ const CollectionSettings = ({
// Revalidate all data
revalidateAll();
addToast('Collection promoted to top section successfully!', {
addToast(intl.formatMessage(messages.collectionPromoted), {
autoDismiss: true,
appearance: 'success',
});
} catch (error) {
addToast('Failed to promote collection', {
addToast(intl.formatMessage(messages.failedPromoteCollection), {
autoDismiss: true,
appearance: 'error',
});
@@ -1734,12 +1771,12 @@ const CollectionSettings = ({
// Revalidate all data
revalidateAll();
addToast('Collection moved to alphabetical section successfully!', {
addToast(intl.formatMessage(messages.collectionMovedToAlphabetical), {
autoDismiss: true,
appearance: 'success',
});
} catch (error) {
addToast('Failed to demote collection', {
addToast(intl.formatMessage(messages.failedDemoteCollection), {
autoDismiss: true,
appearance: 'error',
});
@@ -1765,12 +1802,12 @@ const CollectionSettings = ({
// Revalidate all data
revalidateAll();
addToast('Collection promoted to top section successfully!', {
addToast(intl.formatMessage(messages.collectionPromoted), {
autoDismiss: true,
appearance: 'success',
});
} catch (error) {
addToast('Failed to promote collection', {
addToast(intl.formatMessage(messages.failedPromoteCollection), {
autoDismiss: true,
appearance: 'error',
});
@@ -1796,12 +1833,12 @@ const CollectionSettings = ({
// Revalidate all data
revalidateAll();
addToast('Collection moved to alphabetical section successfully!', {
addToast(intl.formatMessage(messages.collectionMovedToAlphabetical), {
autoDismiss: true,
appearance: 'success',
});
} catch (error) {
addToast('Failed to demote collection', {
addToast(intl.formatMessage(messages.failedDemoteCollection), {
autoDismiss: true,
appearance: 'error',
});
@@ -1829,7 +1866,7 @@ const CollectionSettings = ({
}
>
<PlusIcon className="h-4 w-4" />
<span>Add Collection</span>
<span>{intl.formatMessage(messages.addCollection)}</span>
</Button>
{/* First-time setup discovery hint */}
@@ -1880,7 +1917,11 @@ const CollectionSettings = ({
className="flex items-center space-x-2"
>
<ExclamationTriangleIcon className="h-4 w-4" />
<span>Clean Up Missing Collections ({missingCount})</span>
<span>
{intl.formatMessage(messages.cleanUpMissingCollections, {
count: missingCount,
})}
</span>
</Button>
)}
</div>
@@ -1940,7 +1981,7 @@ const CollectionSettings = ({
: 'text-gray-300'
} block w-full px-4 py-2 text-left text-sm`}
>
Change Schedule
{intl.formatMessage(messages.changeSchedule)}
</button>
)}
</Menu.Item>
@@ -1959,10 +2000,12 @@ const CollectionSettings = ({
<Alert type="warning" title="Filtered Hubs Recommended">
<div className="space-y-2">
<p>
You have at least one collection with{' '}
<strong>Create placeholders for missing items</strong> enabled.
The following default hubs are enabled but will show placeholder
items:
<FormattedMessage
{...messages.placeholderWarning}
values={{
strong: (chunks) => <strong>{chunks}</strong>,
}}
/>
</p>
{libraryIssues.map((issue) => (
<div key={issue.libraryName} className="ml-4">
@@ -1972,17 +2015,23 @@ const CollectionSettings = ({
<ul className="ml-4 list-disc space-y-1">
{issue.problematicHubs.map((hub) => (
<li key={hub.hubName}>
<strong>{hub.hubName}</strong> -{' '}
{hub.hubName} -{' '}
{hub.hasFilteredHub ? (
<>
Disable visibility on this default hub (you have a{' '}
<strong>{hub.filteredHubType}</strong> filtered hub)
</>
<FormattedMessage
{...messages.disableVisibilityFilteredHub}
values={{
filteredHubType: hub.filteredHubType,
strong: (chunks) => <strong>{chunks}</strong>,
}}
/>
) : (
<>
Create a <strong>{hub.filteredHubType}</strong>{' '}
filtered hub to exclude placeholders
</>
<FormattedMessage
{...messages.createFilteredHub}
values={{
filteredHubType: hub.filteredHubType,
strong: (chunks) => <strong>{chunks}</strong>,
}}
/>
)}
</li>
))}
@@ -2013,7 +2062,7 @@ const CollectionSettings = ({
: 'border-transparent text-gray-400 hover:border-gray-300 hover:text-gray-300'
}`}
>
Home
{intl.formatMessage(messages.home)}
</button>
<button
onClick={() => {
@@ -2031,7 +2080,7 @@ const CollectionSettings = ({
: 'border-transparent text-gray-400 hover:border-gray-300 hover:text-gray-300'
}`}
>
Recommended
{intl.formatMessage(messages.recommended)}
</button>
<button
onClick={() => {
@@ -2049,15 +2098,18 @@ const CollectionSettings = ({
: 'border-transparent text-gray-400 hover:border-gray-300 hover:text-gray-300'
}`}
>
Library
{intl.formatMessage(messages.library)}
</button>
</nav>
{/* Ordering Explanation */}
<div className="bg-stone-800/30 px-4 py-2 text-center text-xs text-gray-500">
Collections in <strong>Home & Recommended</strong> share the same
ordering (controls Plex home screen position), while{' '}
<strong>Library</strong> has independent ordering for library tabs.
<FormattedMessage
{...messages.orderingExplanation}
values={{
strong: (chunks) => <strong>{chunks}</strong>,
}}
/>
</div>
</div>
)}
@@ -2093,7 +2145,7 @@ const CollectionSettings = ({
<div className="absolute right-full top-1/2 z-50 mr-3 -translate-y-1/2 transform">
<div className="relative animate-pulse whitespace-nowrap rounded-lg border border-orange-500 bg-orange-900/95 px-3 py-1 text-sm text-orange-100 shadow-sm">
<span className="font-semibold">
Click here to see inactive Collections
{intl.formatMessage(messages.clickToSeeInactive)}
</span>
{/* Arrow pointing to the button */}
<div className="absolute left-full top-1/2 h-0 w-0 -translate-y-1/2 transform border-t-4 border-b-4 border-l-4 border-t-transparent border-b-transparent border-l-orange-500"></div>
@@ -2153,7 +2205,7 @@ const CollectionSettings = ({
{librariesError ? (
<div className="py-8 text-center">
<p className="text-red-400">
Failed to load Plex libraries. Please check your Plex connection.
{intl.formatMessage(messages.failedLoadPlexLibraries)}
</p>
</div>
) : libraries.length === 0 ? (
@@ -2163,7 +2215,7 @@ const CollectionSettings = ({
) : allLibraryIds.size === 0 ? (
<div className="py-8 text-center">
<p className="text-gray-400">
No Collections found. Click Discover to get started!
{intl.formatMessage(messages.noCollectionsFound)}
</p>
</div>
) : (
@@ -52,10 +52,19 @@ import type {
import { ArrowsCrossingIcon } from '@sidekickicons/react/24/solid';
import axios from 'axios';
import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages({
titleWillUpdate: 'Title will be updated on Collection Sync',
orderingControlledInHome: 'Ordering controlled in Home tab',
autoGenerated: 'Auto-generated',
itemCount: '{count} {count, plural, one {item} other {items}}',
bulkEdit: 'Bulk Edit',
collectionSyncStarted: 'Collection sync started successfully',
});
// Frontend collection promotion utilities
function isLibraryPromoted(
collection: CollectionFormConfig | PlexHubConfig | PreExistingCollectionConfig
@@ -161,6 +170,7 @@ const SortableItem = ({
onIndividualSync,
isSyncing,
}: SortableItemProps) => {
const intl = useIntl();
const isHub = configType === 'hub';
const isPreExisting = configType === 'preExisting';
const isCollection = configType === 'collection';
@@ -257,7 +267,7 @@ const SortableItem = ({
<div className="mb-2 flex items-center">
<h5 className="text-base font-medium text-white">
{config.name === 'DYNAMIC_RANDOM_TITLE' ? (
<em>Title will be updated on Collection Sync</em>
<em>{intl.formatMessage(messages.titleWillUpdate)}</em>
) : isCollection &&
(config as CollectionFormConfig).type === 'tmdb' &&
(config as CollectionFormConfig).subtype ===
@@ -285,13 +295,13 @@ const SortableItem = ({
{/* Greyed out indicator for Recommended tab - inline with title */}
{isGreyedInRecommended && (
<span className="ml-2 text-xs italic text-gray-500">
Ordering controlled in Home tab
{intl.formatMessage(messages.orderingControlledInHome)}
</span>
)}
{configType === 'collection' &&
(config as CollectionFormConfig).isExpandedConfig && (
<Badge badgeType="warning" className="ml-2 !bg-opacity-40">
Auto-generated
{intl.formatMessage(messages.autoGenerated)}
</Badge>
)}
</div>
@@ -552,6 +562,7 @@ const LibraryCollectionGroup = ({
// Ensure badgeClickCount is "used" to satisfy linter - this is part of easter egg state management
void badgeClickCount;
const intl = useIntl();
const [isCollapsed, setIsCollapsed] = useState(false);
const [syncingIds, setSyncingIds] = useState<Set<string>>(new Set());
const { addToast } = useToasts();
@@ -583,7 +594,7 @@ const LibraryCollectionGroup = ({
try {
await axios.post(`/api/v1/collections/${collectionId}/sync`);
addToast('Collection sync started successfully', {
addToast(intl.formatMessage(messages.collectionSyncStarted), {
appearance: 'success',
autoDismiss: true,
});
@@ -784,7 +795,9 @@ const LibraryCollectionGroup = ({
<div className="flex items-center space-x-3">
<h4 className="text-lg font-medium text-white">{library.name}</h4>
<Badge badgeType="default">
{allConfigs.length} item{allConfigs.length !== 1 ? 's' : ''}
{intl.formatMessage(messages.itemCount, {
count: allConfigs.length,
})}
</Badge>
</div>
<div className="flex items-center space-x-4">
@@ -796,7 +809,7 @@ const LibraryCollectionGroup = ({
title="Bulk Edit Collections"
>
<PencilSquareIcon className="h-3.5 w-3.5" />
<span>Bulk Edit</span>
<span>{intl.formatMessage(messages.bulkEdit)}</span>
</button>
)}
<button
@@ -17,7 +17,6 @@ const messages = defineMessages({
logs: 'Logs',
exportButton: 'Export',
cancel: 'Cancel',
exporting: 'Preparing export...',
exportSuccess: 'Debug export downloaded successfully',
exportFailed: 'Failed to export debug information',
noItemsSelected: 'Please select at least one item to export',
@@ -13,7 +13,7 @@ const messages = defineMessages({
loading: 'Loading directories...',
errorLoading: 'Failed to load directories',
noDirectories: 'No subdirectories found',
goToParent: 'Go to parent directory',
parentDirectory: '.. (Parent Directory)',
goToRoot: 'Go to Root',
select: 'Select',
cancel: 'Cancel',
@@ -134,7 +134,7 @@ const FolderBrowser = ({
onClick={handleParentClick}
disabled={isLoading}
>
<span>.. (Parent Directory)</span>
<span>{intl.formatMessage(messages.parentDirectory)}</span>
</Button>
)}
<Button
+7 -1
View File
@@ -3,6 +3,11 @@ import type { Permission } from '@server/lib/permissions';
import { hasPermission } from '@server/lib/permissions';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
selectTab: 'Select a Tab',
});
export interface SettingsRoute {
text: string;
@@ -77,12 +82,13 @@ const SettingsTabs = ({
}) => {
const router = useRouter();
const { user: currentUser } = useUser();
const intl = useIntl();
return (
<>
<div className="sm:hidden">
<label htmlFor="tabs" className="sr-only">
Select a Tab
{intl.formatMessage(messages.selectTab)}
</label>
<select
onChange={(e) => {
@@ -15,9 +15,6 @@ import useSWR from 'swr';
const messages = defineMessages({
collectionStatistics: 'Collection Statistics',
topCollections: 'Top Collections',
mostPlayed: 'Most Played',
mostWatched: 'Most Watched (Duration)',
noData: 'No collection data available',
tautulliRequired: 'Tautulli Setup Required',
tautulliDescription:
@@ -26,10 +23,15 @@ const messages = defineMessages({
plays: 'plays',
hours: 'hours',
items: 'items',
lastPlayed: 'Last played',
never: 'Never',
viewAll: 'View All',
refresh: 'Refresh',
failedToLoad: 'Failed to load collection statistics',
daysLabel: 'Days:',
playsButton: 'Plays',
durationButton: 'Duration',
emptyState:
'Create some collections and start watching to see statistics here.',
viewerCount: '{count} {count, plural, one {viewer} other {viewers}}',
lastUpdated: 'Last updated: {time}',
});
interface CollectionStats {
@@ -134,7 +136,7 @@ const CollectionStatsGrid: React.FC = () => {
</div>
<div className="p-6 text-center">
<p className="mb-2 text-red-400">
Failed to load collection statistics
{intl.formatMessage(messages.failedToLoad)}
</p>
<p className="text-sm text-gray-400">{error.message}</p>
</div>
@@ -154,7 +156,7 @@ const CollectionStatsGrid: React.FC = () => {
{/* Days input */}
<div className="flex items-center space-x-2">
<label htmlFor="days-input" className="text-sm text-gray-400">
Days:
{intl.formatMessage(messages.daysLabel)}
</label>
<input
id="days-input"
@@ -176,7 +178,7 @@ const CollectionStatsGrid: React.FC = () => {
onClick={() => setStatType('plays')}
>
<PlayIcon className="mr-1 h-4 w-4" />
Plays
{intl.formatMessage(messages.playsButton)}
</Button>
<Button
buttonSize="sm"
@@ -184,7 +186,7 @@ const CollectionStatsGrid: React.FC = () => {
onClick={() => setStatType('duration')}
>
<ClockIcon className="mr-1 h-4 w-4" />
Duration
{intl.formatMessage(messages.durationButton)}
</Button>
</div>
</div>
@@ -203,7 +205,7 @@ const CollectionStatsGrid: React.FC = () => {
{intl.formatMessage(messages.noData)}
</p>
<p className="text-sm text-gray-500">
Create some collections and start watching to see statistics here.
{intl.formatMessage(messages.emptyState)}
</p>
</div>
) : (
@@ -241,8 +243,9 @@ const CollectionStatsGrid: React.FC = () => {
<>
<span></span>
<span>
{collection.user_stats.length} viewer
{collection.user_stats.length !== 1 ? 's' : ''}
{intl.formatMessage(messages.viewerCount, {
count: collection.user_stats.length,
})}
</span>
</>
)}
@@ -266,10 +269,11 @@ const CollectionStatsGrid: React.FC = () => {
<div className="mt-3 border-t border-gray-700 pt-3">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
Last updated:{' '}
{new Date(
collectionStats.metadata.timestamp
).toLocaleString()}
{intl.formatMessage(messages.lastUpdated, {
time: new Date(
collectionStats.metadata.timestamp
).toLocaleString(),
})}
</p>
<Button
buttonSize="sm"
+4 -3
View File
@@ -18,15 +18,14 @@ const messages = defineMessages({
collectionPlays: 'Collection Views',
movieCollectionPlays: 'Movie Collection Views',
tvCollectionPlays: 'TV Collection Views',
agregarrCollections: 'Agregarr',
preExistingCollections: 'Pre-existing',
fromCollections: 'from collections',
totalServer: 'total',
thisWeek: 'this week',
tautulliRequired: 'Tautulli Setup Required',
tautulliDescription:
'Configure Tautulli in your settings to view play statistics from your Plex server.',
configureTautulli: 'Configure Tautulli',
failedToLoad: 'Failed to load dashboard statistics',
});
interface DashboardData {
@@ -98,7 +97,9 @@ const DashboardStats: React.FC = () => {
return (
<div className="rounded-lg bg-stone-800 p-6 shadow-sm">
<div className="text-center">
<p className="text-red-400">Failed to load dashboard statistics</p>
<p className="text-red-400">
{intl.formatMessage(messages.failedToLoad)}
</p>
<p className="mt-1 text-sm text-gray-500">{error.message}</p>
</div>
</div>
+17 -5
View File
@@ -37,6 +37,10 @@ const messages = defineMessages({
statusPartiallyAvailable: 'Partially Available',
autoRequest: 'Auto',
manualRequest: 'Manual',
failedToLoad: 'Failed to load missing items',
requestsCount: '{total} {mediaType} requests',
showingRecent: 'Showing recent missing item requests',
lastUpdatedNow: 'Last updated: {time}',
});
interface MissingItem {
@@ -185,7 +189,9 @@ const MissingItemsFeed: React.FC = () => {
</h3>
</div>
<div className="p-6 text-center">
<p className="mb-2 text-red-400">Failed to load missing items</p>
<p className="mb-2 text-red-400">
{intl.formatMessage(messages.failedToLoad)}
</p>
<p className="text-sm text-gray-400">{error.message}</p>
</div>
</div>
@@ -203,8 +209,10 @@ const MissingItemsFeed: React.FC = () => {
{missingItemsData && (
<p className="flex items-center text-sm text-gray-400">
<CalendarDaysIcon className="mr-1 h-4 w-4" />
{missingItemsData.total} {activeTab === 'movies' ? 'movie' : 'TV'}{' '}
requests
{intl.formatMessage(messages.requestsCount, {
total: missingItemsData.total,
mediaType: activeTab === 'movies' ? 'movie' : 'TV',
})}
</p>
)}
</div>
@@ -335,8 +343,12 @@ const MissingItemsFeed: React.FC = () => {
<div className="border-t border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
<div>Showing recent missing item requests</div>
<div>Last updated: {new Date().toLocaleString()}</div>
<div>{intl.formatMessage(messages.showingRecent)}</div>
<div>
{intl.formatMessage(messages.lastUpdatedNow, {
time: new Date().toLocaleString(),
})}
</div>
</div>
<div className="flex space-x-2">
<Button
@@ -54,13 +54,14 @@ const messages = defineMessages({
requestedVia: 'via {source}',
autoRequest: 'Auto',
manualRequest: 'Manual',
itemsPerPage: 'Items per page',
showing: 'Showing {start} to {end} of {total} items',
previous: 'Previous',
next: 'Next',
refreshing: 'Refreshing...',
syncStatus: 'Sync Status',
syncing: 'Syncing...',
failedToLoad: 'Failed to load missing items',
requestedBy: 'by {name}',
});
interface MissingItem {
@@ -442,7 +443,7 @@ const MissingItemsModal: React.FC<MissingItemsModalProps> = ({
{error ? (
<div className="py-8 text-center">
<p className="mb-2 text-red-400">
Failed to load missing items
{intl.formatMessage(messages.failedToLoad)}
</p>
<p className="text-sm text-gray-400">{error.message}</p>
</div>
@@ -545,7 +546,9 @@ const MissingItemsModal: React.FC<MissingItemsModalProps> = ({
</div>
{item.requestedBy && (
<div className="mt-1 text-xs text-gray-500">
by {item.requestedBy.displayName}
{intl.formatMessage(messages.requestedBy, {
name: item.requestedBy.displayName,
})}
</div>
)}
</div>
@@ -1,6 +1,11 @@
import type { ApplicationCondition } from '@server/entity/OverlayTemplate';
import { defineMessages, useIntl } from 'react-intl';
import { CONDITION_FIELD_CATEGORIES } from './types';
const messages = defineMessages({
alwaysApply: 'Always apply (no condition)',
});
// Operator display labels
const OPERATOR_LABELS: Record<string, string> = {
eq: '=',
@@ -33,10 +38,12 @@ interface ConditionDisplayProps {
export const ConditionDisplay: React.FC<ConditionDisplayProps> = ({
condition,
}) => {
const intl = useIntl();
if (!condition || !condition.sections || condition.sections.length === 0) {
return (
<div className="rounded bg-stone-800 px-2 py-1 text-xs italic text-stone-500">
Always apply (no condition)
{intl.formatMessage(messages.alwaysApply)}
</div>
);
}
@@ -59,6 +59,8 @@ const messages = defineMessages({
opExists: 'exists',
and: 'AND',
or: 'OR',
true: 'true',
false: 'false',
});
// List of numeric fields
@@ -309,8 +311,8 @@ const RuleItem: React.FC<RuleItemProps> = ({
}}
className="flex-1 rounded border border-stone-600 bg-stone-700 px-2 py-1 text-sm text-white"
>
<option value="true">true</option>
<option value="false">false</option>
<option value="true">{intl.formatMessage(messages.true)}</option>
<option value="false">{intl.formatMessage(messages.false)}</option>
</select>
) : isTagField ? (
<select
@@ -38,14 +38,7 @@ const messages = defineMessages({
description: 'Description (Optional)',
enterName: 'Enter a name',
enterDescription: 'Enter a description',
undo: 'Undo',
redo: 'Redo',
previewPoster: 'Preview Poster',
refreshPoster: 'Next Poster',
loadingMetadata: 'Loading metadata...',
snapToGuides: 'Snap to Guides',
posterInfo: 'Poster Info',
noPosterLoaded: 'No poster loaded',
// Application condition
applicationCondition: 'Application Condition',
editConditions: 'Edit Conditions',
@@ -113,6 +113,9 @@ const messages = defineMessages({
opRegex: 'matches regex',
opBegins: 'begins with',
opEnds: 'ends with',
locked: 'Locked',
unlocked: 'Unlocked',
unknownElement: 'Unknown element type: {type}',
});
interface FontInfo {
@@ -701,23 +704,23 @@ export const OverlayLayerPanel: React.FC<OverlayLayerPanelProps> = ({
props.borderRadius !== undefined ? (
<>
<LockClosedIcon className="h-3 w-3" />
<span>Locked</span>
<span>{intl.formatMessage(messages.locked)}</span>
</>
) : (
<>
<LockOpenIcon className="h-3 w-3" />
<span>Unlocked</span>
<span>{intl.formatMessage(messages.unlocked)}</span>
</>
)
) : props.lockCorners ? (
<>
<LockClosedIcon className="h-3 w-3" />
<span>Locked</span>
<span>{intl.formatMessage(messages.locked)}</span>
</>
) : (
<>
<LockOpenIcon className="h-3 w-3" />
<span>Unlocked</span>
<span>{intl.formatMessage(messages.unlocked)}</span>
</>
)}
</button>
@@ -1610,7 +1613,9 @@ export const OverlayLayerPanel: React.FC<OverlayLayerPanelProps> = ({
default:
return (
<p className="text-xs text-stone-400">
Unknown element type: {selectedElement.type}
{intl.formatMessage(messages.unknownElement, {
type: selectedElement.type,
})}
</p>
);
}
+63 -47
View File
@@ -27,9 +27,11 @@ import type {
const messages = defineMessages({
layers: 'Layers',
addText: 'Add Text',
addCustomText: 'Add Custom Text',
addCollectionTitle: 'Add Collection Title',
addImage: 'Add Image',
addIcon: 'Add Icon',
addSourceLogo: 'Add Source Logo',
addCustomSVGIcon: 'Add Custom SVG Icon',
addGrid: 'Add Grid',
moveUp: 'Move Up',
moveDown: 'Move Down',
@@ -43,6 +45,8 @@ const messages = defineMessages({
customIcon: 'Custom Icon',
contentGrid: 'Content Grid',
noElementSelected: 'Select an element to edit its properties',
elements: 'Elements',
noElementsAdded: 'No elements added yet',
// Text properties
fontSize: 'Font Size',
fontFamily: 'Font Family',
@@ -50,11 +54,7 @@ const messages = defineMessages({
fontStyle: 'Font Style',
textTransform: 'Text Transform',
textColor: 'Text Color',
textAlign: 'Text Align',
maxLines: 'Max Lines',
left: 'Left',
center: 'Center',
right: 'Right',
normal: 'Normal',
bold: 'Bold',
italic: 'Italic',
@@ -78,18 +78,32 @@ const messages = defineMessages({
// Background properties
background: 'Background',
backgroundType: 'Type',
backgroundColors: 'Background Colors',
color: 'Color',
gradient: 'Gradient',
radial: 'Radial Gradient',
intensity: 'Intensity',
primary: 'Primary',
secondary: 'Secondary',
primaryColor: 'Primary Color',
secondaryColor: 'Secondary Color',
useSourceColors: 'Use Source Colors',
disabledUsingSourceColors: 'Disabled - using source colors',
sourceType: 'Source Type',
sourceTypeForText: 'Source Type for Text',
text: 'Text',
collectionTitleNote:
"This text will automatically display the collection's name when used.",
useSourceTextColors: 'Use Source Text Colors',
disabledUsingSourceTextColors: 'Disabled - using source text colors',
sourceLogoNote:
"This logo will automatically change based on the collection's source when used.",
customizeColors: 'Customize Colors',
setSourceColors: 'Set Source Colors',
saveSourceColors: 'Save Colors',
sourceColorsSaved: 'Colors Saved!',
pixelsUnit: 'px',
linesUnit: 'lines',
pixelsAutoLabel: 'px - Auto',
lockedToAspectRatio: 'Locked to poster aspect ratio (2:3)',
});
interface FontInfo {
@@ -900,7 +914,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
{/* Background Colors */}
<div className="space-y-2">
<h6 className="text-xs font-medium text-stone-400">
Background Colors
{intl.formatMessage(messages.backgroundColors)}
</h6>
<div className="grid grid-cols-2 gap-2">
<div>
@@ -908,7 +922,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
htmlFor={`primary-color-${selectedSourceType}`}
className="mb-1 block text-xs text-stone-400"
>
Primary
{intl.formatMessage(messages.primary)}
</label>
<input
id={`primary-color-${selectedSourceType}`}
@@ -938,7 +952,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
htmlFor={`secondary-color-${selectedSourceType}`}
className="mb-1 block text-xs text-stone-400"
>
Secondary
{intl.formatMessage(messages.secondary)}
</label>
<input
id={`secondary-color-${selectedSourceType}`}
@@ -970,7 +984,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
htmlFor={`text-color-${selectedSourceType}`}
className="mb-1 block text-xs text-stone-400"
>
Text Color
{intl.formatMessage(messages.textColor)}
</label>
<input
id={`text-color-${selectedSourceType}`}
@@ -1040,7 +1054,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
/>
{isTemplate && posterData.background.useSourceColors && (
<p className="mt-1 text-xs text-stone-500">
Disabled - using source colors
{intl.formatMessage(messages.disabledUsingSourceColors)}
</p>
)}
</div>
@@ -1068,7 +1082,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
/>
{isTemplate && posterData.background.useSourceColors && (
<p className="mt-1 text-xs text-stone-500">
Disabled - using source colors
{intl.formatMessage(messages.disabledUsingSourceColors)}
</p>
)}
</div>
@@ -1090,7 +1104,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
className="flex w-full items-center justify-center gap-1 rounded border border-stone-600 bg-stone-700 px-2 py-1 text-xs text-white hover:bg-stone-600"
>
<PlusIcon className="h-3 w-3" />
Add Custom Text
{intl.formatMessage(messages.addCustomText)}
</button>
{isTemplate && (
<button
@@ -1099,7 +1113,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
className="mt-1 flex w-full items-center justify-center gap-1 rounded border border-orange-600 bg-stone-700 px-2 py-1 text-xs text-white hover:bg-stone-600"
>
<PlusIcon className="h-3 w-3" />
Add Collection Title
{intl.formatMessage(messages.addCollectionTitle)}
</button>
)}
</div>
@@ -1122,7 +1136,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
className="flex w-full items-center justify-center gap-1 rounded border border-orange-600 bg-stone-700 px-2 py-1 text-xs text-white hover:bg-stone-600"
>
<PlusIcon className="h-3 w-3" />
Add Source Logo
{intl.formatMessage(messages.addSourceLogo)}
</button>
<button
type="button"
@@ -1130,7 +1144,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
className="mt-1 flex w-full items-center justify-center gap-1 rounded border border-stone-600 bg-stone-700 px-2 py-1 text-xs text-white hover:bg-stone-600"
>
<PlusIcon className="h-3 w-3" />
Add Custom SVG Icon
{intl.formatMessage(messages.addCustomSVGIcon)}
</button>
</div>
@@ -1148,11 +1162,13 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
{/* 3. Element List Section */}
<div className="space-y-3 border-b border-stone-700 pb-4">
<h3 className="text-sm font-medium text-stone-300">Elements</h3>
<h3 className="text-sm font-medium text-stone-300">
{intl.formatMessage(messages.elements)}
</h3>
<div className="space-y-1">
{sortedElements.length === 0 ? (
<div className="py-4 text-center text-xs text-stone-500">
No elements added yet
{intl.formatMessage(messages.noElementsAdded)}
</div>
) : (
sortedElements.map((element) => {
@@ -1265,7 +1281,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
className="rounded border-stone-600 bg-stone-800 text-orange-600 focus:ring-orange-500"
/>
<span className="text-xs text-stone-300">
Use Source Text Colors
{intl.formatMessage(messages.useSourceTextColors)}
</span>
</label>
@@ -1278,7 +1294,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
htmlFor={`source-type-text-${selectedElement.id}`}
className="mb-1 block text-xs text-stone-400"
>
Source Type for Text
{intl.formatMessage(messages.sourceTypeForText)}
</label>
<select
id={`source-type-text-${selectedElement.id}`}
@@ -1319,7 +1335,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
htmlFor={`text-input-${selectedElement.id}`}
className="mb-1 block text-xs text-stone-400"
>
Text
{intl.formatMessage(messages.text)}
</label>
<input
id={`text-input-${selectedElement.id}`}
@@ -1348,8 +1364,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
.elementType === 'collection-title' ? (
<div>
<p className="mt-1 rounded bg-orange-100 px-2 py-1 text-xs text-orange-800">
This text will automatically display the
collection&apos;s name when used.
{intl.formatMessage(messages.collectionTitleNote)}
</p>
</div>
) : null}
@@ -1360,8 +1375,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
{intl.formatMessage(messages.fontSize)} (
{localSliderValues[`fontSize-${selectedElement.id}`] ??
(selectedElement.properties as TextElementProps)
.fontSize}
px)
.fontSize}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -1450,7 +1465,9 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
(selectedElement.properties as TextElementProps)
.useSourceColors && (
<p className="mt-1 text-xs text-stone-500">
Disabled - using source text colors
{intl.formatMessage(
messages.disabledUsingSourceTextColors
)}
</p>
)}
</div>
@@ -1555,7 +1572,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
((selectedElement.properties as TextElementProps)
.maxLines ||
1)}{' '}
lines)
{intl.formatMessage(messages.linesUnit)})
</label>
<input
type="range"
@@ -1662,8 +1679,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
<label className="mb-1 block text-xs text-stone-400">
{intl.formatMessage(messages.width)} (
{localSliderValues[`width-${selectedElement.id}`] ??
selectedElement.width}
px)
selectedElement.width}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -1728,8 +1745,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
<label className="mb-1 block text-xs text-stone-400">
{intl.formatMessage(messages.height)} (
{localSliderValues[`height-${selectedElement.id}`] ??
selectedElement.height}
px)
selectedElement.height}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -1861,8 +1878,7 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
'source-logo' ? (
<div>
<p className="mt-1 rounded bg-orange-100 px-2 py-1 text-xs text-orange-800">
This logo will automatically change based on the
collection&apos;s source when used.
{intl.formatMessage(messages.sourceLogoNote)}
</p>
</div>
) : (
@@ -1935,8 +1951,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
<label className="mb-1 block text-xs text-stone-400">
{intl.formatMessage(messages.width)} (
{localSliderValues[`width-${selectedElement.id}`] ??
selectedElement.width}
px)
selectedElement.width}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -2001,8 +2017,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
<label className="mb-1 block text-xs text-stone-400">
{intl.formatMessage(messages.height)} (
{localSliderValues[`height-${selectedElement.id}`] ??
selectedElement.height}
px)
selectedElement.height}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -2256,8 +2272,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
{intl.formatMessage(messages.spacing)} (
{localSliderValues[`spacing-${selectedElement.id}`] ??
(selectedElement.properties as ContentGridProps)
.spacing}
px)
.spacing}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -2362,8 +2378,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
`cornerRadius-${selectedElement.id}`
] ??
(selectedElement.properties as ContentGridProps)
.cornerRadius}
px)
.cornerRadius}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -2410,8 +2426,8 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
<label className="mb-1 block text-xs text-stone-400">
{intl.formatMessage(messages.width)} (
{localSliderValues[`width-${selectedElement.id}`] ??
selectedElement.width}
px)
selectedElement.width}{' '}
{intl.formatMessage(messages.pixelsUnit)})
</label>
<input
type="range"
@@ -2507,10 +2523,10 @@ export const LayerPanel: React.FC<LayerPanelProps> = ({
(currentRows - 1) * currentSpacing;
return Math.round(calculatedHeight);
})()}
px - Auto)
{intl.formatMessage(messages.pixelsAutoLabel)})
</label>
<div className="flex h-8 items-center justify-center rounded border border-stone-600 bg-stone-800 px-2 text-xs text-stone-400">
Locked to poster aspect ratio (2:3)
{intl.formatMessage(messages.lockedToAspectRatio)}
</div>
</div>
</div>
@@ -37,9 +37,6 @@ const messages = defineMessages({
selectCollection: 'Select a collection...',
sampleCollectionHelp:
'Choose a collection to see how your template will look with real data. This is for preview only - templates save as reusable designs.',
undo: 'Undo',
redo: 'Redo',
snapToGuides: 'Snap to Guides',
});
export type EditorMode =
@@ -18,7 +18,6 @@ const messages = defineMessages({
deselectAll: 'Deselect All',
copyElements: 'Copy {elementCount} elements to {templateCount} templates',
cancel: 'Cancel',
copied: 'Elements copied successfully',
copyFailed: 'Failed to copy elements',
noElementsSelected: 'Select at least one element',
noTemplatesSelected: 'Select at least one template',
@@ -27,6 +26,9 @@ const messages = defineMessages({
elementVariable: 'Variable',
elementRaster: 'Image',
elementSvg: 'Icon',
copyingFrom: 'Copying from:',
noElementsInTemplate: 'No elements in template',
noTemplatesAvailable: 'No templates available',
});
interface Template {
@@ -200,7 +202,9 @@ const CopyTemplateModal: React.FC<CopyTemplateModalProps> = ({
</div>
<div className="rounded-lg bg-stone-800 p-3">
<div className="text-xs text-stone-500">Copying from:</div>
<div className="text-xs text-stone-500">
{intl.formatMessage(messages.copyingFrom)}
</div>
<div className="mt-1 font-medium text-white">
{sourceTemplate.name}
</div>
@@ -226,7 +230,7 @@ const CopyTemplateModal: React.FC<CopyTemplateModalProps> = ({
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border border-stone-700 p-3">
{sourceElements.length === 0 ? (
<div className="py-8 text-center text-sm text-stone-500">
No elements in template
{intl.formatMessage(messages.noElementsInTemplate)}
</div>
) : (
sourceElements.map((element) => (
@@ -277,7 +281,7 @@ const CopyTemplateModal: React.FC<CopyTemplateModalProps> = ({
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border border-stone-700 p-3">
{availableTemplates.length === 0 ? (
<div className="py-8 text-center text-sm text-stone-500">
No templates available
{intl.formatMessage(messages.noTemplatesAvailable)}
</div>
) : (
availableTemplates.map((template) => (
@@ -17,8 +17,6 @@ import LibraryDetailConfigView from './LibraryDetailConfigView';
import PosterResetModal from './PosterResetModal';
const messages = defineMessages({
libraryConfig: 'Library Configuration',
selectLibrary: 'Select a library to configure overlays',
loading: 'Loading libraries...',
noLibraries: 'No libraries found',
configure: 'Configure',
@@ -29,6 +27,8 @@ const messages = defineMessages({
syncOverlaysConfirm: 'Confirm?',
overlaySyncStarted: 'Overlay sync started for {libraryName}',
overlaySyncError: 'Failed to start overlay sync',
failedToLoad: 'Failed to load libraries',
noOverlays: 'No overlays configured',
});
interface PlexLibrary {
@@ -323,7 +323,9 @@ const LibraryConfigView: React.FC = () => {
if (librariesError) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-red-400">Failed to load libraries</div>
<div className="text-red-400">
{intl.formatMessage(messages.failedToLoad)}
</div>
</div>
);
}
@@ -399,7 +401,9 @@ const LibraryConfigView: React.FC = () => {
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="mt-2 text-xs">No overlays configured</p>
<p className="mt-2 text-xs">
{intl.formatMessage(messages.noOverlays)}
</p>
</div>
</div>
)}
@@ -147,28 +147,18 @@ const ConditionDisplay: React.FC<{ condition: string }> = ({ condition }) => {
const messages = defineMessages({
configureOverlays: 'Configure Overlays',
availableOverlays: 'Available Overlays',
comingSoonForced:
'Coming Soon overlays are automatically applied to placeholder items',
ratings: 'Ratings',
metadata: 'TMDB Metadata',
mediaInfo: 'Media Info',
status: 'Status',
generic: 'General Purpose',
save: 'Save Configuration',
cancel: 'Cancel',
saving: 'Saving...',
worksOn: 'Works on',
placeholders: 'Placeholders',
realItems: 'Real items',
both: 'Both',
comingSoon: 'Coming Soon (Auto-applied)',
optionalStatus: 'Status Overlays (Coming Soon/Placeholder items)',
editDesign: 'Edit design in Templates tab',
configure: 'Configure',
savedSuccessfully: 'Library configuration saved',
saveFailed: 'Failed to save configuration',
alwaysApply: 'Always apply',
preview: 'Preview',
cyclePoster: 'Cycle Poster',
selectOverlaysPreview: 'Select overlays to see preview',
dragToReorder:
'Drag to reorder • Top overlays render on top of bottom overlays',
tmdbPosterLanguage: 'TMDB Poster Language:',
useGlobalSetting: 'Use global setting',
languageDescription: 'Language for fetching poster metadata from TMDB',
});
interface Template {
@@ -627,7 +617,9 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
{/* Large Preview Panel - Main Focus */}
<div className="flex flex-shrink-0 flex-col">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Preview</h3>
<h3 className="text-sm font-semibold text-white">
{intl.formatMessage(messages.preview)}
</h3>
{previewUrl && !previewLoading && (
<button
onClick={handleCyclePoster}
@@ -635,7 +627,7 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
title="Cycle poster"
>
<ArrowPathIcon className="h-3.5 w-3.5" />
Cycle
{intl.formatMessage(messages.cyclePoster)}
</button>
)}
</div>
@@ -653,7 +645,9 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
/>
) : (
<div className="flex h-full items-center justify-center text-center text-sm text-stone-500">
<span>Select overlays to see preview</span>
<span>
{intl.formatMessage(messages.selectOverlaysPreview)}
</span>
</div>
)}
</div>
@@ -662,7 +656,7 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
{/* Overlay Selection - Drag & Drop Scrollable List */}
<div className="min-w-0 flex-1 overflow-y-auto pr-2">
<div className="mb-3 text-xs text-stone-400">
Drag to reorder Top overlays render on top of bottom overlays
{intl.formatMessage(messages.dragToReorder)}
</div>
<DndContext
sensors={sensors}
@@ -704,7 +698,7 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
htmlFor="tmdbLanguage"
className="text-sm font-medium text-white"
>
TMDB Poster Language:
{intl.formatMessage(messages.tmdbPosterLanguage)}
</label>
<select
id="tmdbLanguage"
@@ -712,7 +706,9 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
onChange={(e) => setTmdbLanguage(e.target.value || undefined)}
className="rounded-md border-stone-600 bg-stone-700 px-3 py-1.5 text-sm text-white"
>
<option value="">Use global setting</option>
<option value="">
{intl.formatMessage(messages.useGlobalSetting)}
</option>
{TMDB_LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
@@ -720,7 +716,7 @@ const LibraryDetailConfigView: React.FC<LibraryDetailConfigViewProps> = ({
))}
</select>
<span className="text-xs text-stone-400">
Language for fetching poster metadata from TMDB
{intl.formatMessage(messages.languageDescription)}
</span>
</div>
</div>
+2 -2
View File
@@ -36,7 +36,6 @@ const messages = defineMessages({
importTemplate: 'Import Template',
importSuccess: 'Overlay template imported successfully',
importError: 'Failed to import overlay template',
loading: 'Loading...',
error: 'Failed to load overlay data',
templatesDescription:
'Design reusable overlay templates for ratings, metadata, and more',
@@ -48,6 +47,7 @@ const messages = defineMessages({
overlaySyncQueued:
'Per-library syncs are running. Full sync will start when they complete.',
overlaySyncError: 'Failed to start overlay sync',
testItem: 'Test Item',
});
interface OverlayTemplate {
@@ -336,7 +336,7 @@ const OverlaysView: React.FC = () => {
className="flex items-center space-x-2"
>
<BeakerIcon className="h-4 w-4" />
<span>Test Item</span>
<span>{intl.formatMessage(messages.testItem)}</span>
</Button>
<Button
buttonType="ghost"
@@ -5,7 +5,6 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
title: 'Reset All Posters',
confirmTitle: 'Confirm Poster Reset',
confirmDescription:
'This will reset ALL posters in "{libraryName}" to their base versions (without overlays). The poster source setting ({posterSource}) will be respected.',
@@ -22,7 +21,6 @@ const messages = defineMessages({
resetFailed: 'Reset Failed',
cancelReset: 'Cancel Reset',
runInBackground: 'Run in Background',
close: 'Close',
itemsFailed: '{count} items failed',
libraryLabel: 'Library: {name}',
progressLabel: '{current} / {total}',
@@ -30,9 +30,7 @@ const messages = defineMessages({
populateFromPlexDescription:
'Download all current Plex posters and save them to local folders. Great for migrating from Plex posters to local posters.',
generatingFoldersTitle: 'Generating Folder Structure',
generatingFoldersDescription: 'Creating folders for all library items...',
populatingTitle: 'Populating from Plex',
populatingDescription: 'Downloading Plex posters to local folders...',
operationComplete: 'Operation Complete',
cancelOperation: 'Cancel Operation',
cancel: 'Cancel',
@@ -49,16 +47,15 @@ const messages = defineMessages({
downloadingDescription:
'Downloading posters from your Plex libraries for overlay processing...',
downloadComplete: 'Download Complete',
downloadFailed: 'Download Failed',
cancelDownload: 'Cancel Download',
runInBackground: 'Run in Background',
close: 'Close',
itemsFailed: '{count} items failed (no poster available)',
itemsSkipped: '{count} items skipped (no TMDB ID)',
savingSettings: 'Saving settings...',
settingsSaved: 'Settings saved successfully',
settingsFailed: 'Failed to save settings',
confirm: 'Confirm',
redownloadInstructions:
'Only re-download if you have reset all your Plex posters to clean versions (Plex Dance or Manual)',
utilityButtons: 'Utility buttons to help manage local posters:',
});
interface PosterSourceSetupModalProps {
@@ -541,17 +538,7 @@ const PosterSourceSetupModal: React.FC<PosterSourceSetupModalProps> = ({
!showRedownloadConfirm && (
<div className="rounded-lg border-2 border-gray-600 bg-gray-600 bg-opacity-10 p-4">
<p className="mb-3 text-sm text-gray-200">
Only re-download if you have reset all your Plex posters to
clean versions (
<a
href="https://forums.plex.tv/t/the-plex-dance/197064"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-yellow-100"
>
Plex Dance
</a>{' '}
or Manual)
{intl.formatMessage(messages.redownloadInstructions)}
</p>
<Button
buttonType="warning"
@@ -610,7 +597,7 @@ const PosterSourceSetupModal: React.FC<PosterSourceSetupModalProps> = ({
{selectedSource === 'local' && !showRedownloadConfirm && (
<div className="space-y-3 rounded-lg border-2 border-gray-600 bg-gray-600 bg-opacity-10 p-4">
<p className="mb-3 text-sm text-gray-200">
Utility buttons to help manage local posters:
{intl.formatMessage(messages.utilityButtons)}
</p>
<div className="space-y-4">
{/* Generate Folders Section */}
@@ -29,8 +29,8 @@ const messages = defineMessages({
cancel: 'Cancel',
noTemplates: 'No templates found',
createFirstTemplate: 'Create your first template to get started',
createTemplate: 'Create Template',
lastUpdated: 'Last updated',
previewUnavailable: 'Preview unavailable',
});
interface PosterTemplate {
@@ -285,7 +285,9 @@ const PosterTemplateGrid: React.FC<PosterTemplateGridProps> = ({
<div className="absolute inset-0 flex hidden items-center justify-center bg-stone-700 text-stone-400">
<div className="text-center">
<div className="text-sm font-medium">{template.name}</div>
<div className="text-xs">Preview unavailable</div>
<div className="text-xs">
{intl.formatMessage(messages.previewUnavailable)}
</div>
</div>
</div>
</div>
@@ -31,14 +31,11 @@ const messages = defineMessages({
importTemplate: 'Import Template',
importSourceColors: 'Import Source Colors',
exportSourceColors: 'Export Source Colors',
loading: 'Loading...',
error: 'Failed to load data',
importSuccess: 'Template imported successfully',
importError: 'Failed to import file',
sourceColorsExportSuccess: 'Source colors exported successfully',
sourceColorsExportError: 'Failed to export source colors',
invalidFileFormat:
'Invalid file format. Please select a valid template or source colors JSON file.',
});
interface PosterTemplate {
+15 -12
View File
@@ -17,16 +17,9 @@ import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
edit: 'Edit',
duplicate: 'Duplicate',
delete: 'Delete',
confirmDelete: 'Are you sure you want to delete this poster?',
deletePoster: 'Delete Poster',
cancel: 'Cancel',
noPosters: 'No saved posters found',
createFirstPoster: 'Create your first poster to get started',
createPoster: 'Create Poster',
lastUpdated: 'Last updated',
selectAll: 'Select All',
deselectAll: 'Deselect All',
deleteSelected: 'Delete Selected ({count})',
@@ -42,6 +35,11 @@ const messages = defineMessages({
'The following posters are currently in use. Do you want to delete them anyway?',
deleteUnusedOnly: 'Delete Unused Only',
deleteAllAnyway: 'Delete All Anyway',
selected: '{count} selected',
deleteAction: 'Delete',
fileSource: 'File',
unusedDeleted:
'{count} unused {count, plural, one {poster} other {posters}} have already been deleted.',
});
interface SavedPoster {
@@ -385,7 +383,9 @@ const SavedPosterGrid: React.FC<SavedPosterGridProps> = ({
</Button>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm text-stone-400">
{selectedPosters.size} selected
{intl.formatMessage(messages.selected, {
count: selectedPosters.size,
})}
</span>
<Button
buttonType="danger"
@@ -522,7 +522,9 @@ const SavedPosterGrid: React.FC<SavedPosterGridProps> = ({
}
>
{deleteConfirmId === poster.id ? (
<span className="text-xs">Delete</span>
<span className="text-xs">
{intl.formatMessage(messages.deleteAction)}
</span>
) : (
<TrashIcon className="h-3 w-3" />
)}
@@ -534,7 +536,7 @@ const SavedPosterGrid: React.FC<SavedPosterGridProps> = ({
<div className="absolute top-1 right-1">
{!poster.isEditable && (
<Badge badgeType="warning" className="text-xs">
File
{intl.formatMessage(messages.fileSource)}
</Badge>
)}
</div>
@@ -618,8 +620,9 @@ const SavedPosterGrid: React.FC<SavedPosterGridProps> = ({
</p>
{bulkDeleteUsageModal.unusedPosterIds.length > 0 && (
<p className="mb-4 text-green-400">
{bulkDeleteUsageModal.unusedPosterIds.length} unused poster(s)
have already been deleted.
{intl.formatMessage(messages.unusedDeleted, {
count: bulkDeleteUsageModal.unusedPosterIds.length,
})}
</p>
)}
<div className="max-h-96 space-y-3 overflow-y-auto">
+45 -16
View File
@@ -14,14 +14,31 @@ import {
} from '@heroicons/react/24/solid';
import axios from 'axios';
import { Fragment, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
search: 'Search',
cancel: 'Cancel',
testOverlay: 'Test Overlay',
renderedPoster: 'Rendered Poster',
library: 'Library: {name}',
templateResults: 'Template Results',
noConditions: 'No conditions (always applies)',
conditionEvaluation: 'Condition Evaluation:',
actual: '(actual: {value})',
contextVariables: 'Context Variables ({count})',
undefined: 'undefined',
noPoster: 'No Poster',
});
interface TestItemModalProps {
isOpen: boolean;
onClose: () => void;
}
const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
const intl = useIntl();
const { addToast } = useToasts();
const [stage, setStage] = useState<'search' | 'results'>('search');
const [searchQuery, setSearchQuery] = useState('');
@@ -158,7 +175,9 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
buttonType="primary"
>
<MagnifyingGlassIcon className="h-5 w-5" />
<span className="ml-2">Search</span>
<span className="ml-2">
{intl.formatMessage(messages.search)}
</span>
</Button>
</div>
@@ -189,7 +208,7 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
/>
) : (
<div className="flex h-full items-center justify-center bg-stone-800 text-stone-500">
No Poster
{intl.formatMessage(messages.noPoster)}
</div>
)}
</div>
@@ -208,14 +227,14 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
{/* Action Buttons */}
<div className="flex justify-end space-x-2 border-t border-stone-700 pt-4">
<Button buttonType="ghost" onClick={handleClose}>
Cancel
{intl.formatMessage(messages.cancel)}
</Button>
<Button
buttonType="primary"
onClick={handleTest}
disabled={!selectedItem || isTesting}
>
Test Overlay
{intl.formatMessage(messages.testOverlay)}
</Button>
</div>
</>
@@ -228,7 +247,7 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
{/* Left Column: Poster with Overlays */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white">
Rendered Poster
{intl.formatMessage(messages.renderedPoster)}
</h3>
{isTesting ? (
<div className="flex h-96 items-center justify-center rounded-lg bg-stone-900">
@@ -250,7 +269,11 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
</strong>{' '}
{testResults.item.year && `(${testResults.item.year})`}
</p>
<p>Library: {testResults.item.libraryName}</p>
<p>
{intl.formatMessage(messages.library, {
name: testResults.item.libraryName,
})}
</p>
</div>
</>
) : null}
@@ -263,7 +286,7 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
{/* Templates Section */}
<div className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-3 text-lg font-semibold text-white">
Template Results
{intl.formatMessage(messages.templateResults)}
</h3>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
{testResults.templates.map((template) => (
@@ -308,12 +331,14 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
{template.conditionResults.sectionResults
.length === 0 ? (
<p className="text-xs text-stone-500">
No conditions (always applies)
{intl.formatMessage(messages.noConditions)}
</p>
) : (
<div className="space-y-2">
<p className="text-xs font-semibold text-stone-400">
Condition Evaluation:
{intl.formatMessage(
messages.conditionEvaluation
)}
</p>
{template.conditionResults.sectionResults.map(
(section, sIdx) => (
@@ -355,11 +380,14 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
)}
</span>
<span className="ml-2 text-stone-500">
(actual:{' '}
{JSON.stringify(
rule.actualValue
{intl.formatMessage(
messages.actual,
{
value: JSON.stringify(
rule.actualValue
),
}
)}
)
</span>
</div>
</div>
@@ -381,8 +409,9 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
{/* Context Variables Section */}
<div className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-3 text-lg font-semibold text-white">
Context Variables (
{Object.keys(testResults.context).length})
{intl.formatMessage(messages.contextVariables, {
count: Object.keys(testResults.context).length,
})}
</h3>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-stone-700 bg-stone-800">
<div className="divide-y divide-stone-700">
@@ -398,7 +427,7 @@ const TestItemModal: React.FC<TestItemModalProps> = ({ isOpen, onClose }) => {
<span className="ml-4 font-mono text-white">
{value === undefined || value === null ? (
<span className="text-stone-600">
undefined
{intl.formatMessage(messages.undefined)}
</span>
) : value instanceof Date ? (
value.toISOString()
@@ -23,6 +23,9 @@ const messages = defineMessages({
validationApiKeyRequired: 'You must provide an API key',
toastOverseerrTestSuccess: 'Overseerr connection established successfully!',
toastOverseerrTestFailure: 'Failed to connect to Overseerr.',
connectionRefused: 'Connection refused. Check hostname and port.',
hostNotFound: 'Host not found. Check hostname.',
connectionTimeout: 'Connection timeout. Check network connectivity.',
add: 'Add Connection',
hostname: 'Hostname or IP Address',
port: 'Port',
@@ -31,12 +34,6 @@ const messages = defineMessages({
apiKeyTip: 'Get your API key from Overseerr Settings > General > API Key',
urlBase: 'URL Base',
externalUrl: 'External URL',
serverId: 'Default Server',
serverIdTip: 'Default Radarr/Sonarr server for requests',
profileId: 'Default Quality Profile',
profileIdTip: 'Default quality profile for requests',
rootFolder: 'Default Root Folder',
rootFolderTip: 'Default root folder for requests',
tags: 'Default Tags',
tagsTip: 'Default tags for requests',
selectServer: 'Select a server',
@@ -44,8 +41,6 @@ const messages = defineMessages({
selectRootFolder: 'Select a root folder',
selectTags: 'Select tags',
loadingServers: 'Loading servers…',
loadingProfiles: 'Loading quality profiles…',
loadingRootFolders: 'Loading root folders…',
loadingTags: 'Loading tags…',
testFirstServers: 'Test connection to load servers',
testFirstProfiles: 'Select a server first',
@@ -57,6 +52,26 @@ const messages = defineMessages({
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
granularUsers: 'Create Overseerr users for Requests',
moviesRadarrDefaults: 'Movies (Radarr) Defaults',
defaultRadarrServer: 'Default Radarr Server',
defaultServerForMovieRequests: 'Default server for movie requests',
defaultMovieProfile: 'Default Movie Profile',
defaultQualityProfileForMovieRequests:
'Default quality profile for movie requests',
defaultMovieRootFolder: 'Default Movie Root Folder',
defaultRootFolderForMovieRequests: 'Default root folder for movie requests',
tvShowsSonarrDefaults: 'TV Shows (Sonarr) Defaults',
defaultSonarrServer: 'Default Sonarr Server',
defaultServerForTvShowRequests: 'Default server for TV show requests',
defaultTvProfile: 'Default TV Profile',
defaultQualityProfileForTvShowRequests:
'Default quality profile for TV show requests',
defaultTvRootFolder: 'Default TV Root Folder',
defaultRootFolderForTvShowRequests:
'Default root folder for TV show requests',
singleUser: 'Single user (Agregarr)',
perService: 'Per service (TraktAgregarr, TMDbAgregarr)',
granular: 'Granular (TraktTrendingAgregarr, TMDbPopularAgregarr)',
});
interface TestResponse {
@@ -225,12 +240,15 @@ const OverseerrModal = ({
// If no server message, provide client-side diagnostics
if (!e.response?.data?.message) {
if (e.code === 'ECONNREFUSED') {
errorMessage += ' - Connection refused. Check hostname and port.';
errorMessage += ` - ${intl.formatMessage(
messages.connectionRefused
)}`;
} else if (e.code === 'ENOTFOUND') {
errorMessage += ' - Host not found. Check hostname.';
errorMessage += ` - ${intl.formatMessage(messages.hostNotFound)}`;
} else if (e.code === 'ETIMEDOUT') {
errorMessage +=
' - Connection timeout. Check network connectivity.';
errorMessage += ` - ${intl.formatMessage(
messages.connectionTimeout
)}`;
} else if (e.message) {
errorMessage += ` - ${e.message}`;
}
@@ -539,15 +557,17 @@ const OverseerrModal = ({
{/* Movies (Radarr) Defaults */}
<div className="form-row">
<div className="text-label font-semibold">
Movies (Radarr) Defaults
{intl.formatMessage(messages.moviesRadarrDefaults)}
</div>
</div>
<div className="form-row">
<label htmlFor="radarrServerId" className="text-label">
Default Radarr Server
{intl.formatMessage(messages.defaultRadarrServer)}
<span className="label-tip">
Default server for movie requests
{intl.formatMessage(
messages.defaultServerForMovieRequests
)}
</span>
</label>
<div className="form-input-area">
@@ -582,12 +602,13 @@ const OverseerrModal = ({
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="radarrProfileId" className="text-label">
Default Movie Profile
{intl.formatMessage(messages.defaultMovieProfile)}
<span className="label-tip">
Default quality profile for movie requests
{intl.formatMessage(
messages.defaultQualityProfileForMovieRequests
)}
</span>
</label>
<div className="form-input-area">
@@ -629,9 +650,11 @@ const OverseerrModal = ({
<div className="form-row">
<label htmlFor="radarrRootFolder" className="text-label">
Default Movie Root Folder
{intl.formatMessage(messages.defaultMovieRootFolder)}
<span className="label-tip">
Default root folder for movie requests
{intl.formatMessage(
messages.defaultRootFolderForMovieRequests
)}
</span>
</label>
<div className="form-input-area">
@@ -746,15 +769,17 @@ const OverseerrModal = ({
{/* TV Shows (Sonarr) Defaults */}
<div className="form-row">
<div className="text-label font-semibold">
TV Shows (Sonarr) Defaults
{intl.formatMessage(messages.tvShowsSonarrDefaults)}
</div>
</div>
<div className="form-row">
<label htmlFor="sonarrServerId" className="text-label">
Default Sonarr Server
{intl.formatMessage(messages.defaultSonarrServer)}
<span className="label-tip">
Default server for TV show requests
{intl.formatMessage(
messages.defaultServerForTvShowRequests
)}
</span>
</label>
<div className="form-input-area">
@@ -792,9 +817,11 @@ const OverseerrModal = ({
<div className="form-row">
<label htmlFor="sonarrProfileId" className="text-label">
Default TV Profile
{intl.formatMessage(messages.defaultTvProfile)}
<span className="label-tip">
Default quality profile for TV show requests
{intl.formatMessage(
messages.defaultQualityProfileForTvShowRequests
)}
</span>
</label>
<div className="form-input-area">
@@ -836,9 +863,11 @@ const OverseerrModal = ({
<div className="form-row">
<label htmlFor="sonarrRootFolder" className="text-label">
Default TV Root Folder
{intl.formatMessage(messages.defaultTvRootFolder)}
<span className="label-tip">
Default root folder for TV show requests
{intl.formatMessage(
messages.defaultRootFolderForTvShowRequests
)}
</span>
</label>
<div className="form-input-area">
@@ -962,12 +991,14 @@ const OverseerrModal = ({
id="userCreationMode"
name="userCreationMode"
>
<option value="single">Single user (Agregarr)</option>
<option value="single">
{intl.formatMessage(messages.singleUser)}
</option>
<option value="per-service">
Per service (TraktAgregarr, TMDbAgregarr)
{intl.formatMessage(messages.perService)}
</option>
<option value="granular">
Granular (TraktTrendingAgregarr, TMDbPopularAgregarr)
{intl.formatMessage(messages.granular)}
</option>
</Field>
</div>
@@ -18,9 +18,7 @@ type OptionType = {
const messages = defineMessages({
createradarr: 'Add New Radarr Server',
create4kradarr: 'Add New 4K Radarr Server',
editradarr: 'Edit Radarr Server',
edit4kradarr: 'Edit 4K Radarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
@@ -33,22 +31,18 @@ const messages = defineMessages({
toastRadarrTestFailure: 'Failed to connect to Radarr.',
add: 'Add Server',
defaultserver: 'Default Server',
default4kserver: 'Default 4K Server',
servername: 'Server Name',
hostname: 'Hostname or IP Address',
port: 'Port',
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
minimumAvailability: 'Minimum Availability',
server4k: '4K Server',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectMinimumAvailability: 'Select minimum availability',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
@@ -56,7 +50,6 @@ const messages = defineMessages({
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Automatic Tag Mode',
tagRequestsInfo:
'Choose how Agregarr tags Radarr downloads (tags are created if they do not exist).',
@@ -24,8 +24,6 @@ const messages = defineMessages({
about: 'About',
agregarrinformation: 'About Agregarr',
version: 'Version',
totalmedia: 'Total Media',
totalrequests: 'Total Requests',
gettingsupport: 'Getting Support',
githubdiscussions: 'GitHub Discussions',
agregarrdocs: 'Agregarr Documentation',
@@ -34,7 +32,6 @@ const messages = defineMessages({
appDataPath: 'Data Directory',
supportagregarr: 'Support Agregarr',
helppaycoffee: 'Help Pay for Coffee',
documentation: 'Documentation',
preferredmethod: 'Preferred',
outofdate: 'Out of Date',
uptodate: 'Up to Date',
+2 -39
View File
@@ -41,7 +41,6 @@ const messages = defineMessages({
default4k: 'Default 4K',
is4k: '4K',
address: 'Address',
activeProfile: 'Active Profile',
addoverseerr: 'Add Overseerr Connection',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
@@ -57,43 +56,8 @@ const messages = defineMessages({
overseerrSettings: 'Overseerr Settings',
overseerrSettingsDescription:
'Configure connection to add missing items as Requests in Overseerr.',
overseerrHostname: 'Hostname or IP Address',
overseerrPort: 'Port',
overseerrApiKey: 'API Key',
overseerrApiKeyTip:
'Get your API key from Overseerr Settings > General > API Key',
overseerrUseSsl: 'Use SSL',
overseerrUrlBase: 'URL Base',
overseerrExternalUrl: 'External URL',
overseerrServerId: 'Default Server',
overseerrServerIdTip: 'Default Radarr/Sonarr server for requests',
overseerrProfileId: 'Default Quality Profile',
overseerrProfileIdTip: 'Default quality profile for requests',
overseerrRootFolder: 'Default Root Folder',
overseerrRootFolderTip: 'Default root folder for requests',
testOverseerrConnection: 'Test Connection',
overseerrConnectionSuccess: 'Connected to Overseerr successfully!',
overseerrConnectionFailure: 'Failed to connect to Overseerr',
toastOverseerrSettingsSuccess: 'Overseerr settings saved successfully!',
toastOverseerrSettingsFailure:
'Something went wrong while saving Overseerr settings.',
save: 'Save Changes',
saving: 'Saving…',
testing: 'Testing…',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKey: 'You must provide an API key',
validationUrl: 'You must provide a valid URL',
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationUrlBaseLeadingSlash: 'URL base must have a leading slash',
validationUrlBaseTrailingSlash: 'URL base must not end in a trailing slash',
serviceUserSettings: 'Service User Settings',
serviceUserSettingsDescription:
'Configure how Agregarr creates users in Overseerr for tracking requests.',
granularUsers: 'Create Overseerr users for Requests',
toastServiceUserSettingsSuccess: 'Service user settings saved successfully!',
toastServiceUserSettingsFailure:
'Something went wrong while saving service user settings.',
placeholderSettings: 'Placeholder Root Folders',
placeholderSettingsDescription:
'Configure root folders for placeholder files for each library. These paths should match the mounted Plex library paths inside the Agregarr container.',
@@ -107,8 +71,6 @@ const messages = defineMessages({
youtubeSettings: 'YouTube Cookie Configuration',
youtubeSettingsDescription:
'Recommended: Set up YouTube cookies to prevent bot detection and IP bans when downloading trailers for placeholder feature. Once banned, adding cookies may not be enough to unban you (from downloading youtube videos without being signed in)',
youtubeSettingsInstructions:
'To set up cookies: 1) Install a browser extension to export cookies {firefoxLink} / {chromeLink}. 2) Visit YouTube while logged in. 3) Export cookies and save as {cookiesPath} in your Agregarr config directory.',
firefoxExtension: 'Firefox',
chromeExtension: 'Chrome',
youtubeCookiesNotFound: 'YouTube cookies file not found',
@@ -123,6 +85,7 @@ const messages = defineMessages({
youtubeSetupStep2: 'Visit YouTube while logged in to your account',
youtubeSetupStep3:
'Export cookies and save as {cookiesPath} in your Agregarr config directory',
noLibrariesFound: 'No libraries found. Configure your Plex connection first.',
});
interface ServerInstanceProps {
@@ -827,7 +790,7 @@ const SettingsDownloads = ({ onComplete }: SettingsDownloadsProps) => {
</div>
) : (
<div className="text-sm text-stone-400">
No libraries found. Configure your Plex connection first.
{intl.formatMessage(messages.noLibrariesFound)}
</div>
)}
@@ -76,6 +76,12 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
imagecachesize: 'Total Cache Size',
toastCollectionsSyncSkipped:
'Plex collections sync skipped - collections are disabled. Enable collections in Plex settings to run this job.',
followingExecutionMinutes:
'Following execution in {count} {count, plural, one {minute} other {minutes}}',
followingExecutionHours:
'Following execution in {count} {count, plural, one {hour} other {hours}}',
followingExecutionDays:
'Following execution in {count} {count, plural, one {day} other {days}}',
});
interface Job {
@@ -667,22 +673,34 @@ const SettingsJobs = () => {
if (hoursUntil < 1) {
return (
<div className="text-xs leading-4 text-gray-400">
Following execution in {minutesUntil} minute
{minutesUntil !== 1 ? 's' : ''}
{intl.formatMessage(
messages.followingExecutionMinutes,
{
count: minutesUntil,
}
)}
</div>
);
} else if (hoursUntil <= 48) {
return (
<div className="text-xs leading-4 text-gray-400">
Following execution in {hoursUntil} hour
{hoursUntil !== 1 ? 's' : ''}
{intl.formatMessage(
messages.followingExecutionHours,
{
count: hoursUntil,
}
)}
</div>
);
} else {
return (
<div className="text-xs leading-4 text-gray-400">
Following execution in {daysUntil} day
{daysUntil !== 1 ? 's' : ''}
{intl.formatMessage(
messages.followingExecutionDays,
{
count: daysUntil,
}
)}
</div>
);
}
@@ -6,11 +6,9 @@ import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
menuGeneralSettings: 'General',
menuUsers: 'Users',
menuPlexSettings: 'Plex',
menuSources: 'Sources',
menuDownloads: 'Downloads',
menuNotifications: 'Notifications',
menuLogs: 'Logs',
menuJobs: 'Jobs',
menuAbout: 'About',
@@ -36,7 +36,6 @@ const messages = defineMessages({
toastApiKeyFailure: 'Something went wrong while generating a new API key.',
toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
hideAvailable: 'Hide Available Media',
csrfProtection: 'Enable CSRF Protection',
csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)',
csrfProtectionHoverTip:
@@ -47,7 +46,6 @@ const messages = defineMessages({
validationApplicationTitle: 'You must provide an application title',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language',
tmdbLanguage: 'TMDB Language',
tmdbLanguageTip: 'Language for TMDB posters',
-13
View File
@@ -43,19 +43,6 @@ const messages = defineMessages({
hostname: 'Hostname or IP Address',
port: 'Port',
enablessl: 'Use SSL',
plexlibraries: 'Discover Libraries and Collections',
plexlibrariesDescription:
'Discover your Plex libraries and existing collections. This will set up the basic structure for managing your collections and hubs.',
scanning: 'Discovering…',
scan: 'Discover Libraries and Existing Collections',
manualscan: 'Collection Discovery',
manualscanDescription:
'Discover your Plex libraries and any existing collections to set up the foundation for Agregarr collection management. This is a one-time setup process.',
notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start Scan',
cancelscan: 'Cancel Scan',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationUrl: 'You must provide a valid URL',
+83 -46
View File
@@ -17,7 +17,7 @@ import axios from 'axios';
import { Field, Formik } from 'formik';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
@@ -47,7 +47,7 @@ const messages = defineMessages({
traktBasicDescription:
'Use public Trakt features like trending, popular, and public custom lists. Just enter your Client ID.',
traktBasicTip:
'Create an application at https://trakt.tv/oauth/applications/new with redirect URI urn:ietf:wg:oauth:2.0:oob and copy the Client ID.',
'Create an application at <code>https://trakt.tv/oauth/applications/new</code> with redirect URI <code>urn:ietf:wg:oauth:2.0:oob</code> and copy the Client ID.',
traktOAuthSetup: 'Advanced OAuth Setup (Optional)',
traktOAuthDescription: 'Enable Trakt OAuth for access to private lists',
traktOAuthBenefits: 'Access private lists, watchlists, and recommendations.',
@@ -57,16 +57,12 @@ const messages = defineMessages({
traktClientId: 'Trakt Client ID',
traktClientSecret: 'Trakt Client Secret',
traktAccessToken: 'Trakt Access Token',
traktCredentialsTip:
'Create an application at https://trakt.tv/oauth/applications/new and copy the Client ID, Client Secret, and personal Access Token.',
traktAccessTokenTip:
'The access token is fetched via OAuth and auto-refreshed.',
traktStatusLabel: 'Status',
traktStatusConnected: 'Connected',
traktStatusPending: 'Not tested',
traktStatusMissing: 'Not configured',
traktCredsHint:
'(redirect URI: urn:ietf:wg:oauth:2.0:oob) and copy the Client ID above, and Client Secret below.',
'Copy the Client Secret from your Trakt application at <code>https://trakt.tv/oauth/applications</code> (redirect URI: <code>urn:ietf:wg:oauth:2.0:oob</code>).',
traktReconnect: 'Reconnect',
traktDisconnect: 'Disconnect',
traktDisconnected: 'Disconnected from Trakt',
@@ -118,6 +114,29 @@ const messages = defineMessages({
testing: 'Testing...',
save: 'Save Changes',
saving: 'Saving…',
oauthTokensAutoRefresh:
'OAuth tokens are saved and will auto-refresh. Reconnect to update, or disconnect to remove them.',
configureBasicSetupFirst: 'Configure Basic Setup First',
configureBasicSetupMessage:
'You must configure and save your Trakt Client ID in the Basic Setup section above before setting up OAuth.',
connectWithTrakt: 'Connect with Trakt',
traktCodeModalTitle: 'Enter Trakt Code',
traktCodeModalSubtitle: 'Paste the code from the Trakt authorization window.',
traktCodeExchangeButton: 'Exchange Code',
traktCodeInstructions:
'After approving access in the Trakt window, copy the code shown and paste it here to finish connecting.',
mdblistApiKeyTip:
'Get your API key from <code>https://mdblist.com/preferences/</code> and generate a new API key.',
myanimelistApiKeyTip:
'Get your API key from <code>https://myanimelist.net/apiconfig</code> and copy the <code>Client ID</code>. Critical fields: App Type - <code>Web</code>, App Redirect URL - <code>http://localhost/</code>, Homepage URL - <code>https://github.com/agregarr/agregarr</code>, <code>Non-Commercial</code>, <code>Hobbyist</code>.',
overseerrDownloadsPageInfo:
'To use Overseerr Requests as a collection source, Radarr/Sonarr tags and "Coming Soon" collections, or to enable automatic downloading of missing items, configure these services on the <strong>Downloads</strong> page (next step in setup).',
overseerrDownloadsPageInfoSettings:
'To use Overseerr Requests as a collection source, Radarr/Sonarr tags and "Coming Soon" collections, or to enable automatic downloading of missing items, configure these services on the <strong>Settings → Downloads</strong> page.',
overseerrDownloadsTitle:
'Overseerr, Radarr, and Sonarr are configured on the next page',
overseerrDownloadsTitleSettings:
'Overseerr, Radarr, and Sonarr are configured on the Downloads page',
});
interface SettingsSourcesProps {
@@ -411,7 +430,12 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
<label htmlFor="traktClientId" className="text-label">
{intl.formatMessage(messages.traktClientId)}
<span className="label-tip">
{intl.formatMessage(messages.traktBasicTip)}
<FormattedMessage
{...messages.traktBasicTip}
values={{
code: (chunks) => <code>{chunks}</code>,
}}
/>
</span>
</label>
<div className="form-input-area">
@@ -670,8 +694,7 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
{intl.formatMessage(messages.traktStatusConnected)}
</Badge>
<p className="text-sm text-gray-300">
OAuth tokens are saved and will auto-refresh.
Reconnect to update, or disconnect to remove them.
{intl.formatMessage(messages.oauthTokensAutoRefresh)}
</p>
</div>
<div className="flex gap-2">
@@ -703,16 +726,27 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
) : (
<form onSubmit={handleSubmit}>
{!hasClientId && (
<Alert title="Configure Basic Setup First" type="warning">
You must configure and save your Trakt Client ID in the
Basic Setup section above before setting up OAuth.
<Alert
title={intl.formatMessage(
messages.configureBasicSetupFirst
)}
type="warning"
>
{intl.formatMessage(
messages.configureBasicSetupMessage
)}
</Alert>
)}
<div className="form-row">
<label htmlFor="traktClientSecret" className="text-label">
{intl.formatMessage(messages.traktClientSecret)}
<span className="label-tip">
{intl.formatMessage(messages.traktCredsHint)}
<FormattedMessage
{...messages.traktCredsHint}
values={{
code: (chunks) => <code>{chunks}</code>,
}}
/>
</span>
</label>
<div className="form-input-area">
@@ -728,7 +762,9 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
</div>
</div>
<div className="form-row">
<div className="text-label">Connect with Trakt</div>
<div className="text-label">
{intl.formatMessage(messages.connectWithTrakt)}
</div>
<div className="form-input-area">
<Button
buttonType="primary"
@@ -750,8 +786,8 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
{showTraktCodeModal && (
<Modal
title="Enter Trakt Code"
subTitle="Paste the code from the Trakt authorization window."
title={intl.formatMessage(messages.traktCodeModalTitle)}
subTitle={intl.formatMessage(messages.traktCodeModalSubtitle)}
onCancel={() => {
setShowTraktCodeModal(false);
setTraktCode('');
@@ -760,14 +796,13 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
e?.preventDefault();
exchangeTraktCode();
}}
okText="Exchange Code"
okText={intl.formatMessage(messages.traktCodeExchangeButton)}
okDisabled={!traktCode.trim()}
loading={isExchangingCode}
>
<div className="space-y-3">
<p className="text-sm text-gray-300">
After approving access in the Trakt window, copy the code
shown and paste it here to finish connecting.
{intl.formatMessage(messages.traktCodeInstructions)}
</p>
<input
type="text"
@@ -894,9 +929,12 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
<label htmlFor="mdblistApiKey" className="text-label">
{intl.formatMessage(messages.mdblistApiKey)}
<span className="label-tip mb-2">
Get your API key from
<code>https://mdblist.com/preferences/</code> and generate a
new API key.
<FormattedMessage
{...messages.mdblistApiKeyTip}
values={{
code: (chunks) => <code>{chunks}</code>,
}}
/>
</span>
</label>
<div className="form-input-area">
@@ -1371,13 +1409,12 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
<label htmlFor="myanimelistApiKey" className="text-label">
{intl.formatMessage(messages.myanimelistApiKey)}
<span className="label-tip mb-2">
Get your API key from{' '}
<code>https://myanimelist.net/apiconfig</code> copy the{' '}
<code>Client ID</code>. Critical fields: App Type -{' '}
<code>Web</code>, App Redirect URL -{' '}
<code>http://localhost/</code>, Homepage URL -{' '}
<code>https://github.com/agregarr/agregarr</code>,{' '}
<code>Non-Commerical</code>, <code>Hobbyist</code>
<FormattedMessage
{...messages.myanimelistApiKeyTip}
values={{
code: (chunks) => <code>{chunks}</code>,
}}
/>
</span>
</label>
<div className="form-input-area">
@@ -1743,27 +1780,27 @@ const SettingsSources = ({ onComplete }: SettingsSourcesProps) => {
{/* Helper info for Downloads page */}
<div className="section mt-10">
<Alert
title={
title={intl.formatMessage(
isSetupMode
? 'Overseerr, Radarr, and Sonarr are configured on the next page'
: 'Overseerr, Radarr, and Sonarr are configured on the Downloads page'
}
? messages.overseerrDownloadsTitle
: messages.overseerrDownloadsTitleSettings
)}
type="info"
>
{isSetupMode ? (
<>
To use Overseerr Requests as a collection source, Radarr/Sonarr
tags and &ldquo;Coming Soon&rdquo; collections, or to enable
automatic downloading of missing items, configure these services
on the <strong>Downloads</strong> page (next step in setup).
</>
<FormattedMessage
{...messages.overseerrDownloadsPageInfo}
values={{
strong: (chunks) => <strong>{chunks}</strong>,
}}
/>
) : (
<>
To use Overseerr Requests as a collection source, Radarr/Sonarr
tags and &ldquo;Coming Soon&rdquo; collections, or to enable
automatic downloading of missing items, configure these services
on the <strong>Settings Downloads</strong> page.
</>
<FormattedMessage
{...messages.overseerrDownloadsPageInfoSettings}
values={{
strong: (chunks) => <strong>{chunks}</strong>,
}}
/>
)}
</Alert>
</div>
+12 -19
View File
@@ -19,21 +19,17 @@ type OptionType = {
const messages = defineMessages({
createsonarr: 'Add New Sonarr Server',
create4ksonarr: 'Add New 4K Sonarr Server',
editsonarr: 'Edit Sonarr Server',
edit4ksonarr: 'Edit 4K Sonarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
validationLanguageProfileRequired: 'You must select a language profile',
toastSonarrTestSuccess: 'Sonarr connection established successfully!',
toastSonarrTestFailure: 'Failed to connect to Sonarr.',
add: 'Add Server',
defaultserver: 'Default Server',
default4kserver: 'Default 4K Server',
servername: 'Server Name',
hostname: 'Hostname or IP Address',
port: 'Port',
@@ -41,31 +37,20 @@ const messages = defineMessages({
apiKey: 'API Key',
baseUrl: 'URL Base',
qualityprofile: 'Quality Profile',
languageprofile: 'Language Profile',
rootfolder: 'Root Folder',
seriesType: 'Series Type',
animeSeriesType: 'Anime Series Type',
animequalityprofile: 'Anime Quality Profile',
animelanguageprofile: 'Anime Language Profile',
animerootfolder: 'Anime Root Folder',
seasonfolders: 'Season Folders',
monitorByDefault: 'Monitor by Default',
searchOnAdd: 'Search on Add',
server4k: '4K Server',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
selectLanguageProfile: 'Select language profile',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadinglanguageprofiles: 'Loading language profiles…',
testFirstLanguageProfiles: 'Test connection to load language profiles',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Automatic Tag Mode',
tagRequestsInfo:
'Choose how Agregarr tags Sonarr downloads (tags are created if they do not exist).',
@@ -78,9 +63,11 @@ const messages = defineMessages({
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
tags: 'Tags',
animeTags: 'Anime Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
seriesTypeStandard: 'Standard',
seriesTypeDaily: 'Daily',
seriesTypeAnime: 'Anime',
});
interface TestResponse {
@@ -515,9 +502,15 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
name="seriesType"
disabled={!isValidated || isTesting}
>
<option value="standard">Standard</option>
<option value="daily">Daily</option>
<option value="anime">Anime</option>
<option value="standard">
{intl.formatMessage(messages.seriesTypeStandard)}
</option>
<option value="daily">
{intl.formatMessage(messages.seriesTypeDaily)}
</option>
<option value="anime">
{intl.formatMessage(messages.seriesTypeAnime)}
</option>
</Field>
</div>
</div>
-35
View File
@@ -1,35 +1,8 @@
import { defineMessages } from 'react-intl';
const globalMessages = defineMessages({
available: 'Available',
partiallyavailable: 'Partially Available',
deleted: 'Deleted',
processing: 'Processing',
unavailable: 'Unavailable',
notrequested: 'Not Requested',
requested: 'Requested',
requesting: 'Requesting…',
request: 'Request',
request4k: 'Request in 4K',
failed: 'Failed',
pending: 'Pending',
declined: 'Declined',
approved: 'Approved',
completed: 'Completed',
movie: 'Movie',
movies: 'Movies',
collection: 'Collection',
tvshow: 'Series',
tvshows: 'Series',
cancel: 'Cancel',
canceling: 'Canceling…',
approve: 'Approve',
decline: 'Decline',
delete: 'Delete',
retry: 'Retry',
retrying: 'Retrying…',
view: 'View',
deleting: 'Deleting…',
test: 'Test',
testing: 'Testing…',
save: 'Save Changes',
@@ -38,25 +11,17 @@ const globalMessages = defineMessages({
importing: 'Importing…',
close: 'Close',
edit: 'Edit',
areyousure: 'Are you sure?',
back: 'Back',
next: 'Next',
previous: 'Previous',
status: 'Status',
all: 'All',
experimental: 'Experimental',
advanced: 'Advanced',
restartRequired: 'Restart Required',
loading: 'Loading…',
settings: 'Settings',
usersettings: 'User Settings',
delimitedlist: '{a}, {b}',
showingresults:
'Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results',
resultsperpage: 'Display {pageSize} results per page',
noresults: 'No results.',
open: 'Open',
resolved: 'Resolved',
specials: 'Specials',
});
+367 -214
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -9,7 +9,6 @@ const messages = defineMessages({
libraryTitle: 'Library',
libraryDescription:
'Collections in the Library tab. Ordering in the Library tabs can be independent from the Home/Recommended views.',
noCollections: 'No library collections configured yet',
});
const LibraryPage: NextPage = () => {
+2 -1
View File
@@ -11,11 +11,12 @@ const messages = defineMessages({
const PostersPage: React.FC = () => {
const intl = useIntl();
const pageTitle = `${intl.formatMessage(messages.posters)} - Agregarr`;
return (
<>
<Head>
<title>{intl.formatMessage(messages.posters)} - Agregarr</title>
<title>{pageTitle}</title>
</Head>
<PageTitle title={intl.formatMessage(messages.posters)} />
<div className="mb-6">
-1
View File
@@ -9,7 +9,6 @@ const messages = defineMessages({
recommendedTitle: 'Recommended',
recommendedDescription:
'Collections and Hubs in the Recommended tabs. Ordering is shared between Home and Recommended views, but can have separate visibility setings.',
noCollections: 'No recommended collections configured yet',
});
const RecommendedPage: NextPage = () => {