Skip to content

Commit b95c4cf

Browse files
authored
WEB-66 Add archive functionality to documents (#109)
2 parents d2e5a51 + c3c0541 commit b95c4cf

18 files changed

Lines changed: 500 additions & 160 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Document" ADD COLUMN "archivedAt" TIMESTAMP(3);

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ model Document {
1616
routes Route[]
1717
createdAt DateTime @default(now())
1818
updatedAt DateTime @updatedAt
19+
archivedAt DateTime?
1920
}
2021

2122
model Version {

src/app/editor/DocumentList.tsx

Lines changed: 186 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,106 @@
11
"use client";
22

3-
import Link from "next/link";
3+
import type { ReactNode } from "react";
44
import { useRouter } from "next/navigation";
5-
import { useTransition } from "react";
6-
import { FileText, Pencil } from "lucide-react";
7-
import { createDocumentAction, renameDocumentAction } from "../../lib/actions";
5+
import { useState, useTransition } from "react";
6+
import { Archive, ArchiveRestore, ChevronDown, FileText, Pencil } from "lucide-react";
7+
import {
8+
archiveDocumentAction,
9+
createDocumentAction,
10+
renameDocumentAction,
11+
unarchiveDocumentAction,
12+
} from "../../lib/actions";
813
import { runAction } from "./runAction";
914
import { getDocumentName } from "../../lib/documents";
10-
import { ResourceCard, NewResourceCard, ActionButton, formatRelativeTime } from "./ResourceCard";
15+
import { ResourceCard, NewResourceCard, formatRelativeTime } from "./ResourceCard";
16+
import { Button } from "@/components/ui/button";
1117
import { useDialogs } from "@/components/ui/dialog-provider";
18+
import {
19+
Collapsible,
20+
CollapsibleTrigger,
21+
CollapsibleContent,
22+
} from "@/components/ui/collapsible";
23+
24+
type DocumentItem = { id: number; name: string | null; lastModified: Date | null };
25+
26+
function DocumentCard({
27+
id,
28+
name,
29+
lastModified,
30+
actions,
31+
}: {
32+
id: number;
33+
name: string | null;
34+
lastModified: Date | null;
35+
actions?: ReactNode;
36+
}) {
37+
const displayName = getDocumentName({ id, name });
38+
39+
return (
40+
<ResourceCard
41+
preview={<FileText className="h-10 w-10 text-muted-foreground" />}
42+
name={displayName}
43+
date={lastModified ? formatRelativeTime(lastModified) : "No versions"}
44+
href={`/editor/${id}`}
45+
actions={actions}
46+
/>
47+
);
48+
}
49+
50+
function useDocumentActions() {
51+
const router = useRouter();
52+
const [isPending, startTransition] = useTransition();
53+
const { prompt, alert, confirm } = useDialogs();
54+
55+
async function handleRename(id: number, currentName: string) {
56+
const newName = await prompt({ title: `Rename "${currentName}"`, label: "New name", defaultValue: currentName });
57+
if (newName === null || newName.trim() === "" || newName.trim() === currentName) return;
58+
startTransition(async () => {
59+
const result = await runAction(renameDocumentAction({ id, name: newName.trim() }));
60+
if (result.success) {
61+
router.refresh();
62+
} else {
63+
await alert(result.error);
64+
}
65+
});
66+
}
67+
68+
async function handleArchive(id: number, displayName: string) {
69+
const confirmed = await confirm({ message: `Archive "${displayName}"?`, actionLabel: "Archive" });
70+
if (!confirmed) return;
71+
startTransition(async () => {
72+
const result = await runAction(archiveDocumentAction({ id }));
73+
if (result.success) {
74+
router.refresh();
75+
} else {
76+
await alert(result.error);
77+
}
78+
});
79+
}
80+
81+
async function handleUnarchive(id: number, displayName: string) {
82+
const confirmed = await confirm({ message: `Unarchive "${displayName}"?`, actionLabel: "Unarchive" });
83+
if (!confirmed) return;
84+
startTransition(async () => {
85+
const result = await runAction(unarchiveDocumentAction({ id }));
86+
if (result.success) {
87+
router.refresh();
88+
} else {
89+
await alert(result.error);
90+
}
91+
});
92+
}
93+
94+
return { isPending, handleRename, handleArchive, handleUnarchive };
95+
}
1296

1397
function NewDocumentCard() {
1498
const router = useRouter();
1599
const [isCreating, startTransition] = useTransition();
16100
const { prompt, alert } = useDialogs();
17101

18102
async function handleCreateDocument() {
19-
const name = await prompt({ title: "Document name" });
103+
const name = await prompt({ title: "Create document", label: "Name" });
20104

21105
if (name === null) {
22106
return;
@@ -52,63 +136,8 @@ function NewDocumentCard() {
52136
);
53137
}
54138

55-
function DocumentCard({
56-
id,
57-
name,
58-
lastModified,
59-
onRename,
60-
disabled,
61-
}: {
62-
id: number;
63-
name: string | null;
64-
lastModified: Date | null;
65-
onRename: (id: number, currentName: string) => void;
66-
disabled: boolean;
67-
}) {
68-
const displayName = getDocumentName({ id, name });
69-
70-
return (
71-
<Link href={`/editor/${id}`} className="block">
72-
<ResourceCard
73-
preview={<FileText className="h-10 w-10 text-gray-400" />}
74-
name={displayName}
75-
date={lastModified ? formatRelativeTime(lastModified) : "No versions"}
76-
actions={
77-
<ActionButton
78-
onClick={(e) => {
79-
e.preventDefault();
80-
onRename(id, displayName);
81-
}}
82-
disabled={disabled}
83-
title="Rename"
84-
>
85-
<Pencil className="h-3 w-3" />
86-
</ActionButton>
87-
}
88-
/>
89-
</Link>
90-
);
91-
}
92-
93-
export function DocumentList({ documents }: {
94-
documents: { id: number; name: string | null; lastModified: Date | null }[];
95-
}) {
96-
const router = useRouter();
97-
const [isPending, startTransition] = useTransition();
98-
const { prompt, alert } = useDialogs();
99-
100-
async function handleRename(id: number, currentName: string) {
101-
const newName = await prompt({ title: "Rename document", defaultValue: currentName });
102-
if (newName === null || newName.trim() === "" || newName.trim() === currentName) return;
103-
startTransition(async () => {
104-
const result = await runAction(renameDocumentAction({ id, name: newName.trim() }));
105-
if (result.success) {
106-
router.refresh();
107-
} else {
108-
await alert(result.error);
109-
}
110-
});
111-
}
139+
export function DocumentList({ documents }: { documents: DocumentItem[] }) {
140+
const { isPending, handleRename, handleArchive } = useDocumentActions();
112141

113142
return (
114143
<div className="flex flex-col gap-4">
@@ -117,17 +146,101 @@ export function DocumentList({ documents }: {
117146
<div className="flex flex-wrap gap-2">
118147
<NewDocumentCard />
119148

120-
{documents.map((doc) => (
121-
<DocumentCard
122-
key={doc.id}
123-
id={doc.id}
124-
name={doc.name}
125-
lastModified={doc.lastModified}
126-
onRename={handleRename}
127-
disabled={isPending}
128-
/>
129-
))}
149+
{documents.map((doc) => {
150+
const displayName = getDocumentName(doc);
151+
return (
152+
<DocumentCard
153+
key={doc.id}
154+
id={doc.id}
155+
name={doc.name}
156+
lastModified={doc.lastModified}
157+
actions={
158+
<>
159+
<Button
160+
variant="ghost"
161+
size="icon-xs"
162+
onClick={(e) => {
163+
e.preventDefault();
164+
e.stopPropagation();
165+
handleRename(doc.id, displayName);
166+
}}
167+
disabled={isPending}
168+
title="Rename"
169+
>
170+
<Pencil className="h-3 w-3" />
171+
</Button>
172+
<Button
173+
variant="ghost"
174+
size="icon-xs"
175+
onClick={(e) => {
176+
e.preventDefault();
177+
e.stopPropagation();
178+
handleArchive(doc.id, displayName);
179+
}}
180+
disabled={isPending}
181+
title="Archive"
182+
>
183+
<Archive className="h-3 w-3" />
184+
</Button>
185+
</>
186+
}
187+
/>
188+
);
189+
})}
130190
</div>
131191
</div>
132192
);
133193
}
194+
195+
export function ArchivedDocumentList({ documents }: { documents: DocumentItem[] }) {
196+
const [open, setOpen] = useState(false);
197+
const { isPending, handleUnarchive } = useDocumentActions();
198+
199+
if (documents.length === 0) return null;
200+
201+
return (
202+
<Collapsible open={open} onOpenChange={setOpen}>
203+
<div className="flex items-center gap-2">
204+
<h2 className="text-2xl font-bold leading-none">Archived</h2>
205+
<CollapsibleTrigger
206+
render={<Button variant="ghost" size="icon-xs" />}
207+
>
208+
<ChevronDown
209+
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
210+
/>
211+
</CollapsibleTrigger>
212+
</div>
213+
214+
<CollapsibleContent>
215+
<div className="mt-4 flex flex-wrap gap-2">
216+
{documents.map((doc) => {
217+
const displayName = getDocumentName(doc);
218+
return (
219+
<DocumentCard
220+
key={doc.id}
221+
id={doc.id}
222+
name={doc.name}
223+
lastModified={doc.lastModified}
224+
actions={
225+
<Button
226+
variant="ghost"
227+
size="icon-xs"
228+
onClick={(e) => {
229+
e.preventDefault();
230+
e.stopPropagation();
231+
handleUnarchive(doc.id, displayName);
232+
}}
233+
disabled={isPending}
234+
title="Unarchive"
235+
>
236+
<ArchiveRestore className="h-3 w-3" />
237+
</Button>
238+
}
239+
/>
240+
);
241+
})}
242+
</div>
243+
</CollapsibleContent>
244+
</Collapsible>
245+
);
246+
}

src/app/editor/MediaLibrary.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { toast } from "sonner";
66
import { uploadMediaAction, deleteMediaAction, renameMediaAction } from "../../lib/actions";
77
import type { Media } from "../../generated/prisma/client";
88
import { runAction } from "./runAction";
9-
import { ResourceCard, NewResourceCard, ActionButton, formatRelativeTime } from "./ResourceCard";
9+
import { ResourceCard, NewResourceCard, formatRelativeTime } from "./ResourceCard";
10+
import { Button } from "@/components/ui/button";
1011
import { useDialogs } from "@/components/ui/dialog-provider";
1112

1213
type MediaWithUrl = Media & { url: string };
@@ -72,22 +73,24 @@ function MediaCard({
7273
className="h-full w-full object-cover"
7374
/>
7475
) : (
75-
<FileIcon className="h-10 w-10 text-gray-400" />
76+
<FileIcon className="h-10 w-10 text-muted-foreground" />
7677
)
7778
}
7879
name={file.name}
7980
date={`${createdDate} \u00B7 ${formatFileSize(file.size)}`}
81+
href={file.url}
82+
external
8083
actions={
8184
<>
82-
<ActionButton onClick={() => onRename(file)} disabled={disabled} title="Rename">
85+
<Button variant="ghost" size="icon-xs" onClick={() => onRename(file)} disabled={disabled} title="Rename">
8386
<Pencil className="h-3 w-3" />
84-
</ActionButton>
85-
<ActionButton onClick={() => { navigator.clipboard.writeText(file.url); toast.success("URL copied"); }} title="Copy URL">
87+
</Button>
88+
<Button variant="ghost" size="icon-xs" onClick={() => { navigator.clipboard.writeText(file.url); toast.success("URL copied"); }} title="Copy URL">
8689
<Copy className="h-3 w-3" />
87-
</ActionButton>
88-
<ActionButton onClick={() => onDelete(file)} disabled={disabled} title="Delete" variant="danger">
90+
</Button>
91+
<Button variant="ghost" size="icon-xs" className="hover:text-destructive" onClick={() => onDelete(file)} disabled={disabled} title="Delete">
8992
<Trash2 className="h-3 w-3" />
90-
</ActionButton>
93+
</Button>
9194
</>
9295
}
9396
/>
@@ -113,7 +116,7 @@ export function MediaLibrary({ files: initialFiles }: { files: MediaWithUrl[] })
113116
}
114117

115118
async function handleRename(file: MediaWithUrl) {
116-
const newName = await prompt({ title: "Rename file", defaultValue: file.name });
119+
const newName = await prompt({ title: `Rename "${file.name}"`, label: "New name", defaultValue: file.name });
117120
if (newName === null || newName.trim() === "" || newName.trim() === file.name) return;
118121
startTransition(async () => {
119122
const result = await runAction(renameMediaAction({ id: file.id, name: newName.trim() }));
@@ -126,7 +129,7 @@ export function MediaLibrary({ files: initialFiles }: { files: MediaWithUrl[] })
126129
}
127130

128131
async function handleDelete(file: MediaWithUrl) {
129-
if (!await confirm({ message: `Delete "${file.name}"?`, actionLabel: "Delete" })) return;
132+
if (!await confirm({ message: `Delete "${file.name}"?`, actionLabel: "Delete", destructive: true })) return;
130133
startTransition(async () => {
131134
const result = await runAction(deleteMediaAction({ id: file.id }));
132135
if (result.success) {

0 commit comments

Comments
 (0)