mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Extract title tags & per-row chevron expand
Add parsing and UX for bracketed title metadata and per-row title expansion. Introduces extractTitleTags (src/lib/utils/title-tags.ts) to pull bracketed tags from result titles (de-duplicated, slash-split) and useIsTruncated (src/lib/hooks/useIsTruncated.ts) to detect horizontal overflow. Refactors InteractiveTorrentSearchModal to a ResultRow component that renders title chips (slate chips) for parsed tags (filtered vs displayFormat), shows a chevron disclosure only when the title is truncated (or while expanded), toggles expansion per GUID, and resets expansion state when the modal closes. Tests added/updated for the component, hook, and parser; documentation updated to reflect behavior.
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import { extractTitleTags } from '@/lib/utils/title-tags';
|
||||
import { useIsTruncated } from '@/lib/hooks/useIsTruncated';
|
||||
import {
|
||||
useInteractiveSearch,
|
||||
useSelectTorrent,
|
||||
@@ -119,6 +121,7 @@ export function InteractiveTorrentSearchModal({
|
||||
const [searchTitle, setSearchTitle] = useState(customSearchTerms || audiobook.title);
|
||||
const [isCustomConfirming, setIsCustomConfirming] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [expandedGuids, setExpandedGuids] = useState<Set<string>>(() => new Set());
|
||||
|
||||
// Stable close handler via ref
|
||||
const onCloseRef = useRef(onClose);
|
||||
@@ -157,6 +160,7 @@ export function InteractiveTorrentSearchModal({
|
||||
useEffect(() => {
|
||||
setSearchTitle(customSearchTerms || audiobook.title);
|
||||
setResults([]);
|
||||
setExpandedGuids(new Set());
|
||||
}, [isOpen, audiobook.title, customSearchTerms]);
|
||||
|
||||
// Perform search when modal opens
|
||||
@@ -189,6 +193,7 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
const performSearch = async () => {
|
||||
setResults([]);
|
||||
setExpandedGuids(new Set());
|
||||
try {
|
||||
let data;
|
||||
if (isEbookMode) {
|
||||
@@ -380,125 +385,24 @@ export function InteractiveTorrentSearchModal({
|
||||
{/* Results List */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{results.map((result) => {
|
||||
const score = Math.round(result.score);
|
||||
const style = getScoreStyle(score);
|
||||
const isUsenet = result.protocol === 'usenet';
|
||||
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
|
||||
const displayFormat = result.format || result.ebookFormat;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.guid}
|
||||
className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group"
|
||||
>
|
||||
{/* Score Badge */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
|
||||
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
|
||||
>
|
||||
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title Row */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={result.infoUrl || result.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={result.title}
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
{/* Rank */}
|
||||
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
|
||||
{/* Indexer / Source */}
|
||||
{isAnnasArchive ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : (
|
||||
<span>{result.indexer}</span>
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{result.size > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatSize(result.size)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
{displayFormat && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
|
||||
{displayFormat}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
|
||||
{!isAnnasArchive && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
{isUsenet ? (
|
||||
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
NZB
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Age */}
|
||||
{result.publishDate && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatAge(result.publishDate)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Points */}
|
||||
{result.bonusPoints > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button
|
||||
onClick={() => handleDownloadClick(result)}
|
||||
disabled={isDownloading}
|
||||
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
Get
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{results.map((result) => (
|
||||
<ResultRow
|
||||
key={result.guid}
|
||||
result={result}
|
||||
isEbookMode={isEbookMode}
|
||||
isExpanded={expandedGuids.has(result.guid)}
|
||||
isDownloading={isDownloading}
|
||||
onToggleExpand={() => {
|
||||
setExpandedGuids((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(result.guid)) next.delete(result.guid);
|
||||
else next.add(result.guid);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onDownload={() => handleDownloadClick(result)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -615,3 +519,175 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
interface ResultRowProps {
|
||||
result: RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string };
|
||||
isEbookMode: boolean;
|
||||
isExpanded: boolean;
|
||||
isDownloading: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
result,
|
||||
isEbookMode,
|
||||
isExpanded,
|
||||
isDownloading,
|
||||
onToggleExpand,
|
||||
onDownload,
|
||||
}: ResultRowProps) {
|
||||
const score = Math.round(result.score);
|
||||
const style = getScoreStyle(score);
|
||||
const isUsenet = result.protocol === 'usenet';
|
||||
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
|
||||
const displayFormat = result.format || result.ebookFormat;
|
||||
const { tags } = extractTitleTags(result.title);
|
||||
const displayFormatLower = (displayFormat ?? '').toLowerCase();
|
||||
const chipTags = tags.filter((t) => t.toLowerCase() !== displayFormatLower);
|
||||
|
||||
const titleRef = useRef<HTMLAnchorElement | null>(null);
|
||||
const isTruncated = useIsTruncated(titleRef);
|
||||
// Why: keep chevron rendered while expanded so users can collapse — once
|
||||
// expanded, scrollWidth no longer exceeds clientWidth and isTruncated flips false.
|
||||
const showChevron = isTruncated || isExpanded;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group">
|
||||
{/* Score Badge */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
|
||||
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
|
||||
>
|
||||
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title Row */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<a
|
||||
ref={titleRef}
|
||||
href={result.infoUrl || result.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`text-sm font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 transition-colors flex-1 min-w-0 ${isExpanded ? 'break-words whitespace-normal' : 'truncate'}`}
|
||||
title={result.title}
|
||||
aria-label={result.title}
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
{showChevron && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? 'Hide full title' : 'Show full title'}
|
||||
className="flex-shrink-0 p-2 -my-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100/70 dark:text-gray-500 dark:hover:text-gray-300 dark:hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ease-out ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
{/* Rank */}
|
||||
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
|
||||
{/* Indexer / Source */}
|
||||
{isAnnasArchive ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : (
|
||||
<span>{result.indexer}</span>
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{result.size > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatSize(result.size)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
{displayFormat && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
|
||||
{displayFormat}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Title tag chips (language/edition/etc.) */}
|
||||
{chipTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-slate-100 dark:bg-slate-500/15 text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
|
||||
{!isAnnasArchive && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
{isUsenet ? (
|
||||
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
NZB
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Age */}
|
||||
{result.publishDate && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatAge(result.publishDate)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Points */}
|
||||
{result.bonusPoints > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button
|
||||
onClick={onDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
Get
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Component: useIsTruncated Hook
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Returns whether the referenced element's content overflows horizontally
|
||||
* (i.e. is being clipped by `truncate` / `overflow: hidden`). Used by the
|
||||
* Interactive Search modal to render an expand-disclosure chevron only when
|
||||
* the title is actually being cut off — keeping the row clean when there's
|
||||
* nothing to disclose.
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, useState, type RefObject } from 'react';
|
||||
|
||||
export function useIsTruncated(ref: RefObject<HTMLElement | null>): boolean {
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const measure = () => {
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
|
||||
return isTruncated;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Component: Title Tag Extraction Utility
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*
|
||||
* Pure parser used by the Interactive Search modal to split a result title
|
||||
* into a "residual" string and a list of bracketed metadata tags. Brackets
|
||||
* are ASCII `[`/`]` only (full-width `【】` is intentionally unsupported —
|
||||
* Audible/indexer titles use ASCII in practice). Inner content is split on
|
||||
* `/`, trimmed, empty segments dropped, then de-duplicated case-insensitively
|
||||
* while preserving first-seen casing. The regex is non-nested by design:
|
||||
* `Foundation [Edition [Deluxe]]` extracts `Deluxe` and leaves the outer
|
||||
* `[Edition ]` in the residual. Accepted trade-off for v1 (rare).
|
||||
*/
|
||||
export interface TitleTags {
|
||||
residual: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// Why: character class excludes brackets so the regex never spans a nested
|
||||
// pair — this is what makes the inner `[Deluxe]` win over the outer group.
|
||||
const BRACKET_GROUP = /\[([^\[\]]*)\]/g;
|
||||
|
||||
export function extractTitleTags(title: string): TitleTags {
|
||||
if (!title) return { residual: '', tags: [] };
|
||||
|
||||
const tags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of title.matchAll(BRACKET_GROUP)) {
|
||||
for (const segment of match[1].split('/')) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
tags.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
const residual = title.replace(BRACKET_GROUP, ' ').replace(/\s+/g, ' ').trim();
|
||||
return { residual, tags };
|
||||
}
|
||||
Reference in New Issue
Block a user