feat: platform update
This commit is contained in:
317
components/channel-input.tsx
Normal file
317
components/channel-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user