Skip to content

Commit 5101029

Browse files
committed
Add article recycle bin and secure team deletion
1 parent 487f704 commit 5101029

8 files changed

Lines changed: 446 additions & 47 deletions

File tree

‎app/admin/articles/[id]/page.tsx‎

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
doc,
88
getDoc,
99
updateDoc,
10-
deleteDoc,
1110
serverTimestamp,
1211
collection,
1312
getDocs,
@@ -41,6 +40,10 @@ import { convertImageToWebP } from "@/lib/image-utils";
4140
import { useDropzone } from "react-dropzone";
4241
import { useAutosave } from "@/hooks/use-autosave";
4342
import { format } from "date-fns";
43+
import {
44+
ARTICLE_TRASH_COLLECTION,
45+
moveArticleToTrash,
46+
} from "@/lib/article-trash";
4447

4548
export default function EditArticlePage() {
4649
const [title, setTitle] = useState("");
@@ -59,6 +62,7 @@ export default function EditArticlePage() {
5962
const [readTime, setReadTime] = useState("");
6063
const [loading, setLoading] = useState(true);
6164
const [saving, setSaving] = useState(false);
65+
const [deleting, setDeleting] = useState(false);
6266
const [error, setError] = useState<string | null>(null);
6367
const [previewHtml, setPreviewHtml] = useState("");
6468
const contentRef = useRef<HTMLTextAreaElement>(null);
@@ -253,11 +257,25 @@ export default function EditArticlePage() {
253257
throw new Error("Missing slug");
254258
}
255259

256-
// ponytail: this prevents normal CMS duplicates; use slug claim documents if concurrent writers become common.
257-
const slugMatches = await getDocs(
258-
query(collection(db, "articles"), where("slug", "==", normalizedSlug)),
259-
);
260-
if (slugMatches.docs.some((item) => item.id !== id)) {
260+
// ponytail: reserve trashed slugs too so restoring a post cannot create a duplicate.
261+
const [slugMatches, trashedSlugMatches] = await Promise.all([
262+
getDocs(
263+
query(
264+
collection(db, "articles"),
265+
where("slug", "==", normalizedSlug),
266+
),
267+
),
268+
getDocs(
269+
query(
270+
collection(db, ARTICLE_TRASH_COLLECTION),
271+
where("article.slug", "==", normalizedSlug),
272+
),
273+
),
274+
]);
275+
if (
276+
slugMatches.docs.some((item) => item.id !== id) ||
277+
!trashedSlugMatches.empty
278+
) {
261279
toast({
262280
title: "Slug already in use",
263281
description: "Choose a different slug before saving this article",
@@ -369,14 +387,14 @@ export default function EditArticlePage() {
369387
const handleManualSave = () => saveToFirestore(true);
370388

371389
const handleDelete = async () => {
390+
setDeleting(true);
372391
try {
373392
if (!id) return;
374-
const ref = doc(db, "articles", id);
375-
await deleteDoc(ref);
393+
await moveArticleToTrash(id);
376394

377395
toast({
378-
title: "Article deleted",
379-
description: "The article has been permanently removed",
396+
title: "Article moved",
397+
description: "The article is now in the recycle bin",
380398
variant: "success",
381399
});
382400

@@ -388,6 +406,8 @@ export default function EditArticlePage() {
388406
description: "Failed to delete article",
389407
variant: "destructive",
390408
});
409+
} finally {
410+
setDeleting(false);
391411
}
392412
};
393413

@@ -842,15 +862,15 @@ export default function EditArticlePage() {
842862
onClick={() => {
843863
if (
844864
window.confirm(
845-
"Are you sure you want to delete this article? This action cannot be undone.",
865+
"Move this article to the recycle bin? You can restore it later.",
846866
)
847867
) {
848868
handleDelete();
849869
}
850870
}}
851-
disabled={saving}
871+
disabled={saving || deleting}
852872
>
853-
Delete Article
873+
{deleting ? "Moving..." : "Delete Article"}
854874
</Button>
855875

856876
<Button

‎app/admin/articles/new/page.tsx‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useDropzone } from "react-dropzone";
3535
import { useAutosave } from "@/hooks/use-autosave";
3636
import { format } from "date-fns";
3737
import { useAuth } from "@/lib/auth-context";
38+
import { ARTICLE_TRASH_COLLECTION } from "@/lib/article-trash";
3839

3940
export default function NewArticlePage() {
4041
// Generate article ID on mount for asset uploads
@@ -316,11 +317,25 @@ export default function NewArticlePage() {
316317
throw new Error("Missing slug");
317318
}
318319

319-
// ponytail: this prevents normal CMS duplicates; use slug claim documents if concurrent writers become common.
320-
const slugMatches = await getDocs(
321-
query(collection(db, "articles"), where("slug", "==", normalizedSlug)),
322-
);
323-
if (slugMatches.docs.some((item) => item.id !== articleId)) {
320+
// ponytail: reserve trashed slugs too so restoring a post cannot create a duplicate.
321+
const [slugMatches, trashedSlugMatches] = await Promise.all([
322+
getDocs(
323+
query(
324+
collection(db, "articles"),
325+
where("slug", "==", normalizedSlug),
326+
),
327+
),
328+
getDocs(
329+
query(
330+
collection(db, ARTICLE_TRASH_COLLECTION),
331+
where("article.slug", "==", normalizedSlug),
332+
),
333+
),
334+
]);
335+
if (
336+
slugMatches.docs.some((item) => item.id !== articleId) ||
337+
!trashedSlugMatches.empty
338+
) {
324339
toast({
325340
title: "Slug already in use",
326341
description: "Choose a different slug before saving this article",

‎app/admin/articles/page.tsx‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import { formatDate } from "@/lib/utils";
2121
import {
2222
usePaginatedCollection,
2323
useUpdateDocument,
24-
useDeleteDocument,
2524
} from "@/hooks/use-firestore-query";
25+
import { moveArticleToTrash } from "@/lib/article-trash";
2626

2727
interface Article {
2828
id: string;
@@ -67,10 +67,12 @@ export default function ArticlesPage() {
6767
const [searchTerm, setSearchTerm] = useState("");
6868
const [sortField, setSortField] = useState<SortField | null>(null);
6969
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
70+
const [deletingArticleId, setDeletingArticleId] = useState<string | null>(
71+
null,
72+
);
7073

7174
const { toast } = useToast();
7275
const updateArticle = useUpdateDocument("articles");
73-
const deleteArticleMutation = useDeleteDocument("articles");
7476

7577
// Set up query constraints based on sort field and order
7678
const getConstraints = () => {
@@ -133,20 +135,21 @@ export default function ArticlesPage() {
133135
const confirmDelete = (articleId: string) => {
134136
if (
135137
window.confirm(
136-
"Are you sure you want to delete this article? This action cannot be undone."
138+
"Move this article to the recycle bin? You can restore it later."
137139
)
138140
) {
139141
deleteArticle(articleId);
140142
}
141143
};
142144

143145
const deleteArticle = async (articleId: string) => {
146+
setDeletingArticleId(articleId);
144147
try {
145-
await deleteArticleMutation.mutateAsync(articleId);
148+
await moveArticleToTrash(articleId);
146149

147150
toast({
148-
title: "Article deleted",
149-
description: "The article has been permanently removed",
151+
title: "Article moved",
152+
description: "The article is now in the recycle bin",
150153
variant: "success",
151154
});
152155

@@ -158,6 +161,8 @@ export default function ArticlesPage() {
158161
description: "Failed to delete article",
159162
variant: "destructive",
160163
});
164+
} finally {
165+
setDeletingArticleId(null);
161166
}
162167
};
163168

@@ -228,11 +233,18 @@ export default function ArticlesPage() {
228233
</div>
229234

230235
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-6">
231-
<Button asChild className="w-full sm:w-auto" variant="outline">
232-
<Link href="/admin/articles/new">
233-
<Plus className="mr-2 h-4 w-4" /> New Post
234-
</Link>
235-
</Button>
236+
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
237+
<Button asChild className="w-full sm:w-auto" variant="outline">
238+
<Link href="/admin/articles/new">
239+
<Plus className="mr-2 h-4 w-4" /> New Post
240+
</Link>
241+
</Button>
242+
<Button asChild className="w-full sm:w-auto" variant="outline">
243+
<Link href="/admin/articles/trash">
244+
<Trash2 className="mr-2 h-4 w-4" /> Recycle Bin
245+
</Link>
246+
</Button>
247+
</div>
236248

237249
{/* Search Bar */}
238250
<div className="relative w-full sm:w-64 md:w-80">
@@ -413,8 +425,8 @@ export default function ArticlesPage() {
413425
variant="ghost"
414426
className="text-red-500"
415427
onClick={() => confirmDelete(articleData.id)}
416-
title="Delete article"
417-
disabled={deleteArticleMutation.isPending}
428+
title="Move article to recycle bin"
429+
disabled={deletingArticleId !== null}
418430
>
419431
<Trash2 className="h-4 w-4" />
420432
</Button>
@@ -425,7 +437,7 @@ export default function ArticlesPage() {
425437
})}
426438
{filteredArticles.length === 0 && (
427439
<tr>
428-
<td colSpan={7} className="p-8 text-center text-white/50">
440+
<td colSpan={8} className="p-8 text-center text-white/50">
429441
{searchTerm
430442
? "No matching articles found."
431443
: "No articles found."}

0 commit comments

Comments
 (0)