Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 60 additions & 119 deletions src/components/projects/DialogueCard.jsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { Download, MoreHorizontal, MessageCircle, Trash2 } from 'lucide-react';
import { MoreHorizontal, MessageCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from '@tanstack/react-router';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { DialogueContextMenu } from '@/components/projects/DialogueContextMenu';
import { useSettingsCommandStore } from '@/stores/settingsCommandStore';
import { useDialogueStore } from '@/stores/dialogueStore';

/**
* Dialogue Card Component
Expand All @@ -22,123 +14,72 @@ export function DialogueCard({ dialogue, projectId }) {
const { t } = useTranslation();
const navigate = useNavigate();
const openSettingsCommand = useSettingsCommandStore((state) => state.openWithContext);
const { deleteDialogue, exportDialogue } = useDialogueStore();

const nodeCount = dialogue.nodeCount || 0;

const handleExport = async () => {
try {
await exportDialogue(dialogue.id);
} catch (error) {
console.error('Failed to export dialogue:', error);
}
};

const handleDelete = async () => {
const confirmed = window.confirm(
t('dialogues.deleteConfirm', {
name: dialogue.name,
defaultValue: `Delete "${dialogue.name}"? This action cannot be undone.`,
})
);
if (!confirmed) return;

try {
await deleteDialogue(dialogue.id);
} catch (error) {
console.error('Failed to delete dialogue:', error);
}
};

return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Link
to="/projects/$projectId/dialogue/$dialogueId"
params={{ projectId, dialogueId: dialogue.id }}
>
<Card className="group cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all h-full flex flex-col overflow-hidden rounded-lg">
<div className="h-32 bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 flex items-center justify-center relative overflow-hidden rounded-t-lg">
<div className="absolute inset-0 opacity-30 dark:opacity-20 bg-[radial-gradient(#9333ea_1px,transparent_1px)] [background-size:16px_16px]" />
<MessageCircle className="h-12 w-12 text-purple-500 dark:text-purple-400 opacity-80 group-hover:scale-110 group-hover:rotate-6 transition-all duration-300 relative z-10" />
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full bg-background/90 backdrop-blur-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openSettingsCommand({
context: {
type: 'dialogue',
name: dialogue.name,
projectId,
dialogueId: dialogue.id,
},
onOpenSettings: () =>
navigate({
to: '/projects/$projectId/dialogue/$dialogueId/settings',
params: { projectId, dialogueId: dialogue.id },
}),
mode: 'detail',
});
}}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</div>
<DialogueContextMenu dialogue={dialogue}>
<Link
to="/projects/$projectId/dialogue/$dialogueId"
params={{ projectId, dialogueId: dialogue.id }}
>
<Card className="group cursor-pointer hover:shadow-xl hover:-translate-y-1 transition-all h-full flex flex-col overflow-hidden rounded-lg">
<div className="h-32 bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/20 flex items-center justify-center relative overflow-hidden rounded-t-lg">
<div className="absolute inset-0 opacity-30 dark:opacity-20 bg-[radial-gradient(#9333ea_1px,transparent_1px)] [background-size:16px_16px]" />
<MessageCircle className="h-12 w-12 text-purple-500 dark:text-purple-400 opacity-80 group-hover:scale-110 group-hover:rotate-6 transition-all duration-300 relative z-10" />
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full bg-background/90 backdrop-blur-sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openSettingsCommand({
context: {
type: 'dialogue',
name: dialogue.name,
projectId,
dialogueId: dialogue.id,
},
onOpenSettings: () =>
navigate({
to: '/projects/$projectId/dialogue/$dialogueId/settings',
params: { projectId, dialogueId: dialogue.id },
}),
mode: 'detail',
});
}}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</div>
</div>

<CardContent className="p-4 flex-1 flex flex-col">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-base group-hover:text-primary transition-colors line-clamp-1">
{dialogue.name}
</h3>
</div>
<CardContent className="p-4 flex-1 flex flex-col">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold text-base group-hover:text-primary transition-colors line-clamp-1">
{dialogue.name}
</h3>
</div>

{dialogue.description && (
<p className="text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed">
{dialogue.description}
</p>
)}
{dialogue.description && (
<p className="text-xs text-muted-foreground mb-3 line-clamp-2 leading-relaxed">
{dialogue.description}
</p>
)}

<div className="mt-auto pt-3 border-t flex items-center justify-center text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<MessageCircle className="h-3.5 w-3.5 group-hover:text-purple-500 transition-colors" />
<span className="font-medium">
{t('dialogues.nodeCount', { count: nodeCount })}
</span>
<div className="mt-auto pt-3 border-t flex items-center justify-center text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<MessageCircle className="h-3.5 w-3.5 group-hover:text-purple-500 transition-colors" />
<span className="font-medium">
{t('dialogues.nodeCount', { count: nodeCount })}
</span>
</div>
</CardContent>
</Card>
</Link>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
void handleExport();
}}
>
<Download className="mr-2 h-4 w-4" />
{t('common.export')}
<ContextMenuShortcut>Ctrl+E</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
void handleDelete();
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('common.delete')}
<ContextMenuShortcut>Del</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</span>
</div>
</CardContent>
</Card>
</Link>
</DialogueContextMenu>
);
}
74 changes: 74 additions & 0 deletions src/components/projects/DialogueContextMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Download, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { useDialogueStore } from '@/stores/dialogueStore';

/**
* Right-click menu (Export, Delete) for one dialogue. Wrap the element that represents the dialogue,
* for example a card or a list row; it is rendered as the context menu trigger.
*/
export function DialogueContextMenu({ dialogue, children }) {
const { t } = useTranslation();
const { deleteDialogue, exportDialogue } = useDialogueStore();

const handleExport = async () => {
try {
await exportDialogue(dialogue.id);
} catch (error) {
console.error('Failed to export dialogue:', error);
}
};

const handleDelete = async () => {
const confirmed = window.confirm(
t('dialogues.deleteConfirm', {
name: dialogue.name,
defaultValue: `Delete "${dialogue.name}"? This action cannot be undone.`,
})
);
if (!confirmed) return;

try {
await deleteDialogue(dialogue.id);
} catch (error) {
console.error('Failed to delete dialogue:', error);
}
};

return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
void handleExport();
}}
>
<Download className="mr-2 h-4 w-4" />
{t('common.export')}
<ContextMenuShortcut>Ctrl+E</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
void handleDelete();
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('common.delete')}
<ContextMenuShortcut>Del</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
42 changes: 22 additions & 20 deletions src/components/projects/sections/OverviewSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { isMobileDevice } from '@/lib/deviceDetection';
import { copyToClipboardWithToast } from '@/lib/clipboard';
import { toast } from '@/components/ui/toaster';
import { SimpleTooltip } from '@/components/ui/tooltip';
import { DialogueContextMenu } from '@/components/projects/DialogueContextMenu';
import { useSettingsCommandStore } from '@/stores/settingsCommandStore';
import { calculateProjectSize } from '@/lib/storageUtils';

