mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 14:21:15 +00:00
feat: add preview management, channel controls, invite polish, and recovery
- Auto-extract preview images from ZIP/RAR/7z archives during ingestion - Upload custom preview images via package drawer - Select preview from archive contents with on-demand extraction UI - Manually add Telegram channels by t.me link, username, or invite link - Invite code UX: bulk create, copy link, usage tracking, delete confirm - Incomplete upload recovery: verify dest messages on worker startup - Rebuild package DB by scanning destination channel with live progress Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,11 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Download } from "lucide-react";
|
||||
import { Download, Plus } from "lucide-react";
|
||||
import { getChannelColumns } from "./channel-columns";
|
||||
import { DestinationCard } from "./destination-card";
|
||||
import { ChannelPickerDialog } from "./channel-picker-dialog";
|
||||
import { JoinChannelDialog } from "./join-channel-dialog";
|
||||
import {
|
||||
deleteChannel,
|
||||
toggleChannelActive,
|
||||
@@ -30,6 +31,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [rescanId, setRescanId] = useState<string | null>(null);
|
||||
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
||||
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
||||
|
||||
// Find the first authenticated account for "Fetch Channels"
|
||||
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
|
||||
@@ -113,6 +115,14 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Fetch Channels
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setJoinDialogOpen(true)}
|
||||
disabled={authenticatedAccounts.length === 0}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{channels.length > 0 && (
|
||||
@@ -152,6 +162,11 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
||||
if (!open) setFetchChannelsAccountId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<JoinChannelDialog
|
||||
open={joinDialogOpen}
|
||||
onOpenChange={setJoinDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useTransition } from "react";
|
||||
import { Database, AlertTriangle, Link2, Plus, Loader2, ArrowRight } from "lucide-react";
|
||||
import {
|
||||
Database,
|
||||
AlertTriangle,
|
||||
Link2,
|
||||
Plus,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { createDestinationViaWorker, setGlobalDestination } from "../actions";
|
||||
import {
|
||||
createDestinationViaWorker,
|
||||
setGlobalDestination,
|
||||
rebuildPackageDatabase,
|
||||
} from "../actions";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -38,12 +50,29 @@ type CreateState =
|
||||
| { phase: "done"; title: string; telegramId: string }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
type RebuildState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "running"; requestId: string }
|
||||
| { phase: "done"; created: number; skipped: number; scanned: number }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
interface RebuildProgress {
|
||||
status: string;
|
||||
messagesScanned: number;
|
||||
documentsFound: number;
|
||||
packagesCreated: number;
|
||||
packagesSkipped: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DestinationCard({ destination, channels = [] }: DestinationCardProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState("dragonsstash db");
|
||||
const [createState, setCreateState] = useState<CreateState>({ phase: "idle" });
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>("");
|
||||
const [rebuildState, setRebuildState] = useState<RebuildState>({ phase: "idle" });
|
||||
const [rebuildProgress, setRebuildProgress] = useState<RebuildProgress | null>(null);
|
||||
|
||||
// Channels that can be assigned as destination (SOURCE channels only, exclude current destination)
|
||||
const assignableChannels = channels.filter(
|
||||
@@ -105,6 +134,86 @@ export function DestinationCard({ destination, channels = [] }: DestinationCardP
|
||||
return () => { mounted = false; };
|
||||
}, [createState]);
|
||||
|
||||
// Poll for rebuild progress
|
||||
useEffect(() => {
|
||||
if (rebuildState.phase !== "running") return;
|
||||
|
||||
let mounted = true;
|
||||
const requestId = rebuildState.requestId;
|
||||
|
||||
const poll = async () => {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/telegram/worker-request?requestId=${requestId}`
|
||||
);
|
||||
if (!res.ok) continue;
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Update live progress from resultJson
|
||||
if (data.result && typeof data.result === "object") {
|
||||
if (mounted) setRebuildProgress(data.result as RebuildProgress);
|
||||
}
|
||||
|
||||
if (data.status === "COMPLETED" && data.result) {
|
||||
const result = data.result as RebuildProgress;
|
||||
if (mounted) {
|
||||
setRebuildState({
|
||||
phase: "done",
|
||||
created: result.packagesCreated,
|
||||
skipped: result.packagesSkipped,
|
||||
scanned: result.messagesScanned,
|
||||
});
|
||||
setRebuildProgress(null);
|
||||
toast.success(
|
||||
`Rebuild complete: ${result.packagesCreated} packages restored, ${result.packagesSkipped} skipped`
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else if (data.status === "FAILED") {
|
||||
if (mounted) {
|
||||
setRebuildState({
|
||||
phase: "error",
|
||||
message: data.error || "Rebuild failed",
|
||||
});
|
||||
setRebuildProgress(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network blip — keep polling
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setRebuildState({ phase: "error", message: "Timed out waiting for rebuild" });
|
||||
setRebuildProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [rebuildState]);
|
||||
|
||||
const handleRebuild = () => {
|
||||
startTransition(async () => {
|
||||
const result = await rebuildPackageDatabase();
|
||||
if (result.success) {
|
||||
setRebuildState({ phase: "running", requestId: result.data.requestId });
|
||||
setRebuildProgress(null);
|
||||
toast.info("Rebuild started — scanning destination channel...");
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to start rebuild");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
@@ -188,37 +297,115 @@ export function DestinationCard({ destination, channels = [] }: DestinationCardP
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{destination.title}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-500/10 text-purple-600 border-purple-500/20 text-[10px]"
|
||||
>
|
||||
DESTINATION
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>ID: {destination.telegramId}</span>
|
||||
{destination.inviteLink && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
Invite link active
|
||||
</span>
|
||||
)}
|
||||
<CardContent className="py-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-purple-500 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{destination.title}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-500/10 text-purple-600 border-purple-500/20 text-[10px]"
|
||||
>
|
||||
DESTINATION
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>ID: {destination.telegramId}</span>
|
||||
{destination.inviteLink && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
Invite link active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRebuild}
|
||||
disabled={isPending || rebuildState.phase === "running"}
|
||||
title="Scan destination channel and rebuild the package database"
|
||||
>
|
||||
{rebuildState.phase === "running" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Rebuild DB
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
|
||||
{/* Rebuild progress */}
|
||||
{rebuildState.phase === "running" && rebuildProgress && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Rebuilding package database...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pl-6 mt-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{rebuildProgress.messagesScanned}
|
||||
</span>{" "}
|
||||
messages scanned
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{rebuildProgress.documentsFound}
|
||||
</span>{" "}
|
||||
archives found
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{rebuildProgress.packagesCreated}
|
||||
</span>{" "}
|
||||
restored
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{rebuildProgress.packagesSkipped}
|
||||
</span>{" "}
|
||||
skipped
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rebuildState.phase === "done" && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-500">
|
||||
<Database className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Rebuild complete: {rebuildState.created} packages restored,{" "}
|
||||
{rebuildState.skipped} skipped ({rebuildState.scanned} messages
|
||||
scanned)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rebuildState.phase === "error" && (
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Rebuild failed: {rebuildState.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
179
src/app/(app)/telegram/_components/join-channel-dialog.tsx
Normal file
179
src/app/(app)/telegram/_components/join-channel-dialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Loader2, Link as LinkIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { joinChannelByLink } from "../actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface JoinChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type JoinState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "submitting"; requestId?: string }
|
||||
| { phase: "success"; title: string }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
export function JoinChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: JoinChannelDialogProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [joinState, setJoinState] = useState<JoinState>({ phase: "idle" });
|
||||
|
||||
// Reset on close
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput("");
|
||||
setJoinState({ phase: "idle" });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const pollForResult = useCallback(async (requestId: string) => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/telegram/worker-request?requestId=${requestId}`
|
||||
);
|
||||
if (!res.ok) continue;
|
||||
|
||||
const data = await res.json();
|
||||
if (data.status === "COMPLETED") {
|
||||
const result = data.result;
|
||||
setJoinState({
|
||||
phase: "success",
|
||||
title: result?.title ?? "Unknown channel",
|
||||
});
|
||||
toast.success(`Channel "${result?.title}" added as source`);
|
||||
// Auto-close after short delay
|
||||
setTimeout(() => onOpenChange(false), 1500);
|
||||
return;
|
||||
} else if (data.status === "FAILED") {
|
||||
setJoinState({
|
||||
phase: "error",
|
||||
message: data.error || "Failed to join channel",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Network error, keep polling
|
||||
}
|
||||
}
|
||||
|
||||
setJoinState({
|
||||
phase: "error",
|
||||
message: "Request timed out. The worker may be busy -- try again later.",
|
||||
});
|
||||
}, [onOpenChange]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!input.trim()) return;
|
||||
|
||||
setJoinState({ phase: "submitting" });
|
||||
|
||||
try {
|
||||
const result = await joinChannelByLink(input);
|
||||
if (!result.success) {
|
||||
setJoinState({ phase: "error", message: result.error ?? "Unknown error" });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = result.data!.requestId;
|
||||
setJoinState({ phase: "submitting", requestId });
|
||||
await pollForResult(requestId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Network error";
|
||||
setJoinState({ phase: "error", message });
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitting = joinState.phase === "submitting";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Join a Telegram channel or group by link, username, or invite link.
|
||||
The channel will be added as an active source.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-input">Channel link or username</Label>
|
||||
<Input
|
||||
id="channel-input"
|
||||
placeholder="@channel, t.me/channel, or t.me/+invite"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isSubmitting && input.trim()) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: @username, https://t.me/username, https://t.me/+invitecode
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{joinState.phase === "submitting" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{joinState.requestId
|
||||
? "Joining channel via worker..."
|
||||
: "Sending request..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{joinState.phase === "error" && (
|
||||
<p className="text-sm text-destructive">{joinState.message}</p>
|
||||
)}
|
||||
|
||||
{joinState.phase === "success" && (
|
||||
<p className="text-sm text-emerald-600">
|
||||
Successfully added "{joinState.title}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{joinState.phase === "success" ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{joinState.phase !== "success" && (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !input.trim()}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Add Channel
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user