feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -0,0 +1,384 @@
"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>
);
}