385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// Channels Catalog Page - Enhanced with Sorting
|
||
// ============================================================================
|
||
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { useParams } from "next/navigation";
|
||
import {
|
||
Loader2,
|
||
Search,
|
||
Building2,
|
||
Users,
|
||
ExternalLink,
|
||
Grid,
|
||
List,
|
||
ArrowUpDown,
|
||
ArrowUp,
|
||
ArrowDown,
|
||
Info,
|
||
} from "lucide-react";
|
||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||
import { channelsApi } from "@/lib/api";
|
||
import { isDemoWorkspace, demoChannels } from "@/lib/demo";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import type { Channel } from "@/lib/types/api";
|
||
|
||
// ============================================================================
|
||
// Helpers
|
||
// ============================================================================
|
||
|
||
const formatNumber = (value: number | null | undefined): string => {
|
||
if (value === null || value === undefined) return "—";
|
||
return new Intl.NumberFormat("ru-RU").format(value);
|
||
};
|
||
|
||
// ============================================================================
|
||
// Sort Types
|
||
// ============================================================================
|
||
|
||
type SortField = "title" | "username" | "subscribers";
|
||
type SortOrder = "asc" | "desc" | null;
|
||
|
||
// ============================================================================
|
||
// Component
|
||
// ============================================================================
|
||
|
||
export default function ChannelsCatalogPage() {
|
||
const params = useParams();
|
||
const workspaceId = params?.workspaceId as string;
|
||
|
||
const [channels, setChannels] = useState<Channel[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [search, setSearch] = useState("");
|
||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||
|
||
useEffect(() => {
|
||
const loadChannels = async () => {
|
||
try {
|
||
setLoading(true);
|
||
// Demo mode - use mock data
|
||
const isDemo = isDemoWorkspace(workspaceId);
|
||
const response = isDemo
|
||
? { items: demoChannels.filter(c => !search || c.username?.includes(search) || c.title.includes(search)), total: demoChannels.length, page: 1, size: 100, pages: 1 }
|
||
: await channelsApi.list({
|
||
username: search || undefined,
|
||
size: 100,
|
||
});
|
||
setChannels(response.items);
|
||
} catch (err) {
|
||
console.error("Failed to load channels:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const debounce = setTimeout(loadChannels, 300);
|
||
return () => clearTimeout(debounce);
|
||
}, [search]);
|
||
|
||
// Handle sort
|
||
const handleSort = (field: SortField) => {
|
||
if (sortField === field) {
|
||
if (sortOrder === "asc") {
|
||
setSortOrder("desc");
|
||
} else if (sortOrder === "desc") {
|
||
setSortField(null);
|
||
setSortOrder(null);
|
||
} else {
|
||
setSortOrder("asc");
|
||
}
|
||
} else {
|
||
setSortField(field);
|
||
setSortOrder("asc");
|
||
}
|
||
};
|
||
|
||
const getSortIcon = (field: SortField) => {
|
||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||
};
|
||
|
||
// Filter and sort channels
|
||
const filteredChannels = useMemo(() => {
|
||
let result = [...channels];
|
||
|
||
// Sort
|
||
if (sortField && sortOrder) {
|
||
result.sort((a, b) => {
|
||
let aVal: string | number = "";
|
||
let bVal: string | number = "";
|
||
|
||
switch (sortField) {
|
||
case "title":
|
||
aVal = a.title.toLowerCase();
|
||
bVal = b.title.toLowerCase();
|
||
return sortOrder === "asc"
|
||
? aVal.localeCompare(bVal)
|
||
: bVal.localeCompare(aVal);
|
||
case "username":
|
||
aVal = (a.username || "").toLowerCase();
|
||
bVal = (b.username || "").toLowerCase();
|
||
return sortOrder === "asc"
|
||
? aVal.localeCompare(bVal)
|
||
: bVal.localeCompare(aVal);
|
||
case "subscribers":
|
||
aVal = a.subscribers_count || 0;
|
||
bVal = b.subscribers_count || 0;
|
||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||
}
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}, [channels, sortField, sortOrder]);
|
||
|
||
return (
|
||
<TooltipProvider>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||
{ label: "Каталог каналов" },
|
||
]}
|
||
showProjectSelector={false}
|
||
/>
|
||
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">Каталог каналов</h1>
|
||
<p className="text-muted-foreground">
|
||
{filteredChannels.length} каналов для размещения рекламы
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Поиск по username..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-1 border rounded-lg p-1">
|
||
<Button
|
||
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setViewMode("table")}
|
||
aria-label="Табличный вид"
|
||
>
|
||
<List className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||
size="sm"
|
||
onClick={() => setViewMode("grid")}
|
||
aria-label="Плиточный вид"
|
||
>
|
||
<Grid className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : channels.length === 0 ? (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||
<h3 className="text-lg font-semibold mb-2">Каналы не найдены</h3>
|
||
<p className="text-muted-foreground text-center">
|
||
{search
|
||
? "Попробуйте изменить поисковый запрос"
|
||
: "В каталоге пока нет каналов"}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : viewMode === "table" ? (
|
||
<Card>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="-ml-3 h-8"
|
||
onClick={() => handleSort("title")}
|
||
>
|
||
Канал
|
||
{getSortIcon("title")}
|
||
</Button>
|
||
</TableHead>
|
||
<TableHead>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="-ml-3 h-8"
|
||
onClick={() => handleSort("username")}
|
||
>
|
||
Username
|
||
{getSortIcon("username")}
|
||
</Button>
|
||
</TableHead>
|
||
<TableHead className="text-right">
|
||
<div className="flex items-center justify-end">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={() => handleSort("subscribers")}
|
||
>
|
||
Подписчики
|
||
{getSortIcon("subscribers")}
|
||
</Button>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help ml-1" />
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Количество подписчиков на момент последнего обновления
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</TableHead>
|
||
<TableHead className="w-[120px]"></TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredChannels.map((channel) => (
|
||
<TableRow key={channel.id}>
|
||
<TableCell>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
|
||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||
</div>
|
||
<div>
|
||
<div className="font-medium">{channel.title}</div>
|
||
{channel.description && (
|
||
<div className="text-xs text-muted-foreground line-clamp-1 max-w-[300px]">
|
||
{channel.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
{channel.username ? (
|
||
<a
|
||
href={`https://t.me/${channel.username}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-primary hover:underline flex items-center gap-1"
|
||
>
|
||
@{channel.username}
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<Users className="h-4 w-4 text-muted-foreground" />
|
||
{formatNumber(channel.subscribers_count)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Button variant="outline" size="sm" asChild>
|
||
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
|
||
Разместить
|
||
</a>
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Card>
|
||
) : (
|
||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
{filteredChannels.map((channel) => (
|
||
<Card key={channel.id}>
|
||
<CardHeader>
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<CardTitle className="text-base truncate">{channel.title}</CardTitle>
|
||
{channel.username && (
|
||
<CardDescription>
|
||
<a
|
||
href={`https://t.me/${channel.username}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="hover:underline inline-flex items-center gap-1"
|
||
>
|
||
@{channel.username}
|
||
<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
</CardDescription>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{channel.description && (
|
||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||
{channel.description}
|
||
</p>
|
||
)}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||
<Users className="h-4 w-4" />
|
||
{formatNumber(channel.subscribers_count)}
|
||
</div>
|
||
<Button variant="outline" size="sm" asChild>
|
||
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
|
||
Разместить
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TooltipProvider>
|
||
);
|
||
}
|