mirror of
https://github.com/agregarr/agregarr.git
synced 2026-04-26 12:28:25 -05:00
chore(i18n): fix duplicate keys, add scipt to detect duplicates prior to extraction
This commit is contained in:
+2
-1
@@ -9,7 +9,8 @@
|
||||
"build": "yarn build:next && yarn build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"i18n:check-duplicates": "node scripts/check-i18n-duplicates.js",
|
||||
"i18n:extract": "yarn i18n:check-duplicates && extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
"migration:create": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:create -d server/datasource.ts",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:run -d server/datasource.ts",
|
||||
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable @typescript-eslint/no-var-requires, no-console */
|
||||
|
||||
/**
|
||||
* Script to detect CONFLICTING i18n message IDs before extraction.
|
||||
*
|
||||
* The extract-react-intl-messages tool generates keys based on folder path + message key.
|
||||
* Files in the same folder INTENTIONALLY share keys (so translations are reused).
|
||||
*
|
||||
* This script only flags duplicates where the DEFAULT MESSAGE TEXT differs,
|
||||
* which would cause one translation to silently overwrite another.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
|
||||
// ANSI color codes
|
||||
const RED = '\x1b[31m';
|
||||
const GREEN = '\x1b[32m';
|
||||
const YELLOW = '\x1b[33m';
|
||||
const CYAN = '\x1b[36m';
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
/**
|
||||
* Convert file path to i18n key prefix
|
||||
* components/Posters/MyComponent.tsx -> components.Posters
|
||||
*
|
||||
* The extract-react-intl-messages tool uses the FOLDER path, not the filename.
|
||||
*/
|
||||
function filePathToKeyPrefix(filePath) {
|
||||
const dirPath = path.dirname(filePath);
|
||||
let keyPath = dirPath.replace(/^src\//, '');
|
||||
if (keyPath === '.' || keyPath === '') {
|
||||
keyPath = 'root';
|
||||
}
|
||||
return keyPath.replace(/\//g, '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract message keys AND their default values from defineMessages in a file
|
||||
*/
|
||||
function extractMessages(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const messages = [];
|
||||
|
||||
// Match defineMessages({ ... }) - handle nested braces for complex messages
|
||||
const defineMessagesRegex = /defineMessages\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
|
||||
|
||||
let match;
|
||||
while ((match = defineMessagesRegex.exec(content)) !== null) {
|
||||
const messagesBlock = match[1];
|
||||
|
||||
// Extract key-value pairs
|
||||
// Matches: keyName: 'value' or keyName: "value" or keyName: `value`
|
||||
// Also handles multiline strings
|
||||
const keyValueRegex =
|
||||
/(\w+)\s*:\s*(?:'([^']*)'|"([^"]*)"|`([^`]*)`|[\s\S]*?defaultMessage\s*:\s*(?:'([^']*)'|"([^"]*)"|`([^`]*)`))/g;
|
||||
|
||||
let kvMatch;
|
||||
while ((kvMatch = keyValueRegex.exec(messagesBlock)) !== null) {
|
||||
const key = kvMatch[1];
|
||||
// Get the value from whichever capture group matched
|
||||
const value =
|
||||
kvMatch[2] ||
|
||||
kvMatch[3] ||
|
||||
kvMatch[4] ||
|
||||
kvMatch[5] ||
|
||||
kvMatch[6] ||
|
||||
kvMatch[7] ||
|
||||
'';
|
||||
|
||||
// Skip if this looks like a nested property (like defaultMessage itself)
|
||||
if (['defaultMessage', 'description', 'id'].includes(key)) continue;
|
||||
|
||||
messages.push({ key, value: value.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
function main() {
|
||||
const srcDir = path.join(__dirname, '..', 'src');
|
||||
|
||||
// Find all TypeScript/JavaScript files
|
||||
const files = glob.sync('**/*.{ts,tsx,js,jsx}', {
|
||||
cwd: srcDir,
|
||||
ignore: ['**/*.test.*', '**/*.spec.*', '**/node_modules/**'],
|
||||
});
|
||||
|
||||
// Map to track all keys, their values, and source files
|
||||
// key -> { value -> [files] }
|
||||
const keyToValuesAndFiles = new Map();
|
||||
|
||||
// Process each file
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(srcDir, file);
|
||||
const messages = extractMessages(fullPath);
|
||||
|
||||
if (messages.length === 0) continue;
|
||||
|
||||
const keyPrefix = filePathToKeyPrefix(file);
|
||||
|
||||
for (const { key, value } of messages) {
|
||||
const fullKey = `${keyPrefix}.${key}`;
|
||||
|
||||
if (!keyToValuesAndFiles.has(fullKey)) {
|
||||
keyToValuesAndFiles.set(fullKey, new Map());
|
||||
}
|
||||
|
||||
const valuesMap = keyToValuesAndFiles.get(fullKey);
|
||||
if (!valuesMap.has(value)) {
|
||||
valuesMap.set(value, []);
|
||||
}
|
||||
valuesMap.get(value).push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Find conflicts (same key, different values)
|
||||
const conflicts = [];
|
||||
for (const [key, valuesMap] of keyToValuesAndFiles.entries()) {
|
||||
if (valuesMap.size > 1) {
|
||||
// Multiple different values for the same key = conflict
|
||||
conflicts.push({
|
||||
key,
|
||||
values: Array.from(valuesMap.entries()).map(([value, files]) => ({
|
||||
value,
|
||||
files,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if (conflicts.length === 0) {
|
||||
console.log(`${GREEN}✓ No conflicting i18n keys found${RESET}`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(
|
||||
`${RED}✗ Found ${conflicts.length} conflicting i18n key(s):${RESET}\n`
|
||||
);
|
||||
console.error(
|
||||
`${CYAN}These keys have DIFFERENT default messages in different files.`
|
||||
);
|
||||
console.error(
|
||||
`One will silently overwrite the other during extraction.${RESET}\n`
|
||||
);
|
||||
|
||||
for (const { key, values } of conflicts) {
|
||||
console.error(`${YELLOW}"${key}"${RESET} has conflicting values:`);
|
||||
for (const { value, files } of values) {
|
||||
const displayValue =
|
||||
value.length > 50 ? value.substring(0, 50) + '...' : value;
|
||||
console.error(` ${CYAN}"${displayValue}"${RESET} in:`);
|
||||
for (const file of files) {
|
||||
console.error(` - src/${file}`);
|
||||
}
|
||||
}
|
||||
console.error('');
|
||||
}
|
||||
|
||||
console.error(
|
||||
`${RED}Fix: Rename one of the keys to be unique, e.g.:${RESET}`
|
||||
);
|
||||
console.error(` title -> overlaySystemTitle`);
|
||||
console.error(` description -> modalDescription\n`);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -22,7 +22,7 @@ const messages = defineMessages({
|
||||
downloadingPoster: 'Downloading poster...',
|
||||
posterDownloadError: 'Failed to download poster from URL',
|
||||
noPosterAvailable: 'No posters available',
|
||||
uploading: 'Uploading...',
|
||||
uploadingPoster: 'Uploading...',
|
||||
generating: 'Generating...',
|
||||
posterUploadSuccess: 'Poster uploaded successfully',
|
||||
posterDeleteSuccess: 'Poster deleted successfully',
|
||||
@@ -566,7 +566,7 @@ const PosterSelectionPopover: React.FC<PosterSelectionPopoverProps> = ({
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-b-2 border-orange-600"></div>
|
||||
{intl.formatMessage(messages.uploading)}
|
||||
{intl.formatMessage(messages.uploadingPoster)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -10,7 +10,7 @@ const messages = defineMessages({
|
||||
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:
|
||||
posterUploadSuccessWithSyncNote:
|
||||
'Poster uploaded successfully. Will be applied on next collection sync.',
|
||||
autoPoster: 'Auto-generate Collection posters',
|
||||
autoPosterHelp:
|
||||
@@ -26,7 +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.',
|
||||
selectLibrariesForPosters: 'Select libraries first to upload custom posters.',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -201,7 +201,7 @@ const PosterUploadSection = ({
|
||||
);
|
||||
|
||||
// Show success message
|
||||
addToast(intl.formatMessage(messages.posterUploadSuccess), {
|
||||
addToast(intl.formatMessage(messages.posterUploadSuccessWithSyncNote), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
@@ -215,7 +215,7 @@ const PosterUploadSection = ({
|
||||
if (selectedLibraryIds.length === 0) {
|
||||
return (
|
||||
<div className="label-tip">
|
||||
{intl.formatMessage(messages.selectLibrariesFirst)}
|
||||
{intl.formatMessage(messages.selectLibrariesForPosters)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
uploading: 'Uploading theme...',
|
||||
uploadingTheme: 'Uploading theme...',
|
||||
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',
|
||||
@@ -16,7 +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.',
|
||||
selectLibrariesForThemes: 'Select libraries first to upload custom themes.',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -218,7 +218,7 @@ const ThemeUploadSection = ({
|
||||
if (selectedLibraryIds.length === 0) {
|
||||
return (
|
||||
<div className="label-tip">
|
||||
{intl.formatMessage(messages.selectLibrariesFirst)}
|
||||
{intl.formatMessage(messages.selectLibrariesForThemes)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -306,7 +306,7 @@ const ThemeUploadSection = ({
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.uploading)}
|
||||
{intl.formatMessage(messages.uploadingTheme)}
|
||||
</span>
|
||||
) : (
|
||||
<PlusIcon className="h-6 w-6 text-gray-400" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
uploading: 'Uploading wallpaper...',
|
||||
uploadingWallpaper: 'Uploading wallpaper...',
|
||||
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',
|
||||
@@ -15,7 +15,8 @@ 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.',
|
||||
selectLibrariesForWallpapers:
|
||||
'Select libraries first to upload custom wallpapers.',
|
||||
});
|
||||
|
||||
interface Library {
|
||||
@@ -177,7 +178,7 @@ const WallpaperUploadSection = ({
|
||||
if (selectedLibraryIds.length === 0) {
|
||||
return (
|
||||
<div className="label-tip">
|
||||
{intl.formatMessage(messages.selectLibrariesFirst)}
|
||||
{intl.formatMessage(messages.selectLibrariesForWallpapers)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -246,7 +247,7 @@ const WallpaperUploadSection = ({
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="text-xs text-gray-400">
|
||||
{intl.formatMessage(messages.uploading)}
|
||||
{intl.formatMessage(messages.uploadingWallpaper)}
|
||||
</span>
|
||||
) : (
|
||||
<PlusIcon className="h-6 w-6 text-gray-400" />
|
||||
|
||||
@@ -17,14 +17,14 @@ const messages = defineMessages({
|
||||
collectionStatistics: 'Collection Statistics',
|
||||
noData: 'No collection data available',
|
||||
tautulliRequired: 'Tautulli Setup Required',
|
||||
tautulliDescription:
|
||||
tautulliDescriptionCollections:
|
||||
'Configure Tautulli in your settings to view detailed statistics about your collections, including play counts, watch time, and viewer activity.',
|
||||
configureTautulli: 'Configure Tautulli',
|
||||
plays: 'plays',
|
||||
hours: 'hours',
|
||||
items: 'items',
|
||||
refresh: 'Refresh',
|
||||
failedToLoad: 'Failed to load collection statistics',
|
||||
failedToLoadCollectionStats: 'Failed to load collection statistics',
|
||||
daysLabel: 'Days:',
|
||||
playsButton: 'Plays',
|
||||
durationButton: 'Duration',
|
||||
@@ -111,7 +111,7 @@ const CollectionStatsGrid: React.FC = () => {
|
||||
{intl.formatMessage(messages.tautulliRequired)}
|
||||
</h4>
|
||||
<p className="mb-6 max-w-md text-gray-400">
|
||||
{intl.formatMessage(messages.tautulliDescription)}
|
||||
{intl.formatMessage(messages.tautulliDescriptionCollections)}
|
||||
</p>
|
||||
<Link href="/settings/sources" passHref>
|
||||
<Button as="a" buttonType="primary">
|
||||
@@ -136,7 +136,7 @@ const CollectionStatsGrid: React.FC = () => {
|
||||
</div>
|
||||
<div className="p-6 text-center">
|
||||
<p className="mb-2 text-red-400">
|
||||
{intl.formatMessage(messages.failedToLoad)}
|
||||
{intl.formatMessage(messages.failedToLoadCollectionStats)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">{error.message}</p>
|
||||
</div>
|
||||
|
||||
@@ -22,10 +22,10 @@ const messages = defineMessages({
|
||||
totalServer: 'total',
|
||||
thisWeek: 'this week',
|
||||
tautulliRequired: 'Tautulli Setup Required',
|
||||
tautulliDescription:
|
||||
tautulliDescriptionPlayStats:
|
||||
'Configure Tautulli in your settings to view play statistics from your Plex server.',
|
||||
configureTautulli: 'Configure Tautulli',
|
||||
failedToLoad: 'Failed to load dashboard statistics',
|
||||
failedToLoadDashboardStats: 'Failed to load dashboard statistics',
|
||||
});
|
||||
|
||||
interface DashboardData {
|
||||
@@ -98,7 +98,7 @@ const DashboardStats: React.FC = () => {
|
||||
<div className="rounded-lg bg-stone-800 p-6 shadow-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400">
|
||||
{intl.formatMessage(messages.failedToLoad)}
|
||||
{intl.formatMessage(messages.failedToLoadDashboardStats)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{error.message}</p>
|
||||
</div>
|
||||
@@ -131,7 +131,7 @@ const DashboardStats: React.FC = () => {
|
||||
{intl.formatMessage(messages.tautulliRequired)}
|
||||
</h4>
|
||||
<p className="mb-6 max-w-md text-gray-400">
|
||||
{intl.formatMessage(messages.tautulliDescription)}
|
||||
{intl.formatMessage(messages.tautulliDescriptionPlayStats)}
|
||||
</p>
|
||||
<Link href="/settings/sources" passHref>
|
||||
<Button as="a" buttonType="primary">
|
||||
|
||||
@@ -37,7 +37,7 @@ const messages = defineMessages({
|
||||
statusPartiallyAvailable: 'Partially Available',
|
||||
autoRequest: 'Auto',
|
||||
manualRequest: 'Manual',
|
||||
failedToLoad: 'Failed to load missing items',
|
||||
failedToLoadMissingItems: 'Failed to load missing items',
|
||||
requestsCount: '{total} {mediaType} requests',
|
||||
showingRecent: 'Showing recent missing item requests',
|
||||
lastUpdatedNow: 'Last updated: {time}',
|
||||
@@ -190,7 +190,7 @@ const MissingItemsFeed: React.FC = () => {
|
||||
</div>
|
||||
<div className="p-6 text-center">
|
||||
<p className="mb-2 text-red-400">
|
||||
{intl.formatMessage(messages.failedToLoad)}
|
||||
{intl.formatMessage(messages.failedToLoadMissingItems)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">{error.message}</p>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ const messages = defineMessages({
|
||||
refreshing: 'Refreshing...',
|
||||
syncStatus: 'Sync Status',
|
||||
syncing: 'Syncing...',
|
||||
failedToLoad: 'Failed to load missing items',
|
||||
failedToLoadMissingItems: 'Failed to load missing items',
|
||||
requestedBy: 'by {name}',
|
||||
});
|
||||
|
||||
@@ -443,7 +443,7 @@ const MissingItemsModal: React.FC<MissingItemsModalProps> = ({
|
||||
{error ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="mb-2 text-red-400">
|
||||
{intl.formatMessage(messages.failedToLoad)}
|
||||
{intl.formatMessage(messages.failedToLoadMissingItems)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">{error.message}</p>
|
||||
</div>
|
||||
|
||||
@@ -106,12 +106,12 @@ const messages = defineMessages({
|
||||
opEquals: 'equals',
|
||||
opNotEquals: 'not equals',
|
||||
opGreaterThan: 'greater than',
|
||||
opGreaterOrEqual: 'at least',
|
||||
opGreaterOrEqualDisplay: 'at least',
|
||||
opLessThan: 'less than',
|
||||
opLessOrEqual: 'at most',
|
||||
opLessOrEqualDisplay: 'at most',
|
||||
opContains: 'contains',
|
||||
opNotContains: 'does not contain',
|
||||
opRegex: 'matches regex',
|
||||
opRegexDisplay: 'matches regex',
|
||||
opBegins: 'begins with',
|
||||
opEnds: 'ends with',
|
||||
locked: 'Locked',
|
||||
|
||||
@@ -22,8 +22,9 @@ import PosterTemplateGrid from './PosterTemplateGrid';
|
||||
import SavedPosterGrid from './SavedPosterGrid';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: 'Collection Posters',
|
||||
description: 'Create and manage poster templates for your collections',
|
||||
collectionPostersTitle: 'Collection Posters',
|
||||
collectionPostersDescription:
|
||||
'Create and manage poster templates for your collections',
|
||||
templates: 'Collection Templates',
|
||||
savedPosters: 'Saved Posters',
|
||||
templatesDescription: 'Design reusable poster templates for your collections',
|
||||
@@ -323,10 +324,10 @@ const CollectionsPageView: React.FC = () => {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{intl.formatMessage(messages.title)}
|
||||
{intl.formatMessage(messages.collectionPostersTitle)}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-stone-400">
|
||||
{intl.formatMessage(messages.description)}
|
||||
{intl.formatMessage(messages.collectionPostersDescription)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ const messages = defineMessages({
|
||||
selectTemplates: 'Select templates to copy to:',
|
||||
selectAll: 'Select All',
|
||||
deselectAll: 'Deselect All',
|
||||
copyElements: 'Copy {elementCount} elements to {templateCount} templates',
|
||||
copyElementsConfirm:
|
||||
'Copy {elementCount} elements to {templateCount} templates',
|
||||
cancel: 'Cancel',
|
||||
copyFailed: 'Failed to copy elements',
|
||||
noElementsSelected: 'Select at least one element',
|
||||
@@ -184,7 +185,7 @@ const CopyTemplateModal: React.FC<CopyTemplateModalProps> = ({
|
||||
title={intl.formatMessage(messages.copyTemplate)}
|
||||
onCancel={onClose}
|
||||
onOk={handleCopy}
|
||||
okText={intl.formatMessage(messages.copyElements, {
|
||||
okText={intl.formatMessage(messages.copyElementsConfirm, {
|
||||
elementCount: selectedElementIds.length,
|
||||
templateCount: selectedTemplateIds.length,
|
||||
})}
|
||||
|
||||
@@ -25,7 +25,7 @@ const messages = defineMessages({
|
||||
cyclePoster: 'Cycle Poster',
|
||||
syncOverlays: 'Sync',
|
||||
syncOverlaysConfirm: 'Confirm?',
|
||||
overlaySyncStarted: 'Overlay sync started for {libraryName}',
|
||||
librarySyncStarted: 'Overlay sync started for {libraryName}',
|
||||
overlaySyncError: 'Failed to start overlay sync',
|
||||
failedToLoad: 'Failed to load libraries',
|
||||
noOverlays: 'No overlays configured',
|
||||
@@ -270,7 +270,7 @@ const LibraryConfigView: React.FC = () => {
|
||||
try {
|
||||
await axios.post(`/api/v1/overlay-library-configs/${libraryId}/apply`);
|
||||
addToast(
|
||||
intl.formatMessage(messages.overlaySyncStarted, { libraryName }),
|
||||
intl.formatMessage(messages.librarySyncStarted, { libraryName }),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
|
||||
@@ -23,14 +23,16 @@ const messages = defineMessages({
|
||||
duplicate: 'Duplicate',
|
||||
delete: 'Delete',
|
||||
export: 'Export',
|
||||
exportSuccess: 'Overlay template exported successfully',
|
||||
exportError: 'Failed to export overlay template',
|
||||
confirmDelete: 'Are you sure you want to delete this overlay template?',
|
||||
overlayExportSuccess: 'Overlay template exported successfully',
|
||||
overlayExportError: 'Failed to export overlay template',
|
||||
confirmDeleteOverlay:
|
||||
'Are you sure you want to delete this overlay template?',
|
||||
deleteTemplate: 'Delete Template',
|
||||
cancel: 'Cancel',
|
||||
noTemplates: 'No overlay templates found',
|
||||
createFirstTemplate: 'Create your first overlay template to get started',
|
||||
copyElements: 'Copy Elements',
|
||||
noOverlayTemplates: 'No overlay templates found',
|
||||
createFirstOverlayTemplate:
|
||||
'Create your first overlay template to get started',
|
||||
copyOverlayElements: 'Copy Elements',
|
||||
alwaysApply: 'Always apply',
|
||||
});
|
||||
|
||||
@@ -215,12 +217,12 @@ const OverlayTemplateGrid: React.FC<OverlayTemplateGridProps> = ({
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
addToast(intl.formatMessage(messages.exportSuccess), {
|
||||
addToast(intl.formatMessage(messages.overlayExportSuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (error) {
|
||||
addToast(intl.formatMessage(messages.exportError), {
|
||||
addToast(intl.formatMessage(messages.overlayExportError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
@@ -329,10 +331,10 @@ const OverlayTemplateGrid: React.FC<OverlayTemplateGridProps> = ({
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-4 text-lg text-stone-300">
|
||||
{intl.formatMessage(messages.noTemplates)}
|
||||
{intl.formatMessage(messages.noOverlayTemplates)}
|
||||
</p>
|
||||
<p className="mt-2 text-stone-400">
|
||||
{intl.formatMessage(messages.createFirstTemplate)}
|
||||
{intl.formatMessage(messages.createFirstOverlayTemplate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,10 +410,10 @@ const OverlayTemplateGrid: React.FC<OverlayTemplateGridProps> = ({
|
||||
<button
|
||||
onClick={() => handleCopyElements(template)}
|
||||
className="flex items-center rounded-md bg-stone-700 px-3 py-2 text-xs text-stone-200 transition-colors hover:bg-stone-600 hover:text-white"
|
||||
title={intl.formatMessage(messages.copyElements)}
|
||||
title={intl.formatMessage(messages.copyOverlayElements)}
|
||||
>
|
||||
<DocumentDuplicateIcon className="mr-2 h-3 w-3" />
|
||||
{intl.formatMessage(messages.copyElements)}
|
||||
{intl.formatMessage(messages.copyOverlayElements)}
|
||||
</button>
|
||||
{!template.isDefault && (
|
||||
<button
|
||||
@@ -444,7 +446,7 @@ const OverlayTemplateGrid: React.FC<OverlayTemplateGridProps> = ({
|
||||
{intl.formatMessage(messages.deleteTemplate)}
|
||||
</h4>
|
||||
<p className="mb-4 text-sm text-stone-300">
|
||||
{intl.formatMessage(messages.confirmDelete)}
|
||||
{intl.formatMessage(messages.confirmDeleteOverlay)}
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
|
||||
@@ -27,23 +27,23 @@ import PosterSourceSetupModal from './PosterSourceSetupModal';
|
||||
import TestItemModal from './TestItemModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: 'Overlay System',
|
||||
description:
|
||||
overlaySystemTitle: 'Overlay System',
|
||||
overlaySystemDescription:
|
||||
'Create overlay templates and apply them to your Plex library posters',
|
||||
templatesTab: 'Overlay Templates',
|
||||
librariesTab: 'Library Configuration',
|
||||
createTemplate: 'Create Overlay Template',
|
||||
createOverlayTemplate: 'Create Overlay Template',
|
||||
importTemplate: 'Import Template',
|
||||
importSuccess: 'Overlay template imported successfully',
|
||||
importError: 'Failed to import overlay template',
|
||||
error: 'Failed to load overlay data',
|
||||
templatesDescription:
|
||||
overlayImportSuccess: 'Overlay template imported successfully',
|
||||
overlayImportError: 'Failed to import overlay template',
|
||||
overlayLoadError: 'Failed to load overlay data',
|
||||
overlayTemplatesDescription:
|
||||
'Design reusable overlay templates for ratings, metadata, and more',
|
||||
librariesDescription: 'Configure which overlays are applied to each library',
|
||||
overlaySettings: 'Posters Source',
|
||||
fullOverlaysSync: 'Full Overlays Sync',
|
||||
fullOverlaysSyncConfirm: 'Confirm Full Sync?',
|
||||
overlaySyncStarted: 'Full overlay sync started',
|
||||
fullOverlaySyncStarted: 'Full overlay sync started',
|
||||
overlaySyncQueued:
|
||||
'Per-library syncs are running. Full sync will start when they complete.',
|
||||
overlaySyncError: 'Failed to start overlay sync',
|
||||
@@ -180,7 +180,7 @@ const OverlaysPageView: React.FC = () => {
|
||||
}
|
||||
|
||||
mutateTemplates();
|
||||
addToast(intl.formatMessage(messages.importSuccess), {
|
||||
addToast(intl.formatMessage(messages.overlayImportSuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
@@ -188,7 +188,7 @@ const OverlaysPageView: React.FC = () => {
|
||||
addToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: intl.formatMessage(messages.importError),
|
||||
: intl.formatMessage(messages.overlayImportError),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
@@ -264,7 +264,7 @@ const OverlaysPageView: React.FC = () => {
|
||||
|
||||
// Show different message if queued vs started immediately
|
||||
if (!hasRunningLibraries) {
|
||||
addToast(intl.formatMessage(messages.overlaySyncStarted), {
|
||||
addToast(intl.formatMessage(messages.fullOverlaySyncStarted), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
@@ -288,7 +288,7 @@ const OverlaysPageView: React.FC = () => {
|
||||
key: 'templates',
|
||||
name: intl.formatMessage(messages.templatesTab),
|
||||
count: templates.length,
|
||||
description: intl.formatMessage(messages.templatesDescription),
|
||||
description: intl.formatMessage(messages.overlayTemplatesDescription),
|
||||
},
|
||||
{
|
||||
key: 'libraries',
|
||||
@@ -304,7 +304,9 @@ const OverlaysPageView: React.FC = () => {
|
||||
if (templatesError) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-red-400">{intl.formatMessage(messages.error)}</div>
|
||||
<div className="text-red-400">
|
||||
{intl.formatMessage(messages.overlayLoadError)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -314,10 +316,10 @@ const OverlaysPageView: React.FC = () => {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
{intl.formatMessage(messages.title)}
|
||||
{intl.formatMessage(messages.overlaySystemTitle)}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-stone-400">
|
||||
{intl.formatMessage(messages.description)}
|
||||
{intl.formatMessage(messages.overlaySystemDescription)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -376,7 +378,7 @@ const OverlaysPageView: React.FC = () => {
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span>{intl.formatMessage(messages.createTemplate)}</span>
|
||||
<span>{intl.formatMessage(messages.createOverlayTemplate)}</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -40,7 +40,7 @@ const messages = defineMessages({
|
||||
searchOnAdd: 'Search on add',
|
||||
seasonFolders: 'Season folders',
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
saving: 'Saving…',
|
||||
lastSync: 'Last Sync',
|
||||
noRadarrConfigured: 'No Radarr servers configured',
|
||||
noSonarrConfigured: 'No Sonarr servers configured',
|
||||
|
||||
+43
-16
@@ -231,7 +231,8 @@
|
||||
"components.Collections.FormSections.posterRemoveConfirm": "Poster will be removed on next collection sync",
|
||||
"components.Collections.FormSections.posterUploadError": "Failed to upload poster",
|
||||
"components.Collections.FormSections.posterUploadHelpMulti": "Upload custom poster images for each selected library. Posters will be applied to Plex collections during the next sync.",
|
||||
"components.Collections.FormSections.posterUploadSuccess": "Poster uploaded successfully. Will be applied on next collection sync.",
|
||||
"components.Collections.FormSections.posterUploadSuccess": "Poster uploaded successfully",
|
||||
"components.Collections.FormSections.posterUploadSuccessWithSyncNote": "Poster uploaded successfully. Will be applied on next collection sync.",
|
||||
"components.Collections.FormSections.preview": "Preview:",
|
||||
"components.Collections.FormSections.previewUnavailable": "Preview unavailable",
|
||||
"components.Collections.FormSections.processMovies": "Grab Missing Movies",
|
||||
@@ -262,7 +263,9 @@
|
||||
"components.Collections.FormSections.selectInstanceFirst": "Select an instance first",
|
||||
"components.Collections.FormSections.selectLanguages": "Select languages...",
|
||||
"components.Collections.FormSections.selectLibraries": "Select Libraries",
|
||||
"components.Collections.FormSections.selectLibrariesFirst": "Select libraries first to upload custom wallpapers.",
|
||||
"components.Collections.FormSections.selectLibrariesForPosters": "Select libraries first to upload custom posters.",
|
||||
"components.Collections.FormSections.selectLibrariesForThemes": "Select libraries first to upload custom themes.",
|
||||
"components.Collections.FormSections.selectLibrariesForWallpapers": "Select libraries first to upload custom wallpapers.",
|
||||
"components.Collections.FormSections.selectOverseerrRadarrProfile": "Radarr Quality Profile (Movies)",
|
||||
"components.Collections.FormSections.selectOverseerrRadarrRootFolder": "Radarr Root Folder (Movies)",
|
||||
"components.Collections.FormSections.selectOverseerrRadarrServer": "Radarr Server (Movies)",
|
||||
@@ -331,7 +334,9 @@
|
||||
"components.Collections.FormSections.tvSeasonLimitHelp": "0 = no limit",
|
||||
"components.Collections.FormSections.tvShows": "TV Shows",
|
||||
"components.Collections.FormSections.uploadNewPoster": "Upload New Poster",
|
||||
"components.Collections.FormSections.uploading": "Uploading wallpaper...",
|
||||
"components.Collections.FormSections.uploadingPoster": "Uploading...",
|
||||
"components.Collections.FormSections.uploadingTheme": "Uploading theme...",
|
||||
"components.Collections.FormSections.uploadingWallpaper": "Uploading wallpaper...",
|
||||
"components.Collections.FormSections.urlInvalid": "Invalid",
|
||||
"components.Collections.FormSections.urlValid": "Valid",
|
||||
"components.Collections.FormSections.useSeparator": "Use Separator",
|
||||
@@ -648,7 +653,9 @@
|
||||
"components.Dashboard.durationButton": "Duration",
|
||||
"components.Dashboard.emptyState": "Create some collections and start watching to see statistics here.",
|
||||
"components.Dashboard.failed": "Failed",
|
||||
"components.Dashboard.failedToLoad": "Failed to load missing items",
|
||||
"components.Dashboard.failedToLoadCollectionStats": "Failed to load collection statistics",
|
||||
"components.Dashboard.failedToLoadDashboardStats": "Failed to load dashboard statistics",
|
||||
"components.Dashboard.failedToLoadMissingItems": "Failed to load missing items",
|
||||
"components.Dashboard.filter": "Filter",
|
||||
"components.Dashboard.hours": "hours",
|
||||
"components.Dashboard.imdb": "IMDb",
|
||||
@@ -697,7 +704,8 @@
|
||||
"components.Dashboard.statusProcessing": "Processing",
|
||||
"components.Dashboard.syncStatus": "Sync Status",
|
||||
"components.Dashboard.syncing": "Syncing...",
|
||||
"components.Dashboard.tautulliDescription": "Configure Tautulli in your settings to view play statistics from your Plex server.",
|
||||
"components.Dashboard.tautulliDescriptionCollections": "Configure Tautulli in your settings to view detailed statistics about your collections, including play counts, watch time, and viewer activity.",
|
||||
"components.Dashboard.tautulliDescriptionPlayStats": "Configure Tautulli in your settings to view play statistics from your Plex server.",
|
||||
"components.Dashboard.tautulliRequired": "Tautulli Setup Required",
|
||||
"components.Dashboard.thisWeek": "this week",
|
||||
"components.Dashboard.tmdb": "TMDB",
|
||||
@@ -781,14 +789,17 @@
|
||||
"components.OverlayEditor.opEnds": "ends with",
|
||||
"components.OverlayEditor.opEquals": "equals",
|
||||
"components.OverlayEditor.opExists": "exists",
|
||||
"components.OverlayEditor.opGreaterOrEqual": "at least",
|
||||
"components.OverlayEditor.opGreaterOrEqual": "greater than or equal",
|
||||
"components.OverlayEditor.opGreaterOrEqualDisplay": "at least",
|
||||
"components.OverlayEditor.opGreaterThan": "greater than",
|
||||
"components.OverlayEditor.opIn": "in",
|
||||
"components.OverlayEditor.opLessOrEqual": "at most",
|
||||
"components.OverlayEditor.opLessOrEqual": "less than or equal",
|
||||
"components.OverlayEditor.opLessOrEqualDisplay": "at most",
|
||||
"components.OverlayEditor.opLessThan": "less than",
|
||||
"components.OverlayEditor.opNotContains": "does not contain",
|
||||
"components.OverlayEditor.opNotEquals": "not equals",
|
||||
"components.OverlayEditor.opRegex": "matches regex",
|
||||
"components.OverlayEditor.opRegex": "regex",
|
||||
"components.OverlayEditor.opRegexDisplay": "matches regex",
|
||||
"components.OverlayEditor.opacity": "Opacity",
|
||||
"components.OverlayEditor.or": "OR",
|
||||
"components.OverlayEditor.prefix": "Prefix Text",
|
||||
@@ -925,24 +936,30 @@
|
||||
"components.Posters.cancelOperation": "Cancel Operation",
|
||||
"components.Posters.cancelReset": "Cancel Reset",
|
||||
"components.Posters.collectionPosters": "Collection Posters",
|
||||
"components.Posters.collectionPostersDescription": "Create and manage poster templates for your collections",
|
||||
"components.Posters.collectionPostersTitle": "Collection Posters",
|
||||
"components.Posters.conditionEvaluation": "Condition Evaluation:",
|
||||
"components.Posters.configure": "Configure",
|
||||
"components.Posters.configureOverlays": "Configure Overlays",
|
||||
"components.Posters.confirm": "Confirm",
|
||||
"components.Posters.confirmBulkDelete": "Are you sure you want to delete {count} selected poster(s)?",
|
||||
"components.Posters.confirmDelete": "Are you sure you want to delete this template?",
|
||||
"components.Posters.confirmDeleteOverlay": "Are you sure you want to delete this overlay template?",
|
||||
"components.Posters.confirmDescription": "This will reset ALL posters in \"{libraryName}\" to their base versions (without overlays). The poster source setting ({posterSource}) will be respected.",
|
||||
"components.Posters.confirmTitle": "Confirm Poster Reset",
|
||||
"components.Posters.contextVariables": "Context Variables ({count})",
|
||||
"components.Posters.continue": "Continue",
|
||||
"components.Posters.copyDescription": "Select which elements to copy from this template to other templates.",
|
||||
"components.Posters.copyElements": "Copy Elements",
|
||||
"components.Posters.copyElementsConfirm": "Copy {elementCount} elements to {templateCount} templates",
|
||||
"components.Posters.copyFailed": "Failed to copy elements",
|
||||
"components.Posters.copyOverlayElements": "Copy Elements",
|
||||
"components.Posters.copyTemplate": "Copy Elements",
|
||||
"components.Posters.copyingFrom": "Copying from:",
|
||||
"components.Posters.createFirstOverlayTemplate": "Create your first overlay template to get started",
|
||||
"components.Posters.createFirstPoster": "Create your first poster to get started",
|
||||
"components.Posters.createFirstTemplate": "Create your first template to get started",
|
||||
"components.Posters.createTemplate": "Create Overlay Template",
|
||||
"components.Posters.createOverlayTemplate": "Create Overlay Template",
|
||||
"components.Posters.createTemplate": "Create Template",
|
||||
"components.Posters.cyclePoster": "Cycle Poster",
|
||||
"components.Posters.default": "Default",
|
||||
"components.Posters.delete": "Delete",
|
||||
@@ -966,22 +983,23 @@
|
||||
"components.Posters.elementText": "Text",
|
||||
"components.Posters.elementTile": "Tile",
|
||||
"components.Posters.elementVariable": "Variable",
|
||||
"components.Posters.error": "Failed to load overlay data",
|
||||
"components.Posters.error": "Failed to load data",
|
||||
"components.Posters.export": "Export",
|
||||
"components.Posters.exportError": "Failed to export template",
|
||||
"components.Posters.exportSourceColors": "Export Source Colors",
|
||||
"components.Posters.exportSuccess": "Template exported successfully",
|
||||
"components.Posters.failedToLoad": "Failed to load libraries",
|
||||
"components.Posters.fileSource": "File",
|
||||
"components.Posters.fullOverlaySyncStarted": "Full overlay sync started",
|
||||
"components.Posters.fullOverlaysSync": "Full Overlays Sync",
|
||||
"components.Posters.fullOverlaysSyncConfirm": "Confirm Full Sync?",
|
||||
"components.Posters.generateFolders": "Generate Folder Structure",
|
||||
"components.Posters.generateFoldersDescription": "Create empty folders for all library items upfront. Note: Folders are also created automatically when overlays are applied to new items.",
|
||||
"components.Posters.generatingFoldersTitle": "Generating Folder Structure",
|
||||
"components.Posters.import": "Import",
|
||||
"components.Posters.importError": "Failed to import overlay template",
|
||||
"components.Posters.importError": "Failed to import file",
|
||||
"components.Posters.importSourceColors": "Import Source Colors",
|
||||
"components.Posters.importSuccess": "Overlay template imported successfully",
|
||||
"components.Posters.importSuccess": "Template imported successfully",
|
||||
"components.Posters.importTemplate": "Import Template",
|
||||
"components.Posters.itemsFailed": "{count} items failed (no poster available)",
|
||||
"components.Posters.languageDescription": "Language for fetching poster metadata from TMDB",
|
||||
@@ -990,6 +1008,7 @@
|
||||
"components.Posters.librariesTab": "Library Configuration",
|
||||
"components.Posters.library": "Library: {name}",
|
||||
"components.Posters.libraryLabel": "Library: {name}",
|
||||
"components.Posters.librarySyncStarted": "Overlay sync started for {libraryName}",
|
||||
"components.Posters.loading": "Loading libraries...",
|
||||
"components.Posters.localDescription": "Use custom poster images from organized folders. Place images in the folder structure shown below. Can be populated with Plex Posters. Falls back to TMDB if file not found.",
|
||||
"components.Posters.localFileFormat": "Supported Files",
|
||||
@@ -1001,6 +1020,7 @@
|
||||
"components.Posters.noElementsInTemplate": "No elements in template",
|
||||
"components.Posters.noElementsSelected": "Select at least one element",
|
||||
"components.Posters.noLibraries": "No libraries found",
|
||||
"components.Posters.noOverlayTemplates": "No overlay templates found",
|
||||
"components.Posters.noOverlays": "No overlays configured",
|
||||
"components.Posters.noPoster": "No Poster",
|
||||
"components.Posters.noPosters": "No saved posters found",
|
||||
@@ -1008,10 +1028,17 @@
|
||||
"components.Posters.noTemplatesAvailable": "No templates available",
|
||||
"components.Posters.noTemplatesSelected": "Select at least one template",
|
||||
"components.Posters.operationComplete": "Operation Complete",
|
||||
"components.Posters.overlayExportError": "Failed to export overlay template",
|
||||
"components.Posters.overlayExportSuccess": "Overlay template exported successfully",
|
||||
"components.Posters.overlayImportError": "Failed to import overlay template",
|
||||
"components.Posters.overlayImportSuccess": "Overlay template imported successfully",
|
||||
"components.Posters.overlayLoadError": "Failed to load overlay data",
|
||||
"components.Posters.overlaySettings": "Posters Source",
|
||||
"components.Posters.overlaySyncError": "Failed to start overlay sync",
|
||||
"components.Posters.overlaySyncQueued": "Per-library syncs are running. Full sync will start when they complete.",
|
||||
"components.Posters.overlaySyncStarted": "Full overlay sync started",
|
||||
"components.Posters.overlaySystemDescription": "Create overlay templates and apply them to your Plex library posters",
|
||||
"components.Posters.overlaySystemTitle": "Overlay System",
|
||||
"components.Posters.overlayTemplatesDescription": "Design reusable overlay templates for ratings, metadata, and more",
|
||||
"components.Posters.overlaysEnabled": "{count} overlays enabled",
|
||||
"components.Posters.plexDescription": "Plex posters will be downloaded and used as the base poster for future overlay runs. If you want to change the base poster used, just update it in Plex and Agregarr will detect the change on the next run and download the new poster and use it going forward.",
|
||||
"components.Posters.plexOption": "Plex Posters",
|
||||
@@ -1061,7 +1088,7 @@
|
||||
"components.Posters.syncOverlaysConfirm": "Confirm?",
|
||||
"components.Posters.templateResults": "Template Results",
|
||||
"components.Posters.templates": "Collection Templates",
|
||||
"components.Posters.templatesDescription": "Design reusable overlay templates for ratings, metadata, and more",
|
||||
"components.Posters.templatesDescription": "Design reusable poster templates for your collections",
|
||||
"components.Posters.templatesTab": "Overlay Templates",
|
||||
"components.Posters.testItem": "Test Item",
|
||||
"components.Posters.testOverlay": "Test Overlay",
|
||||
@@ -1449,7 +1476,7 @@
|
||||
"components.Settings.restartrequiredTooltip": "Agregarr must be restarted for changes to this setting to take effect",
|
||||
"components.Settings.rootFolder": "Root Folder",
|
||||
"components.Settings.save": "Save Changes",
|
||||
"components.Settings.saving": "Saving...",
|
||||
"components.Settings.saving": "Saving…",
|
||||
"components.Settings.searchOnAdd": "Search on add",
|
||||
"components.Settings.seasonFolders": "Season folders",
|
||||
"components.Settings.selectFolder": "Select a folder...",
|
||||
|
||||
Reference in New Issue
Block a user