318 lines
9.6 KiB
TypeScript
318 lines
9.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|