mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: add Telegram integration with forum topic support and creator tracking
Adds full Telegram ZIP ingestion pipeline: TDLib worker service scans source channels for archive files, deduplicates by content hash, extracts metadata, uploads to archive channel, and indexes in Postgres. Forum supergroups are scanned per-topic with topic names used as creator. Filename-based creator extraction (e.g. "Mammoth Factory - 2026-01.zip") serves as fallback. Includes admin UI for managing accounts/channels, simplified account setup (API credentials via env vars), auth code/password submission dialog, package browser with creator column, and live ingestion activity tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
149
src/app/(app)/stls/_components/ingestion-status.tsx
Normal file
149
src/app/(app)/stls/_components/ingestion-status.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, CheckCircle2, XCircle, CloudOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
|
||||
interface IngestionStatusProps {
|
||||
initialStatus: IngestionAccountStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls /api/ingestion/status every 3 seconds while a run is active,
|
||||
* or every 30 seconds when idle. Shows a compact status banner with
|
||||
* a spinning throbber when ingestion is running.
|
||||
*/
|
||||
export function IngestionStatus({ initialStatus }: IngestionStatusProps) {
|
||||
const [accounts, setAccounts] = useState(initialStatus);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Determine if any account is currently running
|
||||
const activeRun = accounts.find((a) => a.currentRun);
|
||||
const isRunning = !!activeRun;
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
let mounted = true;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ingestion/status");
|
||||
if (!res.ok) throw new Error("fetch failed");
|
||||
const data = await res.json();
|
||||
if (mounted) {
|
||||
setAccounts(data.accounts ?? []);
|
||||
setError(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) setError(true);
|
||||
}
|
||||
if (mounted) {
|
||||
// Poll fast while running, slow when idle
|
||||
const interval = accounts.some((a) => a.currentRun) ? 3_000 : 30_000;
|
||||
timer = setTimeout(poll, interval);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after a short delay to avoid double-fetching on mount
|
||||
timer = setTimeout(poll, 3_000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isRunning]);
|
||||
|
||||
// Nothing to show if no accounts configured
|
||||
if (accounts.length === 0 && !error) return null;
|
||||
|
||||
// If we can't reach the API, show a muted offline badge
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
|
||||
<CloudOff className="h-3.5 w-3.5" />
|
||||
<span>Sync status unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active run — show throbber with live activity
|
||||
if (activeRun?.currentRun) {
|
||||
const run = activeRun.currentRun;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-primary">
|
||||
{run.currentActivity ?? "Syncing..."}
|
||||
</p>
|
||||
{run.downloadPercent != null && run.downloadPercent > 0 && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-primary/20">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, run.downloadPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-primary/70">{run.downloadPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{run.totalFiles != null && run.currentFileNum != null && (
|
||||
<span className="shrink-0 text-[10px] text-primary/60">
|
||||
{run.currentFileNum}/{run.totalFiles}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// All idle — show last run summary
|
||||
const lastCompleted = accounts
|
||||
.filter((a) => a.lastRun)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastRun!.finishedAt ?? b.lastRun!.startedAt).getTime() -
|
||||
new Date(a.lastRun!.finishedAt ?? a.lastRun!.startedAt).getTime()
|
||||
)[0];
|
||||
|
||||
if (!lastCompleted?.lastRun) return null;
|
||||
|
||||
const last = lastCompleted.lastRun;
|
||||
const isFailed = last.status === "FAILED";
|
||||
const timeAgo = getTimeAgo(last.finishedAt ?? last.startedAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-2 text-xs",
|
||||
isFailed
|
||||
? "border-red-500/20 bg-red-500/5 text-red-400"
|
||||
: "border-border bg-card text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isFailed ? (
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{isFailed
|
||||
? `Last sync failed ${timeAgo}`
|
||||
: `Last sync ${timeAgo} — ${last.zipsIngested} new, ${last.zipsDuplicate} skipped`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "just now";
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
154
src/app/(app)/stls/_components/package-columns.tsx
Normal file
154
src/app/(app)/stls/_components/package-columns.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { FileArchive, Eye, ImageIcon } from "lucide-react";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface PackageRow {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: string;
|
||||
contentHash: string;
|
||||
archiveType: "ZIP" | "RAR";
|
||||
fileCount: number;
|
||||
isMultipart: boolean;
|
||||
hasPreview: boolean;
|
||||
creator: string | null;
|
||||
indexedAt: string;
|
||||
sourceChannel: {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PackageColumnsProps {
|
||||
onViewFiles: (pkg: PackageRow) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
const bytes = Number(bytesStr);
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
||||
if (pkg.hasPreview) {
|
||||
return (
|
||||
<img
|
||||
src={`/api/zips/${pkg.id}/preview`}
|
||||
alt=""
|
||||
className="h-9 w-9 rounded-md object-cover bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<FileArchive className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getPackageColumns({
|
||||
onViewFiles,
|
||||
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: "preview",
|
||||
header: "",
|
||||
cell: ({ row }) => <PreviewCell pkg={row.original} />,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
size: 52,
|
||||
},
|
||||
{
|
||||
accessorKey: "fileName",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="File Name" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span>
|
||||
{row.original.isMultipart && (
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
Multi
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "archiveType",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{row.original.archiveType}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "fileSize",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatBytes(row.original.fileSize)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "fileCount",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Files" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm">
|
||||
{row.original.fileCount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "creator",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Creator" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[160px] block">
|
||||
{row.original.creator ?? "\u2014"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "channel",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[160px] block">
|
||||
{row.original.sourceChannel.title}
|
||||
</span>
|
||||
),
|
||||
accessorFn: (row) => row.sourceChannel.title,
|
||||
},
|
||||
{
|
||||
accessorKey: "indexedAt",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Indexed" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(row.original.indexedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onViewFiles(row.original)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
412
src/app/(app)/stls/_components/package-files-drawer.tsx
Normal file
412
src/app/(app)/stls/_components/package-files-drawer.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PackageRow } from "./package-columns";
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
extension: string | null;
|
||||
compressedSize: string;
|
||||
uncompressedSize: string;
|
||||
crc32: string | null;
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
isFolder: boolean;
|
||||
children: Map<string, TreeNode>;
|
||||
file?: FileItem;
|
||||
}
|
||||
|
||||
interface PackageFilesDrawerProps {
|
||||
pkg: PackageRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
const bytes = Number(bytesStr);
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
const EXTENSION_COLORS: Record<string, string> = {
|
||||
stl: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
obj: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
||||
"3mf": "bg-cyan-500/15 text-cyan-400 border-cyan-500/30",
|
||||
gcode: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
png: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
jpg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
jpeg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
pdf: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
txt: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
lys: "bg-pink-500/15 text-pink-400 border-pink-500/30",
|
||||
};
|
||||
|
||||
function getExtBadgeClass(ext: string | null): string {
|
||||
if (!ext) return "bg-zinc-500/15 text-zinc-400 border-zinc-500/30";
|
||||
return EXTENSION_COLORS[ext.toLowerCase()] ?? "bg-zinc-500/15 text-zinc-400 border-zinc-500/30";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree structure from flat file paths.
|
||||
*/
|
||||
function buildFileTree(files: FileItem[]): TreeNode {
|
||||
const root: TreeNode = { name: "", isFolder: true, children: new Map() };
|
||||
|
||||
for (const file of files) {
|
||||
// Normalize path separators (Windows RAR archives may use backslashes)
|
||||
const parts = file.path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, {
|
||||
name: part,
|
||||
isFolder: !isLast,
|
||||
children: new Map(),
|
||||
file: isLast ? file : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively renders a file tree node with indentation.
|
||||
*/
|
||||
function TreeNodeView({
|
||||
node,
|
||||
depth,
|
||||
search,
|
||||
defaultOpen,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
search: string;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
// Sort children: folders first, then files, alphabetical within each group
|
||||
const sortedChildren = useMemo(() => {
|
||||
const arr = Array.from(node.children.values());
|
||||
return arr.sort((a, b) => {
|
||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [node.children]);
|
||||
|
||||
// If searching, force all open
|
||||
useEffect(() => {
|
||||
if (search) setOpen(true);
|
||||
}, [search]);
|
||||
|
||||
if (node.isFolder && node.children.size > 0) {
|
||||
return (
|
||||
<div>
|
||||
{/* Don't render a row for the root node */}
|
||||
{depth >= 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-1 py-1 text-sm hover:bg-muted/50 transition-colors"
|
||||
style={{ paddingLeft: `${Math.max(0, depth) * 16 + 4}px` }}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{open ? (
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-primary/70" />
|
||||
) : (
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-primary/70" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{node.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||
{countFiles(node)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{open &&
|
||||
sortedChildren.map((child) => (
|
||||
<TreeNodeView
|
||||
key={child.name}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
search={search}
|
||||
defaultOpen={depth < 1} // Auto-expand first 2 levels
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File node
|
||||
if (node.file) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md px-1 py-1 hover:bg-muted/50 transition-colors"
|
||||
style={{ paddingLeft: `${Math.max(0, depth) * 16 + 4}px` }}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm truncate flex-1 min-w-0" title={node.file.path}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.file.extension && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${getExtBadgeClass(node.file.extension)}`}
|
||||
>
|
||||
.{node.file.extension}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatBytes(node.file.uncompressedSize)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function countFiles(node: TreeNode): number {
|
||||
if (!node.isFolder) return 1;
|
||||
let count = 0;
|
||||
for (const child of node.children.values()) {
|
||||
count += countFiles(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDrawerProps) {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (pageNum: number, append: boolean) => {
|
||||
if (!pkg) return;
|
||||
if (pageNum === 1) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(pageNum),
|
||||
limit: String(PAGE_SIZE),
|
||||
});
|
||||
const res = await fetch(`/api/zips/${pkg.id}/files?${params}`);
|
||||
if (!res.ok) throw new Error("fetch failed");
|
||||
const data = await res.json();
|
||||
setFiles((prev) => (append ? [...prev, ...data.items] : data.items));
|
||||
setTotal(data.pagination.total);
|
||||
} catch {
|
||||
// Silently handle
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[pkg]
|
||||
);
|
||||
|
||||
// Reset and fetch when package changes
|
||||
useEffect(() => {
|
||||
if (open && pkg) {
|
||||
setFiles([]);
|
||||
setTotal(0);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
fetchFiles(1, false);
|
||||
}
|
||||
}, [open, pkg, fetchFiles]);
|
||||
|
||||
const loadMore = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchFiles(nextPage, true);
|
||||
};
|
||||
|
||||
const hasMore = files.length < total;
|
||||
|
||||
// Client-side search filter (over loaded files)
|
||||
const filtered = search
|
||||
? files.filter(
|
||||
(f) =>
|
||||
f.fileName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
f.path.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: files;
|
||||
|
||||
// Build tree from filtered files
|
||||
const tree = useMemo(() => buildFileTree(filtered), [filtered]);
|
||||
|
||||
// If all files are in root (no folders), skip the tree and show flat list
|
||||
const hasNesting = useMemo(() => {
|
||||
return filtered.some((f) => f.path.replace(/\\/g, "/").includes("/"));
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
|
||||
{/* Preview image + title row */}
|
||||
<div className="flex gap-4">
|
||||
{pkg?.hasPreview && (
|
||||
<img
|
||||
src={`/api/zips/${pkg.id}/preview`}
|
||||
alt=""
|
||||
className="h-20 w-20 rounded-lg object-cover bg-muted shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate pr-8">
|
||||
{pkg?.fileName ?? "Package Files"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{total.toLocaleString()} file{total !== 1 ? "s" : ""} in archive
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search within file list */}
|
||||
{files.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter files..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-4 py-3">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading files...</span>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<FileText className="h-6 w-6 text-muted-foreground/50" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{search ? "No matching files" : "No files indexed"}
|
||||
</span>
|
||||
</div>
|
||||
) : hasNesting ? (
|
||||
<>
|
||||
{/* Render as folder tree */}
|
||||
{Array.from(tree.children.values())
|
||||
.sort((a, b) => {
|
||||
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((child) => (
|
||||
<TreeNodeView
|
||||
key={child.name}
|
||||
node={child}
|
||||
depth={0}
|
||||
search={search}
|
||||
defaultOpen={true}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Flat list for archives without folders */}
|
||||
{filtered.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm truncate" title={file.path}>
|
||||
{file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
{file.extension && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${getExtBadgeClass(file.extension)}`}
|
||||
>
|
||||
.{file.extension}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatBytes(file.uncompressedSize)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{hasMore && !search && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="gap-1"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Load more ({files.length} of {total.toLocaleString()})
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
95
src/app/(app)/stls/_components/stl-table.tsx
Normal file
95
src/app/(app)/stls/_components/stl-table.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Search, FileBox } from "lucide-react";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getPackageColumns, type PackageRow } from "./package-columns";
|
||||
import { PackageFilesDrawer } from "./package-files-drawer";
|
||||
import { IngestionStatus } from "./ingestion-status";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
|
||||
interface StlTableProps {
|
||||
data: PackageRow[];
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
ingestionStatus: IngestionAccountStatus[];
|
||||
}
|
||||
|
||||
export function StlTable({
|
||||
data,
|
||||
pageCount,
|
||||
totalCount,
|
||||
ingestionStatus,
|
||||
}: StlTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
|
||||
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
|
||||
|
||||
const updateSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set("search", value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const columns = getPackageColumns({
|
||||
onViewFiles: (pkg) => setViewPkg(pkg),
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data, columns, pageCount });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="STL Files"
|
||||
description="Browse indexed archive packages from Telegram channels"
|
||||
>
|
||||
<IngestionStatus initialStatus={ingestionStatus} />
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search packages or files..."
|
||||
value={searchValue}
|
||||
onChange={(e) => updateSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
emptyMessage="No packages found. Archives will appear here after ingestion."
|
||||
/>
|
||||
<DataTablePagination table={table} totalCount={totalCount} />
|
||||
|
||||
<PackageFilesDrawer
|
||||
pkg={viewPkg}
|
||||
open={!!viewPkg}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setViewPkg(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/(app)/stls/page.tsx
Normal file
50
src/app/(app)/stls/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listPackages, searchPackages, getIngestionStatus } from "@/lib/telegram/queries";
|
||||
import { StlTable } from "./_components/stl-table";
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export default async function StlFilesPage({ searchParams }: Props) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const params = await searchParams;
|
||||
|
||||
const page = Number(params.page) || 1;
|
||||
const perPage = Number(params.perPage) || 20;
|
||||
const sort = (params.sort as string) ?? "indexedAt";
|
||||
const order = (params.order as "asc" | "desc") ?? "desc";
|
||||
const search = (params.search as string) ?? "";
|
||||
const creator = (params.creator as string) || undefined;
|
||||
|
||||
// Fetch packages and ingestion status in parallel
|
||||
const [result, ingestionStatus] = await Promise.all([
|
||||
search
|
||||
? searchPackages({
|
||||
query: search,
|
||||
page,
|
||||
limit: perPage,
|
||||
searchIn: "both",
|
||||
})
|
||||
: listPackages({
|
||||
page,
|
||||
limit: perPage,
|
||||
creator,
|
||||
sortBy: sort as "indexedAt" | "fileName" | "fileSize",
|
||||
order,
|
||||
}),
|
||||
getIngestionStatus(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<StlTable
|
||||
data={result.items}
|
||||
pageCount={result.pagination.totalPages}
|
||||
totalCount={result.pagination.total}
|
||||
ingestionStatus={ingestionStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
184
src/app/(app)/telegram/_components/account-columns.tsx
Normal file
184
src/app/(app)/telegram/_components/account-columns.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Power,
|
||||
Link2,
|
||||
Play,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { AccountRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
const authStateColors: Record<string, string> = {
|
||||
PENDING: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
|
||||
AWAITING_CODE: "bg-orange-500/10 text-orange-600 border-orange-500/20",
|
||||
AWAITING_PASSWORD: "bg-orange-500/10 text-orange-600 border-orange-500/20",
|
||||
AUTHENTICATED: "bg-green-500/10 text-green-600 border-green-500/20",
|
||||
EXPIRED: "bg-red-500/10 text-red-600 border-red-500/20",
|
||||
};
|
||||
|
||||
interface AccountColumnsProps {
|
||||
onEdit: (account: AccountRow) => void;
|
||||
onToggleActive: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onViewLinks: (id: string) => void;
|
||||
onTriggerSync: (id: string) => void;
|
||||
onEnterCode: (account: AccountRow) => void;
|
||||
}
|
||||
|
||||
export function getAccountColumns({
|
||||
onEdit,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
onViewLinks,
|
||||
onTriggerSync,
|
||||
onEnterCode,
|
||||
}: AccountColumnsProps): ColumnDef<AccountRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "displayName",
|
||||
header: "Account",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{row.original.displayName || row.original.phone}
|
||||
</span>
|
||||
{row.original.displayName && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "authState",
|
||||
header: "Auth State",
|
||||
cell: ({ row }) => {
|
||||
const needsCode =
|
||||
row.original.authState === "AWAITING_CODE" ||
|
||||
row.original.authState === "AWAITING_PASSWORD";
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={authStateColors[row.original.authState] ?? ""}
|
||||
>
|
||||
{row.original.authState.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{needsCode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
onClick={() => onEnterCode(row.original)}
|
||||
>
|
||||
<KeyRound className="h-3 w-3" />
|
||||
Enter Code
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "isActive",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isActive ? "default" : "secondary"}>
|
||||
{row.original.isActive ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
header: "Channels",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => onViewLinks(row.original.id)}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{row.original.channelCount}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "runs",
|
||||
header: "Runs",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.runCount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "lastSeenAt",
|
||||
header: "Last Seen",
|
||||
cell: ({ row }) =>
|
||||
row.original.lastSeenAt ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(row.original.lastSeenAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Never</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onViewLinks(row.original.id)}>
|
||||
<Link2 className="mr-2 h-3.5 w-3.5" />
|
||||
Manage Channels
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTriggerSync(row.original.id)}>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Sync Now
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleActive(row.original.id)}
|
||||
>
|
||||
<Power className="mr-2 h-3.5 w-3.5" />
|
||||
{row.original.isActive ? "Disable" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
102
src/app/(app)/telegram/_components/account-form.tsx
Normal file
102
src/app/(app)/telegram/_components/account-form.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
telegramAccountSchema,
|
||||
type TelegramAccountInput,
|
||||
} from "@/schemas/telegram";
|
||||
import { createAccount, updateAccount } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import type { AccountRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface AccountFormProps {
|
||||
account?: AccountRow;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AccountForm({ account, onSuccess }: AccountFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const isEditing = !!account;
|
||||
|
||||
const form = useForm<TelegramAccountInput>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(telegramAccountSchema) as any,
|
||||
defaultValues: {
|
||||
phone: account?.phone ?? "",
|
||||
displayName: account?.displayName ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: TelegramAccountInput) {
|
||||
startTransition(async () => {
|
||||
const result = isEditing
|
||||
? await updateAccount(account!.id, values)
|
||||
: await createAccount(values);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(isEditing ? "Account updated" : "Account created");
|
||||
form.reset();
|
||||
onSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="+31612345678" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
International format with country code
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Bot Account" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
233
src/app/(app)/telegram/_components/account-links-drawer.tsx
Normal file
233
src/app/(app)/telegram/_components/account-links-drawer.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback } from "react";
|
||||
import { Link2Off, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { linkChannel, unlinkChannel } from "../actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface ChannelLink {
|
||||
id: string;
|
||||
channelId: string;
|
||||
role: string;
|
||||
lastProcessedMessageId: string | null;
|
||||
channel: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
telegramId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UnlinkedChannel {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
telegramId: string;
|
||||
}
|
||||
|
||||
interface AccountLinksDrawerProps {
|
||||
accountId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AccountLinksDrawer({
|
||||
accountId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AccountLinksDrawerProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [links, setLinks] = useState<ChannelLink[]>([]);
|
||||
const [unlinked, setUnlinked] = useState<UnlinkedChannel[]>([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState<"READER" | "WRITER">("READER");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchLinks = useCallback(async () => {
|
||||
if (!accountId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [linksRes, unlinkedRes] = await Promise.all([
|
||||
fetch(`/api/telegram/accounts/${accountId}/links`),
|
||||
fetch(`/api/telegram/accounts/${accountId}/unlinked-channels`),
|
||||
]);
|
||||
if (linksRes.ok) setLinks(await linksRes.json());
|
||||
if (unlinkedRes.ok) setUnlinked(await unlinkedRes.json());
|
||||
} catch {
|
||||
toast.error("Failed to load channel links");
|
||||
}
|
||||
setLoading(false);
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && accountId) {
|
||||
fetchLinks();
|
||||
}
|
||||
}, [open, accountId, fetchLinks]);
|
||||
|
||||
const handleLink = () => {
|
||||
if (!accountId || !selectedChannelId) return;
|
||||
startTransition(async () => {
|
||||
const result = await linkChannel({
|
||||
accountId,
|
||||
channelId: selectedChannelId,
|
||||
role: selectedRole,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Channel linked");
|
||||
setSelectedChannelId("");
|
||||
await fetchLinks();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnlink = (linkId: string) => {
|
||||
startTransition(async () => {
|
||||
const result = await unlinkChannel(linkId);
|
||||
if (result.success) {
|
||||
toast.success("Channel unlinked");
|
||||
await fetchLinks();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Channel Links</DialogTitle>
|
||||
<DialogDescription>
|
||||
Link channels to this account. The account will read from Source
|
||||
channels and write to Destination channels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Add new link */}
|
||||
{unlinked.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Link a Channel</h4>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedChannelId}
|
||||
onValueChange={setSelectedChannelId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unlinked.map((ch) => (
|
||||
<SelectItem key={ch.id} value={ch.id}>
|
||||
{ch.title} ({ch.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={(v) => setSelectedRole(v as "READER" | "WRITER")}
|
||||
>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="READER">Reader</SelectItem>
|
||||
<SelectItem value="WRITER">Writer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!selectedChannelId || isPending}
|
||||
onClick={handleLink}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Link
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing links */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
Linked Channels ({links.length})
|
||||
</h4>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No channels linked to this account.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{link.channel.title}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
link.channel.type === "SOURCE"
|
||||
? "bg-blue-500/10 text-blue-600 border-blue-500/20"
|
||||
: "bg-purple-500/10 text-purple-600 border-purple-500/20"
|
||||
}
|
||||
>
|
||||
{link.channel.type}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{link.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {link.channel.telegramId}
|
||||
{link.lastProcessedMessageId &&
|
||||
` | Last msg: ${link.lastProcessedMessageId}`}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => handleUnlink(link.id)}
|
||||
>
|
||||
<Link2Off className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
44
src/app/(app)/telegram/_components/account-modal.tsx
Normal file
44
src/app/(app)/telegram/_components/account-modal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { AccountForm } from "./account-form";
|
||||
import type { AccountRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface AccountModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
account?: AccountRow;
|
||||
}
|
||||
|
||||
export function AccountModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
account,
|
||||
}: AccountModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{account ? "Edit Account" : "Add Telegram Account"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{account
|
||||
? "Update the account details below."
|
||||
: "Configure a new Telegram account for ingestion. You'll need an API ID and hash from my.telegram.org."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AccountForm
|
||||
account={account}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
140
src/app/(app)/telegram/_components/accounts-tab.tsx
Normal file
140
src/app/(app)/telegram/_components/accounts-tab.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Play } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getAccountColumns } from "./account-columns";
|
||||
import { AccountModal } from "./account-modal";
|
||||
import { AccountLinksDrawer } from "./account-links-drawer";
|
||||
import { AuthCodeDialog } from "./auth-code-dialog";
|
||||
import { deleteAccount, toggleAccountActive, triggerIngestion } from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AccountRow } from "@/lib/telegram/admin-queries";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
|
||||
interface AccountsTabProps {
|
||||
accounts: AccountRow[];
|
||||
}
|
||||
|
||||
export function AccountsTab({ accounts }: AccountsTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editAccount, setEditAccount] = useState<AccountRow | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [linksAccountId, setLinksAccountId] = useState<string | null>(null);
|
||||
const [authCodeAccount, setAuthCodeAccount] = useState<AccountRow | null>(null);
|
||||
|
||||
const columns = getAccountColumns({
|
||||
onEdit: (account) => {
|
||||
setEditAccount(account);
|
||||
setModalOpen(true);
|
||||
},
|
||||
onToggleActive: (id) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleAccountActive(id);
|
||||
if (result.success) toast.success("Account toggled");
|
||||
else toast.error(result.error);
|
||||
});
|
||||
},
|
||||
onDelete: (id) => setDeleteId(id),
|
||||
onViewLinks: (id) => setLinksAccountId(id),
|
||||
onEnterCode: (account) => setAuthCodeAccount(account),
|
||||
onTriggerSync: (id) => {
|
||||
startTransition(async () => {
|
||||
const result = await triggerIngestion(id);
|
||||
if (result.success) toast.success("Ingestion triggered");
|
||||
else toast.error(result.error);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: accounts,
|
||||
columns,
|
||||
pageCount: 1,
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
startTransition(async () => {
|
||||
const result = await deleteAccount(deleteId);
|
||||
if (result.success) {
|
||||
toast.success("Account deleted");
|
||||
setDeleteId(null);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditAccount(undefined);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Account
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
startTransition(async () => {
|
||||
const result = await triggerIngestion();
|
||||
if (result.success) toast.success("Ingestion triggered for all accounts");
|
||||
else toast.error(result.error);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Sync All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
emptyMessage="No accounts configured. Add your first Telegram account."
|
||||
/>
|
||||
|
||||
<AccountModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) setEditAccount(undefined);
|
||||
}}
|
||||
account={editAccount}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(open) => !open && setDeleteId(null)}
|
||||
title="Delete Account"
|
||||
description="This will permanently delete this Telegram account and all its channel links. Existing packages will NOT be deleted."
|
||||
onConfirm={handleDelete}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
|
||||
<AccountLinksDrawer
|
||||
accountId={linksAccountId}
|
||||
open={!!linksAccountId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLinksAccountId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthCodeDialog
|
||||
account={authCodeAccount}
|
||||
open={!!authCodeAccount}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setAuthCodeAccount(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/app/(app)/telegram/_components/auth-code-dialog.tsx
Normal file
102
src/app/(app)/telegram/_components/auth-code-dialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { submitAuthCode } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { AccountRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface AuthCodeDialogProps {
|
||||
account: AccountRow | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AuthCodeDialog({
|
||||
account,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: AuthCodeDialogProps) {
|
||||
const [code, setCode] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isPassword = account?.authState === "AWAITING_PASSWORD";
|
||||
const title = isPassword ? "Enter 2FA Password" : "Enter Auth Code";
|
||||
const description = isPassword
|
||||
? "Your Telegram account requires a two-factor authentication password."
|
||||
: "Enter the code sent to your Telegram app or SMS.";
|
||||
const placeholder = isPassword ? "Password" : "12345";
|
||||
|
||||
function handleSubmit() {
|
||||
if (!account || !code.trim()) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitAuthCode(account.id, { code: code.trim() });
|
||||
if (result.success) {
|
||||
toast.success(isPassword ? "Password submitted" : "Code submitted");
|
||||
setCode("");
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setCode("");
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-code">
|
||||
{isPassword ? "Password" : "Code"}
|
||||
</Label>
|
||||
<Input
|
||||
id="auth-code"
|
||||
type={isPassword ? "password" : "text"}
|
||||
placeholder={placeholder}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !code.trim()}
|
||||
>
|
||||
{isPending ? "Submitting..." : "Submit"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
132
src/app/(app)/telegram/_components/channel-columns.tsx
Normal file
132
src/app/(app)/telegram/_components/channel-columns.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface ChannelColumnsProps {
|
||||
onEdit: (channel: ChannelRow) => void;
|
||||
onToggleActive: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function getChannelColumns({
|
||||
onEdit,
|
||||
onToggleActive,
|
||||
onDelete,
|
||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Channel",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {row.original.telegramId}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
row.original.type === "SOURCE"
|
||||
? "bg-blue-500/10 text-blue-600 border-blue-500/20"
|
||||
: "bg-purple-500/10 text-purple-600 border-purple-500/20"
|
||||
}
|
||||
>
|
||||
{row.original.type}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "isActive",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isActive ? "default" : "secondary"}>
|
||||
{row.original.isActive ? "Active" : "Disabled"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "accounts",
|
||||
header: "Accounts",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.accountCount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "packages",
|
||||
header: "Packages",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.packageCount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleActive(row.original.id)}
|
||||
>
|
||||
<Power className="mr-2 h-3.5 w-3.5" />
|
||||
{row.original.isActive ? "Disable" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(row.original.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
142
src/app/(app)/telegram/_components/channel-form.tsx
Normal file
142
src/app/(app)/telegram/_components/channel-form.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
telegramChannelSchema,
|
||||
type TelegramChannelInput,
|
||||
} from "@/schemas/telegram";
|
||||
import { createChannel, updateChannel } from "../actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import type { ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface ChannelFormProps {
|
||||
channel?: ChannelRow;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ChannelForm({ channel, onSuccess }: ChannelFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const isEditing = !!channel;
|
||||
|
||||
const form = useForm<TelegramChannelInput>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(telegramChannelSchema) as any,
|
||||
defaultValues: {
|
||||
telegramId: channel ? Number(channel.telegramId) : (0 as unknown as number),
|
||||
title: channel?.title ?? "",
|
||||
type: channel?.type ?? "SOURCE",
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: TelegramChannelInput) {
|
||||
startTransition(async () => {
|
||||
const result = isEditing
|
||||
? await updateChannel(channel!.id, values)
|
||||
: await createChannel(values);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(isEditing ? "Channel updated" : "Channel created");
|
||||
form.reset();
|
||||
onSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Channel name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="telegramId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telegram ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1234567890"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Numeric ID of the Telegram channel or group
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="SOURCE">Source (read archives)</SelectItem>
|
||||
<SelectItem value="DESTINATION">
|
||||
Destination (forward indexed)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
44
src/app/(app)/telegram/_components/channel-modal.tsx
Normal file
44
src/app/(app)/telegram/_components/channel-modal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ChannelForm } from "./channel-form";
|
||||
import type { ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface ChannelModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel?: ChannelRow;
|
||||
}
|
||||
|
||||
export function ChannelModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
}: ChannelModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{channel ? "Edit Channel" : "Add Channel"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{channel
|
||||
? "Update the channel details below."
|
||||
: "Add a Telegram channel. Source channels are scanned for archives, destination channels receive indexed files."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ChannelForm
|
||||
channel={channel}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
97
src/app/(app)/telegram/_components/channels-tab.tsx
Normal file
97
src/app/(app)/telegram/_components/channels-tab.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getChannelColumns } from "./channel-columns";
|
||||
import { ChannelModal } from "./channel-modal";
|
||||
import { deleteChannel, toggleChannelActive } from "../actions";
|
||||
import { DataTable } from "@/components/shared/data-table";
|
||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
|
||||
interface ChannelsTabProps {
|
||||
channels: ChannelRow[];
|
||||
}
|
||||
|
||||
export function ChannelsTab({ channels }: ChannelsTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editChannel, setEditChannel] = useState<ChannelRow | undefined>();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const columns = getChannelColumns({
|
||||
onEdit: (channel) => {
|
||||
setEditChannel(channel);
|
||||
setModalOpen(true);
|
||||
},
|
||||
onToggleActive: (id) => {
|
||||
startTransition(async () => {
|
||||
const result = await toggleChannelActive(id);
|
||||
if (result.success) toast.success("Channel toggled");
|
||||
else toast.error(result.error);
|
||||
});
|
||||
},
|
||||
onDelete: (id) => setDeleteId(id),
|
||||
});
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: channels,
|
||||
columns,
|
||||
pageCount: 1,
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
startTransition(async () => {
|
||||
const result = await deleteChannel(deleteId);
|
||||
if (result.success) {
|
||||
toast.success("Channel deleted");
|
||||
setDeleteId(null);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditChannel(undefined);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
emptyMessage="No channels configured. Add a Telegram channel to start ingesting."
|
||||
/>
|
||||
|
||||
<ChannelModal
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) setEditChannel(undefined);
|
||||
}}
|
||||
channel={editChannel}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(open) => !open && setDeleteId(null)}
|
||||
title="Delete Channel"
|
||||
description="This will permanently delete this channel and unlink it from all accounts. Existing packages will NOT be deleted."
|
||||
onConfirm={handleDelete}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/app/(app)/telegram/_components/telegram-admin.tsx
Normal file
41
src/app/(app)/telegram/_components/telegram-admin.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { AccountsTab } from "./accounts-tab";
|
||||
import { ChannelsTab } from "./channels-tab";
|
||||
import type { AccountRow, ChannelRow } from "@/lib/telegram/admin-queries";
|
||||
|
||||
interface TelegramAdminProps {
|
||||
accounts: AccountRow[];
|
||||
channels: ChannelRow[];
|
||||
}
|
||||
|
||||
export function TelegramAdmin({ accounts, channels }: TelegramAdminProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Telegram"
|
||||
description="Manage Telegram accounts, channels, and ingestion"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="accounts" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="accounts">
|
||||
Accounts ({accounts.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels">
|
||||
Channels ({channels.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
<AccountsTab accounts={accounts} />
|
||||
</TabsContent>
|
||||
<TabsContent value="channels">
|
||||
<ChannelsTab channels={channels} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
src/app/(app)/telegram/actions.ts
Normal file
345
src/app/(app)/telegram/actions.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ActionResult } from "@/types/api.types";
|
||||
import {
|
||||
telegramAccountSchema,
|
||||
telegramChannelSchema,
|
||||
linkChannelSchema,
|
||||
submitAuthCodeSchema,
|
||||
} from "@/schemas/telegram";
|
||||
|
||||
const REVALIDATE_PATH = "/telegram";
|
||||
|
||||
async function requireAdmin(): Promise<
|
||||
{ success: true; userId: string } | { success: false; error: string }
|
||||
> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
if (session.user.role !== "ADMIN")
|
||||
return { success: false, error: "Admin access required" };
|
||||
return { success: true, userId: session.user.id };
|
||||
}
|
||||
|
||||
// ── Account actions ──
|
||||
|
||||
export async function createAccount(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const parsed = telegramAccountSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const account = await prisma.telegramAccount.create({
|
||||
data: {
|
||||
phone: parsed.data.phone.replace(/[\s\-]/g, ""),
|
||||
displayName: parsed.data.displayName || null,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: account.id } };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint failed")
|
||||
) {
|
||||
return { success: false, error: "Phone number already registered" };
|
||||
}
|
||||
return { success: false, error: "Failed to create account" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccount(
|
||||
id: string,
|
||||
input: unknown
|
||||
): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const parsed = telegramAccountSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
const existing = await prisma.telegramAccount.findUnique({ where: { id } });
|
||||
if (!existing) return { success: false, error: "Account not found" };
|
||||
|
||||
try {
|
||||
await prisma.telegramAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
phone: parsed.data.phone.replace(/[\s\-]/g, ""),
|
||||
displayName: parsed.data.displayName || null,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint failed")
|
||||
) {
|
||||
return { success: false, error: "Phone number already registered" };
|
||||
}
|
||||
return { success: false, error: "Failed to update account" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAccountActive(id: string): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const existing = await prisma.telegramAccount.findUnique({ where: { id } });
|
||||
if (!existing) return { success: false, error: "Account not found" };
|
||||
|
||||
try {
|
||||
await prisma.telegramAccount.update({
|
||||
where: { id },
|
||||
data: { isActive: !existing.isActive },
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to toggle account" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccount(id: string): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const existing = await prisma.telegramAccount.findUnique({ where: { id } });
|
||||
if (!existing) return { success: false, error: "Account not found" };
|
||||
|
||||
try {
|
||||
await prisma.telegramAccount.delete({ where: { id } });
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to delete account" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitAuthCode(
|
||||
accountId: string,
|
||||
input: unknown
|
||||
): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const parsed = submitAuthCodeSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
const existing = await prisma.telegramAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Account not found" };
|
||||
if (
|
||||
existing.authState !== "AWAITING_CODE" &&
|
||||
existing.authState !== "AWAITING_PASSWORD"
|
||||
) {
|
||||
return { success: false, error: "Account is not waiting for a code" };
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.telegramAccount.update({
|
||||
where: { id: accountId },
|
||||
data: { authCode: parsed.data.code },
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to submit code" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel actions ──
|
||||
|
||||
export async function createChannel(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const parsed = telegramChannelSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const channel = await prisma.telegramChannel.create({
|
||||
data: {
|
||||
telegramId: BigInt(parsed.data.telegramId),
|
||||
title: parsed.data.title,
|
||||
type: parsed.data.type,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: channel.id } };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint failed")
|
||||
) {
|
||||
return { success: false, error: "Channel with this Telegram ID already exists" };
|
||||
}
|
||||
return { success: false, error: "Failed to create channel" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
id: string,
|
||||
input: unknown
|
||||
): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const parsed = telegramChannelSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
const existing = await prisma.telegramChannel.findUnique({ where: { id } });
|
||||
if (!existing) return { success: false, error: "Channel not found" };
|
||||
|
||||
try {
|
||||
await prisma.telegramChannel.update({
|
||||
where: { id },
|
||||
data: {
|
||||
telegramId: BigInt(parsed.data.telegramId),
|
||||
title: parsed.data.title,
|
||||
type: parsed.data.type,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint failed")
|
||||
) {
|
||||
return { success: false, error: "Channel with this Telegram ID already exists" };
|
||||
}
|
||||
return { success: false, error: "Failed to update channel" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleChannelActive(id: string): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const existing = await prisma.telegramChannel.findUnique({ where: { id } });
|
||||
if (!existing) return { success: false, error: "Channel not found" };
|
||||
|
||||
try {
|
||||
await prisma.telegramChannel.update({
|
||||
where: { id },
|
||||
data: { isActive: !existing.isActive },
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to toggle channel" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChannel(id: string): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const existing = await prisma.telegramChannel.findUnique({ where: { id } });
|
||||
if (!existing) return { success: false, error: "Channel not found" };
|
||||
|
||||
try {
|
||||
await prisma.telegramChannel.delete({ where: { id } });
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to delete channel" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account-Channel link actions ──
|
||||
|
||||
export async function linkChannel(
|
||||
input: unknown
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const parsed = linkChannelSchema.safeParse(input);
|
||||
if (!parsed.success) return { success: false, error: "Validation failed" };
|
||||
|
||||
try {
|
||||
const link = await prisma.accountChannelMap.create({
|
||||
data: {
|
||||
accountId: parsed.data.accountId,
|
||||
channelId: parsed.data.channelId,
|
||||
role: parsed.data.role,
|
||||
},
|
||||
});
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: { id: link.id } };
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Unique constraint failed")
|
||||
) {
|
||||
return { success: false, error: "This channel is already linked to this account" };
|
||||
}
|
||||
return { success: false, error: "Failed to link channel" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkChannel(id: string): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const existing = await prisma.accountChannelMap.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) return { success: false, error: "Link not found" };
|
||||
|
||||
try {
|
||||
await prisma.accountChannelMap.delete({ where: { id } });
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to unlink channel" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ingestion trigger ──
|
||||
|
||||
export async function triggerIngestion(
|
||||
accountId?: string
|
||||
): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/api/ingestion/trigger`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": process.env.INGESTION_API_KEY || "",
|
||||
},
|
||||
body: JSON.stringify({ accountId }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
error: (data as { error?: string }).error || "Failed to trigger ingestion",
|
||||
};
|
||||
}
|
||||
|
||||
revalidatePath(REVALIDATE_PATH);
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to trigger ingestion" };
|
||||
}
|
||||
}
|
||||
17
src/app/(app)/telegram/page.tsx
Normal file
17
src/app/(app)/telegram/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listAccounts, listChannels } from "@/lib/telegram/admin-queries";
|
||||
import { TelegramAdmin } from "./_components/telegram-admin";
|
||||
|
||||
export default async function TelegramPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
if (session.user.role !== "ADMIN") redirect("/dashboard");
|
||||
|
||||
const [accounts, channels] = await Promise.all([
|
||||
listAccounts(),
|
||||
listChannels(),
|
||||
]);
|
||||
|
||||
return <TelegramAdmin accounts={accounts} channels={channels} />;
|
||||
}
|
||||
Reference in New Issue
Block a user