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 { useRouter, usePathname, useSearchParams } from "next/navigation";
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 { useDataTable } from "@/hooks/use-data-table";
import {
@@ -16,6 +16,7 @@ import {
import { PackageFilesDrawer } from "./package-files-drawer";
import { IngestionStatus } from "./ingestion-status";
import { SkippedPackagesTab } from "./skipped-packages-tab";
import { CreatorFilter } from "./creator-filter";
import { DataTable } from "@/components/shared/data-table";
import { DataTablePagination } from "@/components/shared/data-table-pagination";
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
@@ -49,6 +50,7 @@ import {
createGroupAction,
removeFromGroupAction,
sendAllInGroupAction,
sendAllFromCreatorAction,
updateGroupPreviewAction,
mergeGroupsAction,
} from "../actions";
@@ -59,6 +61,7 @@ interface StlTableProps {
totalCount: number;
ingestionStatus: IngestionAccountStatus[];
availableTags: string[];
availableCreators: string[];
searchTerm: string;
skippedData: SkippedRow[];
skippedPageCount: number;
@@ -74,6 +77,7 @@ export function StlTable({
totalCount,
ingestionStatus,
availableTags,
availableCreators,
searchTerm,
skippedData,
skippedPageCount,
@@ -85,6 +89,7 @@ export function StlTable({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeCreator = searchParams.get("creator") ?? "";
const [searchValue, setSearchValue] = useState(searchParams.get("search") ?? "");
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
@@ -207,6 +212,20 @@ export function StlTable({
[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 updateTab = useCallback(
@@ -277,6 +296,23 @@ export function StlTable({
[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(
(packageId: string) => {
startTransition(async () => {
@@ -500,11 +536,29 @@ export function StlTable({
</SelectContent>
</Select>
)}
{availableCreators.length > 0 && (
<CreatorFilter
creators={availableCreators}
value={activeCreator}
onChange={updateCreatorFilter}
/>
)}
<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>
{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 && (
<Button
variant="outline"

View File

@@ -589,3 +589,82 @@ export async function sendAllInGroupAction(
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 { 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 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";
// 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
? searchPackages({
query: search,
@@ -42,6 +42,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
}),
getIngestionStatus(),
getAllPackageTags(),
getAllPackageCreators(),
countSkippedPackages(),
countUngroupedPackages(),
]);
@@ -68,6 +69,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
totalCount={result.pagination.total}
ingestionStatus={ingestionStatus}
availableTags={availableTags}
availableCreators={availableCreators}
searchTerm={search}
skippedData={skippedResult?.items ?? []}
skippedPageCount={skippedResult?.pagination.totalPages ?? 0}

View File

@@ -534,6 +534,15 @@ export async function getAllPackageTags(): Promise<string[]> {
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[]> {
const accounts = await prisma.telegramAccount.findMany({
orderBy: { createdAt: "asc" },