Expand Down Expand Up @@ -337,27 +338,28 @@ export function OverviewSection({
<>
<div className="divide-y divide-border">
{recentDialogues.map((dialogue) => (
<Link
key={dialogue.id}
to="/projects/$projectId/dialogue/$dialogueId"
params={{ projectId: project.id, dialogueId: dialogue.id }}
className="p-4 flex items-center justify-between group hover:bg-accent transition-colors cursor-pointer hover:rounded-lg rounded-lg"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center text-blue-500">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<h4 className="font-medium">{dialogue.name}</h4>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(dialogue.modifiedAt)} • {dialogue.nodeCount || 0} {t('dialogues.nodes')}
</p>
<DialogueContextMenu key={dialogue.id} dialogue={dialogue}>
<Link
to="/projects/$projectId/dialogue/$dialogueId"
params={{ projectId: project.id, dialogueId: dialogue.id }}
className="p-4 flex items-center justify-between group hover:bg-accent transition-colors cursor-pointer hover:rounded-lg rounded-lg"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center text-blue-500">
<MessageCircle className="h-5 w-5" />
</div>
<div>
<h4 className="font-medium">{dialogue.name}</h4>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(dialogue.modifiedAt)} • {dialogue.nodeCount || 0} {t('dialogues.nodes')}
</p>
</div>
</div>
</div>
<button className="opacity-0 group-hover:opacity-100 p-2 rounded-lg bg-card shadow-sm border border-border text-primary hover:bg-primary hover:text-primary-foreground transition-all">
<Edit3 className="h-4 w-4" />
</button>
</Link>
<button className="opacity-0 group-hover:opacity-100 p-2 rounded-lg bg-card shadow-sm border border-border text-primary hover:bg-primary hover:text-primary-foreground transition-all">
<Edit3 className="h-4 w-4" />
</button>
</Link>
</DialogueContextMenu>
))}
</div>
<div className="bg-muted/50 px-4 py-3 border-t border-border text-center">
Expand Down
44 changes: 44 additions & 0 deletions tests/e2e/dialogue-context-menu.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
import { seedLocalState, uniqueToken, openDashboard, createProject, openDialoguesSection, createDialogue } from './helpers/appHarness';

async function projectWithDialogue(page, dialogueName) {
await seedLocalState(page);
await openDashboard(page);
await createProject(page, uniqueToken('proj'));
await openDialoguesSection(page);
await createDialogue(page, dialogueName);
const projectHash = new URL(page.url()).hash.replace(/\/dialogue\/.*$/, '');
await page.goto(`/${projectHash}`);
await expect(page.getByRole('heading', { name: /dialogues/i }).first()).toBeVisible();
}

// The project overview lists recent dialogues; they offer the same right-click menu as the cards in the Dialogues tab.
test('a dialogue in the project overview can be deleted from its context menu', async ({ page }) => {
const name = uniqueToken('Dlg');
await projectWithDialogue(page, name);
const row = page.locator('a', { hasText: name });
await expect(row).toBeVisible();

await row.click({ button: 'right' });
await expect(page.getByRole('menuitem', { name: /export/i })).toBeVisible();

page.once('dialog', (dialog) => dialog.dismiss());
await page.getByRole('menuitem', { name: /delete/i }).click();
await expect(row).toBeVisible(); // cancelling the confirmation keeps it
await page.keyboard.press('Escape');

await row.click({ button: 'right' });
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('menuitem', { name: /delete/i }).click();
await expect(row).toHaveCount(0);
});

test('the Dialogues tab keeps its context menu', async ({ page }) => {
const name = uniqueToken('Dlg');
await projectWithDialogue(page, name);
await openDialoguesSection(page);
const card = page.locator('a', { hasText: name });
await card.click({ button: 'right' });
await expect(page.getByRole('menuitem', { name: /export/i })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /delete/i })).toBeVisible();
});
Loading