mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-24 07:42:47 +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,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<PackageRow | null>(null);
|
||||
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(
|
||||
(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<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({
|
||||
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({
|
||||
</Select>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<DataTable
|
||||
table={table}
|
||||
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} />
|
||||
</TabsContent>
|
||||
@@ -217,6 +482,47 @@ export function StlTable({
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user