mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 14:21:15 +00:00
feat: fix channel scanning bugs, add package tags, and kickstarters tab
Bug fixes: - Fix channels not being scanned by paginating TDLib getChats (was only loading first batch, additional channels were unknown to TDLib) - Add per-channel getChat pre-load as safety net before scanning - Fix preview pictures not loading by checking previewData instead of previewMsgId for hasPreview flag - Prevent previewMsgId from being set when preview download fails Package Tags: - Add tags Text[] column to Package with migration backfilling from channel categories - Worker auto-inherits source channel category as initial tag - Tag filter dropdown and Tags column in STL Files table - Server actions for individual and bulk tag editing Kickstarters Tab: - New KickstarterHost, Kickstarter, and KickstarterPackage models - Full CRUD with delivery status, payment status, host management - Package linking (many-to-many with existing packages) - Sidebar entry with Gift icon - Table with search, filters, modal forms Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { FileArchive, Eye, Pencil } from "lucide-react";
|
||||
import { FileArchive, Eye } from "lucide-react";
|
||||
import { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -17,6 +17,7 @@ export interface PackageRow {
|
||||
isMultipart: boolean;
|
||||
hasPreview: boolean;
|
||||
creator: string | null;
|
||||
tags: string[];
|
||||
indexedAt: string;
|
||||
sourceChannel: {
|
||||
id: string;
|
||||
@@ -27,6 +28,7 @@ export interface PackageRow {
|
||||
interface PackageColumnsProps {
|
||||
onViewFiles: (pkg: PackageRow) => void;
|
||||
onSetCreator: (pkg: PackageRow) => void;
|
||||
onSetTags: (pkg: PackageRow) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytesStr: string): string {
|
||||
@@ -59,6 +61,7 @@ function PreviewCell({ pkg }: { pkg: PackageRow }) {
|
||||
export function getPackageColumns({
|
||||
onViewFiles,
|
||||
onSetCreator,
|
||||
onSetTags,
|
||||
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
@@ -124,6 +127,42 @@ export function getPackageColumns({
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Tags" />,
|
||||
cell: ({ row }) => {
|
||||
const tags = row.original.tags;
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => onSetTags(row.original)}
|
||||
title="Click to add tags"
|
||||
>
|
||||
{"\u2014"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className="flex flex-wrap gap-1 cursor-pointer"
|
||||
onClick={() => onSetTags(row.original)}
|
||||
title="Click to edit tags"
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="text-[10px] bg-primary/5"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
accessorFn: (row) => row.tags.join(", "),
|
||||
},
|
||||
{
|
||||
id: "channel",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Source" />,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useTransition } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Search, FileBox } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { getPackageColumns, type PackageRow } from "./package-columns";
|
||||
import { PackageFilesDrawer } from "./package-files-drawer";
|
||||
@@ -13,14 +13,22 @@ import { DataTablePagination } from "@/components/shared/data-table-pagination";
|
||||
import { DataTableViewOptions } from "@/components/shared/data-table-view-options";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||
import { updatePackageCreator } from "../actions";
|
||||
import { updatePackageCreator, updatePackageTags } from "../actions";
|
||||
|
||||
interface StlTableProps {
|
||||
data: PackageRow[];
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
ingestionStatus: IngestionAccountStatus[];
|
||||
availableTags: string[];
|
||||
}
|
||||
|
||||
export function StlTable({
|
||||
@@ -28,6 +36,7 @@ export function StlTable({
|
||||
pageCount,
|
||||
totalCount,
|
||||
ingestionStatus,
|
||||
availableTags,
|
||||
}: StlTableProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -52,6 +61,20 @@ export function StlTable({
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const updateTagFilter = useCallback(
|
||||
(value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value && value !== "all") {
|
||||
params.set("tag", value);
|
||||
params.set("page", "1");
|
||||
} else {
|
||||
params.delete("tag");
|
||||
}
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, pathname, searchParams]
|
||||
);
|
||||
|
||||
const columns = getPackageColumns({
|
||||
onViewFiles: (pkg) => setViewPkg(pkg),
|
||||
onSetCreator: (pkg) => {
|
||||
@@ -67,10 +90,29 @@ export function StlTable({
|
||||
}
|
||||
});
|
||||
},
|
||||
onSetTags: (pkg) => {
|
||||
const value = prompt(
|
||||
"Enter tags (comma-separated):",
|
||||
pkg.tags.join(", ")
|
||||
);
|
||||
if (value === null) return;
|
||||
const tags = value.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
startTransition(async () => {
|
||||
const result = await updatePackageTags(pkg.id, tags);
|
||||
if (result.success) {
|
||||
toast.success(tags.length > 0 ? `Tags updated` : "Tags removed");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { table } = useDataTable({ data, columns, pageCount });
|
||||
|
||||
const activeTag = searchParams.get("tag") ?? "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
@@ -90,6 +132,21 @@ export function StlTable({
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
{availableTags.length > 0 && (
|
||||
<Select value={activeTag || "all"} onValueChange={updateTagFilter}>
|
||||
<SelectTrigger className="w-[160px] h-9">
|
||||
<SelectValue placeholder="All Tags" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Tags</SelectItem>
|
||||
{availableTags.map((tag) => (
|
||||
<SelectItem key={tag} value={tag}>
|
||||
{tag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,6 +71,48 @@ export async function uploadPackagePreview(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePackageTags(
|
||||
packageId: string,
|
||||
tags: string[]
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const cleaned = tags.map((t) => t.trim()).filter(Boolean);
|
||||
// Deduplicate
|
||||
const unique = [...new Set(cleaned)];
|
||||
await prisma.package.update({
|
||||
where: { id: packageId },
|
||||
data: { tags: unique },
|
||||
});
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update tags" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkSetTags(
|
||||
packageIds: string[],
|
||||
tags: string[]
|
||||
): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { success: false, error: "Unauthorized" };
|
||||
|
||||
try {
|
||||
const cleaned = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
|
||||
await prisma.package.updateMany({
|
||||
where: { id: { in: packageIds } },
|
||||
data: { tags: cleaned },
|
||||
});
|
||||
revalidatePath("/stls");
|
||||
return { success: true, data: undefined };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update tags" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkSetCreator(
|
||||
packageIds: string[],
|
||||
creator: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listPackages, searchPackages, getIngestionStatus } from "@/lib/telegram/queries";
|
||||
import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags } from "@/lib/telegram/queries";
|
||||
import { StlTable } from "./_components/stl-table";
|
||||
|
||||
interface Props {
|
||||
@@ -19,9 +19,10 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
const order = (params.order as "asc" | "desc") ?? "desc";
|
||||
const search = (params.search as string) ?? "";
|
||||
const creator = (params.creator as string) || undefined;
|
||||
const tag = (params.tag as string) || undefined;
|
||||
|
||||
// Fetch packages and ingestion status in parallel
|
||||
const [result, ingestionStatus] = await Promise.all([
|
||||
// Fetch packages, ingestion status, and available tags in parallel
|
||||
const [result, ingestionStatus, availableTags] = await Promise.all([
|
||||
search
|
||||
? searchPackages({
|
||||
query: search,
|
||||
@@ -33,10 +34,12 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
page,
|
||||
limit: perPage,
|
||||
creator,
|
||||
tag,
|
||||
sortBy: sort as "indexedAt" | "fileName" | "fileSize",
|
||||
order,
|
||||
}),
|
||||
getIngestionStatus(),
|
||||
getAllPackageTags(),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -45,6 +48,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
|
||||
pageCount={result.pagination.totalPages}
|
||||
totalCount={result.pagination.total}
|
||||
ingestionStatus={ingestionStatus}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user