mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11: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:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Copy, Plus, Trash2 } from "lucide-react";
|
||||
import { Copy, Link2, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,7 +16,30 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { createInviteCode, deleteInviteCode } from "../actions";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { createInviteCode, createBulkInviteCodes, deleteInviteCode } from "../actions";
|
||||
|
||||
type InviteUser = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type InviteCode = {
|
||||
id: string;
|
||||
@@ -25,6 +49,7 @@ type InviteCode = {
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
creator: { name: string | null };
|
||||
usedBy: InviteUser[];
|
||||
};
|
||||
|
||||
export function InviteManager({
|
||||
@@ -37,8 +62,10 @@ export function InviteManager({
|
||||
const [maxUses, setMaxUses] = useState(1);
|
||||
const [expiresInDays, setExpiresInDays] = useState(7);
|
||||
const [noExpiry, setNoExpiry] = useState(false);
|
||||
const [bulkCount, setBulkCount] = useState(5);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [copiedType, setCopiedType] = useState<"code" | "link" | null>(null);
|
||||
|
||||
function handleCreate() {
|
||||
startTransition(async () => {
|
||||
@@ -49,35 +76,64 @@ export function InviteManager({
|
||||
});
|
||||
}
|
||||
|
||||
function handleBulkCreate() {
|
||||
startTransition(async () => {
|
||||
await createBulkInviteCodes({
|
||||
count: bulkCount,
|
||||
maxUses,
|
||||
expiresInDays: noExpiry ? null : expiresInDays,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
startTransition(async () => {
|
||||
await deleteInviteCode(id);
|
||||
});
|
||||
}
|
||||
|
||||
function copyLink(code: string, id: string) {
|
||||
const url = `${appUrl}/register?code=${code}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
function copyToClipboard(text: string, id: string, type: "code" | "link") {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
setCopiedType(type);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
setCopiedType(null);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function getStatus(invite: InviteCode) {
|
||||
function getStatus(invite: InviteCode): "active" | "used" | "expired" {
|
||||
if (invite.uses >= invite.maxUses) return "used";
|
||||
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return "Expired";
|
||||
if (diffDays === 0) return "Today";
|
||||
if (diffDays === 1) return "Tomorrow";
|
||||
return `${diffDays} days`;
|
||||
}
|
||||
|
||||
const activeCount = inviteCodes.filter((i) => getStatus(i) === "active").length;
|
||||
const usedCount = inviteCodes.filter((i) => getStatus(i) === "used").length;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="max-w-5xl space-y-6">
|
||||
{/* Create Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Invite Code</CardTitle>
|
||||
<CardTitle>Generate Invite Codes</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a new invite code to share with someone
|
||||
Create single or bulk invite codes to share with new users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxUses">Max Uses</Label>
|
||||
@@ -92,9 +148,7 @@ export function InviteManager({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiresInDays">
|
||||
Expires in (days)
|
||||
</Label>
|
||||
<Label htmlFor="expiresInDays">Expires in (days)</Label>
|
||||
<Input
|
||||
id="expiresInDays"
|
||||
type="number"
|
||||
@@ -107,28 +161,55 @@ export function InviteManager({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
id="noExpiry"
|
||||
checked={noExpiry}
|
||||
onChange={(e) => setNoExpiry(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
onCheckedChange={setNoExpiry}
|
||||
/>
|
||||
<Label htmlFor="noExpiry" className="text-sm">No expiry</Label>
|
||||
<Label htmlFor="noExpiry" className="text-sm">
|
||||
No expiry
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 border-t pt-4">
|
||||
<Button onClick={handleCreate} disabled={isPending}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isPending ? "Creating..." : "Create"}
|
||||
{isPending ? "Creating..." : "Create One"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bulkCount">Count</Label>
|
||||
<Input
|
||||
id="bulkCount"
|
||||
type="number"
|
||||
min={2}
|
||||
max={25}
|
||||
value={bulkCount}
|
||||
onChange={(e) => setBulkCount(Number(e.target.value))}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBulkCreate}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isPending ? "Creating..." : `Create ${bulkCount}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Codes Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invite Codes</CardTitle>
|
||||
<CardDescription>
|
||||
{inviteCodes.length} invite code{inviteCodes.length !== 1 ? "s" : ""} created
|
||||
{inviteCodes.length} total · {activeCount} active · {usedCount} fully used
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -143,6 +224,7 @@ export function InviteManager({
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Redeemed By</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
@@ -151,6 +233,11 @@ export function InviteManager({
|
||||
<TableBody>
|
||||
{inviteCodes.map((invite) => {
|
||||
const status = getStatus(invite);
|
||||
const isCopiedCode =
|
||||
copiedId === invite.id && copiedType === "code";
|
||||
const isCopiedLink =
|
||||
copiedId === invite.id && copiedType === "link";
|
||||
|
||||
return (
|
||||
<TableRow key={invite.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
@@ -173,32 +260,146 @@ export function InviteManager({
|
||||
{invite.uses} / {invite.maxUses}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.expiresAt
|
||||
? new Date(invite.expiresAt).toLocaleDateString()
|
||||
: "Never"}
|
||||
{invite.usedBy.length === 0 ? (
|
||||
<span className="text-muted-foreground">--</span>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{invite.usedBy.map((user) => (
|
||||
<Tooltip key={user.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-sm cursor-default">
|
||||
{user.name ?? user.email ?? "Unknown"}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-xs">
|
||||
{user.email && <div>{user.email}</div>}
|
||||
<div>
|
||||
Joined{" "}
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(invite.createdAt).toLocaleDateString()}
|
||||
{invite.expiresAt ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">
|
||||
{formatRelativeDate(invite.expiresAt)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{new Date(invite.expiresAt).toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Never</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default">
|
||||
{new Date(invite.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
by {invite.creator.name ?? "Unknown"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyLink(invite.code, invite.id)}
|
||||
disabled={status !== "active"}
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
{copiedId === invite.id ? "Copied!" : "Copy Link"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(invite.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
invite.code,
|
||||
invite.id,
|
||||
"code"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
{isCopiedCode && (
|
||||
<span className="ml-1">Copied!</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`${appUrl}/register?code=${invite.code}`,
|
||||
invite.id,
|
||||
"link"
|
||||
)
|
||||
}
|
||||
disabled={status !== "active"}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{isCopiedLink && (
|
||||
<span className="ml-1">Copied!</span>
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy registration link</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<AlertDialog>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete code</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete invite code?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the invite code{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{invite.code}
|
||||
</span>
|
||||
.{" "}
|
||||
{status === "active" &&
|
||||
"Anyone with this code will no longer be able to register."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(invite.id)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -33,6 +33,45 @@ export async function createInviteCode(input: {
|
||||
return { success: true, data: { code } };
|
||||
}
|
||||
|
||||
export async function createBulkInviteCodes(input: {
|
||||
count: number;
|
||||
maxUses: number;
|
||||
expiresInDays: number | null;
|
||||
}): Promise<ActionResult<{ codes: string[] }>> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
if (input.count < 1 || input.count > 25) {
|
||||
return { success: false, error: "Can generate between 1 and 25 codes at a time" };
|
||||
}
|
||||
|
||||
const expiresAt = input.expiresInDays
|
||||
? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const codes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < input.count; i++) {
|
||||
const code = crypto.randomBytes(6).toString("hex");
|
||||
codes.push(code);
|
||||
await tx.inviteCode.create({
|
||||
data: {
|
||||
code,
|
||||
maxUses: input.maxUses,
|
||||
expiresAt,
|
||||
createdBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/invites");
|
||||
return { success: true, data: { codes } };
|
||||
}
|
||||
|
||||
export async function deleteInviteCode(id: string): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
@@ -48,7 +87,10 @@ export async function deleteInviteCode(id: string): Promise<ActionResult> {
|
||||
export async function getInviteCodes() {
|
||||
const codes = await prisma.inviteCode.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { creator: { select: { name: true } } },
|
||||
include: {
|
||||
creator: { select: { name: true } },
|
||||
usedBy: { select: { id: true, name: true, email: true, createdAt: true } },
|
||||
},
|
||||
});
|
||||
return codes;
|
||||
}
|
||||
|
||||
399
src/app/(app)/stls/_components/archive-preview-picker.tsx
Normal file
399
src/app/(app)/stls/_components/archive-preview-picker.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useTransition } from "react";
|
||||
import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
ImageOff,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { setPreviewFromExtract } from "../actions";
|
||||
|
||||
interface ArchiveImage {
|
||||
id: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
extension: string | null;
|
||||
size: string;
|
||||
}
|
||||
|
||||
interface ThumbnailState {
|
||||
status: "idle" | "loading" | "loaded" | "failed";
|
||||
requestId?: string;
|
||||
imageUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ArchivePreviewPickerProps {
|
||||
packageId: string;
|
||||
packageName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPreviewSet?: () => 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"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function ArchivePreviewPicker({
|
||||
packageId,
|
||||
packageName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onPreviewSet,
|
||||
}: ArchivePreviewPickerProps) {
|
||||
const [images, setImages] = useState<ArchiveImage[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||
// Track which paths have already been requested to avoid re-requesting
|
||||
const requestedPaths = useRef<Set<string>>(new Set());
|
||||
|
||||
// Cleanup poll timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of pollTimers.current.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch image list when opened
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setImages([]);
|
||||
setThumbnails(new Map());
|
||||
setSelectedPath(null);
|
||||
requestedPaths.current.clear();
|
||||
|
||||
// Clear any leftover poll timers
|
||||
for (const timer of pollTimers.current.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
pollTimers.current.clear();
|
||||
|
||||
const fetchImages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/zips/${packageId}/images`);
|
||||
if (!res.ok) throw new Error("Failed to fetch images");
|
||||
const data = await res.json();
|
||||
setImages(data.images);
|
||||
} catch {
|
||||
toast.error("Failed to load archive images");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchImages();
|
||||
}, [open, packageId]);
|
||||
|
||||
// Poll callback for a specific request
|
||||
const startPolling = useCallback(
|
||||
(filePath: string, requestId: string) => {
|
||||
// Clear any existing poll for this path
|
||||
const existing = pollTimers.current.get(filePath);
|
||||
if (existing) clearInterval(existing);
|
||||
|
||||
const pollId = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`/api/zips/${packageId}/extract/${requestId}`
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
const pollData = await pollRes.json();
|
||||
|
||||
if (pollData.status === "COMPLETED") {
|
||||
clearInterval(pollId);
|
||||
pollTimers.current.delete(filePath);
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "loaded",
|
||||
requestId,
|
||||
imageUrl: `/api/zips/${packageId}/extract/${requestId}?image=true`,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
} else if (pollData.status === "FAILED") {
|
||||
clearInterval(pollId);
|
||||
pollTimers.current.delete(filePath);
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "failed",
|
||||
error: pollData.error || "Extraction failed",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently retry on network error
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
pollTimers.current.set(filePath, pollId);
|
||||
},
|
||||
[packageId]
|
||||
);
|
||||
|
||||
// Request extraction for a specific image
|
||||
const requestThumbnail = useCallback(
|
||||
async (filePath: string) => {
|
||||
// Don't re-request if already in progress
|
||||
if (requestedPaths.current.has(filePath)) return;
|
||||
requestedPaths.current.add(filePath);
|
||||
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, { status: "loading" });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/zips/${packageId}/extract`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filePath }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Extract failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "COMPLETED") {
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "loaded",
|
||||
requestId: data.requestId,
|
||||
imageUrl: `/api/zips/${packageId}/extract/${data.requestId}?image=true`,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending or in-progress: start polling
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, { status: "loading", requestId: data.requestId });
|
||||
return next;
|
||||
});
|
||||
|
||||
startPolling(filePath, data.requestId);
|
||||
} catch (err) {
|
||||
requestedPaths.current.delete(filePath);
|
||||
setThumbnails((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(filePath, {
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Failed to extract",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[packageId, startPolling]
|
||||
);
|
||||
|
||||
// Auto-request thumbnails for the first batch of images
|
||||
useEffect(() => {
|
||||
if (!open || images.length === 0) return;
|
||||
|
||||
// Request the first 12 images automatically
|
||||
const toRequest = images.slice(0, 12);
|
||||
for (const img of toRequest) {
|
||||
requestThumbnail(img.path);
|
||||
}
|
||||
// Only trigger when images list changes, not on every requestThumbnail change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [images, open]);
|
||||
|
||||
// Handle selection confirmation
|
||||
const handleConfirm = () => {
|
||||
if (!selectedPath) return;
|
||||
const thumbState = thumbnails.get(selectedPath);
|
||||
if (!thumbState?.requestId) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await setPreviewFromExtract(packageId, thumbState.requestId!);
|
||||
if (result.success) {
|
||||
toast.success("Preview updated from archive image");
|
||||
onOpenChange(false);
|
||||
onPreviewSet?.();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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-1">
|
||||
<DialogTitle>Select Preview Image</DialogTitle>
|
||||
<DialogDescription className="text-sm">
|
||||
Choose an image from the archive to use as the preview for{" "}
|
||||
<span className="font-medium text-foreground">{packageName}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-4">
|
||||
{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 image list...
|
||||
</span>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12">
|
||||
<ImageOff className="h-6 w-6 text-muted-foreground/50" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No images found in this archive
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{images.map((img) => {
|
||||
const thumbState = thumbnails.get(img.path);
|
||||
const isSelected = selectedPath === img.path;
|
||||
const isLoaded = thumbState?.status === "loaded";
|
||||
const isLoading = thumbState?.status === "loading";
|
||||
const isFailed = thumbState?.status === "failed";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={img.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all",
|
||||
"hover:border-primary/50 cursor-pointer group",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-border",
|
||||
isFailed && "opacity-60"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isLoaded) {
|
||||
setSelectedPath(img.path);
|
||||
} else if (isFailed) {
|
||||
// Allow retry on failed
|
||||
requestedPaths.current.delete(img.path);
|
||||
requestThumbnail(img.path);
|
||||
} else if (!thumbState || thumbState.status === "idle") {
|
||||
requestThumbnail(img.path);
|
||||
}
|
||||
}}
|
||||
title={img.path}
|
||||
>
|
||||
{isLoaded && thumbState.imageUrl ? (
|
||||
<img
|
||||
src={thumbState.imageUrl}
|
||||
alt={img.fileName}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : isFailed ? (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center bg-muted gap-1">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-[10px] text-destructive px-1 text-center">
|
||||
Click to retry
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted">
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection checkmark */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File info overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">
|
||||
{img.fileName}
|
||||
</p>
|
||||
<p className="text-[9px] text-white/70">
|
||||
{formatBytes(img.size)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
{images.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{images.length} image{images.length !== 1 ? "s" : ""} found
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!selectedPath || isPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
Setting...
|
||||
</>
|
||||
) : (
|
||||
"Use as Preview"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
@@ -9,6 +10,9 @@ import {
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
ImagePlus,
|
||||
Images,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,6 +28,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PackageRow } from "./package-columns";
|
||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||
import { uploadPackagePreview } from "../actions";
|
||||
import { ArchivePreviewPicker } from "./archive-preview-picker";
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
@@ -224,6 +230,46 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePreviewUpload = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !pkg) return;
|
||||
|
||||
// Reset file input so the same file can be re-selected
|
||||
e.target.value = "";
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await uploadPackagePreview(pkg.id, formData);
|
||||
if (result.success) {
|
||||
toast.success("Preview image uploaded");
|
||||
// Show uploaded image immediately via local object URL
|
||||
setLocalPreviewUrl(URL.createObjectURL(file));
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to upload preview image");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[pkg]
|
||||
);
|
||||
|
||||
// Clean up local preview URL when drawer closes or package changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl);
|
||||
};
|
||||
}, [localPreviewUrl]);
|
||||
|
||||
const fetchFiles = useCallback(
|
||||
async (pageNum: number, append: boolean) => {
|
||||
@@ -258,6 +304,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
setTotal(0);
|
||||
setSearch("");
|
||||
setPage(1);
|
||||
setLocalPreviewUrl(null);
|
||||
fetchFiles(1, false);
|
||||
}
|
||||
}, [open, pkg, fetchFiles]);
|
||||
@@ -293,12 +340,49 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
<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"
|
||||
/>
|
||||
{/* Preview image area with upload capability */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handlePreviewUpload}
|
||||
/>
|
||||
{(pkg?.hasPreview || localPreviewUrl) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
title="Click to replace preview image"
|
||||
>
|
||||
<img
|
||||
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-20 w-20 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50 hover:bg-muted hover:border-muted-foreground/50 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
title="Upload preview image"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate pr-8">
|
||||
@@ -308,11 +392,22 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
{total.toLocaleString()} file{total !== 1 ? "s" : ""} in archive
|
||||
</DialogDescription>
|
||||
{pkg && (
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<SendToTelegramButton
|
||||
packageId={pkg.id}
|
||||
packageName={pkg.fileName}
|
||||
/>
|
||||
{pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => setShowPreviewPicker(true)}
|
||||
>
|
||||
<Images className="h-3.5 w-3.5" />
|
||||
Pick Preview
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -416,6 +511,20 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDraw
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
|
||||
{/* Archive preview picker modal */}
|
||||
{pkg && pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && (
|
||||
<ArchivePreviewPicker
|
||||
packageId={pkg.id}
|
||||
packageName={pkg.fileName}
|
||||
open={showPreviewPicker}
|
||||
onOpenChange={setShowPreviewPicker}
|
||||
onPreviewSet={() => {
|
||||
// Refresh the preview by setting a cache-busting URL
|
||||
setLocalPreviewUrl(`/api/zips/${pkg.id}/preview?t=${Date.now()}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/types/api.types";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
] as const;
|
||||
const MAX_IMAGE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||
|
||||
export async function updatePackageCreator(
|
||||
packageId: string,
|
||||
creator: string | null
|
||||
@@ -24,6 +31,46 @@ export async function updatePackageCreator(
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPackagePreview(
|
||||
packageId: string,
|
||||
formData: FormData
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return { success: false, error: "No file provided" };
|
||||
}
|
||||
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number])) {
|
||||
return { success: false, error: "Only JPG, PNG, and WebP images are accepted" };
|
||||
}
|
||||
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return { success: false, error: "Image must be smaller than 2 MB" };
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
await prisma.package.update({
|
||||
where: { id: packageId },
|
||||
data: {
|
||||
previewData: buffer,
|
||||
// Set previewMsgId to 0 as sentinel so hasPreview checks work
|
||||
previewMsgId: 0n,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to upload preview image" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkSetCreator(
|
||||
packageIds: string[],
|
||||
creator: string
|
||||
@@ -42,3 +89,49 @@ export async function bulkSetCreator(
|
||||
return { success: false, error: "Failed to update creators" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a package's preview from an extracted archive image.
|
||||
* Reads the image data from a completed ArchiveExtractRequest.
|
||||
*/
|
||||
export async function setPreviewFromExtract(
|
||||
packageId: string,
|
||||
extractRequestId: string
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const extractReq = await prisma.archiveExtractRequest.findUnique({
|
||||
where: { id: extractRequestId },
|
||||
select: { status: true, imageData: true, packageId: true },
|
||||
});
|
||||
|
||||
if (!extractReq) {
|
||||
return { success: false, error: "Extract request not found" };
|
||||
}
|
||||
|
||||
if (extractReq.packageId !== packageId) {
|
||||
return { success: false, error: "Extract request does not belong to this package" };
|
||||
}
|
||||
|
||||
if (extractReq.status !== "COMPLETED" || !extractReq.imageData) {
|
||||
return { success: false, error: "Image extraction not yet completed" };
|
||||
}
|
||||
|
||||
await prisma.package.update({
|
||||
where: { id: packageId },
|
||||
data: {
|
||||
previewData: extractReq.imageData,
|
||||
// Set previewMsgId to 0 as sentinel so hasPreview checks work
|
||||
// (original Telegram-matched previews have the actual message ID)
|
||||
previewMsgId: 0n,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to set preview from archive image" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -501,6 +501,56 @@ export async function saveChannelSelections(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Join channel by link/username ──
|
||||
|
||||
/**
|
||||
* Request the worker to join a channel by t.me link, invite link, or @username.
|
||||
* Uses ChannelFetchRequest as a generic DB-mediated request with pg_notify.
|
||||
* Returns the requestId so the UI can poll for completion.
|
||||
*/
|
||||
export async function joinChannelByLink(
|
||||
input: string
|
||||
): Promise<ActionResult<{ requestId: string }>> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { success: false, error: "Input is required" };
|
||||
|
||||
try {
|
||||
// Need at least one authenticated account for TDLib
|
||||
const account = await prisma.telegramAccount.findFirst({
|
||||
where: { isActive: true, authState: "AUTHENTICATED" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!account) {
|
||||
return { success: false, error: "At least one authenticated account is needed" };
|
||||
}
|
||||
|
||||
// Create a fetch request to track progress
|
||||
const fetchRequest = await prisma.channelFetchRequest.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
// Signal worker via pg_notify
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('join_channel', $1)`,
|
||||
JSON.stringify({
|
||||
requestId: fetchRequest.id,
|
||||
input: trimmed,
|
||||
accountId: account.id,
|
||||
})
|
||||
);
|
||||
|
||||
return { success: true, data: { requestId: fetchRequest.id } };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to request channel join" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global destination channel ──
|
||||
|
||||
export async function setGlobalDestination(
|
||||
@@ -631,6 +681,63 @@ export async function createDestinationChannel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the worker to rebuild the package database by scanning the
|
||||
* destination channel for uploaded archives and recreating Package records.
|
||||
* Uses ChannelFetchRequest as a generic DB-mediated request with pg_notify.
|
||||
* Returns the requestId so the UI can poll for progress.
|
||||
*/
|
||||
export async function rebuildPackageDatabase(): Promise<
|
||||
ActionResult<{ requestId: string }>
|
||||
> {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin.success) return admin;
|
||||
|
||||
try {
|
||||
// Need at least one authenticated account for TDLib
|
||||
const hasAccount = await prisma.telegramAccount.findFirst({
|
||||
where: { isActive: true, authState: "AUTHENTICATED" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hasAccount) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"At least one authenticated account is needed to scan the destination channel",
|
||||
};
|
||||
}
|
||||
|
||||
// Need a destination channel
|
||||
const destSetting = await prisma.globalSetting.findUnique({
|
||||
where: { key: "destination_channel_id" },
|
||||
});
|
||||
if (!destSetting) {
|
||||
return {
|
||||
success: false,
|
||||
error: "No destination channel configured",
|
||||
};
|
||||
}
|
||||
|
||||
// Create a fetch request to track progress
|
||||
const fetchRequest = await prisma.channelFetchRequest.create({
|
||||
data: {
|
||||
accountId: hasAccount.id,
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
// Signal worker via pg_notify
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('rebuild_packages', $1)`,
|
||||
fetchRequest.id
|
||||
);
|
||||
|
||||
return { success: true, data: { requestId: fetchRequest.id } };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to request package database rebuild" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the worker to create a new Telegram supergroup as the destination.
|
||||
* Uses ChannelFetchRequest as a generic DB-mediated request with pg_notify.
|
||||
|
||||
@@ -17,15 +17,15 @@ export async function registerUser(input: unknown): Promise<ActionResult<{ id: s
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
return { success: false, error: "Invalid invite code" };
|
||||
return { success: false, error: "Invalid invite code. Please check the code and try again." };
|
||||
}
|
||||
|
||||
if (invite.uses >= invite.maxUses) {
|
||||
return { success: false, error: "This invite code has already been used" };
|
||||
return { success: false, error: "This invite code has reached its maximum number of uses" };
|
||||
}
|
||||
|
||||
if (invite.expiresAt && invite.expiresAt < new Date()) {
|
||||
return { success: false, error: "This invite code has expired" };
|
||||
return { success: false, error: "This invite code has expired. Please request a new one." };
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
@@ -46,6 +46,7 @@ export async function registerUser(input: unknown): Promise<ActionResult<{ id: s
|
||||
email: parsed.data.email,
|
||||
hashedPassword,
|
||||
role: "USER",
|
||||
usedInviteId: invite.id,
|
||||
settings: {
|
||||
create: {
|
||||
lowStockThreshold: 10,
|
||||
|
||||
@@ -100,7 +100,11 @@ export default function RegisterPage() {
|
||||
<FormItem>
|
||||
<FormLabel>Invite Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your invite code" {...field} />
|
||||
<Input
|
||||
placeholder="Enter your invite code"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
73
src/app/api/zips/[id]/extract/[requestId]/route.ts
Normal file
73
src/app/api/zips/[id]/extract/[requestId]/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/zips/:id/extract/:requestId
|
||||
* Get the status and/or image data for an extraction request.
|
||||
* Query param: ?image=true returns the raw image bytes if completed.
|
||||
* Otherwise returns status JSON.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string; requestId: string }> }
|
||||
) {
|
||||
const authResult = await authenticateApiRequest(request);
|
||||
if ("error" in authResult) return authResult.error;
|
||||
|
||||
const { requestId } = await params;
|
||||
const url = new URL(request.url);
|
||||
const wantImage = url.searchParams.get("image") === "true";
|
||||
|
||||
if (wantImage) {
|
||||
// Return the raw image bytes
|
||||
const req = await prisma.archiveExtractRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { status: true, imageData: true, contentType: true, error: true },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (req.status !== "COMPLETED" || !req.imageData) {
|
||||
return NextResponse.json(
|
||||
{ status: req.status, error: req.error },
|
||||
{ status: req.status === "FAILED" ? 400 : 202 }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer =
|
||||
req.imageData instanceof Buffer
|
||||
? req.imageData
|
||||
: Buffer.from(req.imageData);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": req.contentType || "image/jpeg",
|
||||
"Content-Length": String(buffer.length),
|
||||
"Cache-Control": "public, max-age=3600, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Return status JSON (without image data to avoid large payloads)
|
||||
const req = await prisma.archiveExtractRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, status: true, error: true, contentType: true },
|
||||
});
|
||||
|
||||
if (!req) {
|
||||
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: req.id,
|
||||
status: req.status,
|
||||
error: req.error,
|
||||
contentType: req.contentType,
|
||||
});
|
||||
}
|
||||
118
src/app/api/zips/[id]/extract/route.ts
Normal file
118
src/app/api/zips/[id]/extract/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* POST /api/zips/:id/extract
|
||||
* Request extraction of an image from a package archive.
|
||||
* Body: { filePath: string }
|
||||
* Returns: { requestId: string, status: string }
|
||||
*
|
||||
* If a completed extraction already exists for this package+filePath,
|
||||
* returns it immediately.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authResult = await authenticateApiRequest(request);
|
||||
if ("error" in authResult) return authResult.error;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const filePath = body?.filePath;
|
||||
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "filePath is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify package exists
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, destChannelId: true, destMessageId: true, archiveType: true, isMultipart: true, partCount: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json({ error: "Package not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!pkg.destChannelId || !pkg.destMessageId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Package has not been uploaded to destination channel" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pkg.archiveType === "DOCUMENT") {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot extract images from standalone documents" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pkg.isMultipart && pkg.partCount > 1) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image extraction is not supported for multipart archives" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for an existing completed extraction
|
||||
const existing = await prisma.archiveExtractRequest.findFirst({
|
||||
where: {
|
||||
packageId: id,
|
||||
filePath,
|
||||
status: "COMPLETED",
|
||||
imageData: { not: null },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
requestId: existing.id,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for an in-progress request
|
||||
const pending = await prisma.archiveExtractRequest.findFirst({
|
||||
where: {
|
||||
packageId: id,
|
||||
filePath,
|
||||
status: { in: ["PENDING", "IN_PROGRESS"] },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (pending) {
|
||||
return NextResponse.json({
|
||||
requestId: pending.id,
|
||||
status: pending.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new extraction request
|
||||
const extractRequest = await prisma.archiveExtractRequest.create({
|
||||
data: {
|
||||
packageId: id,
|
||||
filePath,
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the worker via pg_notify
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('archive_extract', $1)`,
|
||||
extractRequest.id
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
requestId: extractRequest.id,
|
||||
status: "PENDING",
|
||||
});
|
||||
}
|
||||
56
src/app/api/zips/[id]/images/route.ts
Normal file
56
src/app/api/zips/[id]/images/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "webp", "gif", "bmp"];
|
||||
|
||||
/**
|
||||
* GET /api/zips/:id/images
|
||||
* Lists image files inside a package's archive (from PackageFile records).
|
||||
* Returns a list of image file paths that can be used as preview candidates.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const authResult = await authenticateApiRequest(request);
|
||||
if ("error" in authResult) return authResult.error;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, archiveType: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json({ error: "Package not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const images = await prisma.packageFile.findMany({
|
||||
where: {
|
||||
packageId: id,
|
||||
extension: { in: IMAGE_EXTENSIONS },
|
||||
},
|
||||
orderBy: { path: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
extension: true,
|
||||
uncompressedSize: true,
|
||||
},
|
||||
});
|
||||
|
||||
const mapped = images.map((img) => ({
|
||||
id: img.id,
|
||||
path: img.path,
|
||||
fileName: img.fileName,
|
||||
extension: img.extension,
|
||||
size: img.uncompressedSize.toString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ images: mapped });
|
||||
}
|
||||
Reference in New Issue
Block a user