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 = {
|
export type TaskFrontmatter = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other'
|
||||||
version: number;
|
version: number;
|
||||||
mode: string;
|
mode: string;
|
||||||
inline: boolean; // quick/interactive tasks run in the modal instead of as a background job
|
inline: boolean; // quick/interactive tasks run in the modal instead of as a background job
|
||||||
@@ -37,6 +38,7 @@ export type TaskSummary = {
|
|||||||
dirName: string;
|
dirName: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
mode: string;
|
mode: string;
|
||||||
inline: boolean;
|
inline: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -47,6 +49,7 @@ export type TaskSummary = {
|
|||||||
const FM_KEYS = [
|
const FM_KEYS = [
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
|
'category',
|
||||||
'version',
|
'version',
|
||||||
'mode',
|
'mode',
|
||||||
'inline',
|
'inline',
|
||||||
@@ -106,6 +109,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
|
|||||||
fm: {
|
fm: {
|
||||||
name: parsed.name == null ? '' : String(parsed.name),
|
name: parsed.name == null ? '' : String(parsed.name),
|
||||||
description: parsed.description == null ? null : String(parsed.description),
|
description: parsed.description == null ? null : String(parsed.description),
|
||||||
|
category: parsed.category == null ? null : String(parsed.category),
|
||||||
version,
|
version,
|
||||||
mode: parsed.mode == null ? 'agentic' : String(parsed.mode),
|
mode: parsed.mode == null ? 'agentic' : String(parsed.mode),
|
||||||
inline: parsed.inline === true,
|
inline: parsed.inline === true,
|
||||||
@@ -163,6 +167,7 @@ export async function listTasks(): Promise<TaskSummary[]> {
|
|||||||
dirName: entry.name,
|
dirName: entry.name,
|
||||||
name: fm.name || entry.name,
|
name: fm.name || entry.name,
|
||||||
description: fm.description,
|
description: fm.description,
|
||||||
|
category: fm.category,
|
||||||
mode: fm.mode,
|
mode: fm.mode,
|
||||||
inline: fm.inline,
|
inline: fm.inline,
|
||||||
version: fm.version,
|
version: fm.version,
|
||||||
@@ -209,6 +214,7 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
|
|||||||
dirName,
|
dirName,
|
||||||
name,
|
name,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
|
category: null,
|
||||||
mode,
|
mode,
|
||||||
inline: false,
|
inline: false,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ tasksRouter.get('/', async (ctx) => {
|
|||||||
dirName: row.dirName,
|
dirName: row.dirName,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
triggers: (row.trigger as TriggerConfig[]) ?? [],
|
triggers: (row.trigger as TriggerConfig[]) ?? [],
|
||||||
mode: row.mode ?? 'agentic',
|
mode: row.mode ?? 'agentic',
|
||||||
}));
|
}));
|
||||||
@@ -29,6 +30,7 @@ tasksRouter.get('/:name', async (ctx) => {
|
|||||||
dirName: task.dirName,
|
dirName: task.dirName,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
|
category: task.category,
|
||||||
mode: task.mode,
|
mode: task.mode,
|
||||||
inline: task.inline,
|
inline: task.inline,
|
||||||
language: task.language,
|
language: task.language,
|
||||||
|
|||||||
+259
-182
@@ -1,5 +1,24 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
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 { getIcon } from 'material-file-icons';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -23,7 +42,7 @@ import {
|
|||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||||
import type { TaskSummary } from '../../useTasks';
|
import { groupTasksByCategory, type TaskSummary } from '../../useTasks';
|
||||||
import { getFileType } from '../../../FileViewer';
|
import { getFileType } from '../../../FileViewer';
|
||||||
|
|
||||||
export type FileItemProps = {
|
export type FileItemProps = {
|
||||||
@@ -118,101 +137,133 @@ const DropdownMenuItems = ({
|
|||||||
const showExtract = fileType === 'archive';
|
const showExtract = fileType === 'archive';
|
||||||
const showPlay = fileType === 'audio' || entry.type === 'directory';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showPlay && (
|
{showPlay && (
|
||||||
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||||
<Music className="mr-2 h-4 w-4" />
|
<Music className="mr-2 h-4 w-4" />
|
||||||
Play
|
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>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
|
||||||
{showReadAloud && (
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
Copy
|
||||||
<Volume2 className="mr-2 h-4 w-4" />
|
|
||||||
Read Aloud
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
{!multiSelected && (
|
||||||
{showOcr && (
|
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
|
||||||
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
<ScanText className="mr-2 h-4 w-4" />
|
Rename
|
||||||
Extract Text (OCR)
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
|
||||||
|
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||||
|
Copy path
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||||
{showTranscribe && (
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
|
Download
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Transcribe
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
<DropdownMenuSeparator />
|
||||||
{showExtractAudio && (
|
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||||
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
<AudioLines className="mr-2 h-4 w-4" />
|
Chat...
|
||||||
Extract Audio
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
{entry.type === 'directory' && (
|
||||||
{showExtract && (
|
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||||
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
<FolderArchive className="mr-2 h-4 w-4" />
|
Create Dashboard here
|
||||||
Extract
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
</DropdownMenuItem>
|
</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 showExtract = fileType === 'archive';
|
||||||
const showPlay = fileType === 'audio' || entry.type === 'directory';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{showPlay && (
|
{showPlay && (
|
||||||
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
|
||||||
<Music className="mr-2 h-4 w-4" />
|
<Music className="mr-2 h-4 w-4" />
|
||||||
Play
|
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>
|
</ContextMenuItem>
|
||||||
)}
|
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
|
||||||
{showReadAloud && (
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
|
Copy
|
||||||
<Volume2 className="mr-2 h-4 w-4" />
|
|
||||||
Read Aloud
|
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
{!multiSelected && (
|
||||||
{showOcr && (
|
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
|
||||||
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
<ScanText className="mr-2 h-4 w-4" />
|
Rename
|
||||||
Extract Text (OCR)
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
|
||||||
|
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||||
|
Copy path
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
|
||||||
{showTranscribe && (
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
|
Download
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Transcribe
|
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
<ContextMenuSeparator />
|
||||||
{showExtractAudio && (
|
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||||
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
<AudioLines className="mr-2 h-4 w-4" />
|
Chat...
|
||||||
Extract Audio
|
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
{entry.type === 'directory' && (
|
||||||
{showExtract && (
|
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
|
||||||
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
<FolderArchive className="mr-2 h-4 w-4" />
|
Create Dashboard here
|
||||||
Extract
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
</ContextMenuItem>
|
</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);
|
onClick(ev);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
|
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'}`}
|
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||||
>
|
>
|
||||||
{checked && <Check className="h-3 w-3" />}
|
{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 ${
|
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'
|
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
|
||||||
} ${cutOpacity} select-none`}
|
} ${cutOpacity} select-none`}
|
||||||
style={
|
style={selected ? undefined : cardStyle()}
|
||||||
selected
|
|
||||||
? undefined
|
|
||||||
: cardStyle()
|
|
||||||
}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onPointerDown={(ev) => ev.stopPropagation()}
|
onPointerDown={(ev) => ev.stopPropagation()}
|
||||||
|
|||||||
@@ -9,11 +9,41 @@ export type TaskSummary = {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
category: string | null;
|
||||||
triggers: TriggerConfig[];
|
triggers: TriggerConfig[];
|
||||||
mode: 'script' | 'agentic' | 'pipeline';
|
mode: 'script' | 'agentic' | 'pipeline';
|
||||||
userId: number | null;
|
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 = () => {
|
export const useTasks = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user