mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: file upload from UI, notification dismiss, audit false positive fix
Manual file upload:
- Upload dialog in STL page with drag-and-drop file picker
- Files saved to shared Docker volume (/data/uploads)
- Worker processes via pg_notify('manual_upload') channel
- Hashes, reads metadata, splits >2GB, uploads to Telegram
- Multiple files automatically grouped
- Status polling shows upload/processing/complete states
Notification fixes:
- Add dismiss (X) button on each notification
- Add "Clear" button to remove all notifications
- Fix false positive MISSING_PART alerts from legacy packages
(only flag when >1 destMessageIds stored but count wrong,
not when only 1 ID from backfill)
Infrastructure:
- ManualUpload + ManualUploadFile schema + migration
- Shared manual_uploads Docker volume between app and worker
- Upload API routes (POST /api/uploads, GET /api/uploads/[id])
- Worker manual-upload processor with full pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
import { useState, useCallback, useTransition, useMemo, useRef } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Search, Layers } from "lucide-react";
|
||||
import { Search, Layers, Upload } from "lucide-react";
|
||||
import { UploadDialog } from "./upload-dialog";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import {
|
||||
getPackageColumns,
|
||||
@@ -106,6 +107,9 @@ export function StlTable({
|
||||
// Group merge state
|
||||
const [mergeSourceId, setMergeSourceId] = useState<string | null>(null);
|
||||
|
||||
// Upload dialog state
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
|
||||
const toggleGroup = useCallback((groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -497,6 +501,10 @@ export function StlTable({
|
||||
</Select>
|
||||
)}
|
||||
<DataTableViewOptions table={table} />
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload Files
|
||||
</Button>
|
||||
{selectedPackages.size >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -587,6 +595,8 @@ export function StlTable({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
{/* Hidden file input for group preview upload (Task 12) */}
|
||||
<input
|
||||
ref={previewInputRef}
|
||||
|
||||
243
src/app/(app)/stls/_components/upload-dialog.tsx
Normal file
243
src/app/(app)/stls/_components/upload-dialog.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useTransition, useEffect } from "react";
|
||||
import { Upload, File, X, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface UploadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
type UploadStatus = "idle" | "uploading" | "processing" | "done" | "error";
|
||||
|
||||
export function UploadDialog({ open, onOpenChange }: UploadDialogProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [status, setStatus] = useState<UploadStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFiles([]);
|
||||
setGroupName("");
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
if (e.target.files) {
|
||||
setFiles(Array.from(e.target.files));
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function handleUpload() {
|
||||
if (files.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
setStatus("uploading");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
if (groupName.trim()) {
|
||||
formData.append("groupName", groupName.trim());
|
||||
}
|
||||
|
||||
const res = await fetch("/api/uploads", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setError(data.error ?? "Upload failed");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("processing");
|
||||
|
||||
// Poll for completion
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const statusRes = await fetch(`/api/uploads/${data.uploadId}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === "COMPLETED") {
|
||||
setStatus("done");
|
||||
toast.success(`${files.length} file(s) uploaded and indexed`);
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
} else if (statusData.status === "FAILED") {
|
||||
setStatus("error");
|
||||
setError(statusData.errorMessage ?? "Processing failed");
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
}
|
||||
} catch {
|
||||
// Keep polling
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Stop polling after 10 minutes
|
||||
setTimeout(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
setStatus((s) => s === "processing" ? "done" : s);
|
||||
}
|
||||
}, 600_000);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setError("Network error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Files</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload archive files to be processed and indexed. Multiple files will be automatically grouped.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{status === "idle" && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click to select files or drag & drop
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
ZIP, RAR, 7Z files up to 4GB each
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,.rar,.7z,.pdf,.stl"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-2 rounded bg-muted/30">
|
||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-sm flex-1 truncate">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>
|
||||
<button onClick={() => removeFile(i)} className="p-0.5 hover:text-destructive">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 1 && (
|
||||
<div>
|
||||
<Label htmlFor="groupName" className="text-sm">Group Name (optional)</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="Auto-generated from filenames"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "uploading" || status === "processing") && (
|
||||
<div className="flex items-center gap-3 p-6 rounded-lg bg-muted/30 border">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{status === "uploading" ? "Uploading files..." : "Processing & uploading to Telegram..."}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{status === "uploading"
|
||||
? "Sending files to server"
|
||||
: "Hashing, extracting metadata, uploading to destination channel"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "done" && (
|
||||
<div className="flex items-center gap-3 p-6 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-500">Upload complete!</p>
|
||||
<p className="text-xs text-muted-foreground">Files have been indexed and uploaded to Telegram.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-3 p-6 rounded-lg bg-destructive/10 border border-destructive/20">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Upload failed</p>
|
||||
<p className="text-xs text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{status === "idle" && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleUpload} disabled={files.length === 0 || isPending}>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Upload {files.length > 0 ? `(${files.length})` : ""}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(status === "done" || status === "error") && (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { auth } from "@/lib/auth";
|
||||
import {
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
dismissNotification,
|
||||
clearAllNotifications,
|
||||
} from "@/data/notification.queries";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,8 +17,13 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const id = body.id as string | undefined;
|
||||
const action = (body.action as string) ?? "read";
|
||||
|
||||
if (id) {
|
||||
if (action === "dismiss" && id) {
|
||||
await dismissNotification(id);
|
||||
} else if (action === "clear") {
|
||||
await clearAllNotifications();
|
||||
} else if (id) {
|
||||
await markNotificationRead(id);
|
||||
} else {
|
||||
await markAllNotificationsRead();
|
||||
|
||||
43
src/app/api/uploads/[id]/route.ts
Normal file
43
src/app/api/uploads/[id]/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const upload = await prisma.manualUpload.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
files: {
|
||||
select: { id: true, fileName: true, fileSize: true, packageId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!upload || upload.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: upload.id,
|
||||
status: upload.status,
|
||||
groupName: upload.groupName,
|
||||
errorMessage: upload.errorMessage,
|
||||
files: upload.files.map((f) => ({
|
||||
...f,
|
||||
fileSize: f.fileSize.toString(),
|
||||
})),
|
||||
createdAt: upload.createdAt.toISOString(),
|
||||
completedAt: upload.completedAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
83
src/app/api/uploads/route.ts
Normal file
83
src/app/api/uploads/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? "/data/uploads";
|
||||
const MAX_FILE_SIZE = 4 * 1024 * 1024 * 1024; // 4GB per file
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll("files") as File[];
|
||||
const groupName = formData.get("groupName") as string | null;
|
||||
|
||||
if (!files.length) {
|
||||
return NextResponse.json({ error: "No files provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create the upload record
|
||||
const upload = await prisma.manualUpload.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
groupName: groupName || (files.length > 1 ? files[0].name.replace(/\.[^.]+$/, "") : null),
|
||||
status: "PENDING",
|
||||
},
|
||||
});
|
||||
|
||||
// Save files to shared volume
|
||||
const uploadDir = path.join(UPLOAD_DIR, upload.id);
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: `File "${file.name}" exceeds 4GB limit` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = path.join(uploadDir, file.name);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
await prisma.manualUploadFile.create({
|
||||
data: {
|
||||
uploadId: upload.id,
|
||||
fileName: file.name,
|
||||
filePath,
|
||||
fileSize: BigInt(file.size),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Notify worker
|
||||
try {
|
||||
await prisma.$queryRawUnsafe(
|
||||
`SELECT pg_notify('manual_upload', $1)`,
|
||||
upload.id
|
||||
);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
uploadId: upload.id,
|
||||
fileCount: files.length,
|
||||
status: "PENDING",
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : "Upload failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Bell, AlertTriangle, AlertCircle, Info, CheckCircle2 } from "lucide-react";
|
||||
import { Bell, AlertTriangle, AlertCircle, Info, CheckCircle2, X, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -94,6 +94,34 @@ export function NotificationBell() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismiss(id: string) {
|
||||
try {
|
||||
await fetch("/api/notifications/read", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "dismiss" }),
|
||||
});
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAll() {
|
||||
try {
|
||||
await fetch("/api/notifications/read", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "clear" }),
|
||||
});
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRepair(notificationId: string) {
|
||||
try {
|
||||
const res = await fetch("/api/notifications/repair", {
|
||||
@@ -141,16 +169,29 @@ export function NotificationBell() {
|
||||
<PopoverContent className="w-96 p-0" align="end">
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleMarkAllRead}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleMarkAllRead}
|
||||
>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[400px]">
|
||||
{notifications.length === 0 ? (
|
||||
@@ -187,6 +228,13 @@ export function NotificationBell() {
|
||||
{!n.isRead && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
<button
|
||||
className="ml-auto shrink-0 p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); handleDismiss(n.id); }}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{n.message}
|
||||
|
||||
@@ -35,3 +35,11 @@ export async function markAllNotificationsRead() {
|
||||
data: { isRead: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function dismissNotification(id: string) {
|
||||
return prisma.systemNotification.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function clearAllNotifications() {
|
||||
return prisma.systemNotification.deleteMany({});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user