mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-22 23:12:02 +00:00
Compare commits
12 Commits
4f6a6f0f75
...
8b500a1610
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b500a1610 | |||
| f0a9d3b4da | |||
| b90317c007 | |||
| 6f8ddcca81 | |||
| 25ff067ea0 | |||
| 7e58cc29fa | |||
| 5a4e358eee | |||
| c31afc5b92 | |||
| 6324d64870 | |||
| 974769350b | |||
| f9b82f1654 | |||
| 7146a5cf0d |
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable: per-topic user-controlled fetch toggle (forum channels)
|
||||||
|
-- Additive, safe default (true) so existing rows backfill to "enabled" and
|
||||||
|
-- current behaviour is unchanged. Disabling is non-destructive.
|
||||||
|
ALTER TABLE "topic_progress"
|
||||||
|
ADD COLUMN "fetchEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -597,6 +597,11 @@ model TopicProgress {
|
|||||||
accountChannelMapId String
|
accountChannelMapId String
|
||||||
topicId BigInt
|
topicId BigInt
|
||||||
topicName String?
|
topicName String?
|
||||||
|
/// User-controlled fetch toggle. When false, the worker skips this topic
|
||||||
|
/// (no scanning, no fetching/transfer). Defaults true so every existing and
|
||||||
|
/// newly-discovered topic is fetched unless explicitly disabled. Disabling
|
||||||
|
/// is non-destructive — already-fetched packages are kept.
|
||||||
|
fetchEnabled Boolean @default(true)
|
||||||
lastProcessedMessageId BigInt?
|
lastProcessedMessageId BigInt?
|
||||||
/// When this topic was last scanned (any reason). Used by recency-skip.
|
/// When this topic was last scanned (any reason). Used by recency-skip.
|
||||||
lastScannedAt DateTime?
|
lastScannedAt DateTime?
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ImageOff,
|
ImageOff,
|
||||||
|
Maximize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { setPreviewFromExtract } from "../actions";
|
import { setPreviewFromExtract } from "../actions";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
|
||||||
interface ArchiveImage {
|
interface ArchiveImage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,6 +67,7 @@ export function ArchivePreviewPicker({
|
|||||||
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||||
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
const pollTimers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
|
||||||
// Track which paths have already been requested to avoid re-requesting
|
// Track which paths have already been requested to avoid re-requesting
|
||||||
const requestedPaths = useRef<Set<string>>(new Set());
|
const requestedPaths = useRef<Set<string>>(new Set());
|
||||||
@@ -290,71 +293,85 @@ export function ArchivePreviewPicker({
|
|||||||
const isFailed = thumbState?.status === "failed";
|
const isFailed = thumbState?.status === "failed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={img.id} className="group relative">
|
||||||
key={img.id}
|
{isLoaded && thumbState?.imageUrl && (
|
||||||
type="button"
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all",
|
className="absolute top-1.5 left-1.5 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
|
||||||
"hover:border-primary/50 cursor-pointer group",
|
onClick={(e) => {
|
||||||
isSelected
|
e.stopPropagation();
|
||||||
? "border-primary ring-2 ring-primary/30"
|
setLightboxSrc(thumbState.imageUrl!);
|
||||||
: "border-border",
|
}}
|
||||||
isFailed && "opacity-60"
|
title="Enlarge"
|
||||||
)}
|
>
|
||||||
onClick={() => {
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
if (isLoaded) {
|
</button>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square w-full rounded-lg overflow-hidden border-2 transition-all",
|
||||||
|
"hover:border-primary/50 cursor-pointer",
|
||||||
|
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 */}
|
{/* Selection checkmark */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="absolute top-1.5 right-1.5 h-5 w-5 rounded-full bg-primary flex items-center justify-center">
|
<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" />
|
<Check className="h-3 w-3 text-primary-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File info overlay */}
|
{/* 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">
|
<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">
|
<p className="text-[10px] text-white truncate">
|
||||||
{img.fileName}
|
{img.fileName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[9px] text-white/70">
|
<p className="text-[9px] text-white/70">
|
||||||
{formatBytes(img.size)}
|
{formatBytes(img.size)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +411,13 @@ export function ArchivePreviewPicker({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<ImageLightbox
|
||||||
|
src={lightboxSrc}
|
||||||
|
open={!!lightboxSrc}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setLightboxSrc(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/app/(app)/stls/_components/image-lightbox.tsx
Normal file
41
src/app/(app)/stls/_components/image-lightbox.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
src: string | null;
|
||||||
|
alt?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-size, in-page preview viewer. Renders the image at native size capped
|
||||||
|
* to the viewport (object-contain). Dismiss via the close button, Esc, or by
|
||||||
|
* clicking the overlay.
|
||||||
|
*/
|
||||||
|
export function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt = "",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ImageLightboxProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-auto max-w-[95vw] border-0 bg-transparent p-0 shadow-none sm:max-w-[90vw]">
|
||||||
|
<DialogTitle className="sr-only">Enlarged preview image</DialogTitle>
|
||||||
|
{src && (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="mx-auto max-h-[88vh] w-auto max-w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { type ColumnDef } from "@tanstack/react-table";
|
import { type ColumnDef } from "@tanstack/react-table";
|
||||||
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge } from "lucide-react";
|
import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus, GitMerge, Maximize2 } from "lucide-react";
|
||||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
|
||||||
export interface PackageRow {
|
export interface PackageRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,14 +86,30 @@ export function formatBytes(bytesStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
|
|
||||||
if (pkg.hasPreview) {
|
if (pkg.hasPreview) {
|
||||||
|
const src = `/api/zips/${pkg.id}/preview`;
|
||||||
return (
|
return (
|
||||||
<img
|
<>
|
||||||
src={`/api/zips/${pkg.id}/preview`}
|
<button
|
||||||
alt=""
|
type="button"
|
||||||
className="h-9 w-9 rounded-md object-cover bg-muted"
|
className="group/preview relative h-9 w-9 overflow-hidden rounded-md bg-muted"
|
||||||
loading="lazy"
|
onClick={() => setLightboxOpen(true)}
|
||||||
/>
|
title="Click to enlarge"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
className="h-9 w-9 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/50 opacity-0 transition-opacity group-hover/preview:opacity-100">
|
||||||
|
<Maximize2 className="h-3.5 w-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ImageLightbox src={src} open={lightboxOpen} onOpenChange={setLightboxOpen} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Images,
|
Images,
|
||||||
|
Maximize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -30,6 +31,7 @@ import type { PackageRow } from "./package-columns";
|
|||||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||||
import { uploadPackagePreview } from "../actions";
|
import { uploadPackagePreview } from "../actions";
|
||||||
import { ArchivePreviewPicker } from "./archive-preview-picker";
|
import { ArchivePreviewPicker } from "./archive-preview-picker";
|
||||||
|
import { ImageLightbox } from "./image-lightbox";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -264,6 +266,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||||
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
|
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
|
||||||
|
const [previewLightboxOpen, setPreviewLightboxOpen] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handlePreviewUpload = useCallback(
|
const handlePreviewUpload = useCallback(
|
||||||
@@ -384,9 +387,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
|
className="relative group h-20 w-20 shrink-0 rounded-lg overflow-hidden bg-muted"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => setPreviewLightboxOpen(true)}
|
||||||
disabled={uploading}
|
title="Click to enlarge"
|
||||||
title="Click to replace preview image"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
|
src={localPreviewUrl ?? `/api/zips/${pkg!.id}/preview`}
|
||||||
@@ -394,11 +396,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
className="h-full w-full object-cover"
|
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">
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||||
{uploading ? (
|
<Maximize2 className="h-5 w-5 text-white" />
|
||||||
<Loader2 className="h-5 w-5 text-white animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Upload className="h-5 w-5 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -582,6 +580,11 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ImageLightbox
|
||||||
|
src={pkg ? (localPreviewUrl ?? `/api/zips/${pkg.id}/preview`) : null}
|
||||||
|
open={previewLightboxOpen}
|
||||||
|
onOpenChange={setPreviewLightboxOpen}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Tag,
|
Tag,
|
||||||
|
MessagesSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -27,6 +28,7 @@ interface ChannelColumnsProps {
|
|||||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||||
onRescan: (id: string) => void;
|
onRescan: (id: string) => void;
|
||||||
onSetCategory: (id: string, category: string | null) => void;
|
onSetCategory: (id: string, category: string | null) => void;
|
||||||
|
onManageTopics: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannelColumns({
|
export function getChannelColumns({
|
||||||
@@ -35,6 +37,7 @@ export function getChannelColumns({
|
|||||||
onSetType,
|
onSetType,
|
||||||
onRescan,
|
onRescan,
|
||||||
onSetCategory,
|
onSetCategory,
|
||||||
|
onManageTopics,
|
||||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -147,6 +150,14 @@ export function getChannelColumns({
|
|||||||
Rescan Channel
|
Rescan Channel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{row.original.type === "SOURCE" && row.original.isForum && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onManageTopics(row.original.id)}
|
||||||
|
>
|
||||||
|
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Topics
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
|
const cat = prompt("Enter category (e.g. STL, PDF, D&D, Cosplay):", row.original.category ?? "");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getChannelColumns } from "./channel-columns";
|
|||||||
import { DestinationCard } from "./destination-card";
|
import { DestinationCard } from "./destination-card";
|
||||||
import { ChannelPickerDialog } from "./channel-picker-dialog";
|
import { ChannelPickerDialog } from "./channel-picker-dialog";
|
||||||
import { JoinChannelDialog } from "./join-channel-dialog";
|
import { JoinChannelDialog } from "./join-channel-dialog";
|
||||||
|
import { TopicsDrawer } from "./topics-drawer";
|
||||||
import {
|
import {
|
||||||
deleteChannel,
|
deleteChannel,
|
||||||
toggleChannelActive,
|
toggleChannelActive,
|
||||||
@@ -32,6 +33,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
const [rescanId, setRescanId] = useState<string | null>(null);
|
const [rescanId, setRescanId] = useState<string | null>(null);
|
||||||
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
||||||
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
||||||
|
const [topicsChannelId, setTopicsChannelId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Find the first authenticated account for "Fetch Channels"
|
// Find the first authenticated account for "Fetch Channels"
|
||||||
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
|
const authenticatedAccounts = accounts.filter((a) => a.authState === "AUTHENTICATED" && a.isActive);
|
||||||
@@ -60,6 +62,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
else toast.error(result.error);
|
else toast.error(result.error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onManageTopics: (id) => setTopicsChannelId(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
@@ -167,6 +170,17 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
|||||||
open={joinDialogOpen}
|
open={joinDialogOpen}
|
||||||
onOpenChange={setJoinDialogOpen}
|
onOpenChange={setJoinDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TopicsDrawer
|
||||||
|
channelId={topicsChannelId}
|
||||||
|
channelTitle={
|
||||||
|
channels.find((c) => c.id === topicsChannelId)?.title
|
||||||
|
}
|
||||||
|
open={!!topicsChannelId}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTopicsChannelId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
src/app/(app)/telegram/_components/topics-drawer.tsx
Normal file
143
src/app/(app)/telegram/_components/topics-drawer.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { setTopicFetchEnabled } from "../actions";
|
||||||
|
|
||||||
|
interface TopicRow {
|
||||||
|
id: string;
|
||||||
|
topicId: string;
|
||||||
|
topicName: string | null;
|
||||||
|
fetchEnabled: boolean;
|
||||||
|
lastScannedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicsDrawerProps {
|
||||||
|
channelId: string | null;
|
||||||
|
channelTitle?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopicsDrawer({
|
||||||
|
channelId,
|
||||||
|
channelTitle,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: TopicsDrawerProps) {
|
||||||
|
const [topics, setTopics] = useState<TopicRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTopics = useCallback(async () => {
|
||||||
|
if (!channelId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/telegram/channels/${channelId}/topics`);
|
||||||
|
if (res.ok) setTopics(await res.json());
|
||||||
|
else toast.error("Failed to load topics");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to load topics");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [channelId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && channelId) fetchTopics();
|
||||||
|
}, [open, channelId, fetchTopics]);
|
||||||
|
|
||||||
|
const handleToggle = (topic: TopicRow, enabled: boolean) => {
|
||||||
|
// Optimistic update
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.map((t) => (t.id === topic.id ? { ...t, fetchEnabled: enabled } : t))
|
||||||
|
);
|
||||||
|
setPendingId(topic.id);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await setTopicFetchEnabled(topic.id, enabled);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error);
|
||||||
|
// Revert on failure
|
||||||
|
setTopics((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === topic.id ? { ...t, fetchEnabled: !enabled } : t
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPendingId(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg 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 className="truncate pr-8">
|
||||||
|
Topics{channelTitle ? `: ${channelTitle}` : ""}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enabled topics are scanned and their files fetched. Disable a topic to
|
||||||
|
stop fetching new files from it — already-fetched files are kept.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
<div className="px-6 py-4 space-y-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex 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 topics...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : topics.length === 0 ? (
|
||||||
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No topics discovered yet — they'll appear here after the next
|
||||||
|
scan.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
topics.map((topic) => (
|
||||||
|
<div
|
||||||
|
key={topic.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{topic.topicName ?? `Topic ${topic.topicId}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{topic.fetchEnabled ? "Fetching enabled" : "Fetching disabled"}
|
||||||
|
{topic.lastScannedAt
|
||||||
|
? ` · last scanned ${new Date(
|
||||||
|
topic.lastScannedAt
|
||||||
|
).toLocaleDateString()}`
|
||||||
|
: " · not scanned yet"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={topic.fetchEnabled}
|
||||||
|
disabled={pendingId === topic.id}
|
||||||
|
onCheckedChange={(checked) => handleToggle(topic, checked)}
|
||||||
|
aria-label={`Toggle fetching for ${
|
||||||
|
topic.topicName ?? topic.topicId
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -436,6 +436,32 @@ export async function rescanChannel(channelId: string): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Topic fetch toggle ──
|
||||||
|
|
||||||
|
export async function setTopicFetchEnabled(
|
||||||
|
topicProgressId: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin.success) return admin;
|
||||||
|
|
||||||
|
const existing = await prisma.topicProgress.findUnique({
|
||||||
|
where: { id: topicProgressId },
|
||||||
|
});
|
||||||
|
if (!existing) return { success: false, error: "Topic not found" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.topicProgress.update({
|
||||||
|
where: { id: topicProgressId },
|
||||||
|
data: { fetchEnabled: enabled },
|
||||||
|
});
|
||||||
|
revalidatePath(REVALIDATE_PATH);
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to update topic" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account-Channel link actions ──
|
// ── Account-Channel link actions ──
|
||||||
|
|
||||||
export async function linkChannel(
|
export async function linkChannel(
|
||||||
|
|||||||
17
src/app/api/telegram/channels/[channelId]/topics/route.ts
Normal file
17
src/app/api/telegram/channels/[channelId]/topics/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateApiRequest } from "@/lib/telegram/api-auth";
|
||||||
|
import { listChannelTopics } from "@/lib/telegram/admin-queries";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ channelId: string }> }
|
||||||
|
) {
|
||||||
|
const authResult = await authenticateApiRequest(request, true);
|
||||||
|
if ("error" in authResult) return authResult.error;
|
||||||
|
|
||||||
|
const { channelId } = await params;
|
||||||
|
const topics = await listChannelTopics(channelId);
|
||||||
|
return NextResponse.json(topics);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export async function listChannels() {
|
|||||||
telegramId: c.telegramId.toString(),
|
telegramId: c.telegramId.toString(),
|
||||||
title: c.title,
|
title: c.title,
|
||||||
type: c.type,
|
type: c.type,
|
||||||
|
isForum: c.isForum,
|
||||||
isActive: c.isActive,
|
isActive: c.isActive,
|
||||||
category: c.category,
|
category: c.category,
|
||||||
createdAt: c.createdAt.toISOString(),
|
createdAt: c.createdAt.toISOString(),
|
||||||
@@ -140,3 +141,24 @@ export async function getUnlinkedChannels(accountId: string) {
|
|||||||
telegramId: c.telegramId.toString(),
|
telegramId: c.telegramId.toString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Channel topic queries ──
|
||||||
|
|
||||||
|
export async function listChannelTopics(channelId: string) {
|
||||||
|
const rows = await prisma.topicProgress.findMany({
|
||||||
|
where: { accountChannelMap: { channelId } },
|
||||||
|
orderBy: [{ fetchEnabled: "desc" }, { topicName: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
topicId: r.topicId.toString(),
|
||||||
|
topicName: r.topicName,
|
||||||
|
fetchEnabled: r.fetchEnabled,
|
||||||
|
lastScannedAt: r.lastScannedAt?.toISOString() ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelTopicRow = Awaited<
|
||||||
|
ReturnType<typeof listChannelTopics>
|
||||||
|
>[number];
|
||||||
|
|||||||
@@ -659,6 +659,45 @@ export async function upsertTopicProgress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a TopicProgress row exists for every discovered topic so the admin
|
||||||
|
* UI can list and toggle them — including brand-new topics. Inserts missing
|
||||||
|
* rows only (fetchEnabled defaults to true); never overwrites watermarks,
|
||||||
|
* scan-state, or the user's fetchEnabled choice on rows that already exist.
|
||||||
|
*/
|
||||||
|
export async function ensureTopicProgressRows(
|
||||||
|
mappingId: string,
|
||||||
|
topics: { topicId: bigint; name: string | null }[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (topics.length === 0) return;
|
||||||
|
await db.topicProgress.createMany({
|
||||||
|
data: topics.map((t) => ({
|
||||||
|
accountChannelMapId: mappingId,
|
||||||
|
topicId: t.topicId,
|
||||||
|
topicName: t.name,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the CURRENT per-topic fetch flag straight from the DB. Returns true
|
||||||
|
* when no row exists yet (default = enabled). Called fresh inside the topic
|
||||||
|
* loop so a mid-run toggle is honoured for topics not yet started.
|
||||||
|
*/
|
||||||
|
export async function isTopicFetchEnabled(
|
||||||
|
mappingId: string,
|
||||||
|
topicId: bigint
|
||||||
|
): Promise<boolean> {
|
||||||
|
const row = await db.topicProgress.findUnique({
|
||||||
|
where: {
|
||||||
|
accountChannelMapId_topicId: { accountChannelMapId: mappingId, topicId },
|
||||||
|
},
|
||||||
|
select: { fetchEnabled: true },
|
||||||
|
});
|
||||||
|
return row?.fetchEnabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Channel fetch requests (DB-mediated communication with web app) ──
|
// ── Channel fetch requests (DB-mediated communication with web app) ──
|
||||||
|
|
||||||
export async function getChannelFetchRequest(requestId: string) {
|
export async function getChannelFetchRequest(requestId: string) {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
updatePackageTopicContext,
|
updatePackageTopicContext,
|
||||||
upsertChannelScanState,
|
upsertChannelScanState,
|
||||||
upsertTopicScanState,
|
upsertTopicScanState,
|
||||||
|
ensureTopicProgressRows,
|
||||||
|
isTopicFetchEnabled,
|
||||||
} from "./db/queries.js";
|
} from "./db/queries.js";
|
||||||
import type { ActivityUpdate } from "./db/queries.js";
|
import type { ActivityUpdate } from "./db/queries.js";
|
||||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||||
@@ -548,6 +550,15 @@ export async function runWorkerForAccount(
|
|||||||
const rawTopics = await getForumTopicList(client, channel.telegramId);
|
const rawTopics = await getForumTopicList(client, channel.telegramId);
|
||||||
const topicProgressList = await getTopicProgress(mapping.id);
|
const topicProgressList = await getTopicProgress(mapping.id);
|
||||||
|
|
||||||
|
// Persist a TopicProgress row for every discovered topic so the
|
||||||
|
// admin UI can list and toggle them — including brand-new topics.
|
||||||
|
// Inserts missing rows only; existing watermarks / scan-state /
|
||||||
|
// fetchEnabled choices are left untouched.
|
||||||
|
await ensureTopicProgressRows(
|
||||||
|
mapping.id,
|
||||||
|
rawTopics.map((t) => ({ topicId: t.topicId, name: t.name }))
|
||||||
|
);
|
||||||
|
|
||||||
// Process more-specific topics BEFORE "General" so the first
|
// Process more-specific topics BEFORE "General" so the first
|
||||||
// encounter of any file is in its most specific context. This makes
|
// encounter of any file is in its most specific context. This makes
|
||||||
// newly-created Packages carry useful topic info (e.g., a campaign
|
// newly-created Packages carry useful topic info (e.g., a campaign
|
||||||
@@ -567,6 +578,18 @@ export async function runWorkerForAccount(
|
|||||||
for (let tIdx = 0; tIdx < topics.length; tIdx++) {
|
for (let tIdx = 0; tIdx < topics.length; tIdx++) {
|
||||||
const topic = topics[tIdx];
|
const topic = topics[tIdx];
|
||||||
try {
|
try {
|
||||||
|
// ── Per-topic fetch toggle (live, mid-run honouring) ──
|
||||||
|
// Read the CURRENT enabled flag straight from the DB (not the
|
||||||
|
// run-start `topicProgressList` snapshot) so disabling a topic
|
||||||
|
// mid-run skips it for the remainder of this run.
|
||||||
|
if (!(await isTopicFetchEnabled(mapping.id, topic.topicId))) {
|
||||||
|
accountLog.info(
|
||||||
|
{ channel: channel.title, topic: topic.name },
|
||||||
|
"Topic fetch disabled by user — skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let progress = topicProgressList.find(
|
let progress = topicProgressList.find(
|
||||||
(tp) => tp.topicId === topic.topicId
|
(tp) => tp.topicId === topic.topicId
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user