Merge feat/send-all-from-creator: send-all-from-creator button + searchable creator filter

This commit is contained in:
2026-07-04 09:42:08 +02:00
5 changed files with 229 additions and 3 deletions

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface CreatorFilterProps {
creators: string[];
value: string; // active creator, "" when none
onChange: (creator: string) => void; // "" clears the filter
}
export function CreatorFilter({ creators, value, onChange }: CreatorFilterProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="h-9 w-[200px] justify-between"
>
<span className="truncate">{value || "All Creators"}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="Search creators..." className="h-9" />
<CommandList>
<CommandEmpty>No creators found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__all__"
onSelect={() => {
onChange("");
setOpen(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")}
/>
All Creators
</CommandItem>
{creators.map((creator) => (
<CommandItem
key={creator}
value={creator}
onSelect={() => {
onChange(creator);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === creator ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate">{creator}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useTransition, useMemo, useRef } from "react"; import { useState, useCallback, useTransition, useMemo, useRef } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { Search, Layers, Upload } from "lucide-react"; import { Search, Layers, Upload, Send } from "lucide-react";
import { UploadDialog } from "./upload-dialog"; import { UploadDialog } from "./upload-dialog";
import { useDataTable } from "@/hooks/use-data-table"; import { useDataTable } from "@/hooks/use-data-table";
import { import {
@@ -16,6 +16,7 @@ import {
import { PackageFilesDrawer } from "./package-files-drawer"; import { PackageFilesDrawer } from "./package-files-drawer";
import { IngestionStatus } from "./ingestion-status"; import { IngestionStatus } from "./ingestion-status";
import { SkippedPackagesTab } from "./skipped-packages-tab"; import { SkippedPackagesTab } from "./skipped-packages-tab";
import { CreatorFilter } from "./creator-filter";
import { DataTable } from "@/components/shared/data-table"; import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination"; import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options"; import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
@@ -49,6 +50,7 @@ import {
createGroupAction, createGroupAction,
removeFromGroupAction, removeFromGroupAction,
sendAllInGroupAction, sendAllInGroupAction,
sendAllFromCreatorAction,
updateGroupPreviewAction, updateGroupPreviewAction,
mergeGroupsAction, mergeGroupsAction,
} from "../actions"; } from "../actions";
@@ -59,6 +61,7 @@ interface StlTableProps {
totalCount: number; totalCount: number;
ingestionStatus: IngestionAccountStatus[]; ingestionStatus: IngestionAccountStatus[];
availableTags: string[]; availableTags: string[];
availableCreators: string[];
searchTerm: string; searchTerm: string;
skippedData: SkippedRow[]; skippedData: SkippedRow[];
skippedPageCount: number; skippedPageCount: number;
@@ -74,6 +77,7 @@ export function StlTable({
totalCount, totalCount,
ingestionStatus, ingestionStatus,
availableTags, availableTags,
availableCreators,
searchTerm, searchTerm,
skippedData, skippedData,
skippedPageCount, skippedPageCount,
@@ -85,6 +89,7 @@ export function StlTable({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const activeCreator = searchParams.get("creator") ?? "";
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? ""); const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null); const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
@@ -207,6 +212,20 @@ export function StlTable({
[router, pathname, searchParams] [router, pathname, searchParams]
); );
const updateCreatorFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("creator", value);
params.set("page", "1");
} else {
params.delete("creator");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
[router, pathname, searchParams]
);
const activeTab = searchParams.get("tab") ?? "packages"; const activeTab = searchParams.get("tab") ?? "packages";
const updateTab = useCallback( const updateTab = useCallback(
@@ -277,6 +296,23 @@ export function StlTable({
[router] [router]
); );
const handleSendAllFromCreator = useCallback(() => {
if (!confirm(`Send all packages from "${activeCreator}" to your Telegram?`)) return;
startTransition(async () => {
const result = await sendAllFromCreatorAction(activeCreator);
if (result.success) {
const { queued, skipped } = result.data;
toast.success(
`Queued ${queued} package${queued === 1 ? "" : "s"} from ${activeCreator}` +
(skipped ? ` (${skipped} already queued)` : "")
);
router.refresh();
} else {
toast.error(result.error);
}
});
}, [activeCreator, router]);
const handleRemoveFromGroup = useCallback( const handleRemoveFromGroup = useCallback(
(packageId: string) => { (packageId: string) => {
startTransition(async () => { startTransition(async () => {
@@ -500,11 +536,29 @@ export function StlTable({
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
{availableCreators.length > 0 && (
<CreatorFilter
creators={availableCreators}
value={activeCreator}
onChange={updateCreatorFilter}
/>
)}
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
<Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}> <Button variant="outline" size="sm" className="h-9" onClick={() => setUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
Upload Files Upload Files
</Button> </Button>
{activeCreator && (
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={handleSendAllFromCreator}
>
<Send className="h-3.5 w-3.5" />
Send all from {activeCreator}
</Button>
)}
{selectedPackages.size >= 2 && ( {selectedPackages.size >= 2 && (
<Button <Button
variant="outline" variant="outline"

View File

@@ -589,3 +589,82 @@ export async function sendAllInGroupAction(
return { success: false, error: "Failed to send group packages" }; return { success: false, error: "Failed to send group packages" };
} }
} }
export async function sendAllFromCreatorAction(
creatorName: string
): Promise<ActionResult<{ queued: number; skipped: number }>> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
const creator = creatorName.trim();
if (!creator) {
return { success: false, error: "No creator specified" };
}
try {
const telegramLink = await prisma.telegramLink.findUnique({
where: { userId: session.user.id },
});
if (!telegramLink) {
return { success: false, error: "No linked Telegram account. Link one in Settings." };
}
const sendablePackages = await prisma.package.findMany({
where: {
creator,
destChannelId: { not: null },
destMessageId: { not: null },
},
select: { id: true },
});
if (sendablePackages.length === 0) {
return { success: false, error: "No uploaded packages found for this creator" };
}
let queued = 0;
let skipped = 0;
for (const pkg of sendablePackages) {
// Only create if no existing PENDING/SENDING request for this package+link combo
const existing = await prisma.botSendRequest.findFirst({
where: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
status: { in: ["PENDING", "SENDING"] },
},
});
if (existing) {
skipped++;
continue;
}
const sendRequest = await prisma.botSendRequest.create({
data: {
packageId: pkg.id,
telegramLinkId: telegramLink.id,
requestedByUserId: session.user.id,
status: "PENDING",
},
});
// Notify the bot via pg_notify
try {
await prisma.$queryRawUnsafe(
`SELECT pg_notify('bot_send', $1)`,
sendRequest.id
);
} catch {
// Best-effort — the bot also polls periodically
}
queued++;
}
revalidatePath("/stls");
return { success: true, data: { queued, skipped } };
} catch {
return { success: false, error: "Failed to send creator packages" };
}
}

View File

@@ -1,6 +1,6 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries"; import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, getAllPackageCreators, listSkippedPackages, countSkippedPackages, listUngroupedPackages, countUngroupedPackages } from "@/lib/telegram/queries";
import { StlTable } from "./_components/stl-table"; import { StlTable } from "./_components/stl-table";
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types"; import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
@@ -24,7 +24,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
const tab = (params.tab as string) ?? "packages"; const tab = (params.tab as string) ?? "packages";
// Fetch packages, ingestion status, tags, and skipped count in parallel // Fetch packages, ingestion status, tags, and skipped count in parallel
const [result, ingestionStatus, availableTags, skippedCount, ungroupedCount] = await Promise.all([ const [result, ingestionStatus, availableTags, availableCreators, skippedCount, ungroupedCount] = await Promise.all([
search search
? searchPackages({ ? searchPackages({
query: search, query: search,
@@ -42,6 +42,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
}), }),
getIngestionStatus(), getIngestionStatus(),
getAllPackageTags(), getAllPackageTags(),
getAllPackageCreators(),
countSkippedPackages(), countSkippedPackages(),
countUngroupedPackages(), countUngroupedPackages(),
]); ]);
@@ -68,6 +69,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
totalCount={result.pagination.total} totalCount={result.pagination.total}
ingestionStatus={ingestionStatus} ingestionStatus={ingestionStatus}
availableTags={availableTags} availableTags={availableTags}
availableCreators={availableCreators}
searchTerm={search} searchTerm={search}
skippedData={skippedResult?.items ?? []} skippedData={skippedResult?.items ?? []}
skippedPageCount={skippedResult?.pagination.totalPages ?? 0} skippedPageCount={skippedResult?.pagination.totalPages ?? 0}

View File

@@ -534,6 +534,15 @@ export async function getAllPackageTags(): Promise<string[]> {
return result.map((r) => r.tag); return result.map((r) => r.tag);
} }
export async function getAllPackageCreators(): Promise<string[]> {
const result = await prisma.$queryRaw<{ creator: string }[]>`
SELECT DISTINCT creator FROM packages
WHERE creator IS NOT NULL AND creator <> ''
ORDER BY creator
`;
return result.map((r) => r.creator);
}
export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> { export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
const accounts = await prisma.telegramAccount.findMany({ const accounts = await prisma.telegramAccount.findMany({
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },