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) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 22:39:23 +01:00
parent f6e7f5ed3c
commit d50c68f67c
4 changed files with 634 additions and 88 deletions

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { type ColumnDef } from "@tanstack/react-table"; 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 { DataTableColumnHeader } from "@/components/shared/data-table-column-header";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { SendToTelegramButton } from "./send-to-telegram-button"; import { SendToTelegramButton } from "./send-to-telegram-button";
export interface PackageRow { export interface PackageRow {
@@ -25,6 +26,34 @@ export interface PackageRow {
}; };
matchedFileCount: number; matchedFileCount: number;
matchedByContent: boolean; 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 { interface PackageColumnsProps {
@@ -32,9 +61,17 @@ interface PackageColumnsProps {
onSetCreator: (pkg: PackageRow) => void; onSetCreator: (pkg: PackageRow) => void;
onSetTags: (pkg: PackageRow) => void; onSetTags: (pkg: PackageRow) => void;
searchTerm: string; 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<string>;
onToggleSelect: (packageId: string) => void;
} }
function formatBytes(bytesStr: string): string { export function formatBytes(bytesStr: string): string {
const bytes = Number(bytesStr); const bytes = Number(bytesStr);
if (bytes === 0) return "0 B"; if (bytes === 0) return "0 B";
const k = 1024; 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 (
<button
className="relative group/preview cursor-pointer"
onClick={() => onUpload(group.id)}
title="Click to change preview image"
>
<img
src={`/api/groups/${group.id}/preview`}
alt=""
className="h-9 w-9 rounded-md object-cover bg-muted"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-black/50 opacity-0 group-hover/preview:opacity-100 transition-opacity">
<ImagePlus className="h-3.5 w-3.5 text-white" />
</div>
</button>
);
}
return (
<button
className="flex h-9 w-9 items-center justify-center rounded-md bg-muted hover:bg-muted/80 transition-colors cursor-pointer"
onClick={() => onUpload(group.id)}
title="Click to add preview image"
>
<Layers className="h-4 w-4 text-muted-foreground" />
</button>
);
}
export function getPackageColumns({ export function getPackageColumns({
onViewFiles, onViewFiles,
onSetCreator, onSetCreator,
onSetTags, onSetTags,
searchTerm, searchTerm,
}: PackageColumnsProps): ColumnDef<PackageRow, unknown>[] { onToggleGroup,
onRenameGroup,
onDissolveGroup,
onSendAllInGroup,
onRemoveFromGroup,
onGroupPreviewUpload,
selectedPackages,
onToggleSelect,
}: PackageColumnsProps): ColumnDef<StlTableRow, unknown>[] {
return [ return [
{
id: "select",
header: "",
cell: ({ row }) => {
const data = row.original;
if (isGroupRow(data)) return null;
return (
<Checkbox
checked={selectedPackages.has(data.id)}
onCheckedChange={() => onToggleSelect(data.id)}
aria-label="Select package"
className="translate-y-[2px]"
/>
);
},
enableHiding: false,
enableSorting: false,
size: 32,
},
{ {
id: "preview", id: "preview",
header: "", header: "",
cell: ({ row }) => <PreviewCell pkg={row.original} />, cell: ({ row }) => {
const data = row.original;
if (isGroupRow(data)) {
return (
<div className="flex items-center gap-1">
<button
className="shrink-0 p-0.5 cursor-pointer"
onClick={() => onToggleGroup(data.id)}
aria-label={data._expanded ? "Collapse group" : "Expand group"}
>
<ChevronRight
className={`h-4 w-4 text-muted-foreground transition-transform ${
data._expanded ? "rotate-90" : ""
}`}
/>
</button>
<GroupPreviewCell group={data} onUpload={onGroupPreviewUpload} />
</div>
);
}
return (
<div className={data._isGroupMember ? "pl-5" : ""}>
<PreviewCell pkg={data} />
</div>
);
},
enableHiding: false, enableHiding: false,
enableSorting: false, enableSorting: false,
size: 52, size: 72,
}, },
{ {
accessorKey: "fileName", accessorKey: "fileName",
header: ({ column }) => <DataTableColumnHeader column={column} title="File Name" />, header: ({ column }) => <DataTableColumnHeader column={column} title="File Name" />,
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
if (isGroupRow(data)) {
return (
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span> <button
{row.original.isMultipart && ( className="font-semibold truncate max-w-[300px] cursor-pointer hover:underline text-left"
onClick={() => onRenameGroup(data.id, data.name)}
title="Click to rename group"
>
{data.name}
</button>
<Badge variant="secondary" className="text-[10px] shrink-0">
{data.packageCount} pkg{data.packageCount !== 1 ? "s" : ""}
</Badge>
</div>
</div>
);
}
return (
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate max-w-[300px]">{data.fileName}</span>
{data.isMultipart && (
<Badge variant="outline" className="text-[10px] shrink-0"> <Badge variant="outline" className="text-[10px] shrink-0">
Multi Multi
</Badge> </Badge>
)} )}
</div> </div>
{searchTerm && row.original.matchedByContent && ( {searchTerm && data.matchedByContent && (
<button <button
className="text-[11px] text-amber-500 hover:text-amber-400 hover:underline cursor-pointer mt-0.5" className="text-[11px] text-amber-500 hover:text-amber-400 hover:underline cursor-pointer mt-0.5"
onClick={() => onViewFiles(row.original)} onClick={() => onViewFiles(data)}
> >
{row.original.matchedFileCount.toLocaleString()} file match{row.original.matchedFileCount !== 1 ? "es" : ""} {data.matchedFileCount.toLocaleString()} file match{data.matchedFileCount !== 1 ? "es" : ""}
</button> </button>
)} )}
</div> </div>
), );
},
enableHiding: false, enableHiding: false,
}, },
{ {
accessorKey: "archiveType", accessorKey: "archiveType",
header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Type" />,
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
if (isGroupRow(data)) {
const types = data.archiveTypes;
if (types.length === 1) {
return (
<Badge variant="secondary" className="text-[10px]"> <Badge variant="secondary" className="text-[10px]">
{row.original.archiveType} {types[0]}
</Badge> </Badge>
), );
}
return (
<Badge variant="secondary" className="text-[10px]">
Mixed
</Badge>
);
}
return (
<Badge variant="secondary" className="text-[10px]">
{data.archiveType}
</Badge>
);
},
}, },
{ {
accessorKey: "fileSize", accessorKey: "fileSize",
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
const size = isGroupRow(data) ? data.totalFileSize : data.fileSize;
return (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{formatBytes(row.original.fileSize)} {formatBytes(size)}
</span> </span>
), );
},
}, },
{ {
accessorKey: "fileCount", accessorKey: "fileCount",
header: ({ column }) => <DataTableColumnHeader column={column} title="Files" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Files" />,
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
const count = isGroupRow(data) ? data.totalFileCount : data.fileCount;
return (
<span className="text-sm"> <span className="text-sm">
{row.original.fileCount.toLocaleString()} {count.toLocaleString()}
</span> </span>
), );
},
}, },
{ {
accessorKey: "creator", accessorKey: "creator",
header: ({ column }) => <DataTableColumnHeader column={column} title="Creator" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Creator" />,
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
if (isGroupRow(data)) {
return <span className="text-sm text-muted-foreground">{"\u2014"}</span>;
}
return (
<button <button
className="text-sm text-muted-foreground truncate max-w-[160px] block hover:text-foreground hover:underline cursor-pointer text-left" className="text-sm text-muted-foreground truncate max-w-[160px] block hover:text-foreground hover:underline cursor-pointer text-left"
onClick={() => onSetCreator(row.original)} onClick={() => onSetCreator(data)}
title="Click to edit creator" title="Click to edit creator"
> >
{row.original.creator || "\u2014"} {data.creator || "\u2014"}
</button> </button>
), );
},
}, },
{ {
id: "tags", id: "tags",
header: ({ column }) => <DataTableColumnHeader column={column} title="Tags" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Tags" />,
cell: ({ row }) => { cell: ({ row }) => {
const tags = row.original.tags; const data = row.original;
const tags = isGroupRow(data) ? data.combinedTags : data.tags;
if (tags.length === 0) { if (tags.length === 0) {
if (isGroupRow(data)) {
return <span className="text-sm text-muted-foreground">{"\u2014"}</span>;
}
return ( return (
<button <button
className="text-sm text-muted-foreground hover:text-foreground cursor-pointer" className="text-sm text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => onSetTags(row.original)} onClick={() => onSetTags(data)}
title="Click to add tags" title="Click to add tags"
> >
{"\u2014"} {"\u2014"}
</button> </button>
); );
} }
const clickHandler = isGroupRow(data) ? undefined : () => onSetTags(data as PackageTableRow);
return ( return (
<button <button
className="flex flex-wrap gap-1 cursor-pointer" className={`flex flex-wrap gap-1 ${clickHandler ? "cursor-pointer" : "cursor-default"}`}
onClick={() => onSetTags(row.original)} onClick={clickHandler}
title="Click to edit tags" title={clickHandler ? "Click to edit tags" : undefined}
> >
{tags.map((tag) => ( {tags.map((tag) => (
<Badge <Badge
@@ -175,7 +359,10 @@ export function getPackageColumns({
</button> </button>
); );
}, },
accessorFn: (row) => row.tags.join(", "), accessorFn: (row) => {
if (isGroupRow(row)) return row.combinedTags.join(", ");
return row.tags.join(", ");
},
}, },
{ {
id: "channel", id: "channel",
@@ -190,31 +377,73 @@ export function getPackageColumns({
{ {
accessorKey: "indexedAt", accessorKey: "indexedAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Indexed" />, header: ({ column }) => <DataTableColumnHeader column={column} title="Indexed" />,
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
const date = isGroupRow(data) ? data.latestIndexedAt : data.indexedAt;
return (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{new Date(row.original.indexedAt).toLocaleDateString()} {new Date(date).toLocaleDateString()}
</span> </span>
), );
},
}, },
{ {
id: "actions", id: "actions",
cell: ({ row }) => ( cell: ({ row }) => {
const data = row.original;
if (isGroupRow(data)) {
return (
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onSendAllInGroup(data.id)}
title="Send all packages in group"
>
<Send className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onDissolveGroup(data.id)}
title="Dissolve group"
>
<Ungroup className="h-4 w-4" />
</Button>
</div>
);
}
return (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<SendToTelegramButton <SendToTelegramButton
packageId={row.original.id} packageId={data.id}
packageName={row.original.fileName} packageName={data.fileName}
variant="icon" variant="icon"
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => onViewFiles(row.original)} onClick={() => onViewFiles(data)}
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
{data._isGroupMember && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onRemoveFromGroup(data.id)}
title="Remove from group"
>
<Ungroup className="h-3.5 w-3.5" />
</Button>
)}
</div> </div>
), );
},
enableHiding: false, enableHiding: false,
}, },
]; ];

View File

@@ -1,11 +1,17 @@
"use client"; "use client";
import { useState, useCallback, useTransition } 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 } from "lucide-react"; import { Search, Layers } from "lucide-react";
import { useDataTable } from "@/hooks/use-data-table"; 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 { 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";
@@ -14,6 +20,7 @@ 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";
import { PageHeader } from "@/components/shared/page-header"; import { PageHeader } from "@/components/shared/page-header";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -21,14 +28,31 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge"; 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 type { SkippedRow } from "./skipped-columns";
import { updatePackageCreator, updatePackageTags } from "../actions"; import {
updatePackageCreator,
updatePackageTags,
renameGroupAction,
dissolveGroupAction,
createGroupAction,
removeFromGroupAction,
sendAllInGroupAction,
updateGroupPreviewAction,
} from "../actions";
interface StlTableProps { interface StlTableProps {
data: PackageRow[]; data: DisplayItem[];
pageCount: number; pageCount: number;
totalCount: number; totalCount: number;
ingestionStatus: IngestionAccountStatus[]; ingestionStatus: IngestionAccountStatus[];
@@ -58,6 +82,88 @@ export function StlTable({
const [viewPkg, setViewPkg] = useState<PackageRow | null>(null); const [viewPkg, setViewPkg] = useState<PackageRow | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
// Group expansion state
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// Package selection state (for manual grouping)
const [selectedPackages, setSelectedPackages] = useState<Set<string>>(new Set());
// Create group dialog state
const [createGroupOpen, setCreateGroupOpen] = useState(false);
const [groupName, setGroupName] = useState("");
// Group preview upload ref
const previewInputRef = useRef<HTMLInputElement>(null);
const [uploadGroupId, setUploadGroupId] = useState<string | null>(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( const updateSearch = useCallback(
(value: string) => { (value: string) => {
setSearchValue(value); setSearchValue(value);
@@ -103,6 +209,131 @@ export function StlTable({
[router, pathname, searchParams] [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<HTMLInputElement>) => {
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({ const columns = getPackageColumns({
onViewFiles: (pkg) => setViewPkg(pkg), onViewFiles: (pkg) => setViewPkg(pkg),
searchTerm, 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") ?? ""; const activeTag = searchParams.get("tag") ?? "";
@@ -191,11 +430,37 @@ export function StlTable({
</Select> </Select>
)} )}
<DataTableViewOptions table={table} /> <DataTableViewOptions table={table} />
{selectedPackages.size >= 2 && (
<Button
variant="outline"
size="sm"
className="h-9 gap-1.5"
onClick={handleCreateGroup}
>
<Layers className="h-3.5 w-3.5" />
Group {selectedPackages.size} Selected
</Button>
)}
{selectedPackages.size > 0 && selectedPackages.size < 2 && (
<span className="text-xs text-muted-foreground">
Select at least 2 packages to group
</span>
)}
</div> </div>
<DataTable <DataTable
table={table} table={table}
emptyMessage="No packages found. Archives will appear here after ingestion." emptyMessage="No packages found. Archives will appear here after ingestion."
rowClassName={(row) => {
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 "";
}}
/> />
<DataTablePagination table={table} totalCount={totalCount} /> <DataTablePagination table={table} totalCount={totalCount} />
</TabsContent> </TabsContent>
@@ -217,6 +482,47 @@ export function StlTable({
}} }}
highlightTerm={searchTerm} highlightTerm={searchTerm}
/> />
{/* Create Group Dialog */}
<Dialog open={createGroupOpen} onOpenChange={setCreateGroupOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Package Group</DialogTitle>
<DialogDescription>
Group {selectedPackages.size} selected packages together. Enter a name for the group.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
placeholder="Group name..."
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitCreateGroup();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateGroupOpen(false)}>
Cancel
</Button>
<Button onClick={submitCreateGroup} disabled={!groupName.trim()}>
<Layers className="h-4 w-4 mr-1" />
Create Group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Hidden file input for group preview upload (Task 12) */}
<input
ref={previewInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handlePreviewFileChange}
/>
</div> </div>
); );
} }

View File

@@ -1,7 +1,8 @@
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; 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 { StlTable } from "./_components/stl-table";
import type { DisplayItem, PackageListItem } from "@/lib/telegram/types";
interface Props { interface Props {
searchParams: Promise<Record<string, string | string[] | undefined>>; searchParams: Promise<Record<string, string | string[] | undefined>>;
@@ -31,7 +32,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
limit: perPage, limit: perPage,
searchIn: "both", searchIn: "both",
}) })
: listPackages({ : listDisplayItems({
page, page,
limit: perPage, limit: perPage,
creator, creator,
@@ -44,6 +45,11 @@ export default async function StlFilesPage({ searchParams }: Props) {
countSkippedPackages(), 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 // Fetch skipped packages only if on that tab
const skippedResult = tab === "skipped" const skippedResult = tab === "skipped"
? await listSkippedPackages({ page, limit: perPage }) ? await listSkippedPackages({ page, limit: perPage })
@@ -51,7 +57,7 @@ export default async function StlFilesPage({ searchParams }: Props) {
return ( return (
<StlTable <StlTable
data={result.items} data={displayItems}
pageCount={result.pagination.totalPages} pageCount={result.pagination.totalPages}
totalCount={result.pagination.total} totalCount={result.pagination.total}
ingestionStatus={ingestionStatus} ingestionStatus={ingestionStatus}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { type Table as TanStackTable, flexRender } from "@tanstack/react-table"; import { type Table as TanStackTable, type Row, flexRender } from "@tanstack/react-table";
import { import {
Table, Table,
TableBody, TableBody,
@@ -10,13 +10,15 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { EmptyState } from "./empty-state"; import { EmptyState } from "./empty-state";
import { cn } from "@/lib/utils";
interface DataTableProps<TData> { interface DataTableProps<TData> {
table: TanStackTable<TData>; table: TanStackTable<TData>;
emptyMessage?: string; emptyMessage?: string;
rowClassName?: (row: Row<TData>) => string;
} }
export function DataTable<TData>({ table, emptyMessage }: DataTableProps<TData>) { export function DataTable<TData>({ table, emptyMessage, rowClassName }: DataTableProps<TData>) {
return ( return (
<div className="rounded-md border border-border"> <div className="rounded-md border border-border">
<Table> <Table>
@@ -36,7 +38,10 @@ export function DataTable<TData>({ table, emptyMessage }: DataTableProps<TData>)
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-10 border-border hover:bg-muted/50"> <TableRow
key={row.id}
className={cn("h-10 border-border hover:bg-muted/50", rowClassName?.(row))}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-1.5 text-sm"> <TableCell key={cell.id} className="py-1.5 text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}