Files
tgex-frontend/components/channel-input.tsx
2026-02-09 13:10:08 +03:00

318 lines
9.6 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";
import { useState, useMemo, useEffect } from "react";
import { Loader2, Check, X, AlertCircle, ExternalLink, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { channelsApi } from "@/lib/api";
import type { Channel } from "@/lib/types/api";
export type ParsedChannel = {
input: string;
type: "invite_link" | "username" | "telemetr" | "tgstat" | "unknown";
normalized: string | null;
isValid: boolean;
channel?: Channel;
error?: string;
};
interface ChannelInputProps {
value: ParsedChannel[];
onChange: (channels: ParsedChannel[]) => void;
placeholder?: string;
maxItems?: number;
}
const CHANNEL_PATTERNS = [
{ regex: /^https:\/\/t\.me\/(.+)$/i, type: "username", normalize: (m: string) => m.replace(/@/, "").toLowerCase() },
{ regex: /^https:\/\/t\.me\/+([a-zA-Z0-9_]+)$/i, type: "invite_link", normalize: (m: string) => m.toUpperCase() },
{ regex: /^@([a-zA-Z0-9_]+)$/i, type: "username", normalize: (m: string) => m.replace(/@/, "").toLowerCase() },
{ regex: /^([a-zA-Z0-9_]+)$/i, type: "username", normalize: (m: string) => m.toLowerCase() },
{ regex: /^https:\/\/telemetr\.me\/content\/([a-zA-Z0-9_]+)$/i, type: "telemetr", normalize: (m: string) => m.toLowerCase() },
{ regex: /^https:\/\/tgstat\.ru\/channel\/@?([a-zA-Z0-9_]+)$/i, type: "tgstat", normalize: (m: string) => m.replace(/@/, "").toLowerCase() },
];
export function ChannelInput({
value,
onChange,
placeholder = "Введите каналы (ссылки или @username), каждый с новой строки",
maxItems = 50,
}: ChannelInputProps) {
const [input, setInput] = useState("");
const [parsing, setParsing] = useState(false);
const [validating, setValidating] = useState(false);
const parsedResults = useMemo(() => {
return value;
}, [value]);
const handleParse = async () => {
if (!input.trim()) return;
setParsing(true);
const lines = input
.split(/[\n,;]/)
.map((s) => s.trim())
.filter(Boolean);
const newChannels: ParsedChannel[] = [];
for (const line of lines) {
let matched = false;
for (const pattern of CHANNEL_PATTERNS) {
const match = line.match(pattern.regex);
if (match) {
const normalized = pattern.normalize(match[1]);
newChannels.push({
input: line,
type: pattern.type as ParsedChannel["type"],
normalized,
isValid: false,
});
matched = true;
break;
}
}
if (!matched) {
newChannels.push({
input: line,
type: "unknown",
normalized: null,
isValid: false,
error: "Неизвестный формат",
});
}
}
setParsing(false);
// Validate through API
await validateChannels(newChannels);
};
const validateChannels = async (channels: ParsedChannel[]) => {
setValidating(true);
const results: ParsedChannel[] = [];
for (const channel of channels) {
if (!channel.normalized) {
results.push(channel);
continue;
}
try {
// Try to find channel by different identifiers
let foundChannel: Channel | undefined;
if (channel.type === "invite_link") {
// Search by invite link pattern
const allChannels = await channelsApi.list({ size: 100 });
foundChannel = allChannels.items.find(
(c) => c.username?.toUpperCase() === channel.normalized?.toUpperCase()
);
} else {
// Search by username
const allChannels = await channelsApi.list({ size: 100 });
foundChannel = allChannels.items.find(
(c) => c.username?.toLowerCase() === channel.normalized?.toLowerCase()
);
}
if (foundChannel) {
results.push({
...channel,
isValid: true,
channel: foundChannel,
});
} else {
results.push({
...channel,
isValid: false,
error: "Канал не найден в базе",
});
}
} catch (err) {
results.push({
...channel,
isValid: false,
error: "Ошибка проверки",
});
}
}
setValidating(false);
onChange([...parsedResults, ...results].slice(0, maxItems));
setInput("");
};
const handleRemove = (index: number) => {
const newValue = [...parsedResults];
newValue.splice(index, 1);
onChange(newValue);
};
const handleClear = () => {
onChange([]);
setInput("");
};
const getTypeIcon = (type: ParsedChannel["type"]) => {
switch (type) {
case "invite_link":
return "🔗";
case "username":
return "@";
case "telemetr":
return "📊";
case "tgstat":
return "📈";
default:
return "?";
}
};
const validCount = parsedResults.filter((c) => c.isValid).length;
const invalidCount = parsedResults.filter((c) => !c.isValid).length;
const existingCount = parsedResults.filter((c) => c.isValid && c.channel).length;
const newCount = parsedResults.filter((c) => c.isValid && !c.channel).length;
return (
<div className="space-y-4">
{/* Input area */}
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={placeholder}
className="min-h-[100px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleParse();
}
}}
/>
</div>
{/* Parse button */}
<div className="flex justify-end">
<Button
onClick={handleParse}
disabled={!input.trim() || parsing || validating}
variant="outline"
>
{parsing || validating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Проверка...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Добавить каналы
</>
)}
</Button>
</div>
{/* Results summary */}
{parsedResults.length > 0 && (
<div className="flex flex-wrap gap-2 text-sm">
{validCount > 0 && (
<Badge variant="secondary" className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
{validCount} найдено
</Badge>
)}
{invalidCount > 0 && (
<Badge variant="secondary" className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
{invalidCount} не найдено
</Badge>
)}
{newCount > 0 && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{newCount} новых
</Badge>
)}
{existingCount > 0 && (
<Badge variant="secondary">
{existingCount} существующих
</Badge>
)}
</div>
)}
{/* Results list */}
{parsedResults.length > 0 && (
<div className="space-y-2 max-h-64 overflow-y-auto border rounded-lg">
{parsedResults.map((item, index) => (
<div
key={index}
className={cn(
"flex items-center gap-3 p-3 border-b last:border-0",
item.isValid
? "bg-muted/30"
: "bg-destructive/5"
)}
>
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center text-sm">
{item.isValid ? (
item.channel ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Plus className="h-4 w-4 text-blue-600" />
)
) : (
<X className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{getTypeIcon(item.type)} {item.normalized || item.input}</span>
{!item.isValid && item.error && (
<Badge variant="destructive" className="text-xs">
{item.error}
</Badge>
)}
</div>
{item.channel && (
<div className="text-xs text-muted-foreground truncate">
{item.channel.title}
</div>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{/* Actions */}
{parsedResults.length > 0 && (
<div className="flex justify-between">
<Button variant="ghost" size="sm" onClick={handleClear}>
Очистить всё
</Button>
<div className="text-xs text-muted-foreground">
Нажмите Enter для быстрого добавления
</div>
</div>
)}
</div>
);
}