mirror of
https://github.com/pdfme/pdfme.git
synced 2026-05-24 06:36:00 -04:00
feat(playground): add project rename duplicate save as (#1496)
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
import {
|
||||
deletePlaygroundProject,
|
||||
duplicatePlaygroundProject,
|
||||
getActivePlaygroundProject,
|
||||
readPlaygroundProjects,
|
||||
renamePlaygroundProject,
|
||||
savePlaygroundProject,
|
||||
setPlaygroundProjectThumbnail,
|
||||
} from '../src/lib/playgroundProjects';
|
||||
@@ -82,6 +84,51 @@ describe('playground project storage', () => {
|
||||
expect(getActivePlaygroundProject(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('renames and duplicates projects without changing the original content', () => {
|
||||
const storage = new MemoryStorage();
|
||||
const saved = savePlaygroundProject(
|
||||
{
|
||||
inputs: [{ name: 'Ada' }],
|
||||
kind: 'template',
|
||||
template,
|
||||
thumbnail: 'data:image/png;base64,abc',
|
||||
title: 'Original',
|
||||
},
|
||||
storage,
|
||||
);
|
||||
|
||||
const renamed = renamePlaygroundProject(saved.id, ' Renamed project ', storage);
|
||||
expect(renamed?.id).toBe(saved.id);
|
||||
expect(renamed?.title).toBe('Renamed project');
|
||||
expect(readPlaygroundProjects(storage)).toHaveLength(1);
|
||||
|
||||
const duplicated = duplicatePlaygroundProject(saved.id, ' Copy project ', storage);
|
||||
expect(duplicated?.id).not.toBe(saved.id);
|
||||
expect(duplicated?.title).toBe('Copy project');
|
||||
expect(duplicated?.template).toEqual(template);
|
||||
expect(duplicated?.inputs).toEqual([{ name: 'Ada' }]);
|
||||
expect(duplicated?.thumbnail).toBe('data:image/png;base64,abc');
|
||||
expect(getActivePlaygroundProject(storage)?.id).toBe(duplicated?.id);
|
||||
|
||||
const duplicateWithSameTitle = duplicatePlaygroundProject(saved.id, ' Copy project ', storage);
|
||||
expect(duplicateWithSameTitle?.title).toBe('Copy project 2');
|
||||
|
||||
const projects = readPlaygroundProjects(storage);
|
||||
expect(projects).toHaveLength(3);
|
||||
expect(projects.map((project) => project.title).sort()).toEqual([
|
||||
'Copy project',
|
||||
'Copy project 2',
|
||||
'Renamed project',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when project actions target a missing project', () => {
|
||||
const storage = new MemoryStorage();
|
||||
|
||||
expect(renamePlaygroundProject('missing', 'Nope', storage)).toBeNull();
|
||||
expect(duplicatePlaygroundProject('missing', 'Nope', storage)).toBeNull();
|
||||
});
|
||||
|
||||
it('migrates legacy localStorage template state into a project', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem('template', JSON.stringify(template));
|
||||
|
||||
@@ -16,17 +16,12 @@ export function NavBar({ items }: NavBarProps) {
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="mx-auto px-2">
|
||||
<div className="relative flex h-16 items-center justify-between">
|
||||
<div className="flex flex-1 items-center justify-start lg:items-stretch lg:justify-start">
|
||||
<div className="hidden lg:block">
|
||||
<div
|
||||
className={'grid gap-4 text-sm items-end justify-items-center'}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<div className="relative flex min-h-16 items-center justify-between py-2 lg:py-0">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-start lg:items-stretch lg:justify-start">
|
||||
<div className="hidden min-w-0 lg:block lg:w-full">
|
||||
<div className="flex min-w-0 items-end gap-5 overflow-x-auto text-sm">
|
||||
{items.map(({ label, content }, index) => (
|
||||
<div key={label || String(index)}>
|
||||
<div key={label || String(index)} className="shrink-0">
|
||||
<label className="block mb-1 font-medium text-gray-700">{label}</label>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type PlaygroundButtonVariant = 'danger' | 'ghost' | 'primary' | 'secondary';
|
||||
|
||||
@@ -18,15 +18,19 @@ const buttonVariants: Record<PlaygroundButtonVariant, string> = {
|
||||
const joinClassNames = (...classes: Array<string | false | null | undefined>) =>
|
||||
classes.filter(Boolean).join(' ');
|
||||
|
||||
export default function PlaygroundButton({
|
||||
className,
|
||||
fullWidth = false,
|
||||
type = 'button',
|
||||
variant = 'secondary',
|
||||
...props
|
||||
}: PlaygroundButtonProps) {
|
||||
const PlaygroundButton = forwardRef<HTMLButtonElement, PlaygroundButtonProps>(function PlaygroundButton(
|
||||
{
|
||||
className,
|
||||
fullWidth = false,
|
||||
type = 'button',
|
||||
variant = 'secondary',
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={joinClassNames(
|
||||
'inline-flex min-w-0 items-center justify-center gap-1 whitespace-nowrap rounded border px-2 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:px-3',
|
||||
@@ -37,4 +41,6 @@ export default function PlaygroundButton({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default PlaygroundButton;
|
||||
|
||||
@@ -54,6 +54,17 @@ const normalizeTitle = (title: string, fallback: string) => {
|
||||
return normalized || fallback;
|
||||
};
|
||||
|
||||
const createUniqueProjectTitle = (title: string, projects: PlaygroundProject[]) => {
|
||||
const normalizedTitle = normalizeTitle(title, 'Untitled Project');
|
||||
const existingTitles = new Set(projects.map((project) => project.title));
|
||||
if (!existingTitles.has(normalizedTitle)) return normalizedTitle;
|
||||
|
||||
for (let index = 2; ; index += 1) {
|
||||
const candidate = `${normalizedTitle} ${index}`;
|
||||
if (!existingTitles.has(candidate)) return candidate;
|
||||
}
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
@@ -212,6 +223,55 @@ export const deletePlaygroundProject = (projectId: string, storage = getStorage(
|
||||
}
|
||||
};
|
||||
|
||||
export const renamePlaygroundProject = (
|
||||
projectId: string,
|
||||
title: string,
|
||||
storage = getStorage(),
|
||||
) => {
|
||||
const projects = readPlaygroundProjects(storage);
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
if (!project) return null;
|
||||
|
||||
const updatedProject: PlaygroundProject = {
|
||||
...project,
|
||||
title: normalizeTitle(title, project.title),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
writePlaygroundProjects(
|
||||
projects
|
||||
.map((item) => (item.id === projectId ? updatedProject : item))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
storage,
|
||||
);
|
||||
return updatedProject;
|
||||
};
|
||||
|
||||
export const duplicatePlaygroundProject = (
|
||||
projectId: string,
|
||||
title?: string,
|
||||
storage = getStorage(),
|
||||
) => {
|
||||
const projects = readPlaygroundProjects(storage);
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
if (!project) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const duplicatedProject: PlaygroundProject = {
|
||||
...project,
|
||||
createdAt: now,
|
||||
id: createProjectId(),
|
||||
title: createUniqueProjectTitle(title ?? `${project.title} Copy`, projects),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
writePlaygroundProjects(
|
||||
[duplicatedProject, ...projects].sort((a, b) => b.updatedAt - a.updatedAt),
|
||||
storage,
|
||||
);
|
||||
setActivePlaygroundProjectId(duplicatedProject.id, storage);
|
||||
return duplicatedProject;
|
||||
};
|
||||
|
||||
export const setPlaygroundProjectThumbnail = (
|
||||
projectId: string,
|
||||
thumbnail: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Code2, Download, Save } from 'lucide-react';
|
||||
import { Code2, Copy, Download, Save } from 'lucide-react';
|
||||
import { cloneDeep, Template, checkTemplate, Lang, isBlankPdf } from '@pdfme/common';
|
||||
import { Designer } from '@pdfme/ui';
|
||||
import {
|
||||
@@ -116,15 +116,16 @@ function DesignerApp() {
|
||||
}, []);
|
||||
|
||||
const onSaveTemplate = useCallback(
|
||||
async (template?: Template) => {
|
||||
async (template?: Template, saveAs = false) => {
|
||||
if (!designer.current) return;
|
||||
|
||||
const currentProject = projectRef.current;
|
||||
const nextTemplate = template || designer.current.getTemplate();
|
||||
const title =
|
||||
currentProject?.title ??
|
||||
window.prompt('Project name', projectTitleRef.current || 'Untitled Template') ??
|
||||
'';
|
||||
const currentTitle =
|
||||
(currentProject?.title ?? projectTitleRef.current) || 'Untitled Template';
|
||||
const title = saveAs
|
||||
? window.prompt('Save as', `${currentTitle} Copy`) ?? ''
|
||||
: currentProject?.title ?? window.prompt('Project name', currentTitle) ?? '';
|
||||
if (!title.trim()) return;
|
||||
|
||||
const thumbnail = await createTemplateThumbnailDataUrl(
|
||||
@@ -132,7 +133,7 @@ function DesignerApp() {
|
||||
currentProject?.inputs,
|
||||
).catch(() => currentProject?.thumbnail);
|
||||
const savedProject = savePlaygroundProject({
|
||||
id: currentProject?.id,
|
||||
id: saveAs ? undefined : currentProject?.id,
|
||||
inputs: currentProject?.inputs,
|
||||
kind: currentProject?.kind ?? 'template',
|
||||
source: currentProject?.source,
|
||||
@@ -405,6 +406,14 @@ function DesignerApp() {
|
||||
<Save className="size-3.5" />
|
||||
Save Project
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
id="save-as"
|
||||
disabled={editingStaticSchemas}
|
||||
onClick={() => void onSaveTemplate(undefined, true)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Save As
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
id="reset-template"
|
||||
disabled={editingStaticSchemas}
|
||||
|
||||
@@ -160,23 +160,23 @@ function FormAndViewerApp() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveInputs = async () => {
|
||||
const onSaveInputs = async (saveAs = false) => {
|
||||
if (!ui.current) return;
|
||||
|
||||
const currentProject = projectRef.current;
|
||||
const nextInputs = ui.current.getInputs();
|
||||
const nextTemplate = ui.current.getTemplate();
|
||||
const title =
|
||||
currentProject?.title ??
|
||||
window.prompt('Project name', projectTitle || 'Untitled Template') ??
|
||||
'';
|
||||
const currentTitle = (currentProject?.title ?? projectTitle) || 'Untitled Template';
|
||||
const title = saveAs
|
||||
? window.prompt('Save as', `${currentTitle} Copy`) ?? ''
|
||||
: currentProject?.title ?? window.prompt('Project name', currentTitle) ?? '';
|
||||
if (!title.trim()) return;
|
||||
|
||||
const thumbnail = await createTemplateThumbnailDataUrl(nextTemplate, nextInputs).catch(
|
||||
() => currentProject?.thumbnail,
|
||||
);
|
||||
const savedProject = savePlaygroundProject({
|
||||
id: currentProject?.id,
|
||||
id: saveAs ? undefined : currentProject?.id,
|
||||
inputs: nextInputs,
|
||||
kind: currentProject?.kind ?? 'template',
|
||||
source: currentProject?.source,
|
||||
@@ -251,6 +251,7 @@ function FormAndViewerApp() {
|
||||
<PlaygroundButton onClick={() => void onSaveInputs()}>
|
||||
Save
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton onClick={() => void onSaveInputs(true)}>Save As</PlaygroundButton>
|
||||
<PlaygroundButton onClick={onResetInputs}>Reset</PlaygroundButton>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { checkTemplate, type Template } from '@pdfme/common';
|
||||
import type { RenderResult } from '@pdfme/jsx';
|
||||
import { Viewer } from '@pdfme/ui';
|
||||
import { Download, ExternalLink, PencilRuler, Save } from 'lucide-react';
|
||||
import { Copy, Download, ExternalLink, PencilRuler, Save } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import CodeEditor from '../components/CodeEditor';
|
||||
import PlaygroundButton from '../components/PlaygroundButton';
|
||||
@@ -334,13 +334,15 @@ export default function JsxPlayground() {
|
||||
downloadJsonFile(template, 'jsx-template');
|
||||
};
|
||||
|
||||
const saveCurrentProject = async (title?: string) => {
|
||||
const saveCurrentProject = async (title?: string, saveAs = false) => {
|
||||
if (!template) return null;
|
||||
|
||||
const currentTitle = projectRef.current?.title ?? `JSX - ${sourceTitle}`;
|
||||
const projectTitle =
|
||||
title ??
|
||||
projectRef.current?.title ??
|
||||
window.prompt('Project name', `JSX - ${sourceTitle}`) ??
|
||||
(saveAs
|
||||
? window.prompt('Save as', `${currentTitle} Copy`)
|
||||
: projectRef.current?.title ?? window.prompt('Project name', currentTitle)) ??
|
||||
'';
|
||||
if (!projectTitle.trim()) return null;
|
||||
|
||||
@@ -348,7 +350,7 @@ export default function JsxPlayground() {
|
||||
() => projectRef.current?.thumbnail,
|
||||
);
|
||||
const savedProject = savePlaygroundProject({
|
||||
id: projectRef.current?.id,
|
||||
id: saveAs ? undefined : projectRef.current?.id,
|
||||
inputs: inputsRef.current,
|
||||
kind: 'jsx',
|
||||
source: {
|
||||
@@ -379,6 +381,16 @@ export default function JsxPlayground() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveAsProject = async () => {
|
||||
try {
|
||||
checkTemplate(template);
|
||||
const savedProject = await saveCurrentProject(undefined, true);
|
||||
if (savedProject) toast.success(<ProjectSavedToast title={savedProject.title} />);
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenDesigner = async () => {
|
||||
if (!template) return;
|
||||
|
||||
@@ -435,6 +447,13 @@ export default function JsxPlayground() {
|
||||
<Save className="size-4" />
|
||||
Save Project
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
disabled={!template || Boolean(error)}
|
||||
onClick={() => void onSaveAsProject()}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Save As
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
disabled={!template || Boolean(error)}
|
||||
onClick={() => void onOpenDesigner()}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import type { Template } from '@pdfme/common';
|
||||
import { md2pdf } from '@pdfme/converter/md2pdf';
|
||||
import { Viewer } from '@pdfme/ui';
|
||||
import { ExternalLink, Save } from 'lucide-react';
|
||||
import { Copy, ExternalLink, Save } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { generatePDF, getFontsData } from '../helper';
|
||||
import { getPlugins } from '../plugins';
|
||||
@@ -173,11 +173,13 @@ export default function Md2Pdf() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSaveProject = async () => {
|
||||
const onSaveProject = async (saveAs = false) => {
|
||||
if (!template) return;
|
||||
|
||||
const title =
|
||||
projectRef.current?.title ?? window.prompt('Project name', `md2pdf - ${sourceTitle}`) ?? '';
|
||||
const currentTitle = projectRef.current?.title ?? `md2pdf - ${sourceTitle}`;
|
||||
const title = saveAs
|
||||
? window.prompt('Save as', `${currentTitle} Copy`) ?? ''
|
||||
: projectRef.current?.title ?? window.prompt('Project name', currentTitle) ?? '';
|
||||
if (!title.trim()) return;
|
||||
|
||||
try {
|
||||
@@ -185,7 +187,7 @@ export default function Md2Pdf() {
|
||||
() => projectRef.current?.thumbnail,
|
||||
);
|
||||
const savedProject = savePlaygroundProject({
|
||||
id: projectRef.current?.id,
|
||||
id: saveAs ? undefined : projectRef.current?.id,
|
||||
inputs,
|
||||
kind: 'md2pdf',
|
||||
source: {
|
||||
@@ -241,6 +243,13 @@ export default function Md2Pdf() {
|
||||
<Save className="size-4" />
|
||||
Save Project
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
disabled={!template || Boolean(error)}
|
||||
onClick={() => void onSaveProject(true)}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Save As
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
disabled={!template || Boolean(error) || isGeneratingPdf}
|
||||
onClick={onGeneratePdf}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { checkTemplate, getInputFromTemplate, type Template } from '@pdfme/common';
|
||||
import {
|
||||
Code2,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
PencilRuler,
|
||||
Trash2,
|
||||
Upload,
|
||||
@@ -17,9 +20,11 @@ import { jsxPlaygroundPresets } from './jsxPlaygroundExamples';
|
||||
import { md2PdfPresets } from './md2PdfPresets';
|
||||
import {
|
||||
deletePlaygroundProject,
|
||||
duplicatePlaygroundProject,
|
||||
getProjectAuthoringPath,
|
||||
getProjectKindLabel,
|
||||
readPlaygroundProjects,
|
||||
renamePlaygroundProject,
|
||||
savePlaygroundProject,
|
||||
setActivePlaygroundProjectId,
|
||||
setPlaygroundProjectThumbnail,
|
||||
@@ -190,7 +195,7 @@ const GalleryCard = ({
|
||||
thumbnail: React.ReactNode;
|
||||
title: string;
|
||||
}) => (
|
||||
<div className="relative flex h-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="relative flex h-full flex-col rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="relative overflow-hidden rounded border border-gray-100 bg-gray-100">
|
||||
{thumbnail}
|
||||
<span className="absolute left-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-green-700 shadow-sm">
|
||||
@@ -217,6 +222,132 @@ const GalleryCard = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
type ProjectActionHandler = (project: PlaygroundProject) => void;
|
||||
|
||||
type ProjectMoreActionsProps = {
|
||||
onDeleteProject: ProjectActionHandler;
|
||||
onDownloadProjectTemplate: ProjectActionHandler;
|
||||
onDuplicateProject: ProjectActionHandler;
|
||||
onOpenDesigner: ProjectActionHandler;
|
||||
onRenameProject: ProjectActionHandler;
|
||||
project: PlaygroundProject;
|
||||
};
|
||||
|
||||
const ProjectMenuItem = ({
|
||||
buttonRef,
|
||||
children,
|
||||
danger = false,
|
||||
onClick,
|
||||
}: {
|
||||
buttonRef?: React.Ref<HTMLButtonElement>;
|
||||
children: React.ReactNode;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition ${
|
||||
danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
function ProjectMoreActions({
|
||||
onDeleteProject,
|
||||
onDownloadProjectTemplate,
|
||||
onDuplicateProject,
|
||||
onOpenDesigner,
|
||||
onRenameProject,
|
||||
project,
|
||||
}: ProjectMoreActionsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const firstMenuItemRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const runAction = (handler: ProjectActionHandler) => {
|
||||
setOpen(false);
|
||||
handler(project);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
firstMenuItemRef.current?.focus();
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<PlaygroundButton
|
||||
ref={triggerRef}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`More actions for ${project.title}`}
|
||||
className="px-2 sm:px-2"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</PlaygroundButton>
|
||||
{open && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close project actions"
|
||||
className="fixed inset-0 z-10 cursor-default"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-20 mt-2 w-52 overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg"
|
||||
>
|
||||
{project.source && (
|
||||
<ProjectMenuItem
|
||||
buttonRef={firstMenuItemRef}
|
||||
onClick={() => runAction(onOpenDesigner)}
|
||||
>
|
||||
<PencilRuler className="size-4" />
|
||||
Designer
|
||||
</ProjectMenuItem>
|
||||
)}
|
||||
<ProjectMenuItem
|
||||
buttonRef={project.source ? undefined : firstMenuItemRef}
|
||||
onClick={() => runAction(onRenameProject)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem onClick={() => runAction(onDuplicateProject)}>
|
||||
<Copy className="size-4" />
|
||||
Duplicate
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem onClick={() => runAction(onDownloadProjectTemplate)}>
|
||||
<Download className="size-4" />
|
||||
Template JSON
|
||||
</ProjectMenuItem>
|
||||
<ProjectMenuItem danger onClick={() => runAction(onDeleteProject)}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</ProjectMenuItem>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Author link component to avoid duplication
|
||||
const AuthorLink = ({ author }: { author: string }) => {
|
||||
if (author === DEVIN_AI_AUTHOR) {
|
||||
@@ -393,6 +524,34 @@ function TemplatesApp() {
|
||||
toast.info(`Deleted "${project.title}"`);
|
||||
};
|
||||
|
||||
const onRenameProject = (project: PlaygroundProject) => {
|
||||
const title = window.prompt('Project name', project.title) ?? '';
|
||||
if (!title.trim()) return;
|
||||
|
||||
const renamedProject = renamePlaygroundProject(project.id, title);
|
||||
if (!renamedProject) {
|
||||
toast.error('Project not found');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProjects();
|
||||
toast.success(`Renamed to "${renamedProject.title}"`);
|
||||
};
|
||||
|
||||
const onDuplicateProject = (project: PlaygroundProject) => {
|
||||
const title = window.prompt('Duplicate as', `${project.title} Copy`) ?? '';
|
||||
if (!title.trim()) return;
|
||||
|
||||
const duplicatedProject = duplicatePlaygroundProject(project.id, title);
|
||||
if (!duplicatedProject) {
|
||||
toast.error('Project not found');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProjects();
|
||||
toast.success(`Duplicated "${duplicatedProject.title}"`);
|
||||
};
|
||||
|
||||
const onDownloadProjectTemplate = (project: PlaygroundProject) => {
|
||||
const fileName = project.title.trim().replace(/[\\/:*?"<>|]+/g, '-') || 'template';
|
||||
downloadJsonFile(project.template, fileName);
|
||||
@@ -447,33 +606,36 @@ function TemplatesApp() {
|
||||
<ProjectThumbnailImage project={project} onCreated={refreshProjects} />
|
||||
}
|
||||
actions={
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{project.source && (
|
||||
<PlaygroundButton onClick={() => navigateToProject(project, 'source')}>
|
||||
<Code2 className="size-4" />
|
||||
Source
|
||||
</PlaygroundButton>
|
||||
)}
|
||||
<PlaygroundButton onClick={() => navigateToProject(project, 'designer')}>
|
||||
<PencilRuler className="size-4" />
|
||||
Designer
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<PlaygroundButton
|
||||
onClick={() =>
|
||||
navigateToProject(project, project.source ? 'source' : 'designer')
|
||||
}
|
||||
>
|
||||
{project.source ? (
|
||||
<>
|
||||
<Code2 className="size-4" />
|
||||
Source
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PencilRuler className="size-4" />
|
||||
Designer
|
||||
</>
|
||||
)}
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton onClick={() => navigateToProject(project, 'form-viewer')}>
|
||||
<Eye className="size-4" />
|
||||
Form/Viewer
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton onClick={() => onDownloadProjectTemplate(project)}>
|
||||
<Download className="size-4" />
|
||||
Template JSON
|
||||
</PlaygroundButton>
|
||||
<PlaygroundButton
|
||||
onClick={() => onDeleteProject(project)}
|
||||
variant="danger"
|
||||
aria-label={`Delete ${project.title}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
Preview
|
||||
</PlaygroundButton>
|
||||
<ProjectMoreActions
|
||||
project={project}
|
||||
onOpenDesigner={(item) => navigateToProject(item, 'designer')}
|
||||
onRenameProject={onRenameProject}
|
||||
onDuplicateProject={onDuplicateProject}
|
||||
onDownloadProjectTemplate={onDownloadProjectTemplate}
|
||||
onDeleteProject={onDeleteProject}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user