group file-browser tasks into category submenus
Every task declares a directory trigger, so right-clicking any folder listed all fifteen at once — Tag Album offered on a folder of photos. TASK.md gains an optional `category`, and the context menu nests by it: Run Task > Video > … Nesting only kicks in when more than one category matches. A .mp4 matches eight tasks that are all Video, so file menus stay flat rather than gaining a pointless hop. Known categories lead (Video, Audio, Cleanup); anything else follows alphabetically with Other last. Applied to both menus — the right-click one and the ⋮ dropdown — which carried identical blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1d0842cc01
commit
6a77ec22df
@@ -11,6 +11,7 @@ const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAM
|
||||
export type TaskFrontmatter = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other'
|
||||
version: number;
|
||||
mode: string;
|
||||
inline: boolean; // quick/interactive tasks run in the modal instead of as a background job
|
||||
@@ -37,6 +38,7 @@ export type TaskSummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
mode: string;
|
||||
inline: boolean;
|
||||
version: number;
|
||||
@@ -47,6 +49,7 @@ export type TaskSummary = {
|
||||
const FM_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'category',
|
||||
'version',
|
||||
'mode',
|
||||
'inline',
|
||||
@@ -106,6 +109,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
|
||||
fm: {
|
||||
name: parsed.name == null ? '' : String(parsed.name),
|
||||
description: parsed.description == null ? null : String(parsed.description),
|
||||
category: parsed.category == null ? null : String(parsed.category),
|
||||
version,
|
||||
mode: parsed.mode == null ? 'agentic' : String(parsed.mode),
|
||||
inline: parsed.inline === true,
|
||||
@@ -163,6 +167,7 @@ export async function listTasks(): Promise<TaskSummary[]> {
|
||||
dirName: entry.name,
|
||||
name: fm.name || entry.name,
|
||||
description: fm.description,
|
||||
category: fm.category,
|
||||
mode: fm.mode,
|
||||
inline: fm.inline,
|
||||
version: fm.version,
|
||||
@@ -209,6 +214,7 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
|
||||
dirName,
|
||||
name,
|
||||
description: input.description ?? null,
|
||||
category: null,
|
||||
mode,
|
||||
inline: false,
|
||||
version: 1,
|
||||
|
||||
@@ -12,6 +12,7 @@ tasksRouter.get('/', async (ctx) => {
|
||||
dirName: row.dirName,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
triggers: (row.trigger as TriggerConfig[]) ?? [],
|
||||
mode: row.mode ?? 'agentic',
|
||||
}));
|
||||
@@ -29,6 +30,7 @@ tasksRouter.get('/:name', async (ctx) => {
|
||||
dirName: task.dirName,
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
category: task.category,
|
||||
mode: task.mode,
|
||||
inline: task.inline,
|
||||
language: task.language,
|
||||
|
||||
+259
-182
@@ -1,5 +1,24 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive, ClipboardCopy, Music } from 'lucide-react';
|
||||
import {
|
||||
Folder,
|
||||
Trash2,
|
||||
Pencil,
|
||||
MoreVertical,
|
||||
MessageSquare,
|
||||
Scissors,
|
||||
Copy,
|
||||
Check,
|
||||
Play,
|
||||
Download,
|
||||
LayoutGrid,
|
||||
Volume2,
|
||||
ScanText,
|
||||
FileText,
|
||||
AudioLines,
|
||||
FolderArchive,
|
||||
ClipboardCopy,
|
||||
Music,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -23,7 +42,7 @@ import {
|
||||
} from '@/components/ui/context-menu';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||
import type { TaskSummary } from '../../useTasks';
|
||||
import { groupTasksByCategory, type TaskSummary } from '../../useTasks';
|
||||
import { getFileType } from '../../../FileViewer';
|
||||
|
||||
export type FileItemProps = {
|
||||
@@ -118,101 +137,133 @@ const DropdownMenuItems = ({
|
||||
const showExtract = fileType === 'archive';
|
||||
const showPlay = fileType === 'audio' || entry.type === 'directory';
|
||||
|
||||
const hasActions = showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || matchingTasks.length > 0;
|
||||
const hasActions =
|
||||
showPlay ||
|
||||
showReadAloud ||
|
||||
showOcr ||
|
||||
showTranscribe ||
|
||||
showExtractAudio ||
|
||||
showExtract ||
|
||||
matchingTasks.length > 0;
|
||||
// Only nest when more than one category is present — a file usually matches a single category,
|
||||
// and Run Task > Video > Convert would just add a hop.
|
||||
const taskGroups = groupTasksByCategory(matchingTasks);
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPlay && (
|
||||
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
<>
|
||||
{showPlay && (
|
||||
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
Read Aloud
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showOcr && (
|
||||
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
|
||||
<ScanText className="mr-2 h-4 w-4" />
|
||||
Extract Text (OCR)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showTranscribe && (
|
||||
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Transcribe
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showExtractAudio && (
|
||||
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
|
||||
<AudioLines className="mr-2 h-4 w-4" />
|
||||
Extract Audio
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showExtract && (
|
||||
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
|
||||
<FolderArchive className="mr-2 h-4 w-4" />
|
||||
Extract
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{matchingTasks.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Run Task
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="z-[600]">
|
||||
{nestTasks
|
||||
? taskGroups.map((group) => (
|
||||
<DropdownMenuSub key={group.category}>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="z-[600]">
|
||||
{group.tasks.map((task) => (
|
||||
<DropdownMenuItem
|
||||
key={task.dirName}
|
||||
onClick={() => onRunTask(task, entry)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{task.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))
|
||||
: matchingTasks.map((task) => (
|
||||
<DropdownMenuItem
|
||||
key={task.dirName}
|
||||
onClick={() => onRunTask(task, entry)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{task.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{hasActions && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Cut
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
Read Aloud
|
||||
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showOcr && (
|
||||
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
|
||||
<ScanText className="mr-2 h-4 w-4" />
|
||||
Extract Text (OCR)
|
||||
{!multiSelected && (
|
||||
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showTranscribe && (
|
||||
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Transcribe
|
||||
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showExtractAudio && (
|
||||
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
|
||||
<AudioLines className="mr-2 h-4 w-4" />
|
||||
Extract Audio
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Chat...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showExtract && (
|
||||
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
|
||||
<FolderArchive className="mr-2 h-4 w-4" />
|
||||
Extract
|
||||
{entry.type === 'directory' && (
|
||||
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{matchingTasks.length > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Run Task
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="z-[600]">
|
||||
{matchingTasks.map((task) => (
|
||||
<DropdownMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
|
||||
{task.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{hasActions && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Cut
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</DropdownMenuItem>
|
||||
{!multiSelected && (
|
||||
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Chat...
|
||||
</DropdownMenuItem>
|
||||
{entry.type === 'directory' && (
|
||||
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -244,101 +295,129 @@ const ContextMenuItems = ({
|
||||
const showExtract = fileType === 'archive';
|
||||
const showPlay = fileType === 'audio' || entry.type === 'directory';
|
||||
|
||||
const hasActions = showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || matchingTasks.length > 0;
|
||||
const hasActions =
|
||||
showPlay ||
|
||||
showReadAloud ||
|
||||
showOcr ||
|
||||
showTranscribe ||
|
||||
showExtractAudio ||
|
||||
showExtract ||
|
||||
matchingTasks.length > 0;
|
||||
// Only nest when more than one category is present — a file usually matches a single category,
|
||||
// and Run Task > Video > Convert would just add a hop.
|
||||
const taskGroups = groupTasksByCategory(matchingTasks);
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPlay && (
|
||||
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
<>
|
||||
{showPlay && (
|
||||
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Play
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
Read Aloud
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showOcr && (
|
||||
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
|
||||
<ScanText className="mr-2 h-4 w-4" />
|
||||
Extract Text (OCR)
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showTranscribe && (
|
||||
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Transcribe
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showExtractAudio && (
|
||||
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
|
||||
<AudioLines className="mr-2 h-4 w-4" />
|
||||
Extract Audio
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showExtract && (
|
||||
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
|
||||
<FolderArchive className="mr-2 h-4 w-4" />
|
||||
Extract
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{matchingTasks.length > 0 && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger className="cursor-pointer">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Run Task
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="z-[600]">
|
||||
{nestTasks
|
||||
? taskGroups.map((group) => (
|
||||
<ContextMenuSub key={group.category}>
|
||||
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="z-[600]">
|
||||
{group.tasks.map((task) => (
|
||||
<ContextMenuItem
|
||||
key={task.dirName}
|
||||
onClick={() => onRunTask(task, entry)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{task.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
))
|
||||
: matchingTasks.map((task) => (
|
||||
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
|
||||
{task.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{hasActions && <ContextMenuSeparator />}
|
||||
<ContextMenuItem onClick={onCut} className="cursor-pointer">
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Cut
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showReadAloud && (
|
||||
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
||||
<Volume2 className="mr-2 h-4 w-4" />
|
||||
Read Aloud
|
||||
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showOcr && (
|
||||
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
|
||||
<ScanText className="mr-2 h-4 w-4" />
|
||||
Extract Text (OCR)
|
||||
{!multiSelected && (
|
||||
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
Copy path
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showTranscribe && (
|
||||
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Transcribe
|
||||
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showExtractAudio && (
|
||||
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
|
||||
<AudioLines className="mr-2 h-4 w-4" />
|
||||
Extract Audio
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Chat...
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showExtract && (
|
||||
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
|
||||
<FolderArchive className="mr-2 h-4 w-4" />
|
||||
Extract
|
||||
{entry.type === 'directory' && (
|
||||
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{matchingTasks.length > 0 && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger className="cursor-pointer">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Run Task
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="z-[600]">
|
||||
{matchingTasks.map((task) => (
|
||||
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
|
||||
{task.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{hasActions && <ContextMenuSeparator />}
|
||||
<ContextMenuItem onClick={onCut} className="cursor-pointer">
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Cut
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
{!multiSelected && (
|
||||
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
Copy path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Chat...
|
||||
</ContextMenuItem>
|
||||
{entry.type === 'directory' && (
|
||||
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -431,7 +510,9 @@ const Checkbox = ({
|
||||
onClick(ev);
|
||||
}}
|
||||
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
|
||||
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
|
||||
checked
|
||||
? 'bg-duck-teal border-duck-teal text-white'
|
||||
: 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
|
||||
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3" />}
|
||||
@@ -614,11 +695,7 @@ export const FileItem = ({
|
||||
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
|
||||
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
|
||||
} ${cutOpacity} select-none`}
|
||||
style={
|
||||
selected
|
||||
? undefined
|
||||
: cardStyle()
|
||||
}
|
||||
style={selected ? undefined : cardStyle()}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
|
||||
@@ -9,11 +9,41 @@ export type TaskSummary = {
|
||||
name: string;
|
||||
description: string;
|
||||
scope: string;
|
||||
category: string | null;
|
||||
triggers: TriggerConfig[];
|
||||
mode: 'script' | 'agentic' | 'pipeline';
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
export type TaskGroup = { category: string; tasks: TaskSummary[] };
|
||||
|
||||
const UNCATEGORIZED = 'Other';
|
||||
|
||||
// Every task carries a `directory` trigger, so right-clicking a folder listed all of them at once.
|
||||
// Grouping by category turns that into Run Task > Video > … Known categories lead in this order;
|
||||
// anything else follows alphabetically, with Other always last.
|
||||
const CATEGORY_ORDER = ['Video', 'Audio', 'Cleanup'];
|
||||
|
||||
const categoryRank = (category: string): number => {
|
||||
const known = CATEGORY_ORDER.indexOf(category);
|
||||
if (known !== -1) return known;
|
||||
return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : CATEGORY_ORDER.length;
|
||||
};
|
||||
|
||||
export const groupTasksByCategory = (tasks: TaskSummary[]): TaskGroup[] => {
|
||||
const groups = new Map<string, TaskSummary[]>();
|
||||
for (const task of tasks) {
|
||||
const category = task.category?.trim() || UNCATEGORIZED;
|
||||
const bucket = groups.get(category);
|
||||
if (bucket) bucket.push(task);
|
||||
else groups.set(category, [task]);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.map(([category, list]) => ({ category, tasks: [...list].sort((a, b) => a.name.localeCompare(b.name)) }))
|
||||
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.category.localeCompare(b.category));
|
||||
};
|
||||
|
||||
export const useTasks = () => {
|
||||
const client = useClient();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user