From d50c68f67c7a70928c7b07acf02bbd1999141b6d Mon Sep 17 00:00:00 2001 From: xCyanGrizzly Date: Wed, 25 Mar 2026 22:39:23 +0100 Subject: [PATCH] feat: add package grouping UI with expand/collapse, selection, and manual grouping - Update STL page to use listDisplayItems query for mixed package/group display - Rewrite package-columns to handle StlTableRow union type (group headers + packages) - Add group expand/collapse with chevron toggle and indented member rows - Add checkbox selection with "Group N Selected" toolbar button and dialog - Add inline group actions: rename, dissolve, send all, remove member - Add clickable group preview thumbnail with file upload for preview images - Extend DataTable with optional rowClassName prop for group row styling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../stls/_components/package-columns.tsx | 379 ++++++++++++++---- src/app/(app)/stls/_components/stl-table.tsx | 320 ++++++++++++++- src/app/(app)/stls/page.tsx | 12 +- src/components/shared/data-table.tsx | 11 +- 4 files changed, 634 insertions(+), 88 deletions(-) diff --git a/src/app/(app)/stls/_components/package-columns.tsx b/src/app/(app)/stls/_components/package-columns.tsx index 00fea5e..f51c45d 100644 --- a/src/app/(app)/stls/_components/package-columns.tsx +++ b/src/app/(app)/stls/_components/package-columns.tsx @@ -1,10 +1,11 @@ "use client"; import { type ColumnDef } from "@tanstack/react-table"; -import { FileArchive, Eye } from "lucide-react"; +import { FileArchive, Eye, ChevronRight, Layers, Ungroup, Send, ImagePlus } 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"; export interface PackageRow { @@ -25,6 +26,34 @@ export interface PackageRow { }; matchedFileCount: number; matchedByContent: boolean; + packageGroupId?: string | null; +} + +export interface GroupHeaderRow { + _rowType: "group"; + id: string; + name: string; + hasPreview: boolean; + totalFileSize: string; + totalFileCount: number; + packageCount: number; + combinedTags: string[]; + archiveTypes: ("ZIP" | "RAR" | "SEVEN_Z" | "DOCUMENT")[]; + latestIndexedAt: string; + sourceChannel: { id: string; title: string }; + _expanded: boolean; +} + +export interface PackageTableRow extends PackageRow { + _rowType: "package"; + _groupId: string | null; + _isGroupMember: boolean; +} + +export type StlTableRow = GroupHeaderRow | PackageTableRow; + +function isGroupRow(row: StlTableRow): row is GroupHeaderRow { + return row._rowType === "group"; } interface PackageColumnsProps { @@ -32,9 +61,17 @@ interface PackageColumnsProps { onSetCreator: (pkg: PackageRow) => void; onSetTags: (pkg: PackageRow) => void; searchTerm: string; + onToggleGroup: (groupId: string) => void; + onRenameGroup: (groupId: string, currentName: string) => void; + onDissolveGroup: (groupId: string) => void; + onSendAllInGroup: (groupId: string) => void; + onRemoveFromGroup: (packageId: string) => void; + onGroupPreviewUpload: (groupId: string) => void; + selectedPackages: Set; + onToggleSelect: (packageId: string) => void; } -function formatBytes(bytesStr: string): string { +export function formatBytes(bytesStr: string): string { const bytes = Number(bytesStr); if (bytes === 0) return "0 B"; const k = 1024; @@ -61,107 +98,254 @@ function PreviewCell({ pkg }: { pkg: PackageRow }) { ); } +function GroupPreviewCell({ + group, + onUpload, +}: { + group: GroupHeaderRow; + onUpload: (groupId: string) => void; +}) { + if (group.hasPreview) { + return ( + + ); + } + return ( + + ); +} + export function getPackageColumns({ onViewFiles, onSetCreator, onSetTags, searchTerm, -}: PackageColumnsProps): ColumnDef[] { + onToggleGroup, + onRenameGroup, + onDissolveGroup, + onSendAllInGroup, + onRemoveFromGroup, + onGroupPreviewUpload, + selectedPackages, + onToggleSelect, +}: PackageColumnsProps): ColumnDef[] { return [ + { + id: "select", + header: "", + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) return null; + return ( + onToggleSelect(data.id)} + aria-label="Select package" + className="translate-y-[2px]" + /> + ); + }, + enableHiding: false, + enableSorting: false, + size: 32, + }, { id: "preview", header: "", - cell: ({ row }) => , + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return ( +
+ + +
+ ); + } + return ( +
+ +
+ ); + }, enableHiding: false, enableSorting: false, - size: 52, + size: 72, }, { accessorKey: "fileName", header: ({ column }) => , - cell: ({ row }) => ( -
-
- {row.original.fileName} - {row.original.isMultipart && ( - - Multi - + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return ( +
+
+ + + {data.packageCount} pkg{data.packageCount !== 1 ? "s" : ""} + +
+
+ ); + } + return ( +
+
+ {data.fileName} + {data.isMultipart && ( + + Multi + + )} +
+ {searchTerm && data.matchedByContent && ( + )}
- {searchTerm && row.original.matchedByContent && ( - - )} -
- ), + ); + }, enableHiding: false, }, { accessorKey: "archiveType", header: ({ column }) => , - cell: ({ row }) => ( - - {row.original.archiveType} - - ), + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + const types = data.archiveTypes; + if (types.length === 1) { + return ( + + {types[0]} + + ); + } + return ( + + Mixed + + ); + } + return ( + + {data.archiveType} + + ); + }, }, { accessorKey: "fileSize", header: ({ column }) => , - cell: ({ row }) => ( - - {formatBytes(row.original.fileSize)} - - ), + cell: ({ row }) => { + const data = row.original; + const size = isGroupRow(data) ? data.totalFileSize : data.fileSize; + return ( + + {formatBytes(size)} + + ); + }, }, { accessorKey: "fileCount", header: ({ column }) => , - cell: ({ row }) => ( - - {row.original.fileCount.toLocaleString()} - - ), + cell: ({ row }) => { + const data = row.original; + const count = isGroupRow(data) ? data.totalFileCount : data.fileCount; + return ( + + {count.toLocaleString()} + + ); + }, }, { accessorKey: "creator", header: ({ column }) => , - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return {"\u2014"}; + } + return ( + + ); + }, }, { id: "tags", header: ({ column }) => , cell: ({ row }) => { - const tags = row.original.tags; + const data = row.original; + const tags = isGroupRow(data) ? data.combinedTags : data.tags; if (tags.length === 0) { + if (isGroupRow(data)) { + return {"\u2014"}; + } return ( ); } + const clickHandler = isGroupRow(data) ? undefined : () => onSetTags(data as PackageTableRow); return ( -
- ), + cell: ({ row }) => { + const data = row.original; + if (isGroupRow(data)) { + return ( +
+ + +
+ ); + } + return ( +
+ + + {data._isGroupMember && ( + + )} +
+ ); + }, enableHiding: false, }, ]; diff --git a/src/app/(app)/stls/_components/stl-table.tsx b/src/app/(app)/stls/_components/stl-table.tsx index 7ed8409..cc54cbf 100644 --- a/src/app/(app)/stls/_components/stl-table.tsx +++ b/src/app/(app)/stls/_components/stl-table.tsx @@ -1,11 +1,17 @@ "use client"; -import { useState, useCallback, useTransition } from "react"; +import { useState, useCallback, useTransition, useMemo, useRef } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { toast } from "sonner"; -import { Search } from "lucide-react"; +import { Search, Layers } from "lucide-react"; import { useDataTable } from "@/hooks/use-data-table"; -import { getPackageColumns, type PackageRow } from "./package-columns"; +import { + getPackageColumns, + type PackageRow, + type StlTableRow, + type PackageTableRow, + type GroupHeaderRow, +} from "./package-columns"; import { PackageFilesDrawer } from "./package-files-drawer"; import { IngestionStatus } from "./ingestion-status"; import { SkippedPackagesTab } from "./skipped-packages-tab"; @@ -14,6 +20,7 @@ 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 { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -21,14 +28,31 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import type { IngestionAccountStatus } from "@/lib/telegram/types"; +import type { DisplayItem, IngestionAccountStatus } from "@/lib/telegram/types"; import type { SkippedRow } from "./skipped-columns"; -import { updatePackageCreator, updatePackageTags } from "../actions"; +import { + updatePackageCreator, + updatePackageTags, + renameGroupAction, + dissolveGroupAction, + createGroupAction, + removeFromGroupAction, + sendAllInGroupAction, + updateGroupPreviewAction, +} from "../actions"; interface StlTableProps { - data: PackageRow[]; + data: DisplayItem[]; pageCount: number; totalCount: number; ingestionStatus: IngestionAccountStatus[]; @@ -58,6 +82,88 @@ export function StlTable({ const [viewPkg, setViewPkg] = useState(null); const [, startTransition] = useTransition(); + // Group expansion state + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + // Package selection state (for manual grouping) + const [selectedPackages, setSelectedPackages] = useState>(new Set()); + + // Create group dialog state + const [createGroupOpen, setCreateGroupOpen] = useState(false); + const [groupName, setGroupName] = useState(""); + + // Group preview upload ref + const previewInputRef = useRef(null); + const [uploadGroupId, setUploadGroupId] = useState(null); + + const toggleGroup = useCallback((groupId: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + const toggleSelect = useCallback((packageId: string) => { + setSelectedPackages((prev) => { + const next = new Set(prev); + if (next.has(packageId)) { + next.delete(packageId); + } else { + next.add(packageId); + } + return next; + }); + }, []); + + // Flatten DisplayItem[] into StlTableRow[] based on expansion state + const tableRows: StlTableRow[] = useMemo(() => { + const rows: StlTableRow[] = []; + for (const item of data) { + if (item.type === "package") { + rows.push({ + ...item.data, + _rowType: "package" as const, + _groupId: null, + _isGroupMember: false, + }); + } else { + const group = item.data; + const isExpanded = expandedGroups.has(group.id); + rows.push({ + _rowType: "group" as const, + id: group.id, + name: group.name, + hasPreview: group.hasPreview, + totalFileSize: group.totalFileSize, + totalFileCount: group.totalFileCount, + packageCount: group.packageCount, + combinedTags: group.combinedTags, + archiveTypes: group.archiveTypes, + latestIndexedAt: group.latestIndexedAt, + sourceChannel: group.sourceChannel, + _expanded: isExpanded, + }); + if (isExpanded) { + for (const pkg of group.packages) { + rows.push({ + ...pkg, + _rowType: "package" as const, + _groupId: group.id, + _isGroupMember: true, + packageGroupId: group.id, + }); + } + } + } + } + return rows; + }, [data, expandedGroups]); + const updateSearch = useCallback( (value: string) => { setSearchValue(value); @@ -103,6 +209,131 @@ export function StlTable({ [router, pathname, searchParams] ); + const handleRenameGroup = useCallback( + (groupId: string, currentName: string) => { + const value = prompt("Enter group name:", currentName); + if (value === null || value.trim() === currentName) return; + startTransition(async () => { + const result = await renameGroupAction(groupId, value); + if (result.success) { + toast.success(`Group renamed to "${value.trim()}"`); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleDissolveGroup = useCallback( + (groupId: string) => { + if (!confirm("Dissolve this group? Packages will become standalone items.")) return; + startTransition(async () => { + const result = await dissolveGroupAction(groupId); + if (result.success) { + toast.success("Group dissolved"); + setExpandedGroups((prev) => { + const next = new Set(prev); + next.delete(groupId); + return next; + }); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleSendAllInGroup = useCallback( + (groupId: string) => { + if (!confirm("Send all packages in this group to your Telegram?")) return; + startTransition(async () => { + const result = await sendAllInGroupAction(groupId); + if (result.success) { + toast.success("Group packages queued for sending"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleRemoveFromGroup = useCallback( + (packageId: string) => { + startTransition(async () => { + const result = await removeFromGroupAction(packageId); + if (result.success) { + toast.success("Package removed from group"); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, + [router] + ); + + const handleCreateGroup = useCallback(() => { + if (selectedPackages.size < 2) return; + setGroupName(""); + setCreateGroupOpen(true); + }, [selectedPackages.size]); + + const submitCreateGroup = useCallback(() => { + if (!groupName.trim() || selectedPackages.size < 2) return; + const ids = Array.from(selectedPackages); + startTransition(async () => { + const result = await createGroupAction(groupName, ids); + if (result.success) { + toast.success(`Group "${groupName.trim()}" created`); + setSelectedPackages(new Set()); + setCreateGroupOpen(false); + router.refresh(); + } else { + toast.error(result.error); + } + }); + }, [groupName, selectedPackages, router]); + + // Group preview upload handler (Task 12) + const handleGroupPreviewUpload = useCallback((groupId: string) => { + setUploadGroupId(groupId); + // Trigger file input after state update + setTimeout(() => { + previewInputRef.current?.click(); + }, 0); + }, []); + + const handlePreviewFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !uploadGroupId) return; + + const formData = new FormData(); + formData.append("file", file); + + startTransition(async () => { + const result = await updateGroupPreviewAction(uploadGroupId, formData); + if (result.success) { + toast.success("Group preview updated"); + router.refresh(); + } else { + toast.error(result.error); + } + setUploadGroupId(null); + }); + + // Reset input so the same file can be selected again + e.target.value = ""; + }, + [uploadGroupId, router] + ); + const columns = getPackageColumns({ onViewFiles: (pkg) => setViewPkg(pkg), searchTerm, @@ -136,9 +367,17 @@ export function StlTable({ } }); }, + onToggleGroup: toggleGroup, + onRenameGroup: handleRenameGroup, + onDissolveGroup: handleDissolveGroup, + onSendAllInGroup: handleSendAllInGroup, + onRemoveFromGroup: handleRemoveFromGroup, + onGroupPreviewUpload: handleGroupPreviewUpload, + selectedPackages, + onToggleSelect: toggleSelect, }); - const { table } = useDataTable({ data, columns, pageCount }); + const { table } = useDataTable({ data: tableRows, columns, pageCount }); const activeTag = searchParams.get("tag") ?? ""; @@ -191,11 +430,37 @@ export function StlTable({ )} + {selectedPackages.size >= 2 && ( + + )} + {selectedPackages.size > 0 && selectedPackages.size < 2 && ( + + Select at least 2 packages to group + + )} { + const data = row.original as StlTableRow; + if (data._rowType === "group") { + return "bg-muted/30 border-border"; + } + if (data._rowType === "package" && (data as PackageTableRow)._isGroupMember) { + return "bg-muted/10"; + } + return ""; + }} /> @@ -217,6 +482,47 @@ export function StlTable({ }} highlightTerm={searchTerm} /> + + {/* Create Group Dialog */} + + + + Create Package Group + + Group {selectedPackages.size} selected packages together. Enter a name for the group. + + +
+ setGroupName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitCreateGroup(); + }} + autoFocus + /> +
+ + + + +
+
+ + {/* Hidden file input for group preview upload (Task 12) */} + ); } diff --git a/src/app/(app)/stls/page.tsx b/src/app/(app)/stls/page.tsx index 4cd7340..87cb02f 100644 --- a/src/app/(app)/stls/page.tsx +++ b/src/app/(app)/stls/page.tsx @@ -1,7 +1,8 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { listPackages, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages } from "@/lib/telegram/queries"; +import { listDisplayItems, searchPackages, getIngestionStatus, getAllPackageTags, listSkippedPackages, countSkippedPackages } from "@/lib/telegram/queries"; import { StlTable } from "./_components/stl-table"; +import type { DisplayItem, PackageListItem } from "@/lib/telegram/types"; interface Props { searchParams: Promise>; @@ -31,7 +32,7 @@ export default async function StlFilesPage({ searchParams }: Props) { limit: perPage, searchIn: "both", }) - : listPackages({ + : listDisplayItems({ page, limit: perPage, creator, @@ -44,6 +45,11 @@ export default async function StlFilesPage({ searchParams }: Props) { countSkippedPackages(), ]); + // For search results, wrap as DisplayItem[]; for non-search, already DisplayItem[] + const displayItems: DisplayItem[] = search + ? (result as { items: PackageListItem[] }).items.map((item) => ({ type: "package" as const, data: item })) + : (result as { items: DisplayItem[] }).items; + // Fetch skipped packages only if on that tab const skippedResult = tab === "skipped" ? await listSkippedPackages({ page, limit: perPage }) @@ -51,7 +57,7 @@ export default async function StlFilesPage({ searchParams }: Props) { return ( { table: TanStackTable; emptyMessage?: string; + rowClassName?: (row: Row) => string; } -export function DataTable({ table, emptyMessage }: DataTableProps) { +export function DataTable({ table, emptyMessage, rowClassName }: DataTableProps) { return (
@@ -36,7 +38,10 @@ export function DataTable({ table, emptyMessage }: DataTableProps) {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - + {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())}