Files
tgex-frontend/app/dashboard/[workspaceId]/channels/page.tsx
2026-02-09 13:10:08 +03:00

462 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useWorkspace } from "@/components/providers/workspace-provider";
import type { Channel, Project } 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 { currentProject, projects } = useWorkspace();
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);
// Placement dialog
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [showPlacementDialog, setShowPlacementDialog] = useState(false);
const [placementProjectId, setPlacementProjectId] = useState<string>("");
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" />;
};
const handleOpenPlacement = (channel: Channel) => {
setSelectedChannel(channel);
if (currentProject) {
setPlacementProjectId(currentProject.id);
}
setShowPlacementDialog(true);
};
const handleConfirmPlacement = () => {
if (!placementProjectId || !selectedChannel) return;
window.location.href = `/dashboard/${workspaceId}/purchase-plans/${placementProjectId}?openPlacement=true&channel_id=${selectedChannel.id}`;
};
const activeProjects = useMemo(() => {
return projects.filter(p => p.status === "active");
}, [projects]);
// 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: "Каталог каналов" },
]}
/>
<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" onClick={() => handleOpenPlacement(channel)}>
Разместить
</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" onClick={() => handleOpenPlacement(channel)}>
Разместить
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
<Dialog open={showPlacementDialog} onOpenChange={setShowPlacementDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Создание размещения</DialogTitle>
<DialogDescription>
Выберите проект для размещения на канале {selectedChannel?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Проект</label>
<Select value={placementProjectId} onValueChange={setPlacementProjectId}>
<SelectTrigger>
<SelectValue placeholder="Выберите проект" />
</SelectTrigger>
<SelectContent>
{activeProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.title}
</SelectItem>
))}
</SelectContent>
</Select>
{activeProjects.length === 0 && (
<p className="text-sm text-muted-foreground">
Нет активных проектов. Создайте проект в настройках.
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setShowPlacementDialog(false)}>
Отмена
</Button>
<Button
onClick={handleConfirmPlacement}
disabled={!placementProjectId || activeProjects.length === 0}
>
Продолжить
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}