Skip to content

Latest commit

Β 

History

92 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ“ Inkwell

Inkwell is a full-featured, full-stack blogging platform where readers explore published stories and admins write rich, beautifully formatted posts. It ships with Google authentication, a WYSIWYG rich-text editor, likes, comments, view tracking, image uploads, and a role-based admin dashboard β€” all wrapped in a warm, editorial "inkwell" design system with dark/light themes.

Inkwell License TypeScript


Table of Contents


Overview

Inkwell is a monorepo with two independently deployable applications:

App Stack Port (dev) Description
frontend React 19, Vite, TailwindCSS v4, TypeScript 5173 Public blog reader + authenticated admin workspace
backend Bun, Hono, Drizzle ORM, TypeScript 8000 REST API (/api/v1), auth, content, uploads

The backend talks to a Neon Postgres database and a Redis instance (used as a refresh-token store), uses Cloudinary for image hosting, and Google OAuth 2.0 as the sole identity provider. The frontend is a single-page app that communicates with the API using fetch with credentials: 'include' (cookie-based refresh).


Features

For Readers

  • πŸ“° Blog listing with debounced search (300 ms) and pagination
  • πŸ“– Article pages rendered from sanitized HTML with a reading-friendly layout
  • ❀️ Like / unlike posts with optimistic UI updates and rollback on failure
  • πŸ’¬ Comments β€” post your own (2000-char limit) and delete ones you own
  • πŸ‘€ View counts β€” tracked once per visit per article
  • πŸ”— Share menu β€” copy link, share to WhatsApp or X/Twitter
  • πŸŒ— Dark / light theme, persisted to localStorage with no flash of unstyled content
  • 🎨 Polished skeleton shimmer loaders, empty states, and error states everywhere

For Writers & Admins

  • πŸ” Google sign-in (OAuth 2.0 authorization-code flow with a state param to prevent CSRF)
  • πŸ“Š Admin dashboard β€” aggregate stats: total/published/draft posts, views, likes, comments
  • ✍️ Rich-text editor (TipTap) with headings, lists, task lists, quotes, code blocks, text alignment, colors, highlighting, links, and images
  • πŸ–ΌοΈ Cover image & avatar uploads β€” signed by the backend, uploaded directly to Cloudinary
  • 🏷️ Tags, slugs, drafts, and publishing toggle for every post
  • πŸ—‚οΈ Content manager β€” list, edit, and delete any post with confirmation dialogs
  • πŸ‘€ Profile page β€” update your username and avatar, or delete your account
  • πŸ›‘οΈ Role-based access β€” admin-only routes redirect unauthorized users automatically

Technical Highlights

  • Layered backend β€” route β†’ controller β†’ service β†’ repository, one module per feature
  • JWT auth with Redis-backed refresh rotation β€” 15-minute access tokens plus 7-day refresh tokens stored only as a SHA-256 hash in httpOnly, sameSite, secure cookies
  • Zod everywhere β€” request validation on the server and typed/validated API responses on the client
  • Centralized error handling β€” operational errors, Zod issues (422), Postgres unique violations (409), and malformed JSON (400) all map to consistent JSON payloads
  • Fail-fast environment validation β€” the server refuses to boot on missing config
  • Security touches β€” DOMPurify sanitization of stored HTML, base64 images disabled in the editor, rel="noreferrer" on outbound share links, and strict CORS with credentials
  • Performance β€” route-level code splitting (React.lazy), React Compiler, direct-to-CDN uploads so the backend never touches binary data
  • Accessibility β€” ARIA labels, role="alert" / role="alertdialog" modals, keyboard (Escape) + outside-click dismissal, and semantic markup
  • SEO β€” Open Graph and Twitter card meta tags plus a 1200Γ—630 social preview image

Tech Stack

Frontend

Backend

Data & services

  • Neon β€” serverless PostgreSQL
  • Redis β€” refresh-token store (via ioredis)
  • Cloudinary β€” image hosting with signed uploads
  • Google Cloud β€” OAuth 2.0 identity (google-auth-library)

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                              Browser (SPA)                              β”‚
β”‚  React 19 + React Router + React Query + Zustand + TailwindCSS v4       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚ fetch, credentials: 'include'
                            β”‚ Bearer <access-token>
                            β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                          Backend (Bun + Hono)                           β”‚
