feat: platform update

This commit is contained in:
ivannoskov
2026-02-09 13:10:08 +03:00
parent f7597270e5
commit 1826483750
15 changed files with 907 additions and 2796 deletions

View File

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