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
37 changes: 34 additions & 3 deletions src/app/editor/DocumentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import type { ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Archive, ArchiveRestore, ChevronDown, FileText, Pencil } from "lucide-react";
import { Archive, ArchiveRestore, ChevronDown, CopyPlus, FileText, Pencil } from "lucide-react";
import {
archiveDocumentAction,
createDocumentAction,
duplicateDocumentAction,
renameDocumentAction,
unarchiveDocumentAction,
} from "../../lib/actions";
Expand Down Expand Up @@ -91,7 +92,24 @@ function useDocumentActions() {
});
}

return { isPending, handleRename, handleArchive, handleUnarchive };
async function handleDuplicate(id: number, displayName: string) {
const name = await prompt({
title: "Duplicate document",
label: "Name",
defaultValue: `Copy of ${displayName}`,
});
if (name === null || name.trim() === "") return;
startTransition(async () => {
const result = await runAction(duplicateDocumentAction({ id, name: name.trim() }));
if (result.success) {
router.refresh();
} else {
await alert(result.error);
}
});
}

return { isPending, handleRename, handleArchive, handleUnarchive, handleDuplicate };
}

function NewDocumentCard() {
Expand Down Expand Up @@ -137,7 +155,7 @@ function NewDocumentCard() {
}

export function DocumentList({ documents }: { documents: DocumentItem[] }) {
const { isPending, handleRename, handleArchive } = useDocumentActions();
const { isPending, handleRename, handleArchive, handleDuplicate } = useDocumentActions();

return (
<div className="flex flex-col gap-4">
Expand Down Expand Up @@ -169,6 +187,19 @@ export function DocumentList({ documents }: { documents: DocumentItem[] }) {
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicate(doc.id, displayName);
}}
disabled={isPending}
title="Duplicate"
>
<CopyPlus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon-xs"
Expand Down
75 changes: 50 additions & 25 deletions src/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
CreateRouteInput,
DeleteMediaInput,
DeleteRouteInput,
DuplicateDocumentInput,
RenameInput,
SaveVersionInput,
PublishVersionInput,
Expand All @@ -23,6 +24,14 @@ import type {
} from "./types";
import { wrapAction } from "./wrap-action";

function validateName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("Name cannot be empty");
}
return trimmed;
}

const ROUTE_SEGMENT_RE = /^[a-z0-9_-]+$/;

function assertValidRoutePath(path: string): void {
Expand Down Expand Up @@ -55,21 +64,21 @@ function assertValidRoutePath(path: string): void {
}

const segments = path.split("/").slice(1);
for (const segment of segments) {
if (!ROUTE_SEGMENT_RE.test(segment)) {
throw new Error(
"Each path segment may only contain lowercase letters, numbers, hyphens, and underscores"
);
}
const isValidSegement = (segment: string) => ROUTE_SEGMENT_RE.test(segment);
if (!segments.every(isValidSegement)) {
throw new Error(
"Each path segment may only contain lowercase letters, numbers, hyphens, and underscores"
);
}
}

export async function createDocumentAction(
input: CreateDocumentInput
): Promise<ActionResult<{ documentId: number }>> {
return wrapAction(async () => {
const name = validateName(input.name);
const content = input.content || { content: [], root: {} };
const document = await createDocumentWithVersion(input.name, content, false);
const document = await createDocumentWithVersion(name, content, false);
return { documentId: document.id };
});
}
Expand Down Expand Up @@ -109,21 +118,6 @@ export async function publishVersionAction(
});
}

async function renameRecord(
model: "document" | "media",
input: RenameInput
): Promise<ActionResult<void>> {
return wrapAction(async () => {
const name = input.name.trim();
if (!name) {
throw new Error("Name cannot be empty");
}
await (prisma[model] as typeof prisma.document).update({
where: { id: input.id },
data: { name },
});
});
}

export async function archiveDocumentAction(
input: ArchiveDocumentInput
Expand All @@ -147,13 +141,38 @@ export async function unarchiveDocumentAction(
});
}

export async function duplicateDocumentAction(
input: DuplicateDocumentInput
): Promise<ActionResult<{ documentId: number }>> {
return wrapAction(async () => {
await assertNotArchived(input.id);
const name = validateName(input.name);
const doc = await prisma.document.findUniqueOrThrow({
where: { id: input.id },
include: { publishedVersion: true },
});
if (!doc.publishedVersion) {
throw new Error("Document has no published version to duplicate");
}
const newDoc = await createDocumentWithVersion(
name,
doc.publishedVersion.content as any,
false
);
return { documentId: newDoc.id };
});
}

export async function renameDocumentAction(
input: RenameInput
): Promise<ActionResult<void>> {
return wrapAction(async () => {
await assertNotArchived(input.id);
const result = await renameRecord("document", input);
if (!result.success) throw new Error(result.error);
const name = validateName(input.name);
await prisma.document.update({
where: { id: input.id },
data: { name },
});
});
}

Expand Down Expand Up @@ -238,7 +257,13 @@ export async function uploadMediaAction(
export async function renameMediaAction(
input: RenameInput
): Promise<ActionResult<void>> {
return renameRecord("media", input);
return wrapAction(async () => {
const name = validateName(input.name);
await prisma.media.update({
where: { id: input.id },
data: { name },
});
});
}

export async function deleteMediaAction(
Expand Down
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@ export type DeleteMediaInput = {
export type ArchiveDocumentInput = {
id: number;
};

export type DuplicateDocumentInput = {
id: number;
name: string;
};
Loading