β”‚                                                                         β”‚
β”‚  Middleware:  cors β†’ errorHandler (global) β†’ requireAuth (per route)    β”‚
β”‚  Modules:     users Β· blogs Β· blog-likes Β· blog-comments Β· uploads      β”‚
β”‚  Layers:      route β†’ controller β†’ service β†’ repository β†’ drizzle       β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚                       β”‚                       β”‚
       β–Ό                       β–Ό                       β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚  Neon   β”‚          β”‚    Redis     β”‚        β”‚  Cloudinary   β”‚
  β”‚ Postgresβ”‚          β”‚ refresh-tokenβ”‚        β”‚   (images)    β”‚
  β”‚         β”‚          β”‚   hashes     β”‚        β”‚               β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β–²
                            β”‚ Google OAuth 2.0 (IdP)
                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                   β”‚  Google Cloud      β”‚
                   β”‚  Identity Platform β”‚
                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Request lifecycle (example: creating a post)

  1. POST /api/v1/blogs hits blogs.route.ts
  2. requireAuth verifies the Bearer JWT and loads { id, role } onto the Hono context
  3. zValidator runs createBlogSchema (strict) against the JSON body
  4. blogs.controller slugifies the title and delegates to blogs.service
  5. blogs.service enforces the admin-only check, then calls blogs.repository
  6. blogs.repository inserts the row via Drizzle into Neon Postgres
  7. Any thrown AppError / ZodError is normalized by the global errorHandler

Auth flow

  1. The client redirects to Google with a random state value stored in sessionStorage
  2. Google redirects back with an authorization code
  3. The client sends { code } to POST /api/v1/users/auth/google
  4. The server exchanges the code, verifies the ID token, and find-or-creates the user
  5. An access token (15 min) is returned in the body; a refresh token (7 d) is set as an httpOnly cookie, and only its SHA-256 hash is stored in Redis
  6. On load, the client silently refreshes via POST /api/v1/users/refresh (cookie)

Project Structure

Inkwell/
β”œβ”€β”€ backend/
β”‚   β”œβ”€β”€ migrations/                # Drizzle Kit SQL migrations
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ config/                # env (Zod), cors, redis, google, cloudinary
β”‚   β”‚   β”œβ”€β”€ db/                    # drizzle client + schema.ts (tables/relations)
β”‚   β”‚   β”œβ”€β”€ middleware/            # requireAuth, global errorHandler
β”‚   β”‚   β”œβ”€β”€ modules/
β”‚   β”‚   β”‚   β”œβ”€β”€ users/             # auth, profile, refresh, logout
β”‚   β”‚   β”‚   β”œβ”€β”€ blogs/             # CRUD, dashboard, admin listing
β”‚   β”‚   β”‚   β”œβ”€β”€ blog_likes/        # like toggle + status
β”‚   β”‚   β”‚   β”œβ”€β”€ blog-comments/     # comments + moderation
β”‚   β”‚   β”‚   └── uploads/           # Cloudinary signed upload params
β”‚   β”‚   β”œβ”€β”€ types/hono.d.ts        # Context augmentation (user: { id, role })
β”‚   β”‚   β”œβ”€β”€ utils/                 # AppError, JWT sign/verify helpers
β”‚   β”‚   └── index.ts               # app bootstrap + route mounting
β”‚   β”œβ”€β”€ drizzle.config.ts
β”‚   β”œβ”€β”€ .env.example
β”‚   └── package.json
β”‚
β”œβ”€β”€ frontend/
β”‚   β”œβ”€β”€ public/                    # favicon.svg, og-image.svg, icons sprite
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ components/            # brand-logo, theme-toggle, theme init
β”‚   β”‚   β”œβ”€β”€ features/
β”‚   β”‚   β”‚   β”œβ”€β”€ auth/              # api, components, useAuth hook, store, schemas
β”‚   β”‚   β”‚   └── blogs/             # api, components (home, list, detail,
β”‚   β”‚   β”‚                          #        editor, admin pages), types
β”‚   β”‚   β”œβ”€β”€ lib/api.ts             # fetch client with Zod-validated responses
β”‚   β”‚   β”œβ”€β”€ styles/globals.css     # Tailwind v4 @theme tokens + custom CSS
β”‚   β”‚   β”œβ”€β”€ App.tsx                # BrowserRouter + lazy routes
β”‚   β”‚   └── main.tsx               # QueryClientProvider + theme bootstrap
β”‚   β”œβ”€β”€ index.html                 # SEO meta, OG/Twitter tags
β”‚   β”œβ”€β”€ vite.config.ts             # tailwindcss + react + babel(reactCompiler)
β”‚   β”œβ”€β”€ .env.example
β”‚   └── package.json
β”‚
└── README.md

