diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f34e970 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +BETTER_AUTH_SECRET= +BETTER_AUTH_URL= +NEON_BRANCH= +DATABASE_URL= +DATABASE_URL_UNPOOLED= +UPLOADTHING_TOKEN= diff --git a/.gitignore b/.gitignore index 5ef6a52..b7e7913 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -39,3 +40,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.neon diff --git a/BLOGLY.md b/BLOGLY.md new file mode 100644 index 0000000..a47378e --- /dev/null +++ b/BLOGLY.md @@ -0,0 +1,130 @@ +# Blogly + +![screenshot](./public/ss1.png) + +A multi-author blogging app. Anyone can read. Sign-in is required to write, comment, like, and follow. + +## Stack + +| Layer | Choice | +| --- | --- | +| Runtime | Node.js 20+, Next.js 16 (App Router, Cache Components) | +| UI | React 19, Tailwind CSS 4, shadcn/ui (Base UI), Remixicon | +| Auth | Better Auth (email/password) | +| Database | Neon Postgres, Drizzle ORM | +| Uploads | UploadThing | +| Editor | Tiptap 3 | +| Package manager | npm | + +## Requirements + +- Node.js 20+ +- A Neon (or other Postgres) database +- An [UploadThing](https://uploadthing.com) token + +## Environment + +Copy `.env.example` to `.env` and fill in: + +| Variable | Purpose | +| --- | --- | +| `DATABASE_URL` | Pooled Postgres URL (app) | +| `DATABASE_URL_UNPOOLED` | Direct Postgres URL (migrations) | +| `BETTER_AUTH_SECRET` | Auth signing secret | +| `BETTER_AUTH_URL` | Public origin, e.g. `http://localhost:3000` | +| `UPLOADTHING_TOKEN` | Image uploads | +| `NEON_BRANCH` | Optional Neon branch name | + +## Setup + +```bash +npm ci +npx drizzle-kit migrate +npm run db:seed +npm run dev +``` + +App: `http://localhost:3000` + +Seed accounts use password `SeedPass1!`: + +- `mira@blogly.dev` / `mira` +- `julian@blogly.dev` / `julian` +- `elena@blogly.dev` / `elena` + +Re-running the seed is idempotent for those authors. + +## Scripts + +| Command | Action | +| --- | --- | +| `npm run dev` | Dev server | +| `npm run build` | Production build | +| `npm run start` | Serve the build | +| `npm run lint` | ESLint | +| `npm run db:generate` | Generate a Drizzle migration | +| `npm run db:migrate` | Apply migrations | +| `npm run db:seed` | Seed sample authors and posts | + +## Routes + +| Path | Access | +| --- | --- | +| `/` | Public feed (For You, Following) | +| `/search?q=` | Public search (posts/people, sort, tags, recency) | +| `/signin`, `/signup` | Auth | +| `/[username]` | Public author profile | +| `/[username]/[slug]` | Public post | +| `/admin` | Own posts (session) | +| `/admin/new`, `/admin/[postId]/edit` | Editor (session) | +| `/settings/account` | Profile, avatar, header image | +| `/settings/security` | Password | +| `/settings/manage` | Account management | +| `/api/auth/[...all]` | Better Auth | +| `/api/uploadthing` | Uploads | + +Reserved usernames: `admin`, `signin`, `signup`, `settings`, `api`, `search`. + +## Data + +Postgres tables: `user`, `session`, `account`, `verification`, `blogs`, `comments`, `likes`, `follows`. + +Posts: `draft` \| `published` \| `archived`. Slugs are unique per author. Public lists show published posts from non-disabled users, 8 per page. + +Schema lives in `lib/db/schemas/`. Migrations live in `drizzle/`. + +## Auth and uploads + +Better Auth stores credentials in Postgres. Sessions are read through `lib/session.ts`. Sign-up requires name, unique email, unique username, and a password of at least 8 characters. + +UploadThing routes (signed-in only): + +- `coverImage`, `postImage`, `headerImage` — 8 MB +- `avatarImage` — 4 MB + +URLs are stored on the user or post row. + +## Caching + +`cacheComponents` is on. Public post queries use `"use cache"` with tag `posts`. Mutations in `app/actions/` call `updateTag` and `revalidatePath` so new posts appear without a hard reload. + +## Layout + +``` +app/ routes, server actions, API +components/ UI (editor, cards, settings) +lib/db/ schema, queries, seed +lib/ auth, session, cache, search +drizzle/ SQL migrations +``` + +## Screenshots + +![screenshot](./public/ss2.png) +![screenshot](./public/ss3.png) +![screenshot](./public/ss4.png) +![screenshot](./public/ss5.png) +![screenshot](./public/ss6.png) +![screenshot](./public/ss7.png) +![screenshot](./public/ss8.png) +![screenshot](./public/ss9.png) diff --git a/README.md b/README.md index 36064cd..e5d7d6a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ © 2026 Renderbit Technologies Pvt. Ltd. +>[!NOTE] +> Read [BLOGLY.md](./BLOGLY.md) for technical documentation. + ## Prerequisites You should be familiar with Node.js, React, Next.js, Git and GitHub. diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..0cbdaf5 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; +import { ModeToggle } from "@/components/mode-toggle"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ + Blogly. + + +
+
+ {children} +
+
+ ); +} diff --git a/app/(auth)/signin/page.tsx b/app/(auth)/signin/page.tsx new file mode 100644 index 0000000..44e91de --- /dev/null +++ b/app/(auth)/signin/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from "next/navigation"; +import { SignInForm } from "@/components/auth/sign-in-form"; +import { safeNextPath } from "@/lib/safe-next"; +import { getSession } from "@/lib/session"; + +export const instant = false; + +export const metadata = { + title: "Sign in · Blogly", + description: "Sign in to write and publish on Blogly.", +}; + +export default async function SignInPage({ + searchParams, +}: { + searchParams: Promise<{ next?: string }>; +}) { + const session = await getSession(); + const nextPath = safeNextPath((await searchParams).next); + if (session?.user) { + redirect(nextPath); + } + + return ; +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..3651588 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { SignUpForm } from "@/components/auth/sign-up-form"; +import { getSession } from "@/lib/session"; + +export const instant = false; + +export const metadata = { + title: "Sign up · Blogly", + description: "Create a Blogly account and claim your username.", +}; + +export default async function SignUpPage() { + const session = await getSession(); + if (session?.user) { + redirect("/"); + } + + return ; +} diff --git a/app/(home)/[username]/[slug]/loading.tsx b/app/(home)/[username]/[slug]/loading.tsx new file mode 100644 index 0000000..3c1f48e --- /dev/null +++ b/app/(home)/[username]/[slug]/loading.tsx @@ -0,0 +1,10 @@ +export default function PostLoading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/app/(home)/[username]/[slug]/page.tsx b/app/(home)/[username]/[slug]/page.tsx new file mode 100644 index 0000000..80c02cd --- /dev/null +++ b/app/(home)/[username]/[slug]/page.tsx @@ -0,0 +1,168 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import CommentForm from "@/components/comment-form"; +import CommentList from "@/components/comment-list"; +import LikeButton from "@/components/like-button"; +import PostBody from "@/components/post-body"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { DEFAULT_COVER } from "@/lib/constants"; +import { listCommentsForPost } from "@/lib/db/queries/comments"; +import { hasLikedPost } from "@/lib/db/queries/likes"; +import { getPublishedPost } from "@/lib/db/queries/posts"; +import { formatPostDate, initialsFromName } from "@/lib/format"; +import { getSession } from "@/lib/session"; + +type PostPageProps = { + params: Promise<{ username: string; slug: string }>; +}; + +type PublishedPost = NonNullable>>; + +export async function generateMetadata({ + params, +}: PostPageProps): Promise { + const { username, slug } = await params; + const post = await getPublishedPost(username, slug); + if (!post) return { title: "Post · Blogly" }; + return { + title: `${post.title} · Blogly`, + description: post.excerpt ?? `A post by ${post.authorName} on Blogly.`, + }; +} + +async function PostSessionActions({ + post, +}: { + post: PublishedPost; +}) { + const session = await getSession(); + const liked = session?.user + ? await hasLikedPost(session.user.id, post.id) + : false; + const returnTo = `/${post.authorUsername}/${post.slug}`; + + return ( + + ); +} + +async function PostComments({ + post, +}: { + post: PublishedPost; +}) { + const [comments, session] = await Promise.all([ + listCommentsForPost(post.id), + getSession(), + ]); + const returnTo = `/${post.authorUsername}/${post.slug}`; + + return ( +
+

+ Comments +

+ + +
+ ); +} + +export default async function PostPage({ params }: PostPageProps) { + const { username, slug } = await params; + const post = await getPublishedPost(username, slug); + if (!post) notFound(); + + const returnTo = `/${post.authorUsername}/${post.slug}`; + const cover = post.coverImage || DEFAULT_COVER; + + return ( +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {post.title} +
+
+

+ {formatPostDate(post.createdAt)} +

+

+ {post.title} +

+ + + {post.authorImage ? ( + + ) : null} + + {initialsFromName(post.authorName)} + + + {post.authorName} + + @{post.authorUsername} + + + {post.hashTags.length > 0 ? ( +
+ {post.hashTags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ + + } + > + + + + +

+ Comments +

+
+ + } + > + + +
+
+ ); +} diff --git a/app/(home)/[username]/loading.tsx b/app/(home)/[username]/loading.tsx new file mode 100644 index 0000000..d4103e0 --- /dev/null +++ b/app/(home)/[username]/loading.tsx @@ -0,0 +1,9 @@ +export default function ProfileLoading() { + return ( +
+
+
+
+
+ ); +} diff --git a/app/(home)/[username]/not-found.tsx b/app/(home)/[username]/not-found.tsx new file mode 100644 index 0000000..e88c91b --- /dev/null +++ b/app/(home)/[username]/not-found.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +export default function UsernameNotFound() { + return ( +
+

+ No writer with that name +

+

+ That username is not on Blogly yet. +

+ +
+ ); +} diff --git a/app/(home)/[username]/page.tsx b/app/(home)/[username]/page.tsx new file mode 100644 index 0000000..393c481 --- /dev/null +++ b/app/(home)/[username]/page.tsx @@ -0,0 +1,74 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import UserProfile from "@/components/user-profile"; +import { isFollowing } from "@/lib/db/queries/follows"; +import { listPublishedPostsByUsername } from "@/lib/db/queries/posts"; +import { getUserByUsername } from "@/lib/db/queries/users"; +import { getSession } from "@/lib/session"; +import { getViewerLikeState } from "@/lib/viewer-likes"; + +type UserPageProps = { + params: Promise<{ username: string }>; + searchParams: Promise<{ page?: string }>; +}; + +function profileHandle(username: string) { + return decodeURIComponent(username).replace(/^@/, ""); +} + +export async function generateMetadata({ + params, +}: UserPageProps): Promise { + const { username } = await params; + const handle = profileHandle(username); + const profile = await getUserByUsername(handle); + + if (!profile || profile.disabled) { + return { title: `${handle} · Blogly` }; + } + + return { + title: `${profile.name} · Blogly`, + description: `Read writing by ${profile.name} on Blogly.`, + }; +} + +export default async function UserPage({ params, searchParams }: UserPageProps) { + const { username } = await params; + const handle = profileHandle(username); + const page = Math.max(1, Number((await searchParams).page) || 1); + + const [profile, feed, session] = await Promise.all([ + getUserByUsername(handle), + listPublishedPostsByUsername(handle, page), + getSession(), + ]); + + if (!profile || profile.disabled) { + notFound(); + } + + const [likeState, following] = await Promise.all([ + getViewerLikeState( + feed.items.map((post) => post.id), + session, + ), + session?.user + ? isFollowing(session.user.id, profile.id) + : Promise.resolve(false), + ]); + + return ( + + ); +} diff --git a/app/(home)/admin/[postId]/edit/page.tsx b/app/(home)/admin/[postId]/edit/page.tsx new file mode 100644 index 0000000..7d11990 --- /dev/null +++ b/app/(home)/admin/[postId]/edit/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation"; +import { updatePost } from "@/app/actions/posts"; +import PostForm from "@/components/post-form"; +import { getPostByIdForAuthor } from "@/lib/db/queries/posts"; +import { getSession } from "@/lib/session"; + +type EditPageProps = { + params: Promise<{ postId: string }>; +}; + +export const instant = false; + +export const metadata = { + title: "Edit post · Blogly", +}; + +export default async function EditPostPage({ params }: EditPageProps) { + const session = await getSession(); + if (!session?.user) return null; + const { postId } = await params; + const post = await getPostByIdForAuthor(postId, session.user.id); + if (!post) notFound(); + + const action = updatePost.bind(null, post.id); + + return ( +
+

Editing “{post.title}”

+ +
+ ); +} diff --git a/app/(home)/admin/layout.tsx b/app/(home)/admin/layout.tsx new file mode 100644 index 0000000..973fce7 --- /dev/null +++ b/app/(home)/admin/layout.tsx @@ -0,0 +1,23 @@ +import { connection } from "next/server"; +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/session"; + +export const instant = false; + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + await connection(); + const session = await getSession(); + if (!session?.user) { + redirect("/signin?next=/admin"); + } + + return ( +
+ {children} +
+ ); +} diff --git a/app/(home)/admin/loading.tsx b/app/(home)/admin/loading.tsx new file mode 100644 index 0000000..3125198 --- /dev/null +++ b/app/(home)/admin/loading.tsx @@ -0,0 +1,9 @@ +export default function AdminLoading() { + return ( +
+
+
+
+
+ ); +} diff --git a/app/(home)/admin/new/page.tsx b/app/(home)/admin/new/page.tsx new file mode 100644 index 0000000..759f6b2 --- /dev/null +++ b/app/(home)/admin/new/page.tsx @@ -0,0 +1,12 @@ +import { createPost } from "@/app/actions/posts"; +import PostForm from "@/components/post-form"; + +export const instant = false; + +export const metadata = { + title: "New post · Blogly", +}; + +export default function NewPostPage() { + return ; +} diff --git a/app/(home)/admin/page.tsx b/app/(home)/admin/page.tsx new file mode 100644 index 0000000..f7c331b --- /dev/null +++ b/app/(home)/admin/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; +import DeletePostButton from "@/components/delete-post-button"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { listPostsByAuthor } from "@/lib/db/queries/posts"; +import { formatPostDate } from "@/lib/format"; +import { getSession } from "@/lib/session"; + +export const instant = false; + +export const metadata = { + title: "Admin · Blogly", +}; + +export default async function AdminPage() { + const session = await getSession(); + if (!session?.user) return null; + + const posts = await listPostsByAuthor(session.user.id); + + return ( +
+
+
+

+ Your posts +

+

+ Create, edit, and delete writing on your Blogly. +

+
+ +
+ {posts.length === 0 ? ( +

+ You have not written anything yet. +

+ ) : ( +
    + {posts.map((post) => ( +
  • +
    +
    +

    {post.title}

    + {post.status} +
    +

    + /{session.user.username}/{post.slug} ·{" "} + {formatPostDate(post.updatedAt)} +

    +
    +
    + {post.status === "published" ? ( + + ) : null} + + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx new file mode 100644 index 0000000..59c1826 --- /dev/null +++ b/app/(home)/layout.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; +import AccountMenu from "@/components/account-menu"; +import AccountMenuFallback from "@/components/account-menu-fallback"; +import Footer from "@/components/footer"; +import Navbar from "@/components/navbar"; + +export default function HomeLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> +
+ + }> + + + + {children} +
+