add collapsible series/season/episode view to subtitle list page
All checks were successful
Build and Push Docker Image / build (push) Successful in 21s
All checks were successful
Build and Push Docker Image / build (push) Successful in 21s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,22 @@ import { unlinkSync } from 'node:fs';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubListItem {
|
||||
id: number; jellyfin_id: string; type: string; name: string;
|
||||
series_name: string | null; season_number: number | null;
|
||||
episode_number: number | null; year: number | null;
|
||||
original_language: string | null; file_path: string;
|
||||
subs_extracted: number | null; sub_count: number; file_count: number;
|
||||
}
|
||||
|
||||
interface SubSeriesGroup {
|
||||
series_key: string; series_name: string; original_language: string | null;
|
||||
season_count: number; episode_count: number;
|
||||
not_extracted_count: number; extracted_count: number; no_subs_count: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
@@ -35,18 +51,22 @@ function loadDetail(db: ReturnType<typeof getDb>, itemId: number) {
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildSubWhere(filter: string): string {
|
||||
switch (filter) {
|
||||
case 'not_extracted': return "sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0";
|
||||
case 'extracted': return "rp.subs_extracted = 1";
|
||||
case 'no_subs': return "sub_count = 0";
|
||||
default: return '1=1';
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/', (c) => {
|
||||
const db = getDb();
|
||||
const filter = c.req.query('filter') ?? 'all';
|
||||
const where = buildSubWhere(filter);
|
||||
|
||||
let where = '1=1';
|
||||
switch (filter) {
|
||||
case 'not_extracted': where = 'rp.subs_extracted = 0 AND sub_count > 0'; break;
|
||||
case 'extracted': where = 'rp.subs_extracted = 1'; break;
|
||||
case 'no_subs': where = 'sub_count = 0'; break;
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
// Movies
|
||||
const movieRows = db.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
@@ -54,14 +74,30 @@ app.get('/', (c) => {
|
||||
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Movie' AND ${where}
|
||||
ORDER BY mi.name LIMIT 500
|
||||
`).all() as SubListItem[];
|
||||
|
||||
// Series groups
|
||||
const series = db.prepare(`
|
||||
SELECT COALESCE(mi.series_jellyfin_id, mi.series_name) as series_key,
|
||||
mi.series_name,
|
||||
MAX(mi.original_language) as original_language,
|
||||
COUNT(DISTINCT mi.season_number) as season_count,
|
||||
COUNT(mi.id) as episode_count,
|
||||
SUM(CASE WHEN sub_count > 0 AND COALESCE(rp.subs_extracted, 0) = 0 THEN 1 ELSE 0 END) as not_extracted_count,
|
||||
SUM(CASE WHEN rp.subs_extracted = 1 THEN 1 ELSE 0 END) as extracted_count,
|
||||
SUM(CASE WHEN sub_count = 0 THEN 1 ELSE 0 END) as no_subs_count
|
||||
FROM (
|
||||
SELECT mi.*,
|
||||
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count
|
||||
FROM media_items mi
|
||||
WHERE mi.type = 'Episode'
|
||||
) mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE ${where}
|
||||
ORDER BY mi.name
|
||||
LIMIT 500
|
||||
`).all() as (Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> & {
|
||||
subs_extracted: number | null;
|
||||
sub_count: number;
|
||||
file_count: number;
|
||||
})[];
|
||||
GROUP BY series_key ORDER BY mi.series_name
|
||||
`).all() as SubSeriesGroup[];
|
||||
|
||||
const totalAll = (db.prepare('SELECT COUNT(*) as n FROM media_items').get() as { n: number }).n;
|
||||
const totalExtracted = (db.prepare('SELECT COUNT(*) as n FROM review_plans WHERE subs_extracted = 1').get() as { n: number }).n;
|
||||
@@ -72,12 +108,52 @@ app.get('/', (c) => {
|
||||
const totalNotExtracted = totalAll - totalExtracted - totalNoSubs;
|
||||
|
||||
return c.json({
|
||||
items: rows,
|
||||
movies: movieRows,
|
||||
series,
|
||||
filter,
|
||||
totalCounts: { all: totalAll, not_extracted: totalNotExtracted, extracted: totalExtracted, no_subs: totalNoSubs },
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Series episodes (subtitles) ─────────────────────────────────────────────
|
||||
|
||||
app.get('/series/:seriesKey/episodes', (c) => {
|
||||
const db = getDb();
|
||||
const seriesKey = decodeURIComponent(c.req.param('seriesKey'));
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT mi.id, mi.jellyfin_id, mi.type, mi.name, mi.series_name, mi.season_number,
|
||||
mi.episode_number, mi.year, mi.original_language, mi.file_path,
|
||||
rp.subs_extracted,
|
||||
(SELECT COUNT(*) FROM media_streams ms WHERE ms.item_id = mi.id AND ms.type = 'Subtitle') as sub_count,
|
||||
(SELECT COUNT(*) FROM subtitle_files sf WHERE sf.item_id = mi.id) as file_count
|
||||
FROM media_items mi
|
||||
LEFT JOIN review_plans rp ON rp.item_id = mi.id
|
||||
WHERE mi.type = 'Episode'
|
||||
AND (mi.series_jellyfin_id = ? OR (mi.series_jellyfin_id IS NULL AND mi.series_name = ?))
|
||||
ORDER BY mi.season_number, mi.episode_number
|
||||
`).all(seriesKey, seriesKey) as SubListItem[];
|
||||
|
||||
const seasonMap = new Map<number | null, SubListItem[]>();
|
||||
for (const r of rows) {
|
||||
const season = r.season_number ?? null;
|
||||
if (!seasonMap.has(season)) seasonMap.set(season, []);
|
||||
seasonMap.get(season)!.push(r);
|
||||
}
|
||||
|
||||
const seasons = Array.from(seasonMap.entries())
|
||||
.sort(([a], [b]) => (a ?? -1) - (b ?? -1))
|
||||
.map(([season, episodes]) => ({
|
||||
season,
|
||||
episodes,
|
||||
extractedCount: episodes.filter((e) => e.subs_extracted === 1).length,
|
||||
notExtractedCount: episodes.filter((e) => e.sub_count > 0 && !e.subs_extracted).length,
|
||||
noSubsCount: episodes.filter((e) => e.sub_count === 0).length,
|
||||
}));
|
||||
|
||||
return c.json({ seasons });
|
||||
});
|
||||
|
||||
// ─── Detail ──────────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/:id', (c) => {
|
||||
|
||||
@@ -4,22 +4,35 @@ import { api } from '~/shared/lib/api';
|
||||
import { Badge } from '~/shared/components/ui/badge';
|
||||
import { Button } from '~/shared/components/ui/button';
|
||||
import { langName } from '~/shared/lib/lang';
|
||||
import type { MediaItem } from '~/shared/lib/types';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SubtitleListItem extends Pick<MediaItem, 'id' | 'jellyfin_id' | 'type' | 'name' | 'series_name' | 'season_number' | 'episode_number' | 'year' | 'original_language' | 'file_path'> {
|
||||
subs_extracted: number | null;
|
||||
sub_count: number;
|
||||
file_count: number;
|
||||
interface SubListItem {
|
||||
id: number; name: string; type: string; series_name: string | null;
|
||||
season_number: number | null; episode_number: number | null;
|
||||
year: number | null; original_language: string | null;
|
||||
subs_extracted: number | null; sub_count: number; file_count: number;
|
||||
}
|
||||
|
||||
interface SubtitleListData {
|
||||
items: SubtitleListItem[];
|
||||
interface SubSeriesGroup {
|
||||
series_key: string; series_name: string; original_language: string | null;
|
||||
season_count: number; episode_count: number;
|
||||
not_extracted_count: number; extracted_count: number; no_subs_count: number;
|
||||
}
|
||||
|
||||
interface SubListData {
|
||||
movies: SubListItem[];
|
||||
series: SubSeriesGroup[];
|
||||
filter: string;
|
||||
totalCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface SeasonGroup {
|
||||
season: number | null;
|
||||
episodes: SubListItem[];
|
||||
extractedCount: number; notExtractedCount: number; noSubsCount: number;
|
||||
}
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'not_extracted', label: 'Not Extracted' },
|
||||
@@ -27,17 +40,130 @@ const FILTER_TABS = [
|
||||
{ key: 'no_subs', label: 'No Subtitles' },
|
||||
];
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const Th = ({ children }: { children: React.ReactNode }) => (
|
||||
<th className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
const Td = ({ children, className }: { children?: React.ReactNode; className?: string }) => (
|
||||
<td className={`py-1.5 px-2 border-b border-gray-100 align-middle ${className ?? ''}`}>{children}</td>
|
||||
);
|
||||
|
||||
function subStatus(item: SubListItem): 'extracted' | 'not_extracted' | 'no_subs' {
|
||||
if (item.sub_count === 0) return 'no_subs';
|
||||
return item.subs_extracted ? 'extracted' : 'not_extracted';
|
||||
}
|
||||
|
||||
function StatusBadge({ item }: { item: SubListItem }) {
|
||||
const s = subStatus(item);
|
||||
if (s === 'extracted') return <Badge variant="keep">extracted</Badge>;
|
||||
if (s === 'not_extracted') return <Badge variant="pending">pending</Badge>;
|
||||
return <Badge variant="noop">no subs</Badge>;
|
||||
}
|
||||
|
||||
// ─── Status pills ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusPills({ g }: { g: SubSeriesGroup }) {
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1 items-center">
|
||||
{g.extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-green-100 text-green-800">{g.extracted_count} extracted</span>}
|
||||
{g.not_extracted_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-800">{g.not_extracted_count} pending</span>}
|
||||
{g.no_subs_count > 0 && <span className="text-[0.72rem] font-semibold px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{g.no_subs_count} no subs</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Series row (collapsible) ─────────────────────────────────────────────────
|
||||
|
||||
function SeriesRow({ g }: { g: SubSeriesGroup }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const urlKey = encodeURIComponent(g.series_key);
|
||||
|
||||
const [seasons, setSeasons] = useState<SeasonGroup[] | null>(null);
|
||||
|
||||
const toggle = async () => {
|
||||
if (!open && seasons === null) {
|
||||
const data = await api.get<{ seasons: SeasonGroup[] }>(`/api/subtitles/series/${urlKey}/episodes`);
|
||||
setSeasons(data.seasons);
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
<tr className="cursor-pointer hover:bg-gray-50" onClick={toggle}>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 font-medium">
|
||||
<span className={`inline-flex items-center justify-center w-4 h-4 rounded text-[0.65rem] text-gray-500 transition-transform ${open ? 'rotate-90' : ''}`}>▶</span>
|
||||
{' '}<strong>{g.series_name}</strong>
|
||||
</td>
|
||||
<Td>{langName(g.original_language)}</Td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.season_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 text-gray-500">{g.episode_count}</td>
|
||||
<Td><StatusPills g={g} /></Td>
|
||||
</tr>
|
||||
{open && seasons && (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-0 border-b border-gray-100">
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{seasons.map((s) => (
|
||||
<>
|
||||
<tr key={`season-${s.season}`} className="bg-gray-50">
|
||||
<td colSpan={5} className="text-[0.7rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-0.5 px-8 border-b border-gray-100">
|
||||
Season {s.season ?? '?'}
|
||||
<span className="ml-3 inline-flex gap-1">
|
||||
{s.extractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-800 text-[0.7rem]">{s.extractedCount} extracted</span>}
|
||||
{s.notExtractedCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-red-100 text-red-800 text-[0.7rem]">{s.notExtractedCount} pending</span>}
|
||||
{s.noSubsCount > 0 && <span className="px-1.5 py-0.5 rounded-full bg-gray-200 text-gray-600 text-[0.7rem]">{s.noSubsCount} no subs</span>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{s.episodes.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] pl-10">
|
||||
<span className="text-gray-400 font-mono text-xs">E{String(item.episode_number ?? 0).padStart(2, '0')}</span>
|
||||
{' '}
|
||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
|
||||
<span className="truncate inline-block max-w-xs align-bottom">{item.name}</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.sub_count}</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem] font-mono text-xs">{item.file_count}</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 text-[0.8rem]">
|
||||
<StatusBadge item={item} />
|
||||
</td>
|
||||
<td className="py-1 px-2 border-b border-gray-100 whitespace-nowrap">
|
||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 no-underline">
|
||||
Detail
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SubtitleListPage() {
|
||||
const { filter } = useSearch({ from: '/review/subtitles/' });
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<SubtitleListData | null>(null);
|
||||
const [data, setData] = useState<SubListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [extractResult, setExtractResult] = useState('');
|
||||
|
||||
const load = () => api.get<SubtitleListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
const load = () => api.get<SubListData>(`/api/subtitles?filter=${filter}`).then((d) => { setData(d); setLoading(false); }).catch(() => setLoading(false));
|
||||
|
||||
useEffect(() => { load(); }, [filter]);
|
||||
|
||||
@@ -55,13 +181,13 @@ export function SubtitleListPage() {
|
||||
if (loading) return <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
||||
if (!data) return <div className="text-red-600">Failed to load.</div>;
|
||||
|
||||
const { items, totalCounts } = data;
|
||||
const { movies, series, totalCounts } = data;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<h1 className="text-xl font-bold m-0">Subtitle Manager</h1>
|
||||
<Button size="sm" onClick={extractAll} disabled={extracting || (data?.totalCounts.not_extracted ?? 0) === 0}>
|
||||
<Button size="sm" onClick={extractAll} disabled={extracting || (totalCounts.not_extracted ?? 0) === 0}>
|
||||
{extracting ? 'Queuing…' : 'Extract All Pending'}
|
||||
</Button>
|
||||
{extractResult && <span className="text-sm text-gray-500">{extractResult}</span>}
|
||||
@@ -82,45 +208,56 @@ export function SubtitleListPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (
|
||||
{movies.length === 0 && series.length === 0 && (
|
||||
<p className="text-gray-500">No items match this filter.</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0"><table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
{['Name', 'Lang', 'Container Subs', 'Extracted Files', 'Status'].map((h) => (
|
||||
<th key={h} className="text-left text-[0.68rem] font-bold uppercase tracking-[0.06em] text-gray-500 py-1 px-2 border-b-2 border-gray-200 whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const status = item.sub_count === 0 ? 'no_subs' : item.subs_extracted ? 'extracted' : 'not_extracted';
|
||||
return (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
|
||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
|
||||
<span className="truncate inline-block max-w-[360px]" title={item.name}>{item.name}</span>
|
||||
</Link>
|
||||
{item.series_name && <span className="text-gray-400 text-[0.72rem]"> — {item.series_name}</span>}
|
||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">{langName(item.original_language)}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.sub_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle font-mono text-xs">{item.file_count}</td>
|
||||
<td className="py-1.5 px-2 border-b border-gray-100 align-middle">
|
||||
{status === 'extracted' && <Badge variant="keep">extracted</Badge>}
|
||||
{status === 'not_extracted' && <Badge variant="pending">pending</Badge>}
|
||||
{status === 'no_subs' && <Badge variant="noop">no subs</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{/* Movies */}
|
||||
{movies.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-5 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500">
|
||||
Movies <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{movies.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead><tr><Th>Name</Th><Th>Lang</Th><Th>Subs</Th><Th>Files</Th><Th>Status</Th></tr></thead>
|
||||
<tbody>
|
||||
{movies.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<Td>
|
||||
<Link to="/review/subtitles/$id" params={{ id: String(item.id) }} className="no-underline text-blue-600 hover:text-blue-800">
|
||||
<span className="truncate inline-block max-w-[200px] sm:max-w-[360px]" title={item.name}>{item.name}</span>
|
||||
</Link>
|
||||
{item.year && <span className="text-gray-400 text-[0.72rem]"> ({item.year})</span>}
|
||||
</Td>
|
||||
<Td>{langName(item.original_language)}</Td>
|
||||
<Td className="font-mono text-xs">{item.sub_count}</Td>
|
||||
<Td className="font-mono text-xs">{item.file_count}</Td>
|
||||
<Td><StatusBadge item={item} /></Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* TV Series */}
|
||||
{series.length > 0 && (
|
||||
<>
|
||||
<div className={`flex items-center gap-2 mb-1.5 text-[0.72rem] font-bold uppercase tracking-[0.07em] text-gray-500 ${movies.length > 0 ? 'mt-5' : 'mt-0'}`}>
|
||||
TV Series <span className="bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{series.length}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-3 px-3 sm:mx-0 sm:px-0">
|
||||
<table className="w-full border-collapse text-[0.82rem]">
|
||||
<thead><tr><Th>Series</Th><Th>Lang</Th><Th>S</Th><Th>Ep</Th><Th>Status</Th></tr></thead>
|
||||
{series.map((g) => <SeriesRow key={g.series_key} g={g} />)}
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
Reference in New Issue
Block a user