The frontend uses a feature-sliced layout: each feature (auth, blogs) is self-contained with its own api/, components/, hooks/, store/, and schemas. Shared, app-wide code lives in lib/, components/, and styles/.


Prerequisites

Make sure the following are installed and set up before you begin:

Requirement Version / Notes
Node.js β‰₯ 20 (for tooling)
Bun β‰₯ 1.4 (backend runtime & package manager)
A code editor VS Code recommended
A terminal with git, curl

You will also need free-tier accounts for the external services:

  • Neon β€” create a project and copy the DATABASE_URL
  • Redis β€” any instance (e.g. Upstash); copy the REDIS_URL
  • Cloudinary β€” copy your cloud name, API key, and API secret
  • Google Cloud Console β€” create an OAuth 2.0 Web application client ID to get GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and set an authorized redirect URI

Getting Started

1. Clone the repository

git clone https://github.com/Anos714/Inkwell.git
cd Inkwell

2. Set up the database & Redis

Create a Neon Postgres project and a Redis instance, then keep both connection strings handy β€” you'll add them to the backend .env in step 4.

Apply the existing migrations (recommended) or generate fresh ones from the schema:

cd backend
bun install
bun run db:migrate     # apply existing migrations in ./migrations
# or
bun run db:generate    # generate a new migration from src/db/schema.ts

Tip β€” promoting a user to admin: authentication is Google-only, and roles default to user. To unlock the admin workspace, promote your account in Postgres:

UPDATE users SET role = 'admin' WHERE email = 'you@example.com';

3. Configure external services

