mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-10 22:01:16 +00:00
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:
@@ -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 }) => {
|
||||||
<div className="min-w-0">
|
const data = row.original;
|
||||||
<div className="flex items-center gap-2">
|
if (isGroupRow(data)) {
|
||||||
<span className="font-medium truncate max-w-[300px]">{row.original.fileName}</span>
|
return (
|
||||||
{row.original.isMultipart && (
|
<div className="min-w-0">
|
||||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
<div className="flex items-center gap-2">
|
||||||
Multi
|
<button
|
||||||
</Badge>
|
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">
|
||||||
|
Multi
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searchTerm && data.matchedByContent && (
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-amber-500 hover:text-amber-400 hover:underline cursor-pointer mt-0.5"
|
||||||
|
onClick={() => onViewFiles(data)}
|
||||||
|
>
|
||||||
|
{data.matchedFileCount.toLocaleString()} file match{data.matchedFileCount !== 1 ? "es" : ""}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{searchTerm && row.original.matchedByContent && (
|
);
|
||||||
<button
|
},
|
||||||
className="text-[11px] text-amber-500 hover:text-amber-400 hover:underline cursor-pointer mt-0.5"
|
|
||||||
onClick={() => onViewFiles(row.original)}
|
|
||||||
>
|
|
||||||
{row.original.matchedFileCount.toLocaleString()} file match{row.original.matchedFileCount !== 1 ? "es" : ""}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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 }) => {
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
const data = row.original;
|
||||||
{row.original.archiveType}
|
if (isGroupRow(data)) {
|
||||||
</Badge>
|
const types = data.archiveTypes;
|
||||||
),
|
if (types.length === 1) {
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{types[0]}
|
||||||
|
</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 }) => {
|
||||||
<span className="text-sm text-muted-foreground">
|
const data = row.original;
|
||||||
{formatBytes(row.original.fileSize)}
|
const size = isGroupRow(data) ? data.totalFileSize : data.fileSize;
|
||||||
</span>
|
return (
|
||||||
),
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatBytes(size)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "fileCount",
|
accessorKey: "fileCount",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Files" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Files" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<span className="text-sm">
|
const data = row.original;
|
||||||
{row.original.fileCount.toLocaleString()}
|
const count = isGroupRow(data) ? data.totalFileCount : data.fileCount;
|
||||||
</span>
|
return (
|
||||||
),
|
<span className="text-sm">
|
||||||
|
{count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "creator",
|
accessorKey: "creator",
|
||||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Creator" />,
|
header: ({ column }) => <DataTableColumnHeader column={column} title="Creator" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<button
|
const data = row.original;
|
||||||
className="text-sm text-muted-foreground truncate max-w-[160px] block hover:text-foreground hover:underline cursor-pointer text-left"
|
if (isGroupRow(data)) {
|
||||||
onClick={() => onSetCreator(row.original)}
|
return <span className="text-sm text-muted-foreground">{"\u2014"}</span>;
|
||||||
title="Click to edit creator"
|
}
|
||||||
>
|
return (
|
||||||
{row.original.creator || "\u2014"}
|
<button
|
||||||
</button>
|
className="text-sm text-muted-foreground truncate max-w-[160px] block hover:text-foreground hover:underline cursor-pointer text-left"
|
||||||
),
|
onClick={() => onSetCreator(data)}
|
||||||
|
title="Click to edit creator"
|
||||||
|
>
|
||||||
|
{data.creator || "\u2014"}
|
||||||
|
</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 }) => {
|
||||||
<span className="text-sm text-muted-foreground">
|
const data = row.original;
|
||||||
{new Date(row.original.indexedAt).toLocaleDateString()}
|
const date = isGroupRow(data) ? data.latestIndexedAt : data.indexedAt;
|
||||||
</span>
|
return (
|
||||||
),
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{new Date(date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="flex items-center gap-0.5">
|
const data = row.original;
|
||||||
<SendToTelegramButton
|
if (isGroupRow(data)) {
|
||||||
packageId={row.original.id}
|
return (
|
||||||
packageName={row.original.fileName}
|
<div className="flex items-center gap-0.5">
|
||||||
variant="icon"
|
<Button
|
||||||
/>
|
variant="ghost"
|
||||||
<Button
|
size="icon"
|
||||||
variant="ghost"
|
className="h-8 w-8"
|
||||||
size="icon"
|
onClick={() => onSendAllInGroup(data.id)}
|
||||||
className="h-8 w-8"
|
title="Send all packages in group"
|
||||||
onClick={() => onViewFiles(row.original)}
|
>
|
||||||
>
|
<Send className="h-4 w-4" />
|
||||||
<Eye className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
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">
|
||||||
|
<SendToTelegramButton
|
||||||
|
packageId={data.id}
|
||||||
|
packageName={data.fileName}
|
||||||
|
variant="icon"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onViewFiles(data)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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())}
|
||||||
|
|||||||
Reference in New Issue
Block a user