feat: regroupement des releases par titre identique dans la UI
- Prowlarr search modal : les releases avec le même titre sont groupées avec rowSpan sur la colonne titre, sources listées en sous-lignes - Downloads page : même logique, titre + volumes affichés une seule fois, sources (indexer, seeders, taille, boutons) en lignes compactes - Fonction utilitaire groupReleasesByTitle() dans les deux composants Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,21 @@ type TFunction = (key: TranslationKey, vars?: Record<string, string | number>) =
|
|||||||
|
|
||||||
const STATUS_ACTIVE = new Set(["downloading", "completed", "importing"]);
|
const STATUS_ACTIVE = new Set(["downloading", "completed", "importing"]);
|
||||||
|
|
||||||
|
/** Group releases by identical title, preserving order of first occurrence. */
|
||||||
|
function groupReleasesByTitle<T extends { title: string }>(releases: T[]): { title: string; items: T[]; originalIndices: number[] }[] {
|
||||||
|
const groups: Map<string, { items: T[]; originalIndices: number[] }> = new Map();
|
||||||
|
releases.forEach((r, idx) => {
|
||||||
|
const existing = groups.get(r.title);
|
||||||
|
if (existing) {
|
||||||
|
existing.items.push(r);
|
||||||
|
existing.originalIndices.push(idx);
|
||||||
|
} else {
|
||||||
|
groups.set(r.title, { items: [r], originalIndices: [idx] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).map(([title, { items, originalIndices }]) => ({ title, items, originalIndices }));
|
||||||
|
}
|
||||||
|
|
||||||
function statusLabel(status: string, t: TFunction): string {
|
function statusLabel(status: string, t: TFunction): string {
|
||||||
const map: Record<string, TranslationKey> = {
|
const map: Record<string, TranslationKey> = {
|
||||||
downloading: "downloads.status.downloading",
|
downloading: "downloads.status.downloading",
|
||||||
@@ -385,24 +400,30 @@ function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDt
|
|||||||
</div>
|
</div>
|
||||||
{r.available_releases && r.available_releases.length > 0 && (
|
{r.available_releases && r.available_releases.length > 0 && (
|
||||||
<div className="space-y-1.5 sm:space-y-1">
|
<div className="space-y-1.5 sm:space-y-1">
|
||||||
{r.available_releases.map((release, idx) => (
|
{groupReleasesByTitle(r.available_releases).map((group) => (
|
||||||
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-1.5 sm:gap-2 py-1 px-2 rounded bg-muted/30">
|
<div key={group.title} className="rounded bg-muted/30 overflow-hidden">
|
||||||
<div className="flex-1 min-w-0">
|
{/* Title + matched volumes (shown once) */}
|
||||||
<p className="text-[11px] sm:text-xs font-mono text-foreground truncate" title={release.title}>{release.title}</p>
|
<div className="flex items-center gap-2 py-1 px-2">
|
||||||
<div className="flex items-center gap-2 sm:gap-3 mt-0.5 flex-wrap">
|
<p className="text-[11px] sm:text-xs font-mono text-foreground truncate flex-1" title={group.title}>{group.title}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1 shrink-0">
|
||||||
|
{group.items[0].matched_missing_volumes.map(vol => (
|
||||||
|
<span key={vol} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">T{vol}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Sources */}
|
||||||
|
{group.items.map((release, si) => {
|
||||||
|
const idx = group.originalIndices[si];
|
||||||
|
return (
|
||||||
|
<div key={idx} className={`flex items-center gap-2 sm:gap-3 py-0.5 px-2 ${si > 0 ? "border-t border-border/20" : ""}`}>
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0 flex-wrap">
|
||||||
{release.indexer && <span className="text-[10px] text-muted-foreground">{release.indexer}</span>}
|
{release.indexer && <span className="text-[10px] text-muted-foreground">{release.indexer}</span>}
|
||||||
{release.seeders != null && (
|
{release.seeders != null && (
|
||||||
<span className="text-[10px] text-success font-medium">{release.seeders}S</span>
|
<span className="text-[10px] text-success font-medium">{release.seeders}S</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
|
<span className="text-[10px] text-muted-foreground">{(release.size / 1024 / 1024).toFixed(0)} MB</span>
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
|
||||||
{release.matched_missing_volumes.map(vol => (
|
|
||||||
<span key={vol} className="text-[10px] px-1 py-0.5 rounded-full bg-success/20 text-success font-medium">T{vol}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 self-end sm:self-auto shrink-0">
|
|
||||||
{release.download_url && (
|
{release.download_url && (
|
||||||
<QbittorrentDownloadButton
|
<QbittorrentDownloadButton
|
||||||
downloadUrl={release.download_url}
|
downloadUrl={release.download_url}
|
||||||
@@ -426,6 +447,9 @@ function AvailableLibraryCard({ lib, onDeleted }: { lib: LatestFoundPerLibraryDt
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ function formatSize(bytes: number): string {
|
|||||||
return bytes + " B";
|
return bytes + " B";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Group releases by identical title, preserving order of first occurrence. */
|
||||||
|
function groupReleasesByTitle<T extends { title: string }>(releases: T[]): { title: string; items: T[] }[] {
|
||||||
|
const groups: Map<string, T[]> = new Map();
|
||||||
|
for (const r of releases) {
|
||||||
|
const key = r.title;
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(r);
|
||||||
|
} else {
|
||||||
|
groups.set(key, [r]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(([title, items]) => ({ title, items }));
|
||||||
|
}
|
||||||
|
|
||||||
export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initialProwlarrConfigured, initialQbConfigured }: ProwlarrSearchModalProps) {
|
export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initialProwlarrConfigured, initialQbConfigured }: ProwlarrSearchModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -224,17 +239,19 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{results.map((release, i) => {
|
{groupReleasesByTitle(results).map((group) => {
|
||||||
const hasMissing = release.matchedMissingVolumes && release.matchedMissingVolumes.length > 0;
|
const first = group.items[0];
|
||||||
return (
|
const hasMissing = first.matchedMissingVolumes && first.matchedMissingVolumes.length > 0;
|
||||||
<tr key={release.guid || i} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
|
return group.items.map((release, si) => (
|
||||||
<td className="px-3 py-2 max-w-[400px]">
|
<tr key={release.guid || `${group.title}-${si}`} className={`transition-colors ${hasMissing ? "bg-green-500/10 hover:bg-green-500/20 border-l-2 border-l-green-500" : "hover:bg-muted/20"}`}>
|
||||||
|
{si === 0 ? (
|
||||||
|
<td className="px-3 py-2 max-w-[400px]" rowSpan={group.items.length}>
|
||||||
<span className="truncate block" title={release.title}>
|
<span className="truncate block" title={release.title}>
|
||||||
{release.title}
|
{release.title}
|
||||||
</span>
|
</span>
|
||||||
{hasMissing && (
|
{hasMissing && (
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
{release.matchedMissingVolumes!.map((vol) => (
|
{first.matchedMissingVolumes!.map((vol) => (
|
||||||
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
<span key={vol} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-500/20 text-green-600">
|
||||||
{t("prowlarr.missingVol", { vol })}
|
{t("prowlarr.missingVol", { vol })}
|
||||||
</span>
|
</span>
|
||||||
@@ -242,6 +259,7 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
) : null}
|
||||||
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
|
||||||
{release.indexer || "—"}
|
{release.indexer || "—"}
|
||||||
</td>
|
</td>
|
||||||
@@ -296,7 +314,7 @@ export function ProwlarrSearchModal({ seriesName, libraryId, missingBooks, initi
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
));
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user