Files
tgex-frontend/app/(dashboard)/external-channels/page.tsx
2025-12-01 06:58:52 +03:00

618 lines
22 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";
// ============================================================================
// External Channels List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { externalChannelsApi, channelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatUsername,
formatCompactNumber,
} from "@/lib/utils/format";
import {
ExternalLink as ExternalLinkIcon,
Loader2,
AlertCircle,
Plus,
Search,
FileUp,
Eye,
Pencil,
Trash2,
Users,
ArrowUpDown,
ArrowUp,
ArrowDown,
LayoutGrid,
Table2,
} from "lucide-react";
import type { ExternalChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
type ViewMode = "table" | "grid";
type SortField =
| "title"
| "username"
| "subscribers_count"
| "telegram_id"
| null;
type SortDirection = "asc" | "desc" | null;
export default function ExternalChannelsPage() {
const { selectedChannel } = useTargetChannel();
const [channels, setChannels] = useState<ExternalChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
// Form state
const [formData, setFormData] = useState({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
useEffect(() => {
if (selectedChannel) {
loadChannels();
} else {
setLoading(false);
}
}, [selectedChannel]);
const loadChannels = async () => {
if (!selectedChannel) return;
try {
setLoading(true);
setError(null);
const response = await externalChannelsApi.list(selectedChannel.id);
setChannels(response.external_channels);
} catch (err: any) {
const errorMsg =
typeof err?.detail === "string"
? err.detail
: err?.error?.message || "Ошибка загрузки каналов";
setError(errorMsg);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
setIsCreating(true);
// Получаем первый доступный target channel
const targetChannelsRes = await channelsApi.list();
if (targetChannelsRes.target_channels.length === 0) {
alert("Добавьте сначала целевой канал");
setIsCreating(false);
return;
}
await externalChannelsApi.create({
telegram_id: 0, // Временно, пока не получим реальный ID
title: formData.title,
username: formData.username || null,
subscribers_count: formData.subscribers_count
? parseInt(formData.subscribers_count)
: null,
description: formData.description || null,
target_channel_ids: [targetChannelsRes.target_channels[0].id],
});
setIsCreateDialogOpen(false);
setFormData({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
loadChannels();
} catch (err: any) {
const errorMsg =
typeof err?.detail === "string"
? err.detail
: err?.error?.message || "Ошибка создания канала";
alert(errorMsg);
} finally {
setIsCreating(false);
}
};
const handleDelete = async (id: string, title: string) => {
if (!confirm(`Удалить канал "${title}"?`)) return;
try {
await externalChannelsApi.delete(id);
loadChannels();
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления канала");
}
};
// Обработка сортировки
const handleSort = (field: SortField) => {
if (sortField === field) {
// Переключение: asc -> desc -> null -> asc
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortField(null);
setSortDirection(null);
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) {
return <ArrowUpDown className="ml-2 h-4 w-4 inline" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="ml-2 h-4 w-4 inline" />;
}
return <ArrowDown className="ml-2 h-4 w-4 inline" />;
};
const filteredChannels = channels
.filter(
(channel) =>
channel.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
channel.username?.toLowerCase().includes(searchQuery.toLowerCase())
)
.sort((a, b) => {
if (!sortField || !sortDirection) return 0;
let aValue: string | number | null = "";
let bValue: string | number | null = "";
switch (sortField) {
case "title":
aValue = a.title;
bValue = b.title;
break;
case "username":
aValue = a.username || "";
bValue = b.username || "";
break;
case "subscribers_count":
aValue = a.subscribers_count || 0;
bValue = b.subscribers_count || 0;
break;
case "telegram_id":
aValue = a.telegram_id;
bValue = b.telegram_id;
break;
}
// Сравнение строк
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
// Сравнение чисел
if (typeof aValue === "number" && typeof bValue === "number") {
return sortDirection === "asc" ? aValue - bValue : bValue - aValue;
}
return 0;
});
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ 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-3xl font-bold tracking-tight">
Внешние каналы
</h1>
<p className="text-muted-foreground mt-1">
Каналы, в которых покупаете рекламу
</p>
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/external-channels/import">
<FileUp className="mr-2 h-4 w-4" />
Импорт из Excel
</Link>
</Button>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Добавить канал
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Добавить внешний канал</DialogTitle>
<DialogDescription>
Добавьте канал, в котором планируете покупать рекламу
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Название *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
placeholder="Название канала"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="link">Ссылка *</Label>
<Input
id="link"
value={formData.link}
onChange={(e) =>
setFormData({ ...formData, link: e.target.value })
}
placeholder="https://t.me/channel_name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
placeholder="@channel_name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="subscribers">Количество подписчиков</Label>
<Input
id="subscribers"
type="number"
value={formData.subscribers_count}
onChange={(e) =>
setFormData({
...formData,
subscribers_count: e.target.value,
})
}
placeholder="50000"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Описание</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
placeholder="Краткое описание канала"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Отмена
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание...
</>
) : (
"Создать"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию или username..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 rounded-lg"
/>
</div>
<Badge variant="secondary">
Всего: {formatNumber(filteredChannels.length)}
</Badge>
</div>
<div className="flex items-center gap-1 border rounded-md">
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="rounded-r-none"
>
<Table2 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="rounded-l-none"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<ExternalLinkIcon className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ? "Каналы не найдены" : "Нет внешних каналов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery
? "Попробуйте изменить поисковый запрос"
: "Добавьте каналы вручную или импортируйте из Excel"}
</p>
</CardContent>
</Card>
) : viewMode === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredChannels.map((channel) => (
<Card
key={channel.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate flex items-center gap-2">
<ExternalLinkIcon className="h-4 w-4 shrink-0" />
{channel.title}
</CardTitle>
<CardDescription className="truncate mt-1">
{channel.username ? (
<a
href={`https://t.me/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{formatUsername(channel.username)}
</a>
) : (
<span className="text-xs">
ID: {channel.telegram_id}
</span>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{channel.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{channel.description}
</p>
)}
<div className="flex items-center gap-2">
<div className="flex-1 rounded-lg border bg-muted/50 p-2">
<div className="flex items-center justify-center gap-1">
<Users className="h-3 w-3 text-muted-foreground" />
<div className="text-sm font-medium">
{channel.subscribers_count
? formatCompactNumber(channel.subscribers_count)
: "—"}
</div>
</div>
<div className="text-xs text-muted-foreground text-center">
Подписчики
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="destructive"
size="sm"
className="flex-1"
onClick={() => handleDelete(channel.id, channel.title)}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Список внешних каналов</CardTitle>
<CardDescription>
Каналы, в которых покупаете рекламу
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("title")}
>
Название
{getSortIcon("title")}
</TableHead>
<TableHead className="cursor-pointer hover:bg-muted/50">
Username
</TableHead>
<TableHead>Описание</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("subscribers_count")}
>
Подписчики
{getSortIcon("subscribers_count")}
</TableHead>
<TableHead className="text-right cursor-pointer hover:bg-muted/50">
Telegram ID
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<a
href={`https://t.me/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 hover:underline text-primary whitespace-nowrap"
tabIndex={0}
aria-label={`Открыть канал ${channel.title} в Telegram`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
window.open(
`https://t.me/${channel.username}`,
"_blank",
"noopener,noreferrer"
);
}
}}
>
<span className="inline-flex items-center gap-2">
<span>{channel.title}</span>
<ExternalLinkIcon className="h-4 w-4 text-muted-foreground shrink-0" />
</span>
</a>
</div>
</TableCell>
<TableCell>
{channel.username ? (
<div>{formatUsername(channel.username)}</div>
) : (
"—"
)}
</TableCell>
<TableCell>
<div className="max-w-[300px] truncate text-muted-foreground">
{channel.description || "—"}
</div>
</TableCell>
<TableCell className="text-right">
{channel.subscribers_count
? formatNumber(channel.subscribers_count)
: "—"}
</TableCell>
<TableCell className="text-right">
{channel.telegram_id}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDelete(channel.id, channel.title)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
</>
);
}