Google OAuth (Cloud Console)

  1. Go to APIs & Services β†’ Credentials β†’ Create credentials β†’ OAuth client ID
  2. Choose Web application
  3. Under Authorized redirect URIs, add:
    • http://localhost:5173/api/auth/google/callback (development)
    • your production callback URL (e.g. https://inkwell-0tx.pages.dev/api/auth/google/callback)
  4. Copy the Client ID and Client secret

Cloudinary

  1. Create an account (or use the programmable media demo environment)
  2. Copy your Cloud name, API Key, and API Secret from the dashboard

4. Run the backend

cd backend
cp .env.example .env          # then fill in the values (see below)
bun install
bun run dev                   # watch mode on http://localhost:8000

Verify it is alive:

curl http://localhost:8000/ping
# { "success": true, "message": "pong" }

Fill backend/.env with the values from Environment Variables. If any required variable is missing, the server prints a structured error and exits.

5. Run the frontend

cd frontend
cp .env.example .env          # then fill in the values (see below)
bun install
bun run dev                   # Vite dev server on http://localhost:5173

Open http://localhost:5173, sign in with Google, and start exploring. πŸŽ‰


Environment Variables

Backend (backend/.env)

The committed backend/.env.example is intentionally a stub β€” copy it and fill in every value below. All variables are validated with Zod at startup; the process exits with a clear error tree if any are missing or malformed.

Variable Required Example / Default Purpose
PORT no 8000 Server port
BUN_ENV no development development | production | test (CORS/cookies)
DATABASE_URL yes postgresql://user:pass@host/db?sslmode=require Neon Postgres connection string
REDIS_URL yes red://default:pass@host:6379 Redis (refresh-token store)
ACCESS_TOKEN_SECRET_KEY yes random 32+ char string JWT access-token signing secret (15 min)
REFRESH_TOKEN_SECRET_KEY yes random 32+ char string JWT refresh-token signing secret (7 days)
FRONTEND_URL yes http://localhost:5173 Allowed CORS origin in production
GOOGLE_CLIENT_ID yes xxxx.apps.googleusercontent.com Google OAuth client ID + IdToken audience
GOOGLE_CLIENT_SECRET yes GOCSPX-xxxx Google OAuth client secret
GOOGLE_REDIRECT_URI yes http://localhost:5173/api/auth/google/callback Must match the Google Console redirect URI
CLOUDINARY_CLOUD_NAME yes your-cloud Cloudinary cloud name
CLOUDINARY_API_KEY yes 123456789012345 Cloudinary API key (sent to client for uploads)
CLOUDINARY_API_SECRET yes xxxx Cloudinary secret (server-side signing only)

Generate strong secrets with:

openssl rand -base64 48

Frontend (frontend/.env)

Variable Required Example / Default Purpose
VITE_API_URL yes http://localhost:8000 Base URL of the backend API
VITE_GOOGLE_CLIENT_ID yes xxxx.apps.googleusercontent.com Google OAuth client ID
VITE_GOOGLE_REDIRECT_URI no ${window.location.origin}/api/auth/google/callback OAuth redirect URI (fallback used if unset)

Available Scripts

Backend (cd backend)

Script Command Description
bun run dev bun --watch Start the dev server with hot reloading
bun run start β€” Start the production server
bun run build β€” Typecheck + bundle to ./dist
bun run typecheck tsc --noEmit Strict type checking
bun run lint eslint Lint src/**/*.ts
bun run db:generate drizzle-kit Generate a migration from src/db/schema.ts
bun run db:migrate drizzle-kit Apply pending migrations

Frontend (cd frontend)

Script Command Description
bun run dev vite Start the Vite dev server
bun run build tsc -b && vite build Typecheck + production build to ./dist
bun run preview vite preview Preview the production build locally
bun run lint eslint . Lint the codebase

API Reference

Base URL: http://localhost:8000 Β· API prefix: /api/v1 Β· Auth: Authorization: Bearer <token>

Health

Method Endpoint Auth Description
GET /ping β€” Health check β†’ { message: "pong" }

Users β€” /api/v1/users

Method Endpoint Auth Description
POST /users/auth/google β€” Google OAuth login/signup (accepts { code }), issues tokens
POST /users/refresh β€” Exchange the refresh cookie for a new access token
GET /users/me βœ… Current authenticated user
PATCH /users/me βœ… Update profile (username, uniqueness-checked)
PATCH /users/me/avatar βœ… Set avatar URL
DELETE /users/me βœ… Delete own account (cascades to likes & comments)
POST /users/logout βœ… Revoke refresh token & clear cookie

Blogs β€” /api/v1/blogs

Method Endpoint Auth Description
GET /blogs β€” List published blogs (?page&limit&search)
GET /blogs/:slug β€” Get a published blog by slug (+ likes count)
POST /blogs/:slug/views β€” Increment the view counter (published only, atomic)
POST /blogs βœ… admin Create a blog (slug auto-generated)
PATCH /blogs/:blogId βœ… admin Update a blog (any subset of fields)
DELETE /blogs/:blogId βœ… admin Delete a blog
GET /blogs/dashboard βœ… admin Aggregate stats (blogs, published, drafts, views, likes, comments)
GET /blogs/admin βœ… admin List all blogs including drafts
GET /blogs/admin/:slug βœ… admin Get any blog by slug including drafts

Likes β€” /api/v1/blog-likes

Method Endpoint Auth Description
POST /blog-likes/:blogId/like βœ… Toggle like β†’ 201 liked / 200 unliked + totalLikes
GET /blog-likes/:id/like-status βœ… Current user's like status + total likes

Comments β€” /api/v1/blog-comments

Method Endpoint Auth Description
POST /blog-comments/:blogId/comments βœ… Create a comment
GET /blog-comments/:blogId/comments β€” List comments (with author username & avatar)
DELETE /blog-comments/comments/:commentId βœ… Delete a comment (owner or admin)

Uploads β€” /api/v1/uploads

Method Endpoint Auth Description
GET /uploads/signature?type= βœ… Cloudinary signed upload params. type: blogCover (5 MB, admin) or avatar (2 MB)

Error format β€” all errors return a consistent JSON payload:

{
  "success": false,
  "message": "Validation failed",
  "issues": [{ "path": ["title"], "message": "Title is required" }]
}

Common status codes: 400 bad request, 401 unauthorized, 403 forbidden (admin-only), 404 not found, 409 conflict (unique violation), 422 validation error, 500 server error.


Database Schema

PostgreSQL (Neon), managed with Drizzle ORM. All IDs are uuid (uuidv7); all timestamps are timestamptz. role is a Postgres enum: user | admin.

users
β”œβ”€β”€ id            uuid PK (uuidv7)
β”œβ”€β”€ username      varchar(100) UNIQUE NOT NULL
β”œβ”€β”€ email         varchar(255) UNIQUE NOT NULL
β”œβ”€β”€ avatar_url    text
β”œβ”€β”€ google_id     text UNIQUE NOT NULL
β”œβ”€β”€ role          role enum DEFAULT 'user'
β”œβ”€β”€ created_at    timestamptz NOT NULL
└── updated_at    timestamptz NOT NULL

blogs
β”œβ”€β”€ id            uuid PK (uuidv7)
β”œβ”€β”€ title         varchar(255) NOT NULL
β”œβ”€β”€ slug          varchar(255) UNIQUE NOT NULL
β”œβ”€β”€ description   text
β”œβ”€β”€ content       jsonb NOT NULL          -- rich-text editor HTML
β”œβ”€β”€ cover_image   text
β”œβ”€β”€ tags          text[] NOT NULL DEFAULT '{}'
β”œβ”€β”€ is_published  boolean NOT NULL DEFAULT false
β”œβ”€β”€ published_at  timestamptz
β”œβ”€β”€ views         integer NOT NULL DEFAULT 0
β”œβ”€β”€ created_at    timestamptz NOT NULL
└── updated_at    timestamptz NOT NULL

blog_likes                        -- composite PK, both FKs ON DELETE CASCADE
β”œβ”€β”€ user_id       uuid FK β†’ users.id
β”œβ”€β”€ blog_id       uuid FK β†’ blogs.id
└── created_at    timestamptz NOT NULL

blog_comments
β”œβ”€β”€ id            uuid PK (uuidv7)
β”œβ”€β”€ blog_id       uuid FK β†’ blogs.id  ON DELETE CASCADE
β”œβ”€β”€ user_id       uuid FK β†’ users.id  ON DELETE CASCADE
β”œβ”€β”€ content       text NOT NULL
β”œβ”€β”€ created_at    timestamptz NOT NULL
└── updated_at    timestamptz NOT NULL

Relations: one user β†’ many likes & comments; one blog β†’ many likes & comments.


Deployment

The project is designed to deploy the two apps independently.

Frontend (Cloudflare Pages) β€” the live demo runs at inkwell-blogs.pages.dev.

  • Build command: bun run build
  • Output directory: frontend/dist
  • Environment variables: VITE_API_URL, VITE_GOOGLE_CLIENT_ID, VITE_GOOGLE_REDIRECT_URI
  • Add a SPA fallback so client-side routes resolve to index.html (Pages does this automatically when there is no matching static asset)

Backend β€” any runtime that supports Bun (or the compiled bundle):

  • Build with bun run build (emits backend/dist), or run bun run start
  • Set all backend environment variables in the host's secret store
  • Ensure FRONTEND_URL points to your deployed frontend so CORS and cookies work
  • Keep the /api/auth/google/callback redirect URI consistent between the client, the server, and the Google Cloud Console

Cookies: in production the refresh cookie is httpOnly, secure, and sameSite=none, so the API must be served over HTTPS for credentials to be sent.


Roadmap

Ideas for future work (not yet implemented):

  • Rate limiting / request throttling
  • Full-text search (Postgres tsvector) instead of ilike
  • Email notifications & newsletter signup
  • Draft previews and scheduling
  • Reading list / bookmarks
  • richer author profiles and bios
  • Automated test suite (unit + integration)
  • Analytics dashboard with charts

Contributing

Contributions are welcome! Please follow this workflow:

  1. Fork the repository
  2. Create a feature branch: git checkout -b feat/your-feature
  3. Commit using Conventional Commits (e.g. feat(blogs): add bookmarks) β€” this repo follows that style
  4. Make sure linting and typechecking pass:
    cd backend  && bun run lint && bun run typecheck
    cd ../frontend && bun run lint
  5. Open a Pull Request describing your changes

License

This project is open source and released under the MIT License β€” see the LICENSE file for details. A permissive license means you're free to learn from, fork, and adapt this project for your own use.


Built with β˜• & Bun + React.
⭐ Star the repo if you like it!

About

A full-stack blogging platform with a rich-text editor, Google OAuth, likes, comments, view tracking, and an admin dashboard. React 19 + Vite + Tailwind v4 frontend, Bun + Hono + Drizzle + Neon Postgres backend.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages