My personal site. theclashfruit.me
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: creating & editing posts

+375 -56
+72
app/(admin)/admin/posts/edit/page.tsx
··· 1 + import type { Metadata } from 'next'; 2 + 3 + import { createHash } from 'node:crypto'; 4 + 5 + import styles from '@/styles/pages/Admin.module.scss'; 6 + import NewPostForm from '@/components/admin/NewPostForm'; 7 + 8 + import { db } from '@/lib/db/drizzle'; 9 + import { commentsTable, postsTable, usersTable } from '@/lib/db/schema'; 10 + 11 + import { eq } from 'drizzle-orm'; 12 + import { notFound, redirect, RedirectType } from 'next/navigation'; 13 + 14 + export const metadata: Metadata = { 15 + title: 'Admin > Posts > New' 16 + }; 17 + 18 + export default async function NewPost({ 19 + searchParams 20 + }: { 21 + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 22 + }) { 23 + const params = await searchParams; 24 + 25 + if (params.action === 'edit') { 26 + const action = (params!.sub ?? params!.action) as string as 'edit' | 'new'; 27 + const post = await getPost(BigInt(params!.post as string)); 28 + 29 + return ( 30 + <> 31 + <h1>{action === 'new' ? 'Create' : 'Edit'} Post</h1> 32 + 33 + <NewPostForm 34 + initialData={post} 35 + action={action} 36 + /> 37 + </> 38 + ); 39 + } else if (params.action === 'new') { 40 + const post = await createPost(); 41 + redirect(`?action=edit&sub=new&post=${post.id}`, RedirectType.replace); 42 + } else { 43 + notFound(); 44 + } 45 + } 46 + 47 + const getPost = async (id: bigint) => { 48 + const post = await db 49 + .select() 50 + .from(postsTable) 51 + .where(eq(postsTable.id, id)) 52 + .limit(1); 53 + 54 + return post[0]; 55 + }; 56 + 57 + const createPost = async () => { 58 + const rnd = createHash('sha1') 59 + .update(new Date().toISOString()) 60 + .digest('hex') 61 + .substring(0, 6); 62 + 63 + const newPost: typeof postsTable.$inferInsert = { 64 + title: `New Post ${rnd}`, 65 + slug: `new-post-${rnd}`, 66 + excerpt: 'Hello, World!', 67 + content: '# Hello, World!' 68 + }; 69 + 70 + const insert = await db.insert(postsTable).values(newPost).returning(); 71 + return insert[0]; 72 + };
+6 -37
app/(admin)/admin/posts/page.tsx
··· 3 3 4 4 import type { Metadata } from 'next'; 5 5 6 - import styles from '@/styles/pages/Admin.module.scss' 6 + import Link from 'next/link'; 7 + 8 + import PostsTable from '@/components/admin/PostsTable'; 7 9 8 10 export const metadata: Metadata = { 9 11 title: 'Admin > Posts' ··· 18 20 return ( 19 21 <> 20 22 <p>Admin {'>'} Posts</p> 23 + 24 + <Link href={'/admin/posts/edit?action=new'}>New Post</Link> 21 25 22 - <div className={styles.tableWrapper}> 23 - <table> 24 - <thead> 25 - <tr> 26 - <th scope="col"></th> 27 - <th scope="col">Slug</th> 28 - <th scope="col">Title</th> 29 - <th scope="col">Excerpt</th> 30 - <th scope="col">Draft</th> 31 - <th scope="col">Published</th> 32 - <th scope="col">Updated</th> 33 - <th scope="col">Actions</th> 34 - </tr> 35 - </thead> 36 - <tbody> 37 - {posts.map((p) => ( 38 - <tr key={p.id}> 39 - <td>{p.id}</td> 40 - <td>{p.slug}</td> 41 - <td>{p.title}</td> 42 - <td>{p.excerpt}</td> 43 - <td> 44 - <input type="checkbox" disabled checked={p.draft} /> 45 - </td> 46 - <td>{p.publishedAt.toLocaleString('hu-HU')}</td> 47 - <td>{p.updatedAt.toLocaleString('hu-HU')}</td> 48 - <td> 49 - <button>Edit</button> 50 - <button>Publish</button> 51 - <button>Delete</button> 52 - </td> 53 - </tr> 54 - ))} 55 - </tbody> 56 - </table> 57 - </div> 26 + <PostsTable posts={posts} /> 58 27 </> 59 28 ); 60 29 }
+3 -3
app/(admin)/layout.tsx
··· 10 10 ImageIcon 11 11 } from 'lucide-react'; 12 12 13 - import styles from '@/styles/layout/Main.module.scss'; 13 + import '@/styles/layout/Admin.module.scss'; 14 14 15 - export default function MarketLayout({ 15 + export default function AdminLayout({ 16 16 children 17 17 }: Readonly<{ 18 18 children: React.ReactNode; 19 19 }>) { 20 20 return ( 21 21 <> 22 - <aside className={styles.sideBar}> 22 + <aside> 23 23 <NavBar 24 24 items={[ 25 25 {
app/(auth)/login/page.tsx

This is a binary file and will not be displayed.

+2
app/(main)/blog/page.tsx
··· 4 4 import Link from 'next/link'; 5 5 6 6 import type { Metadata } from 'next'; 7 + import { not } from 'drizzle-orm'; 7 8 8 9 export const dynamic = 'force-dynamic'; 9 10 ··· 15 16 const posts = await db 16 17 .select() 17 18 .from(postsTable) 19 + .where(not(postsTable.draft)) 18 20 .orderBy(postsTable.publishedAt); 19 21 20 22 return (
+145
components/admin/NewPostForm.tsx
··· 1 + 'use client'; 2 + 3 + import type { InferSelectModel } from 'drizzle-orm'; 4 + import { postsTable } from '@/lib/db/schema'; 5 + 6 + import { Undo2 } from 'lucide-react'; 7 + import { useState } from 'react'; 8 + 9 + import Form from 'next/form'; 10 + 11 + import { editPostAction } from '@/lib/actions'; 12 + 13 + export default function NewPostForm({ 14 + initialData, 15 + action 16 + }: { 17 + initialData: InferSelectModel<typeof postsTable>; 18 + action: 'new' | 'edit'; 19 + }) { 20 + const [title, setTitle] = useState(initialData.title); 21 + const [excerpt, setExcerpt] = useState(initialData.excerpt); 22 + 23 + const [slug, setSlug] = useState(initialData.slug); 24 + const [slugChanged, setSlugChanged] = useState(false); 25 + 26 + const [content, setContent] = useState(initialData.content); 27 + 28 + return ( 29 + <> 30 + <Form 31 + action={editPostAction} 32 + style={{ 33 + display: 'flex', 34 + flexDirection: 'column', 35 + gap: '8px' 36 + }} 37 + > 38 + <input type="hidden" name="id" value={initialData.id.toString()} /> 39 + 40 + <div 41 + style={{ 42 + display: 'flex', 43 + gap: '8px' 44 + }} 45 + > 46 + <input 47 + placeholder="Title" 48 + name="title" 49 + value={title} 50 + onChange={(e) => { 51 + setTitle(e.target.value); 52 + 53 + if (!slugChanged) { 54 + setSlug( 55 + e.target.value 56 + .replace(/[^\w\s]/gi, '') 57 + .replaceAll(' ', '-') 58 + .toLocaleLowerCase() 59 + ); 60 + } 61 + }} 62 + /> 63 + 64 + <div 65 + style={{ 66 + display: 'flex', 67 + gap: '4px' 68 + }} 69 + > 70 + <input 71 + placeholder="Slug" 72 + name="slug" 73 + value={slug} 74 + onChange={(e) => { 75 + setSlug(e.target.value); 76 + setSlugChanged(true); // This stops the first input from overwriting the slug 77 + }} 78 + /> 79 + 80 + <button 81 + type="button" 82 + disabled={!slugChanged} 83 + onClick={() => { 84 + setSlugChanged(false); 85 + setSlug( 86 + title 87 + .replace(/[^\w\s]/gi, '') 88 + .replaceAll(' ', '-') 89 + .toLocaleLowerCase() 90 + ); 91 + }} 92 + > 93 + <Undo2 /> 94 + </button> 95 + </div> 96 + </div> 97 + 98 + <input 99 + placeholder="Excerpt" 100 + name="excerpt" 101 + value={excerpt} 102 + onChange={(e) => { 103 + setExcerpt(e.target.value); 104 + }} 105 + /> 106 + 107 + <textarea 108 + placeholder="MDX Content" 109 + name="content" 110 + rows={12} 111 + value={content} 112 + onChange={(e) => { 113 + setContent(e.target.value); 114 + }} 115 + style={{ 116 + fontFamily: 'var(--fontMonaspaceRadon), monospace', 117 + maxWidth: '100%' 118 + }} 119 + /> 120 + 121 + <div> 122 + <div> 123 + <h2>Images</h2> 124 + 125 + <button type="button">Upload</button> 126 + </div> 127 + 128 + <ul> 129 + <li> 130 + <label>image.jpeg</label> 131 + 132 + <button type="button">Insert</button> 133 + <button type="button">Delete</button> 134 + </li> 135 + </ul> 136 + </div> 137 + 138 + <button type="submit"> 139 + {action === 'new' ? 'Create' : 'Edit'}{' '} 140 + {initialData.draft ? 'Draft' : 'Post'} 141 + </button> 142 + </Form> 143 + </> 144 + ); 145 + }
+70
components/admin/PostsTable.tsx
··· 1 + 'use client'; 2 + 3 + import { postsTable } from '@/lib/db/schema'; 4 + import { InferSelectModel } from 'drizzle-orm'; 5 + 6 + import styles from '@/styles/pages/Admin.module.scss'; 7 + import Link from 'next/link'; 8 + import { SquarePen, Trash, Undo, Undo2, Upload } from 'lucide-react'; 9 + import { deletePostAction, publishPostAction, unPublishPostAction } from '@/lib/actions'; 10 + 11 + export default function PostsTable({ 12 + posts 13 + }: { 14 + posts: InferSelectModel<typeof postsTable>[]; 15 + }) { 16 + return ( 17 + <div className={styles.tableWrapper}> 18 + <table> 19 + <thead> 20 + <tr> 21 + <th scope="col">Slug</th> 22 + <th scope="col">Title</th> 23 + <th scope="col">Draft</th> 24 + <th scope="col">Published</th> 25 + <th scope="col">Updated</th> 26 + <th scope="col">Actions</th> 27 + </tr> 28 + </thead> 29 + <tbody> 30 + {posts.map((p) => ( 31 + <tr key={p.id}> 32 + <td> 33 + <code>{p.slug}</code> 34 + </td> 35 + <td>{p.title}</td> 36 + <td> 37 + <input type="checkbox" disabled checked={p.draft} /> 38 + </td> 39 + <td>{p.publishedAt.toLocaleString('en-GB')}</td> 40 + <td>{p.updatedAt.toLocaleString('en-GB')}</td> 41 + <td> 42 + <Link href={`/admin/posts/edit?action=edit&post=${p.id}`}> 43 + <SquarePen /> 44 + </Link> 45 + {p.draft ? ( 46 + <button 47 + onClick={() => publishPostAction(p.id)} 48 + title="Publish" 49 + > 50 + <Upload /> 51 + </button> 52 + ) : ( 53 + <button 54 + onClick={() => unPublishPostAction(p.id)} 55 + title="Unpublish" 56 + > 57 + <Undo2 /> 58 + </button> 59 + )} 60 + <button onClick={() => deletePostAction(p.id)}> 61 + <Trash /> 62 + </button> 63 + </td> 64 + </tr> 65 + ))} 66 + </tbody> 67 + </table> 68 + </div> 69 + ); 70 + }
+43 -1
lib/actions.ts
··· 1 1 'use server'; 2 2 3 3 import { db } from '@/lib/db/drizzle'; 4 - import { commentsTable } from './db/schema'; 4 + import { commentsTable, postsTable } from './db/schema'; 5 + import { refresh } from 'next/cache'; 6 + import { eq } from 'drizzle-orm'; 5 7 6 8 export const createCommentAction = async (formData: FormData) => { 7 9 const reply = formData.get('reply') ?? false; ··· 27 29 }); 28 30 } 29 31 }; 32 + 33 + export const editPostAction = async (formData: FormData) => { 34 + const id = BigInt(formData.get('id')!.toString()); 35 + 36 + const data = { 37 + title: formData.get('title')!.toString(), 38 + slug: formData.get('slug')!.toString(), 39 + excerpt: formData.get('excerpt')!.toString(), 40 + content: formData.get('content')!.toString() 41 + }; 42 + 43 + await db.update(postsTable).set(data).where(eq(postsTable.id, id)); 44 + refresh(); 45 + }; 46 + 47 + export const publishPostAction = async (id: bigint) => { 48 + await db 49 + .update(postsTable) 50 + .set({ 51 + draft: false, 52 + publishedAt: new Date() 53 + }) 54 + .where(eq(postsTable.id, id)); 55 + refresh(); 56 + }; 57 + 58 + export const unPublishPostAction = async (id: bigint) => { 59 + await db 60 + .update(postsTable) 61 + .set({ 62 + draft: true 63 + }) 64 + .where(eq(postsTable.id, id)); 65 + refresh(); 66 + }; 67 + 68 + export const deletePostAction = async (id: bigint) => { 69 + await db.delete(postsTable).where(eq(postsTable.id, id)); 70 + refresh(); 71 + };
-5
next.config.ts
··· 28 28 // Override default toString()s to return stuff in a 'better' way. 29 29 30 30 // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 - (Date.prototype as any).toJSON = function () { 32 - return this.getTime(); 33 - }; 34 - 35 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 31 (BigInt.prototype as any).toJSON = function () { 37 32 return this.toString(); 38 33 };
+15
styles/layout/Admin.module.scss
··· 1 + body { 2 + max-width: 100dvw; 3 + 4 + display: flex; 5 + 6 + gap: 16px; 7 + 8 + main { 9 + flex: 1; 10 + 11 + margin: 0; 12 + 13 + min-height: 0; 14 + } 15 + }
+19 -10
styles/pages/Admin.module.scss
··· 1 1 .tableWrapper { 2 2 overflow-x: auto; 3 - 3 + 4 + border: 1px solid var(--outlineVariant); 5 + border-radius: 16px; 6 + 4 7 > table { 5 - width: 70vw; 6 - 8 + width: 100%; 9 + 7 10 border-collapse: collapse; 8 11 12 + th, td { 13 + border-bottom: 1px solid var(--outlineVariant); 14 + border-right: 1px solid var(--outlineVariant); 15 + 16 + &:last-child { 17 + border-right: none; 18 + } 19 + } 20 + 9 21 th { 10 22 padding: 12px 16px; 11 - border-bottom: 1px solid var(--outlineVariant); 12 23 } 13 - 24 + 14 25 td { 15 26 padding: 12px 16px; 16 - 17 - border-bottom: 1px solid var(--outlineVariant); 18 - 27 + 19 28 vertical-align: top; 20 29 } 21 - 30 + 22 31 tbody tr:hover td { 23 32 background-color: var(--surfaceContainer); 24 33 } 25 - 34 + 26 35 tbody tr:last-child td { 27 36 border-bottom: none; 28 37 }