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
|
||||
topicId BigInt
|
||||
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?
|
||||
/// When this topic was last scanned (any reason). Used by recency-skip.
|
||||
lastScannedAt DateTime?
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Check,
|
||||
AlertCircle,
|
||||
ImageOff,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { setPreviewFromExtract } from "../actions";
|
||||
import { ImageLightbox } from "./image-lightbox";
|
||||
|
||||
interface ArchiveImage {
|
||||
id: string;
|
||||
@@ -65,6 +67,7 @@ export function ArchivePreviewPicker({
|
||||
const [thumbnails, setThumbnails] = useState<Map<string, ThumbnailState>>(new Map());
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
|
||||
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());
|
||||
@@ -290,71 +293,85 @@ export function ArchivePreviewPicker({
|
||||
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>
|
||||
<div key={img.id} className="group relative">
|
||||
{isLoaded && thumbState?.imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLightboxSrc(thumbState.imageUrl!);
|
||||
}}
|
||||
title="Enlarge"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<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 */}
|
||||
{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>
|
||||
)}
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
@@ -394,6 +411,13 @@ export function ArchivePreviewPicker({
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
<ImageLightbox
|
||||
src={lightboxSrc}
|
||||
open={!!lightboxSrc}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setLightboxSrc(null);
|
||||
}}
|
||||
/>
|
||||
</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";
|
||||
|
||||
import { useState } from "react";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||
import { ImageLightbox } from "./image-lightbox";
|
||||
|
||||
export interface PackageRow {
|
||||
id: string;
|
||||
@@ -84,14 +86,30 @@ export function formatBytes(bytesStr: string): string {
|
||||
}
|
||||
|
||||
function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
if (pkg.hasPreview) {
|
||||
const src = `/api/zips/${pkg.id}/preview`;
|
||||
return (
|
||||
<img
|
||||
src={`/api/zips/${pkg.id}/preview`}
|
||||
alt=""
|
||||
className="h-9 w-9 rounded-md object-cover bg-muted"
|
||||
loading="lazy"
|
||||
/>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="group/preview relative h-9 w-9 overflow-hidden rounded-md bg-muted"
|
||||
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 (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Upload,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -30,6 +31,7 @@ import type { PackageRow } from "./package-columns";
|
||||
import { SendToTelegramButton } from "./send-to-telegram-button";
|
||||
import { uploadPackagePreview } from "../actions";
|
||||
import { ArchivePreviewPicker } from "./archive-preview-picker";
|
||||
import { ImageLightbox } from "./image-lightbox";
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
@@ -264,6 +266,7 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||
const [showPreviewPicker, setShowPreviewPicker] = useState(false);
|
||||
const [previewLightboxOpen, setPreviewLightboxOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePreviewUpload = useCallback(
|
||||
@@ -384,9 +387,8 @@ export function PackageFilesDrawer({ pkg, open, onOpenChange, highlightTerm }: P
|
||||
<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"
|
||||
onClick={() => setPreviewLightboxOpen(true)}
|
||||
title="Click to enlarge"
|
||||
>
|
||||
<img
|
||||
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"
|
||||
/>
|
||||
<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" />
|
||||
)}
|
||||
<Maximize2 className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</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,
|
||||
RefreshCcw,
|
||||
Tag,
|
||||
MessagesSquare,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +28,7 @@ interface ChannelColumnsProps {
|
||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||
onRescan: (id: string) => void;
|
||||
onSetCategory: (id: string, category: string | null) => void;
|
||||
onManageTopics: (id: string) => void;
|
||||
}
|
||||
|
||||
export function getChannelColumns({
|
||||
@@ -35,6 +37,7 @@ export function getChannelColumns({
|
||||
onSetType,
|
||||
onRescan,
|
||||
onSetCategory,
|
||||
onManageTopics,
|
||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -147,6 +150,14 @@ export function getChannelColumns({
|
||||
Rescan Channel
|
||||
</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
|
||||
onClick={() => {
|
||||
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 { ChannelPickerDialog } from "./channel-picker-dialog";
|
||||
import { JoinChannelDialog } from "./join-channel-dialog";
|
||||
import { TopicsDrawer } from "./topics-drawer";
|
||||
import {
|
||||
deleteChannel,
|
||||
toggleChannelActive,
|
||||
@@ -32,6 +33,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
||||
const [rescanId, setRescanId] = useState<string | null>(null);
|
||||
const [fetchChannelsAccountId, setFetchChannelsAccountId] = useState<string | null>(null);
|
||||
const [joinDialogOpen, setJoinDialogOpen] = useState(false);
|
||||
const [topicsChannelId, setTopicsChannelId] = useState<string | null>(null);
|
||||
|
||||
// Find the first authenticated account for "Fetch Channels"
|
||||
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);
|
||||
});
|
||||
},
|
||||
onManageTopics: (id) => setTopicsChannelId(id),
|
||||
});
|
||||
|
||||
const { table } = useDataTable({
|
||||
@@ -167,6 +170,17 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
|
||||
open={joinDialogOpen}
|
||||
onOpenChange={setJoinDialogOpen}
|
||||
/>
|
||||
|
||||
<TopicsDrawer
|
||||
channelId={topicsChannelId}
|
||||
channelTitle={
|
||||
channels.find((c) => c.id === topicsChannelId)?.title
|
||||
}
|
||||
open={!!topicsChannelId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setTopicsChannelId(null);
|
||||
}}
|
||||
/>
|
||||
</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 ──
|
||||
|
||||
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(),
|
||||
title: c.title,
|
||||
type: c.type,
|
||||
isForum: c.isForum,
|
||||
isActive: c.isActive,
|
||||
category: c.category,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
@@ -140,3 +141,24 @@ export async function getUnlinkedChannels(accountId: string) {
|
||||
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) ──
|
||||
|
||||
export async function getChannelFetchRequest(requestId: string) {
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
updatePackageTopicContext,
|
||||
upsertChannelScanState,
|
||||
upsertTopicScanState,
|
||||
ensureTopicProgressRows,
|
||||
isTopicFetchEnabled,
|
||||
} from "./db/queries.js";
|
||||
import type { ActivityUpdate } from "./db/queries.js";
|
||||
import { createTdlibClient, closeTdlibClient } from "./tdlib/client.js";
|
||||
@@ -548,6 +550,15 @@ export async function runWorkerForAccount(
|
||||
const rawTopics = await getForumTopicList(client, channel.telegramId);
|
||||
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
|
||||
// encounter of any file is in its most specific context. This makes
|
||||
// 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++) {
|
||||
const topic = topics[tIdx];
|
||||
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(
|
||||
(tp) => tp.topicId === topic.topicId
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user