mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 12:31:11 +01:00
Feature: Add indexer flags to results (#539)
Display Prowlarr indexer flags by rendering them as distinct, color-coded badges. - [New] TAGS Render Type: Added support for a TAGS column type that renders a list of strings as distinct badges. - Updated `ReleaseCell` to handle the TAGS type: - Desktop: Renders distinct badges side-by-side. - Mobile: Renders as a comma-separated text list (e.g., "FREELEECH, DOUBLE UPLOAD"). - Styling: Added dynamic colors for common flags: - Freeleech → Green - Double Upload → Blue - VIP → Amber - Sticky → Yellow - Prowlarr Source: Updated the "Flags" column to use the new TAGS render type, enable uppercase styling, and show on mobile devices. Desktop screenshot: <img width="2042" height="110" alt="CleanShot 2026-01-25 at 21 32 24@2x" src="https://github.com/user-attachments/assets/d135b1d6-176c-4cb9-afa8-fbbab0bcbc06" /> Mobile screenshot: <img width="567" height="51" alt="image" src="https://github.com/user-attachments/assets/b5b38a05-2466-4b6f-b4c4-ab6cce745408" />
This commit is contained in:
@@ -58,6 +58,7 @@ class ColumnRenderType(str, Enum):
|
||||
"""How the frontend should render the column value."""
|
||||
TEXT = "text" # Plain text
|
||||
BADGE = "badge" # Colored badge (format, language)
|
||||
TAGS = "tags" # List of colored badges
|
||||
SIZE = "size" # File size formatting
|
||||
NUMBER = "number" # Numeric value
|
||||
PEERS = "peers" # Peers display: "S/L" with color based on seeder count
|
||||
|
||||
@@ -199,6 +199,7 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook"
|
||||
"indexer_id": result.get("indexerId"),
|
||||
"files": result.get("files"),
|
||||
"grabs": result.get("grabs"),
|
||||
"indexer_flags": result.get("indexerFlags", []),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -248,6 +249,17 @@ class ProwlarrSource(ReleaseSource):
|
||||
sortable=True,
|
||||
sort_key="seeders",
|
||||
),
|
||||
ColumnSchema(
|
||||
key="extra.indexer_flags",
|
||||
label="Flags",
|
||||
render_type=ColumnRenderType.TAGS,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="minmax(80px, 1.5fr)",
|
||||
hide_mobile=False,
|
||||
color_hint=ColumnColorHint(type="map", value="flags"),
|
||||
fallback="-",
|
||||
uppercase=True,
|
||||
),
|
||||
ColumnSchema(
|
||||
key="content_type",
|
||||
label="Type",
|
||||
@@ -270,7 +282,7 @@ class ProwlarrSource(ReleaseSource):
|
||||
sort_key="size_bytes",
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 90px 80px",
|
||||
grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 80px 90px 80px",
|
||||
leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr
|
||||
supported_filters=["language"], # Enables multi-language query expansion; Prowlarr language metadata is unreliable
|
||||
)
|
||||
|
||||
@@ -50,6 +50,46 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }:
|
||||
);
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
// Tags display: render list of strings as distinct badges
|
||||
// Treat non-array values as single-item array
|
||||
const tags = (Array.isArray(rawValue) ? rawValue : (value ? [value] : [])) as any[];
|
||||
|
||||
// Compact mode: render as comma-separated text
|
||||
if (compact) {
|
||||
if (tags.length === 0) return <span>{column.fallback}</span>;
|
||||
const displayTags = column.uppercase ? tags.map(t => String(t).toUpperCase()) : tags;
|
||||
return <span>{displayTags.join(', ')}</span>;
|
||||
}
|
||||
|
||||
if (!tags.length) {
|
||||
return (
|
||||
<div className={`flex items-center ${alignClass}`}>
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-1.5 ${alignClass}`}>
|
||||
{tags.map((tag, idx) => {
|
||||
const tagStr = String(tag);
|
||||
const displayTag = column.uppercase ? tagStr.toUpperCase() : tagStr;
|
||||
const colorStyle = getColorStyleFromHint(tagStr, column.color_hint);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide whitespace-nowrap`}
|
||||
>
|
||||
{displayTag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'size':
|
||||
if (compact) {
|
||||
return <span>{displayValue}</span>;
|
||||
|
||||
@@ -1248,8 +1248,8 @@ export const ReleaseModal = ({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Scrollable content */}
|
||||
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Book summary - scrolls with content */}
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
{book.preview ? (
|
||||
@@ -1391,18 +1391,17 @@ export const ReleaseModal = ({
|
||||
}}
|
||||
/>
|
||||
{allTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.name}
|
||||
ref={(el) => { tabRefs.current[tab.name] = el; }}
|
||||
onClick={() => setActiveTab(tab.name)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.name
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
<button
|
||||
key={tab.name}
|
||||
ref={(el) => { tabRefs.current[tab.name] = el; }}
|
||||
onClick={() => setActiveTab(tab.name)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${activeTab === tab.name
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.displayName}
|
||||
</button>
|
||||
>
|
||||
{tab.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1423,9 +1422,8 @@ export const ReleaseModal = ({
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
|
||||
manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
|
||||
}`}
|
||||
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
|
||||
}`}
|
||||
aria-label="Manual search query"
|
||||
title="Manual query"
|
||||
>
|
||||
@@ -1444,9 +1442,8 @@ export const ReleaseModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
|
||||
isOpen ? 'bg-[var(--hover-surface)]' : ''
|
||||
}`}
|
||||
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${isOpen ? 'bg-[var(--hover-surface)]' : ''
|
||||
}`}
|
||||
aria-label="Sort releases"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
@@ -1467,11 +1464,10 @@ export const ReleaseModal = ({
|
||||
handleSortChange(null, null);
|
||||
close();
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
|
||||
!currentSort
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${!currentSort
|
||||
? 'text-emerald-600 dark:text-emerald-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<span>Default</span>
|
||||
{!currentSort && (
|
||||
@@ -1493,11 +1489,10 @@ export const ReleaseModal = ({
|
||||
// Don't close - allow toggling direction
|
||||
if (!isSelected) close();
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
|
||||
isSelected
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${isSelected
|
||||
? 'text-emerald-600 dark:text-emerald-400 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<span>{col.label}</span>
|
||||
{isSelected && direction && (
|
||||
@@ -1521,111 +1516,110 @@ export const ReleaseModal = ({
|
||||
{/* Only show filter button if source supports at least one filter type */}
|
||||
{((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) ||
|
||||
(columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0)) && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
panelClassName="w-56"
|
||||
noScrollLimit
|
||||
renderTrigger={({ isOpen, toggle }) => {
|
||||
// Active filter: format is set, or language is not just default
|
||||
const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT);
|
||||
const hasActiveFilter = formatFilter !== '' || hasLanguageFilter;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
|
||||
isOpen ? 'bg-[var(--hover-surface)]' : ''
|
||||
}`}
|
||||
aria-label="Filter releases"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
{hasActiveFilter && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto flex-shrink-0"
|
||||
panelClassName="w-56"
|
||||
noScrollLimit
|
||||
renderTrigger={({ isOpen, toggle }) => {
|
||||
// Active filter: format is set, or language is not just default
|
||||
const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT);
|
||||
const hasActiveFilter = formatFilter !== '' || hasLanguageFilter;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${isOpen ? 'bg-[var(--hover-surface)]' : ''
|
||||
}`}
|
||||
aria-label="Filter releases"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
{hasActiveFilter && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="p-4 space-y-4">
|
||||
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && (
|
||||
<DropdownList
|
||||
label="Format"
|
||||
options={formatOptions}
|
||||
value={formatFilter}
|
||||
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
|
||||
placeholder="All Formats"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div className="p-4 space-y-4">
|
||||
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && (
|
||||
<DropdownList
|
||||
label="Format"
|
||||
options={formatOptions}
|
||||
value={formatFilter}
|
||||
onChange={(val) => setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')}
|
||||
placeholder="All Formats"
|
||||
/>
|
||||
{columnConfig.supported_filters?.includes('language') && (
|
||||
<LanguageMultiSelect
|
||||
label="Language"
|
||||
options={bookLanguages}
|
||||
value={languageFilter}
|
||||
onChange={setLanguageFilter}
|
||||
defaultLanguageCodes={defaultLanguages}
|
||||
/>
|
||||
)}
|
||||
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
|
||||
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
close();
|
||||
if (!book?.provider || !book?.provider_id) return;
|
||||
|
||||
const provider = book.provider;
|
||||
const bookId = book.provider_id;
|
||||
|
||||
// Clear cache and state
|
||||
const key = getCacheKey(provider, bookId, activeTab, contentType);
|
||||
releaseCache.delete(key);
|
||||
cacheTimestamps.delete(key);
|
||||
setExpandedBySource((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[activeTab];
|
||||
return next;
|
||||
});
|
||||
setErrorBySource((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[activeTab];
|
||||
return next;
|
||||
});
|
||||
|
||||
// Fetch with language filter
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
|
||||
try {
|
||||
// Resolve language codes for the API call
|
||||
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
// Don't pass languages if "All" is selected or null
|
||||
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
|
||||
? undefined
|
||||
: langCodes;
|
||||
|
||||
const response = await getReleases(
|
||||
provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType, manualQuery.trim() || undefined
|
||||
);
|
||||
setCachedReleases(provider, bookId, activeTab, contentType, response);
|
||||
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
|
||||
setErrorBySource((prev) => ({ ...prev, [activeTab]: message }));
|
||||
} finally {
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{columnConfig.supported_filters?.includes('language') && (
|
||||
<LanguageMultiSelect
|
||||
label="Language"
|
||||
options={bookLanguages}
|
||||
value={languageFilter}
|
||||
onChange={setLanguageFilter}
|
||||
defaultLanguageCodes={defaultLanguages}
|
||||
/>
|
||||
)}
|
||||
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
|
||||
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
close();
|
||||
if (!book?.provider || !book?.provider_id) return;
|
||||
|
||||
const provider = book.provider;
|
||||
const bookId = book.provider_id;
|
||||
|
||||
// Clear cache and state
|
||||
const key = getCacheKey(provider, bookId, activeTab, contentType);
|
||||
releaseCache.delete(key);
|
||||
cacheTimestamps.delete(key);
|
||||
setExpandedBySource((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[activeTab];
|
||||
return next;
|
||||
});
|
||||
setErrorBySource((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[activeTab];
|
||||
return next;
|
||||
});
|
||||
|
||||
// Fetch with language filter
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: true }));
|
||||
try {
|
||||
// Resolve language codes for the API call
|
||||
const langCodes = getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
|
||||
// Don't pass languages if "All" is selected or null
|
||||
const languagesParam = (langCodes === null || langCodes?.includes(LANGUAGE_OPTION_ALL))
|
||||
? undefined
|
||||
: langCodes;
|
||||
|
||||
const response = await getReleases(
|
||||
provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType, manualQuery.trim() || undefined
|
||||
);
|
||||
setCachedReleases(provider, bookId, activeTab, contentType, response);
|
||||
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
|
||||
setErrorBySource((prev) => ({ ...prev, [activeTab]: message }));
|
||||
} finally {
|
||||
setLoadingBySource((prev) => ({ ...prev, [activeTab]: false }));
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1691,11 +1685,10 @@ export const ReleaseModal = ({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={currentTabLoading || !manualQuery.trim()}
|
||||
className={`px-3 py-2 text-sm font-medium text-white rounded-lg transition-colors ${
|
||||
currentTabLoading || !manualQuery.trim()
|
||||
className={`px-3 py-2 text-sm font-medium text-white rounded-lg transition-colors ${currentTabLoading || !manualQuery.trim()
|
||||
? 'bg-emerald-600/60 cursor-not-allowed'
|
||||
: 'bg-emerald-600 hover:bg-emerald-700'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{currentTabLoading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
@@ -1731,16 +1724,16 @@ export const ReleaseModal = ({
|
||||
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
|
||||
)
|
||||
)) && (
|
||||
<div className="py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpandSearch}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
|
||||
>
|
||||
{columnConfig.action_button?.label ?? 'Expand search'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpandSearch}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
|
||||
>
|
||||
{columnConfig.action_button?.label ?? 'Expand search'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -1768,22 +1761,22 @@ export const ReleaseModal = ({
|
||||
releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? ''
|
||||
)
|
||||
)) && (
|
||||
<div
|
||||
className="py-3 text-center animate-pop-up will-change-transform"
|
||||
style={{
|
||||
animationDelay: `${filteredReleases.length * 30}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpandSearch}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
|
||||
<div
|
||||
className="py-3 text-center animate-pop-up will-change-transform"
|
||||
style={{
|
||||
animationDelay: `${filteredReleases.length * 30}ms`,
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
>
|
||||
{columnConfig.action_button?.label ?? 'Expand search'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpandSearch}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
|
||||
>
|
||||
{columnConfig.action_button?.label ?? 'Expand search'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Expanding search - show skeleton below existing results */}
|
||||
{currentTabLoading && filteredReleases.length > 0 && (
|
||||
<ReleaseSkeleton />
|
||||
|
||||
@@ -204,7 +204,7 @@ export interface ReleaseSource {
|
||||
}
|
||||
|
||||
// Column schema types for plugin-driven release list UI
|
||||
export type ColumnRenderType = 'text' | 'badge' | 'size' | 'number' | 'peers';
|
||||
export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers';
|
||||
export type ColumnAlign = 'left' | 'center' | 'right';
|
||||
|
||||
export interface ColumnColorHint {
|
||||
|
||||
@@ -51,10 +51,18 @@ const CONTENT_TYPE_COLORS: Record<string, ColorStyle> = {
|
||||
audiobook: { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' },
|
||||
};
|
||||
|
||||
const FLAG_COLORS: Record<string, ColorStyle> = {
|
||||
freeleech: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
|
||||
'double upload': { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
|
||||
vip: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
|
||||
sticky: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
|
||||
};
|
||||
|
||||
const DEFAULT_FORMAT_COLOR: ColorStyle = { bg: 'bg-cyan-500/20', text: 'text-cyan-700 dark:text-cyan-300' };
|
||||
const DEFAULT_LANGUAGE_COLOR: ColorStyle = { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' };
|
||||
const DEFAULT_DOWNLOAD_TYPE_COLOR: ColorStyle = { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' };
|
||||
const DEFAULT_CONTENT_TYPE_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
|
||||
const DEFAULT_FLAG_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
|
||||
const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
|
||||
|
||||
export function getFormatColor(format?: string): ColorStyle {
|
||||
@@ -77,6 +85,11 @@ export function getContentTypeColor(contentType?: string): ColorStyle {
|
||||
return CONTENT_TYPE_COLORS[contentType.toLowerCase()] || DEFAULT_CONTENT_TYPE_COLOR;
|
||||
}
|
||||
|
||||
export function getFlagColor(flag?: string): ColorStyle {
|
||||
if (!flag || flag === '-') return FALLBACK_COLOR;
|
||||
return FLAG_COLORS[flag.toLowerCase()] || DEFAULT_FLAG_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color hint type for dynamic column coloring.
|
||||
*/
|
||||
@@ -106,6 +119,8 @@ export function getColorStyleFromHint(value: string, colorHint?: ColumnColorHint
|
||||
return getDownloadTypeColor(value);
|
||||
case 'content_type':
|
||||
return getContentTypeColor(value);
|
||||
case 'flags':
|
||||
return getFlagColor(value);
|
||||
default:
|
||||
return FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user